feat: extension list UI (#4066)

This commit is contained in:
Sami Mazouz 2024-10-16 18:12:46 +01:00 committed by GitHub
parent b0e8f5ca36
commit 0107c96fb7
59 changed files with 1769 additions and 514 deletions

View File

@ -10,7 +10,9 @@
namespace Flarum\ExtensionManager; namespace Flarum\ExtensionManager;
use Flarum\Extend; use Flarum\Extend;
use Flarum\ExtensionManager\Api\Resource\ExternalExtensionResource;
use Flarum\ExtensionManager\Api\Resource\TaskResource; use Flarum\ExtensionManager\Api\Resource\TaskResource;
use Flarum\ExtensionManager\Exception\CannotFetchExternalExtension;
use Flarum\Foundation\Paths; use Flarum\Foundation\Paths;
use Flarum\Frontend\Document; use Flarum\Frontend\Document;
use Illuminate\Contracts\Queue\Queue; use Illuminate\Contracts\Queue\Queue;
@ -29,6 +31,7 @@ return [
->post('/extension-manager/composer', 'extension-manager.composer', Api\Controller\ConfigureComposerController::class), ->post('/extension-manager/composer', 'extension-manager.composer', Api\Controller\ConfigureComposerController::class),
new Extend\ApiResource(TaskResource::class), new Extend\ApiResource(TaskResource::class),
new Extend\ApiResource(ExternalExtensionResource::class),
(new Extend\Frontend('admin')) (new Extend\Frontend('admin'))
->css(__DIR__.'/less/admin.less') ->css(__DIR__.'/less/admin.less')
@ -62,8 +65,10 @@ return [
->handler(Exception\ComposerRequireFailedException::class, Exception\ExceptionHandler::class) ->handler(Exception\ComposerRequireFailedException::class, Exception\ExceptionHandler::class)
->handler(Exception\ComposerUpdateFailedException::class, Exception\ExceptionHandler::class) ->handler(Exception\ComposerUpdateFailedException::class, Exception\ExceptionHandler::class)
->handler(Exception\MajorUpdateFailedException::class, Exception\ExceptionHandler::class) ->handler(Exception\MajorUpdateFailedException::class, Exception\ExceptionHandler::class)
->type(CannotFetchExternalExtension::class, 'cannot_fetch_external_extension')
->status('extension_already_installed', 409) ->status('extension_already_installed', 409)
->status('extension_not_installed', 409) ->status('extension_not_installed', 409)
->status('no_new_major_version', 409) ->status('no_new_major_version', 409)
->status('extension_not_directly_dependency', 409), ->status('extension_not_directly_dependency', 409)
->status('cannot_fetch_external_extension', 503),
]; ];

View File

@ -15,12 +15,7 @@ export default class ControlSection extends Component<ComponentAttrs> {
view() { view() {
return ( return (
<div className="ExtensionPage-permissions ExtensionManager-controlSection"> <div className="ExtensionPage-settings ExtensionManager-controlSection">
<div className="ExtensionPage-permissions-header">
<div className="container">
<h2 className="ExtensionTitle">{app.translator.trans('flarum-extension-manager.admin.sections.control.title')}</h2>
</div>
</div>
<div className="container"> <div className="container">
{app.data['flarum-extension-manager.writable_dirs'] ? ( {app.data['flarum-extension-manager.writable_dirs'] ? (
<Form> <Form>

View File

@ -0,0 +1,298 @@
import app from 'flarum/admin/app';
import Component, { type ComponentAttrs } from 'flarum/common/Component';
import Form from 'flarum/common/components/Form';
import Button from 'flarum/common/components/Button';
import type Mithril from 'mithril';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import ItemList from 'flarum/common/utils/ItemList';
import Input from 'flarum/common/components/Input';
import Stream from 'flarum/common/utils/Stream';
import Alert from 'flarum/common/components/Alert';
import listItems from 'flarum/common/helpers/listItems';
import LinkButton from 'flarum/common/components/LinkButton';
import Dropdown from 'flarum/common/components/Dropdown';
import type ExternalExtension from '../models/ExternalExtension';
import ExtensionCard from './ExtensionCard';
import Pagination from 'flarum/common/components/Pagination';
import InfoTile from 'flarum/common/components/InfoTile';
import classList from 'flarum/common/utils/classList';
import { throttle } from 'flarum/common/utils/throttleDebounce';
export interface IDiscoverSectionAttrs extends ComponentAttrs {}
export default class DiscoverSection<CustomAttrs extends IDiscoverSectionAttrs = IDiscoverSectionAttrs> extends Component<CustomAttrs> {
protected search = Stream('');
protected warningsDismissed = Stream(false);
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
app.extensionManager.extensions.goto(1);
this.warningsDismissed(localStorage.getItem('flarum-extension-manager.warningsDismissed') === 'true');
}
load(page = 1) {
app.extensionManager.extensions.goto(page);
}
view() {
return (
<div className="ExtensionPage-settings ExtensionManager-DiscoverSection">
<div className="container">
<Form>
<div className="Form-group">
<label>{app.translator.trans('flarum-extension-manager.admin.sections.discover.title')}</label>
<div className="helpText">
{app.translator.trans('flarum-extension-manager.admin.sections.discover.description')}
{this.warningsDismissed() && (
<Button
className="Button Button--text Button--warning Button--more"
icon="fas fa-exclamation-triangle"
onclick={() => this.setWarningDismissed(false)}
/>
)}
</div>
</div>
{!this.warningsDismissed() && (
<div className="ExtensionManager-warnings Form-group">
<Alert className="ExtensionManager-primaryWarning" type="warning" dismissible={true} ondismiss={() => this.setWarningDismissed(true)}>
<ul>{listItems(this.warningItems().toArray())}</ul>
</Alert>
</div>
)}
<div className="Tabs">
<div className="Tabs-nav">{this.tabItems().toArray()}</div>
<div className="Tabs-content">
<hr className="Tabs-divider" />
<div className="ExtensionManager-DiscoverSection-toolbar">
<div className="ExtensionManager-DiscoverSection-toolbar-primary">{this.toolbarPrimaryItems().toArray()}</div>
<div className="ExtensionManager-DiscoverSection-toolbar-secondary">{this.toolbarSecondaryItems().toArray()}</div>
</div>
{this.extensionList()}
<div className="ExtensionManager-DiscoverSection-footer">{this.footerItems().toArray()}</div>
</div>
</div>
</Form>
</div>
</div>
);
}
tabFilters(): Record<string, { label: Mithril.Children; active: () => boolean }> {
return {
'': {
label: app.translator.trans('flarum-extension-manager.admin.sections.discover.tabs.discover'),
active: () => !app.extensionManager.extensions.getParams().filter?.type,
},
extension: {
label: app.translator.trans('flarum-extension-manager.admin.sections.discover.tabs.extensions'),
active: () => app.extensionManager.extensions.getParams().filter?.type === 'extension',
},
locale: {
label: app.translator.trans('flarum-extension-manager.admin.sections.discover.tabs.languages'),
active: () => app.extensionManager.extensions.getParams().filter?.type === 'locale',
},
theme: {
label: app.translator.trans('flarum-extension-manager.admin.sections.discover.tabs.themes'),
active: () => app.extensionManager.extensions.getParams().filter?.type === 'theme',
},
};
}
tabItems() {
const items = new ItemList();
const tabs = this.tabFilters();
Object.keys(tabs).forEach((key) => {
const tab = tabs[key];
items.add(
key,
<Button
className="Button Button--link"
active={tab.active()}
onclick={() => {
app.extensionManager.extensions.changeFilter('type', key);
}}
>
{tab.label}
</Button>
);
});
return items;
}
warningItems() {
const items = new ItemList<Mithril.Children>();
items.add('accessWarning', app.translator.trans('flarum-extension-manager.admin.settings.access_warning'));
if (app.data.debugEnabled) {
items.add('devModeWarning', app.translator.trans('flarum-extension-manager.admin.settings.debug_mode_warning'));
}
return items;
}
private applySearch = throttle(1200, (value: string) => {
const params = app.extensionManager.extensions.getParams();
app.extensionManager.extensions.refreshParams({ ...params, filter: { ...params.filter, q: value } }, 1);
});
toolbarPrimaryItems() {
const items = new ItemList();
items.add(
'search',
<Input
value={this.search()}
onchange={(value: string) => {
this.search(value);
this.applySearch(value);
}}
inputAttrs={{ className: 'FormControl-alt' }}
clearable={true}
placeholder={app.translator.trans('flarum-extension-manager.admin.sections.discover.search')}
prefixIcon="fas fa-search"
/>
);
return items;
}
toolbarSecondaryItems() {
const items = new ItemList();
const sortMap = app.extensionManager.extensions.sortMap();
const sortOptions = Object.keys(sortMap).reduce((acc: any, sortId) => {
const sort = sortMap[sortId];
acc[sortId] = typeof sort !== 'string' ? sort.label : sort;
return acc;
}, {});
items.add(
'sort',
<Dropdown
buttonClassName="Button"
label={sortOptions[app.extensionManager.extensions.getParams().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0]}
accessibleToggleLabel={app.translator.trans('flarum-extension-manager.admin.sections.discover.sort.toggle_dropdown_accessible_label')}
>
{Object.keys(sortOptions).map((value) => {
const label = sortOptions[value];
const active = app.extensionManager.extensions.getParams().sort === value;
return (
<Button icon={active ? 'fas fa-check' : true} onclick={() => app.extensionManager.extensions.changeSort(value)} active={active}>
{label}
</Button>
);
})}
</Dropdown>
);
const is = app.extensionManager.extensions.getParams().filter?.is?.[0] ?? null;
const activeType = is || 'all';
items.add(
'party',
<Dropdown
buttonClassName="Button"
label={app.translator.trans('flarum-extension-manager.admin.sections.discover.party_filter.' + activeType)}
accessibleToggleLabel={app.translator.trans('flarum-extension-manager.admin.sections.discover.party_filter.toggle_dropdown_accessible_label')}
>
{['all', 'premium'].map((party) => (
<Button
icon={activeType === party ? 'fas fa-check' : true}
onclick={() => {
app.extensionManager.extensions.changeFilter('is', party === 'all' ? undefined : [party]);
}}
active={activeType === party}
>
{app.translator.trans('flarum-extension-manager.admin.sections.discover.party_filter.' + party)}
</Button>
))}
</Dropdown>
);
return items;
}
extensionList() {
if (!app.extensionManager.extensions.hasItems() && app.extensionManager.extensions.isLoading()) {
return <LoadingIndicator display="block" />;
}
if (!app.extensionManager.extensions.hasItems()) {
return (
<div className="ExtensionManager-DiscoverSection-list ExtensionManager-DiscoverSection-list--empty">
<InfoTile icon="fas fa-plug-circle-exclamation">
{app.translator.trans('flarum-extension-manager.admin.sections.discover.empty_results')}
</InfoTile>
</div>
);
}
return (
<div
className={classList('ExtensionManager-DiscoverSection-list', {
'loading-container': app.extensionManager.extensions.isLoading(),
})}
>
<div className="ExtensionManager-DiscoverSection-list-inner">
{app.extensionManager.extensions
.getPages()
.map((page) => page.items.map((extension: ExternalExtension) => <ExtensionCard extension={extension} key={extension.name()} />))}
</div>
{app.extensionManager.extensions.hasItems() && app.extensionManager.extensions.isLoading() && <LoadingIndicator size="large" />}
</div>
);
}
footerItems() {
const items = new ItemList<Mithril.Children>();
items.add(
'pagination',
<Pagination
total={app.extensionManager.extensions.totalItems}
perPage={app.extensionManager.extensions.pageSize}
currentPage={app.extensionManager.extensions.getLocation().page}
onChange={(page: number) => {
const current = app.extensionManager.extensions.getLocation().page;
if (current === page) {
return;
}
this.load(page);
}}
/>
);
items.add(
'premiumTermsLink',
<LinkButton
className="Button Button--link"
href="https://flarum.org/terms/premium-extensions"
external={true}
target="_blank"
icon="fas fa-circle-info"
>
{app.translator.trans('flarum-extension-manager.admin.sections.discover.premium_extension_terms')}
</LinkButton>
);
return items;
}
private setWarningDismissed(dismissed: boolean) {
this.warningsDismissed(dismissed);
localStorage.setItem('flarum-extension-manager.warningsDismissed', dismissed ? 'true' : 'false');
}
}

View File

@ -0,0 +1,292 @@
import Component, { type ComponentAttrs } from 'flarum/common/Component';
import Icon from 'flarum/common/components/Icon';
import Badge from 'flarum/common/components/Badge';
import app from 'flarum/admin/app';
import Button from 'flarum/common/components/Button';
import formatAmount from 'flarum/common/utils/formatAmount';
import { type Extension as ExtensionInfo } from 'flarum/admin/AdminApplication';
import ExternalExtension from '../models/ExternalExtension';
import { UpdatedPackage } from '../states/ControlSectionState';
import ItemList from 'flarum/common/utils/ItemList';
import type Mithril from 'mithril';
import classList from 'flarum/common/utils/classList';
import Label from './Label';
import Tooltip from 'flarum/common/components/Tooltip';
import Dropdown from 'flarum/common/components/Dropdown';
import WhyNotModal from './WhyNotModal';
import LinkButton from 'flarum/common/components/LinkButton';
export type CommonExtension = ExternalExtension | ExtensionInfo;
export interface IExtensionAttrs extends ComponentAttrs {
extension: CommonExtension;
updates?: UpdatedPackage;
onClickUpdate?:
| CallableFunction
| {
soft: CallableFunction;
hard: CallableFunction;
};
whyNotWarning?: boolean;
isCore?: boolean;
updatable?: boolean;
isDanger?: boolean;
}
export default class ExtensionCard<CustomAttrs extends IExtensionAttrs = IExtensionAttrs> extends Component<CustomAttrs> {
getExtension() {
return this.attrs.extension instanceof ExternalExtension ? this.attrs.extension.toLocalExtension() : this.attrs.extension;
}
view() {
const extension = this.getExtension();
const { isCore, isDanger } = this.attrs;
return (
<div
className={classList('ExtensionCard', {
'ExtensionCard--core': isCore,
'ExtensionCard--danger': isDanger,
})}
>
<div className="ExtensionCard-header">
{this.icon()}
<Tooltip text={extension.name}>
<h4>{extension.extra['flarum-extension'].title}</h4>
</Tooltip>
{this.attrs.extension instanceof ExternalExtension && <div className="ExtensionCard-badges">{this.badges().toArray()}</div>}
<div className="ExtensionCard-actions">{this.actionItems().toArray()}</div>
</div>
<div className="ExtensionCard-body">
<p>{extension.description}</p>
</div>
<div className="ExtensionCard-footer">
<div className="ExtensionCard-meta">{this.metaItems().toArray()}</div>
</div>
</div>
);
}
icon() {
const extension = this.getExtension();
if (this.attrs.extension instanceof ExternalExtension && extension.id in app.data.extensions) {
extension.icon = app.data.extensions[extension.id].icon;
}
const style: any = extension.icon || {};
if (
!extension.icon?.name &&
this.attrs.extension instanceof ExternalExtension &&
!(extension.id in app.data.extensions) &&
this.attrs.extension.iconUrl()
) {
style.backgroundImage = `url(${this.attrs.extension.iconUrl()})`;
}
return (
<span className="ExtensionIcon" style={extension.icon}>
{extension.icon?.name ? <Icon name={extension.icon.name} /> : null}
</span>
);
}
badges() {
const items = new ItemList<Mithril.Children>();
const extension = this.attrs.extension as ExternalExtension;
if (extension.isSupported()) {
items.add(
'compatible',
<Badge
icon="fas fa-check"
type="success"
label={app.translator.trans('flarum-extension-manager.admin.sections.discover.extension.badges.compatible')}
className="Badge--flat Badge--square"
/>
);
} else {
items.add(
'incompatible',
<Badge
icon="fas fa-times"
type="danger"
label={app.translator.trans('flarum-extension-manager.admin.sections.discover.extension.badges.incompatible')}
className="Badge--flat Badge--square"
/>
);
}
if (extension.isPremium()) {
items.add(
'premium',
<Badge
icon="fas fa-dollar-sign"
label={app.translator.trans('flarum-extension-manager.admin.sections.discover.extension.badges.premium')}
className="ExtensionCard-badge--premium Badge--flat Badge--square"
/>
);
}
if (!extension.isStable()) {
items.add(
'unstable',
<Badge
icon="fas fa-flask"
label={app.translator.trans('flarum-extension-manager.admin.sections.discover.extension.badges.unstable')}
className="Badge--flat Badge--square Badge--danger"
/>
);
}
if (extension.name().split('/')[0] === 'fof') {
items.add(
'fof',
<Badge
icon="fas fa-users"
label={app.translator.trans('flarum-extension-manager.admin.sections.discover.extension.badges.fof')}
className="Badge--flat Badge--square"
/>
);
}
if (extension.name().split('/')[0] === 'flarum') {
items.add(
'flarum',
<Badge
icon="fab fa-flarum"
label={app.translator.trans('flarum-extension-manager.admin.sections.discover.extension.badges.flarum')}
className="ExtensionCard-badge--flarum Badge--flat Badge--square"
/>
);
}
return items;
}
metaItems() {
const items = new ItemList<Mithril.Children>();
const { updates, isCore } = this.attrs;
const latestVersion = updates ? updates['latest-minor'] ?? (updates['latest-major'] && !isCore ? updates['latest-major'] : null) : null;
if (this.attrs.extension instanceof ExternalExtension) {
items.add(
'downloads',
<span>
<Icon name="fas fa-circle-down" />
{app.translator.trans('flarum-extension-manager.admin.sections.discover.extension.downloads', {
count: this.attrs.extension.downloads(),
formattedCount: formatAmount(this.attrs.extension.downloads()),
})}
</span>
);
} else {
items.add(
'version',
<div className="ExtensionCard-version">
<span className="ExtensionCard-version-current">{this.version(updates!['version'])}</span>
{latestVersion ? (
<>
<Icon name="fas fa-arrow-right" />
<Label className="ExtensionCard-version-latest" type={updates!['latest-minor'] ? 'success' : 'warning'}>
{this.version(latestVersion)}
</Label>
</>
) : null}
</div>
);
}
if (this.attrs.extension instanceof ExternalExtension) {
items.add('version', <div className="ExtensionCard-version">v{this.version(this.attrs.extension.highestVersion())}</div>);
items.add(
'link',
<LinkButton
className="Button Button--ua-reset Button--link Button--icon"
href={this.attrs.extension.httpUri()}
target="_blank"
icon="fas fa-external-link-alt"
external={true}
/>
);
}
return items;
}
actionItems() {
const items = new ItemList<Mithril.Children>();
const { updates, extension, onClickUpdate, whyNotWarning } = this.attrs;
if (extension instanceof ExternalExtension) {
if (!(extension.extensionId() in app.data.extensions)) {
items.add(
'install',
<Button
className="Button Button--icon Button--flat"
icon="fas fa-cloud-arrow-down"
onclick={() => {
app.extensionManager.control.requirePackage({ package: extension.name() });
}}
/>
);
} else {
items.add('installed', <Button className="Button Button--icon Button--flat Button--success" icon="fas fa-check-circle" disabled={true} />);
}
} else {
if (onClickUpdate && typeof onClickUpdate === 'function') {
items.add(
'update',
<Tooltip text={app.translator.trans('flarum-extension-manager.admin.extensions.update')}>
<Button
icon="fas fa-cloud-arrow-down"
className="Button Button--icon Button--flat"
onclick={onClickUpdate}
aria-label={app.translator.trans('flarum-extension-manager.admin.extensions.update')}
/>
</Tooltip>
);
} else if (onClickUpdate) {
items.add(
'update',
<Dropdown
buttonClassName="Button Button--icon Button--flat"
icon="fas fa-ellipsis"
label={app.translator.trans('flarum-extension-manager.admin.extensions.update')}
>
<Button icon="fas fa-cloud-arrow-down" onclick={onClickUpdate.soft}>
{app.translator.trans('flarum-extension-manager.admin.extensions.update_soft_label')}
</Button>
<Button icon="fas fa-rotate" onclick={onClickUpdate.hard} disabled={!updates!['direct-dependency']}>
{app.translator.trans('flarum-extension-manager.admin.extensions.update_hard_label')}
</Button>
</Dropdown>
);
}
if (whyNotWarning)
items.add(
'whyNot',
<Tooltip text={app.translator.trans('flarum-extension-manager.admin.extensions.check_why_it_failed_updating')}>
<Button
icon="fas fa-exclamation-circle"
className="Button Button--icon Button--flat Button--danger"
onclick={() => app.modal.show(WhyNotModal, { package: extension.name })}
aria-label={app.translator.trans('flarum-extension-manager.admin.extensions.check_why_it_failed_updating')}
/>
</Tooltip>
);
}
return items;
}
version(v: string): string {
return v.charAt(0) === 'v' ? v.substring(1) : v;
}
}

View File

@ -1,99 +0,0 @@
import type Mithril from 'mithril';
import app from 'flarum/admin/app';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import classList from 'flarum/common/utils/classList';
import Icon from 'flarum/common/components/Icon';
import Tooltip from 'flarum/common/components/Tooltip';
import Button from 'flarum/common/components/Button';
import { Extension } from 'flarum/admin/AdminApplication';
import { UpdatedPackage } from '../states/ControlSectionState';
import WhyNotModal from './WhyNotModal';
import Label from './Label';
import Dropdown from 'flarum/common/components/Dropdown';
export interface ExtensionItemAttrs extends ComponentAttrs {
extension: Extension;
updates: UpdatedPackage;
onClickUpdate:
| CallableFunction
| {
soft: CallableFunction;
hard: CallableFunction;
};
whyNotWarning?: boolean;
isCore?: boolean;
updatable?: boolean;
isDanger?: boolean;
}
export default class ExtensionItem<Attrs extends ExtensionItemAttrs = ExtensionItemAttrs> extends Component<Attrs> {
view(vnode: Mithril.Vnode<Attrs, this>): Mithril.Children {
const { extension, updates, onClickUpdate, whyNotWarning, isCore, isDanger } = this.attrs;
const latestVersion = updates['latest-minor'] ?? (updates['latest-major'] && !isCore ? updates['latest-major'] : null);
return (
<div
className={classList({
'ExtensionManager-extension': true,
'ExtensionManager-extension--core': isCore,
'ExtensionManager-extension--danger': isDanger,
})}
>
<div className="ExtensionManager-extension-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? <Icon name={extension.icon.name} /> : ''}
</div>
<div className="ExtensionManager-extension-info">
<div className="ExtensionManager-extension-name">{extension.extra['flarum-extension'].title}</div>
<div className="ExtensionManager-extension-version">
<span className="ExtensionManager-extension-version-current">{this.version(updates['version'])}</span>
{latestVersion ? (
<Label className="ExtensionManager-extension-version-latest" type={updates['latest-minor'] ? 'success' : 'warning'}>
{this.version(latestVersion)}
</Label>
) : null}
</div>
</div>
<div className="ExtensionManager-extension-controls">
{onClickUpdate && typeof onClickUpdate === 'function' ? (
<Tooltip text={app.translator.trans('flarum-extension-manager.admin.extensions.update')}>
<Button
icon="fas fa-arrow-alt-circle-up"
className="Button Button--icon Button--flat"
onclick={onClickUpdate}
aria-label={app.translator.trans('flarum-extension-manager.admin.extensions.update')}
/>
</Tooltip>
) : onClickUpdate ? (
<Dropdown
buttonClassName="Button Button--icon Button--flat"
icon="fas fa-arrow-alt-circle-up"
label={app.translator.trans('flarum-extension-manager.admin.extensions.update')}
>
<Button icon="fas fa-arrow-alt-circle-up" className="Button" onclick={onClickUpdate.soft}>
{app.translator.trans('flarum-extension-manager.admin.extensions.update_soft_label')}
</Button>
<Button icon="fas fa-arrow-alt-circle-up" className="Button" onclick={onClickUpdate.hard} disabled={!updates['direct-dependency']}>
{app.translator.trans('flarum-extension-manager.admin.extensions.update_hard_label')}
</Button>
</Dropdown>
) : null}
{whyNotWarning ? (
<Tooltip text={app.translator.trans('flarum-extension-manager.admin.extensions.check_why_it_failed_updating')}>
<Button
icon="fas fa-exclamation-circle"
className="Button Button--icon Button--flat Button--danger"
onclick={() => app.modal.show(WhyNotModal, { package: extension.name })}
aria-label={app.translator.trans('flarum-extension-manager.admin.extensions.check_why_it_failed_updating')}
/>
</Tooltip>
) : null}
</div>
</div>
);
}
version(v: string): string {
return v.charAt(0) === 'v' ? v.substring(1) : v;
}
}

View File

@ -23,7 +23,7 @@ export default class Installer extends Component<InstallerAttrs> {
<label htmlFor="install-extension">{app.translator.trans('flarum-extension-manager.admin.extensions.install')}</label> <label htmlFor="install-extension">{app.translator.trans('flarum-extension-manager.admin.extensions.install')}</label>
<div className="helpText"> <div className="helpText">
{app.translator.trans('flarum-extension-manager.admin.extensions.install_help', { {app.translator.trans('flarum-extension-manager.admin.extensions.install_help', {
extiverse: <a href="https://extiverse.com">extiverse.com</a>, link: <a href="https://flarum.org/extensions">flarum.org</a>,
semantic_link: <a href="https://devhints.io/semver" />, semantic_link: <a href="https://devhints.io/semver" />,
code: <code />, code: <code />,
})} })}

View File

@ -7,7 +7,7 @@ import Alert from 'flarum/common/components/Alert';
import { UpdatedPackage, UpdateState } from '../states/ControlSectionState'; import { UpdatedPackage, UpdateState } from '../states/ControlSectionState';
import WhyNotModal from './WhyNotModal'; import WhyNotModal from './WhyNotModal';
import ExtensionItem from './ExtensionItem'; import ExtensionCard from './ExtensionCard';
import classList from 'flarum/common/utils/classList'; import classList from 'flarum/common/utils/classList';
export interface MajorUpdaterAttrs extends ComponentAttrs { export interface MajorUpdaterAttrs extends ComponentAttrs {
@ -27,7 +27,6 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
} }
view(): Mithril.Children { view(): Mithril.Children {
// @todo move Form-group--danger class to core for reuse
return ( return (
<div <div
className={classList('Form-group Form-group--danger ExtensionManager-majorUpdate', { className={classList('Form-group Form-group--danger ExtensionManager-majorUpdate', {
@ -63,7 +62,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
{this.updateState.incompatibleExtensions.length ? ( {this.updateState.incompatibleExtensions.length ? (
<div className="ExtensionManager-majorUpdate-incompatibleExtensions ExtensionManager-extensions-grid"> <div className="ExtensionManager-majorUpdate-incompatibleExtensions ExtensionManager-extensions-grid">
{this.updateState.incompatibleExtensions.map((extension: string) => ( {this.updateState.incompatibleExtensions.map((extension: string) => (
<ExtensionItem <ExtensionCard
extension={app.data.extensions[extension.replace('flarum-', '').replace('flarum-ext-', '').replace('/', '-')]} extension={app.data.extensions[extension.replace('flarum-', '').replace('flarum-ext-', '').replace('/', '-')]}
updates={{}} updates={{}}
onClickUpdate={null} onClickUpdate={null}

View File

@ -1,40 +0,0 @@
import app from 'flarum/admin/app';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import Button from 'flarum/common/components/Button';
import QueueState from '../states/QueueState';
interface PaginationAttrs extends ComponentAttrs {
list: QueueState;
}
/**
* @todo make it abstract in core for reusability.
*/
export default class Pagination extends Component<PaginationAttrs> {
view() {
return (
<nav className="Pagination UserListPage-gridPagination">
<Button
disabled={!this.attrs.list.hasPrev() || app.extensionManager.control.isLoading()}
title={app.translator.trans('core.admin.users.pagination.back_button')}
onclick={() => this.attrs.list.prev()}
icon="fas fa-chevron-left"
className="Button Button--icon UserListPage-backBtn"
/>
<span className="UserListPage-pageNumber">
{app.translator.trans('core.admin.users.pagination.page_counter', {
current: this.attrs.list.pageNumber() + 1,
total: this.attrs.list.getTotalPages(),
})}
</span>
<Button
disabled={!this.attrs.list.hasNext() || app.extensionManager.control.isLoading()}
title={app.translator.trans('core.admin.users.pagination.next_button')}
onclick={() => this.attrs.list.next()}
icon="fas fa-chevron-right"
className="Button Button--icon UserListPage-nextBtn"
/>
</nav>
);
}
}

View File

@ -9,12 +9,13 @@ import Icon from 'flarum/common/components/Icon';
import ItemList from 'flarum/common/utils/ItemList'; import ItemList from 'flarum/common/utils/ItemList';
import extractText from 'flarum/common/utils/extractText'; import extractText from 'flarum/common/utils/extractText';
import Link from 'flarum/common/components/Link'; import Link from 'flarum/common/components/Link';
import Pagination from 'flarum/common/components/Pagination';
import classList from 'flarum/common/utils/classList';
import Label from './Label'; import Label from './Label';
import TaskOutputModal from './TaskOutputModal'; import TaskOutputModal from './TaskOutputModal';
import humanDuration from '../utils/humanDuration'; import humanDuration from '../utils/humanDuration';
import Task, { TaskOperations } from '../models/Task'; import Task, { TaskOperations } from '../models/Task';
import Pagination from './Pagination';
interface QueueTableColumn extends ComponentAttrs { interface QueueTableColumn extends ComponentAttrs {
label: string; label: string;
@ -30,7 +31,7 @@ export default class QueueSection extends Component<{}> {
view() { view() {
return ( return (
<section id="ExtensionManager-queueSection" className="ExtensionPage-permissions ExtensionManager-queueSection"> <section id="ExtensionManager-queueSection" className="ExtensionPage-settings ExtensionManager-queueSection">
<div className="ExtensionPage-permissions-header ExtensionManager-queueSection-header"> <div className="ExtensionPage-permissions-header ExtensionManager-queueSection-header">
<div className="container"> <div className="container">
<h2 className="ExtensionTitle">{app.translator.trans('flarum-extension-manager.admin.sections.queue.title')}</h2> <h2 className="ExtensionTitle">{app.translator.trans('flarum-extension-manager.admin.sections.queue.title')}</h2>
@ -174,32 +175,43 @@ export default class QueueSection extends Component<{}> {
return ( return (
<> <>
<table className="Table ExtensionManager-queueTable"> <div
<thead> className={classList('Table-container', {
<tr> 'loading-container': tasks && app.extensionManager.queue.isLoading(),
{columns.toArray().map((item, index) => ( })}
<th key={index}>{item.label}</th> >
))} <table className="Table ExtensionManager-queueTable">
</tr> <thead>
</thead> <tr>
<tbody> {columns.toArray().map((item, index) => (
{tasks.map((task, index) => ( <th key={index}>{item.label}</th>
<tr key={index}> ))}
{columns.toArray().map((item, index) => {
const { label, content, ...attrs } = item;
return (
<td key={index} {...attrs}>
{content(task)}
</td>
);
})}
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {tasks.map((task, index) => (
<tr key={index}>
{columns.toArray().map((item, index) => {
const { label, content, ...attrs } = item;
<Pagination list={app.extensionManager.queue} /> return (
<td key={index} {...attrs}>
{content(task)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
{tasks && app.extensionManager.queue.isLoading() && <LoadingIndicator size="large" />}
</div>
<Pagination
total={app.extensionManager.queue.getTotalItems()}
currentPage={app.extensionManager.queue.pageNumber() + 1}
perPage={app.extensionManager.queue.getPerPage()}
onChange={(page: number) => app.extensionManager.queue.goto(page)}
/>
</> </>
); );
} }

View File

@ -6,36 +6,32 @@ import ItemList from 'flarum/common/utils/ItemList';
import QueueSection from './QueueSection'; import QueueSection from './QueueSection';
import ControlSection from './ControlSection'; import ControlSection from './ControlSection';
import ConfigureComposer from './ConfigureComposer'; import ConfigureComposer from './ConfigureComposer';
import Alert from 'flarum/common/components/Alert';
import listItems from 'flarum/common/helpers/listItems';
import ConfigureAuth from './ConfigureAuth'; import ConfigureAuth from './ConfigureAuth';
import DiscoverSection from './DiscoverSection';
export default class SettingsPage extends ExtensionPage { export default class SettingsPage extends ExtensionPage {
content() { content() {
const settings = app.registry.getSettings(this.extension.id); const settings = app.registry.getSettings(this.extension.id);
const warnings = [app.translator.trans('flarum-extension-manager.admin.settings.access_warning')];
if (app.data.debugEnabled) warnings.push(app.translator.trans('flarum-extension-manager.admin.settings.debug_mode_warning'));
return ( return (
<div className="ExtensionPage-settings"> <div className="ExtensionPage-settings">
<div className="container"> <div className="container">
<div className="ExtensionManager-warnings Form-group">
<Alert className="ExtensionManager-primaryWarning" type="warning" dismissible={false}>
<ul>{listItems(warnings)}</ul>
</Alert>
</div>
{settings ? ( {settings ? (
<div className="FormSectionGroup ExtensionManager-SettingsGroups"> [
<div className="FormSection"> <div className="Form-group">
<label>{app.translator.trans('flarum-extension-manager.admin.settings.title')}</label> <label>{app.translator.trans('flarum-extension-manager.admin.sections.settings.title')}</label>
<div className="Form">{settings.map(this.buildSettingComponent.bind(this))}</div> <div className="helpText">{app.translator.trans('flarum-extension-manager.admin.sections.settings.description')}</div>
<div className="Form-group Form--controls">{this.submitButton()}</div> </div>,
</div> <div className="FormSectionGroup ExtensionManager-SettingsGroups">
<ConfigureComposer buildSettingComponent={this.buildSettingComponent} /> <div className="FormSection">
<ConfigureAuth buildSettingComponent={this.buildSettingComponent} /> <label>{app.translator.trans('flarum-extension-manager.admin.settings.title')}</label>
</div> <div className="Form">{settings.map(this.buildSettingComponent.bind(this))}</div>
<div className="Form-group Form--controls">{this.submitButton()}</div>
</div>
<ConfigureComposer buildSettingComponent={this.buildSettingComponent} />
<ConfigureAuth buildSettingComponent={this.buildSettingComponent} />
</div>,
]
) : ( ) : (
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3> <h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3>
)} )}
@ -47,9 +43,11 @@ export default class SettingsPage extends ExtensionPage {
sections(vnode: Mithril.VnodeDOM<ExtensionPageAttrs, this>): ItemList<unknown> { sections(vnode: Mithril.VnodeDOM<ExtensionPageAttrs, this>): ItemList<unknown> {
const items = super.sections(vnode); const items = super.sections(vnode);
items.setPriority('content', 10); items.add('discover', <DiscoverSection />, 15);
items.add('control', <ControlSection />, 8); items.add('control', <ControlSection />, 10);
items.setPriority('content', 8);
if (app.data.settings['flarum-extension-manager.queue_jobs'] !== '0' && app.data.settings['flarum-extension-manager.queue_jobs']) { if (app.data.settings['flarum-extension-manager.queue_jobs'] !== '0' && app.data.settings['flarum-extension-manager.queue_jobs']) {
items.add('queue', <QueueSection />, 5); items.add('queue', <QueueSection />, 5);

View File

@ -4,9 +4,9 @@ import Button from 'flarum/common/components/Button';
import humanTime from 'flarum/common/helpers/humanTime'; import humanTime from 'flarum/common/helpers/humanTime';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import MajorUpdater from './MajorUpdater'; import MajorUpdater from './MajorUpdater';
import ExtensionItem from './ExtensionItem';
import { Extension } from 'flarum/admin/AdminApplication';
import ItemList from 'flarum/common/utils/ItemList'; import ItemList from 'flarum/common/utils/ItemList';
import InfoTile from 'flarum/common/components/InfoTile';
import ExtensionCard from './ExtensionCard';
export interface IUpdaterAttrs extends ComponentAttrs {} export interface IUpdaterAttrs extends ComponentAttrs {}
@ -59,8 +59,8 @@ export default class Updater extends Component<IUpdaterAttrs> {
if (!(state.extensionUpdates.length || hasMinorCoreUpdate)) { if (!(state.extensionUpdates.length || hasMinorCoreUpdate)) {
return ( return (
<div className="ExtensionManager-extensions"> <div className="ExtensionManager-extensions ExtensionManager-extensions--empty">
<span className="helpText">{app.translator.trans('flarum-extension-manager.admin.updater.up_to_date')}</span> <InfoTile icon="fas fa-plug-circle-check">{app.translator.trans('flarum-extension-manager.admin.updater.up_to_date')}</InfoTile>
</div> </div>
); );
} }
@ -69,7 +69,7 @@ export default class Updater extends Component<IUpdaterAttrs> {
<div className="ExtensionManager-extensions"> <div className="ExtensionManager-extensions">
<div className="ExtensionManager-extensions-grid"> <div className="ExtensionManager-extensions-grid">
{hasMinorCoreUpdate ? ( {hasMinorCoreUpdate ? (
<ExtensionItem <ExtensionCard
extension={state.coreUpdate!.extension} extension={state.coreUpdate!.extension}
updates={state.coreUpdate!.package} updates={state.coreUpdate!.package}
isCore={true} isCore={true}
@ -77,8 +77,8 @@ export default class Updater extends Component<IUpdaterAttrs> {
whyNotWarning={state.lastUpdateRun.limitedPackages().includes('flarum/core')} whyNotWarning={state.lastUpdateRun.limitedPackages().includes('flarum/core')}
/> />
) : null} ) : null}
{state.extensionUpdates.map((extension: Extension) => ( {state.extensionUpdates.map((extension) => (
<ExtensionItem <ExtensionCard
extension={extension} extension={extension}
updates={state.packageUpdates[extension.id]} updates={state.packageUpdates[extension.id]}
onClickUpdate={{ onClickUpdate={{

View File

@ -2,8 +2,14 @@ import Extend from 'flarum/common/extenders';
import app from 'flarum/admin/app'; import app from 'flarum/admin/app';
import extractText from 'flarum/common/utils/extractText'; import extractText from 'flarum/common/utils/extractText';
import SettingsPage from './components/SettingsPage'; import SettingsPage from './components/SettingsPage';
import Task from './models/Task';
import ExternalExtension from './models/ExternalExtension';
export default [ export default [
new Extend.Store() //
.add('extension-manager-tasks', Task)
.add('external-extensions', ExternalExtension),
new Extend.Admin() new Extend.Admin()
.setting(() => ({ .setting(() => ({
setting: 'flarum-extension-manager.queue_jobs', setting: 'flarum-extension-manager.queue_jobs',

View File

@ -4,7 +4,6 @@ import ExtensionPage from 'flarum/admin/components/ExtensionPage';
import Button from 'flarum/common/components/Button'; import Button from 'flarum/common/components/Button';
import LoadingModal from 'flarum/admin/components/LoadingModal'; import LoadingModal from 'flarum/admin/components/LoadingModal';
import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled'; import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled';
import Task from './models/Task';
import jumpToQueue from './utils/jumpToQueue'; import jumpToQueue from './utils/jumpToQueue';
import { AsyncBackendResponse } from './shims'; import { AsyncBackendResponse } from './shims';
import ExtensionManagerState from './states/ExtensionManagerState'; import ExtensionManagerState from './states/ExtensionManagerState';
@ -12,8 +11,6 @@ import ExtensionManagerState from './states/ExtensionManagerState';
export { default as extend } from './extend'; export { default as extend } from './extend';
app.initializers.add('flarum-extension-manager', (app) => { app.initializers.add('flarum-extension-manager', (app) => {
app.store.models['extension-manager-tasks'] = Task;
app.extensionManager = new ExtensionManagerState(); app.extensionManager = new ExtensionManagerState();
if (app.data['flarum-extension-manager.using_sync_queue']) { if (app.data['flarum-extension-manager.using_sync_queue']) {

View File

@ -0,0 +1,73 @@
import Model from 'flarum/common/Model';
import app from 'flarum/admin/app';
import type { Extension } from 'flarum/admin/AdminApplication';
export default class ExternalExtension extends Model {
extensionId = Model.attribute<string>('extensionId');
name = Model.attribute<string>('name');
title = Model.attribute<string>('title');
description = Model.attribute<string>('description');
iconUrl = Model.attribute<string>('iconUrl');
icon = Model.attribute<{
name: string;
[key: string]: string;
}>('icon');
highestVersion = Model.attribute<string>('highestVersion');
httpUri = Model.attribute<string>('httpUri');
discussUri = Model.attribute<string>('discussUri');
vendor = Model.attribute<string>('vendor');
isPremium = Model.attribute<boolean>('isPremium');
isLocale = Model.attribute<boolean>('isLocale');
locale = Model.attribute<string>('locale');
latestFlarumVersionSupported = Model.attribute<string>('latestFlarumVersionSupported');
downloads = Model.attribute<number>('downloads');
readonly installed = false;
public isSupported(): boolean {
const currentVersion = app.data.settings.version;
const latestCompatibleVersion = this.latestFlarumVersionSupported();
// If stability is not the same, it's not compatible.
if (currentVersion.split('-')[1] !== latestCompatibleVersion.split('-')[1]) {
return false;
}
// Minor versions are compatible.
return currentVersion.split('.')[0] === latestCompatibleVersion.split('.')[0];
}
public isStable(): boolean {
const split = this.highestVersion().split('-');
if (split.length === 1) {
return true;
}
const stability = split[1].split('.');
return stability[0] === 'stable';
}
public toLocalExtension(): Extension {
return {
id: this.extensionId(),
name: this.name(),
version: this.highestVersion(),
description: this.description(),
icon: this.icon() || {
name: 'fas fa-box-open',
backgroundColor: '#117187',
color: '#fff',
},
links: {
discuss: this.discussUri(),
website: this.httpUri(),
},
extra: {
'flarum-extension': {
title: this.title(),
},
},
};
}
}

View File

@ -0,0 +1,32 @@
import app from 'flarum/admin/app';
import PaginatedListState, { SortMap } from 'flarum/common/states/PaginatedListState';
import ExternalExtension from '../models/ExternalExtension';
export default class ExtensionListState extends PaginatedListState<ExternalExtension> {
get type(): string {
return 'external-extensions';
}
constructor() {
super(
{
sort: '-downloads',
},
1,
12
);
}
sortMap(): SortMap {
return {
'-createdAt': {
sort: '-createdAt',
label: app.translator.trans('flarum-extension-manager.admin.sections.discover.sort.latest', {}, true),
},
'-downloads': {
sort: '-downloads',
label: app.translator.trans('flarum-extension-manager.admin.sections.discover.sort.top', {}, true),
},
};
}
}

View File

@ -1,7 +1,9 @@
import QueueState from './QueueState'; import QueueState from './QueueState';
import ControlSectionState from './ControlSectionState'; import ControlSectionState from './ControlSectionState';
import ExtensionListState from './ExtensionListState';
export default class ExtensionManagerState { export default class ExtensionManagerState {
public queue: QueueState = new QueueState(); public queue: QueueState = new QueueState();
public control: ControlSectionState = new ControlSectionState(); public control: ControlSectionState = new ControlSectionState();
public extensions: ExtensionListState = new ExtensionListState();
} }

View File

@ -8,9 +8,10 @@ export default class QueueState {
private limit = 20; private limit = 20;
private offset = 0; private offset = 0;
private total = 0; private total = 0;
private loading = false;
load(params?: ApiQueryParamsPlural, actionTaken = false): Promise<Task[]> { load(params?: ApiQueryParamsPlural, actionTaken = false): Promise<Task[]> {
this.tasks = null; this.loading = true;
params = { params = {
page: { page: {
limit: this.limit, limit: this.limit,
@ -22,7 +23,7 @@ export default class QueueState {
return app.store.find<Task[]>('extension-manager-tasks', params || {}).then((data) => { return app.store.find<Task[]>('extension-manager-tasks', params || {}).then((data) => {
this.tasks = data; this.tasks = data;
this.total = data.payload.meta?.total || 0; this.total = data.payload.meta?.page?.total || 0;
m.redraw(); m.redraw();
@ -40,14 +41,24 @@ export default class QueueState {
app.extensionManager.control.setLoading(null); app.extensionManager.control.setLoading(null);
} }
this.loading = false;
return data; return data;
}); });
} }
isLoading() {
return this.loading;
}
getItems() { getItems() {
return this.tasks; return this.tasks;
} }
getTotalItems() {
return this.total;
}
getTotalPages(): number { getTotalPages(): number {
return Math.ceil(this.total / this.limit); return Math.ceil(this.total / this.limit);
} }
@ -56,6 +67,10 @@ export default class QueueState {
return Math.ceil(this.offset / this.limit); return Math.ceil(this.offset / this.limit);
} }
getPerPage() {
return this.limit;
}
hasPrev(): boolean { hasPrev(): boolean {
return this.pageNumber() !== 0; return this.pageNumber() !== 0;
} }
@ -78,6 +93,11 @@ export default class QueueState {
} }
} }
goto(page: number): void {
this.offset = (page - 1) * this.limit;
this.load();
}
pollQueue(actionTaken = false): void { pollQueue(actionTaken = false): void {
if (this.polling) { if (this.polling) {
clearTimeout(this.polling); clearTimeout(this.polling);

View File

@ -2,6 +2,8 @@
@import "admin/TaskOutputModal"; @import "admin/TaskOutputModal";
@import "admin/QueueSection"; @import "admin/QueueSection";
@import "admin/ControlSection"; @import "admin/ControlSection";
@import "admin/DiscoverSection";
@import "admin/ExtensionCard";
.ExtensionManager-controlSection { .ExtensionManager-controlSection {
> .container { > .container {
@ -16,12 +18,6 @@
gap: 4px; gap: 4px;
} }
.Form-group--danger {
border: 2px solid var(--alert-error-bg);
border-radius: var(--border-radius);
background-color: transparent;
}
.ButtonGroup--full { .ButtonGroup--full {
width: 100%; width: 100%;
display: flex; display: flex;
@ -47,6 +43,7 @@
column-count: 3; column-count: 3;
column-gap: 30px; column-gap: 30px;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 24px;
.FormSection { .FormSection {
min-width: 300px; min-width: 300px;
@ -63,11 +60,3 @@
} }
} }
} }
.ExtensionManager-warnings {
margin-bottom: 24px;
> .Alert {
width: 100%;
}
}

View File

@ -25,62 +25,6 @@
} }
} }
.ExtensionManager-extension {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--control-bg);
padding: 8px;
border-radius: var(--border-radius);
&-controls {
margin-left: auto;
}
&-icon {
--size: 40px;
}
&-name {
font-weight: bold;
}
&-version {
display: flex;
align-items: center;
gap: 8px;
&-latest {
text-transform: lowercase;
padding: 2px 6px;
font-size: 0.7rem;
}
}
&--core {
--bg-hover: darken(#e7672e, 5);
background-color: #e7672e;
color: #fff;
--button-color: #fff;
--button-bg-hover: var(--bg-hover);
.Button--danger {
color: #fff;
--button-bg-hover: var(--bg-hover);
}
}
&--core &-icon {
background-size: 100%;
background-color: transparent;
filter: grayscale(1) brightness(3.5);
}
&--danger {
background-color: var(--control-danger-bg);
}
}
.ExtensionManager-majorUpdate { .ExtensionManager-majorUpdate {
--space: 16px; --space: 16px;
padding: var(--space); padding: var(--space);
@ -169,3 +113,8 @@
.ExtensionManager-primaryWarning ul { .ExtensionManager-primaryWarning ul {
margin: 0; margin: 0;
} }
.ExtensionManager-extensions--empty {
border: 1px solid var(--control-bg);
border-radius: var(--border-radius);
}

View File

@ -0,0 +1,51 @@
.ExtensionManager-DiscoverSection .Tabs-divider {
margin-left: -30px;
margin-right: -30px;
}
.ExtensionManager-DiscoverSection-list-inner {
--cards: 1;
--gap: 24px;
display: grid;
grid-template-columns: repeat(auto-fit, calc((100% / var(--cards)) - (var(--gap) - (var(--gap) / var(--cards)))));
gap: var(--gap);
&--empty {
display: block;
}
@media @tablet-up {
--cards: 2;
}
@media @desktop-xl {
--cards: 3;
}
@media @desktop-xxl {
--cards: 4;
}
@media @desktop-xxxl {
--cards: 5;
}
}
.ExtensionManager-DiscoverSection-toolbar {
margin-bottom: 14px;
display: flex;
justify-content: space-between;
&-primary, &-secondary {
display: flex;
gap: 8px;
}
}
.ExtensionManager-DiscoverSection-footer {
margin: 24px 0 0;
> * {
margin-top: 16px;
}
}

View File

@ -0,0 +1,128 @@
.ExtensionCard {
display: flex;
flex-direction: column;
gap: 12px;
border-radius: var(--border-radius);
padding: 12px;
border: 1px solid var(--control-bg);
color: var(--control-color);
}
.ExtensionCard-header {
display: flex;
align-items: center;
gap: 12px;
}
.ExtensionCard-header .ExtensionIcon {
--size: 36px;
background-color: transparent;
flex-shrink: 0;
}
.ExtensionCard-badges {
display: flex;
gap: 4px;
}
.ExtensionCard-badge--premium {
--badge-bg: #FBDB33;
--badge-color: #4B4940;
}
.ExtensionCard--core .ExtensionIcon, .ExtensionCard-badge--flarum::before {
filter: grayscale(1) brightness(3.5);
}
.ExtensionCard-badge--flarum {
background-color: #e7672e;
position: relative;
&::before {
background-image: url('../extensions/flarum-extension-manager/flarum.svg');
background-size: 100%;
content: '';
display: block;
position: absolute;
width: 80%;
height: 80%;
}
}
.ExtensionCard-actions {
margin-inline-start: auto;
}
.ExtensionCard-header h4 {
color: var(--text-color);
font-size: 14px;
margin: 0;
max-width: 45%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ExtensionCard-meta {
display: flex;
gap: 12px;
}
.ExtensionCard-badges .Badge {
--size: 18px;
}
.ExtensionCard-meta > * {
display: flex;
align-items: center;
gap: 6px;
}
.ExtensionCard-body {
margin-bottom: 6px;
flex-grow: 1;
p {
margin-bottom: 0;
}
}
.ExtensionCard {
&-version {
display: flex;
align-items: center;
gap: 8px;
&-latest {
text-transform: lowercase;
padding: 2px 6px;
font-size: 0.7rem;
}
}
&--core {
--bg-hover: #e2571a;
--text-color: #fff;
--button-color: #fff;
--button-bg-hover: var(--bg-hover);
background-color: #e7672e;
border-color: var(--bg-hover);
color: #fff;
.Button--danger {
color: #fff;
--button-bg-hover: var(--bg-hover);
}
}
&--core .ExtensionIcon {
background-size: 100%;
background-color: transparent;
}
&--danger {
background-color: var(--control-danger-bg);
border-color: var(--control-danger-bg-hover);
color: var(--control-danger-color);
}
}

View File

@ -4,7 +4,7 @@ flarum-extension-manager:
add_label: New authentication method add_label: New authentication method
add_modal: add_modal:
host_label: Host host_label: Host
host_placeholder: "example: extiverse.com" host_placeholder: "example: bearer.flarum.org"
submit_button: Submit submit_button: Submit
token_label: Token token_label: Token
type_label: Type type_label: Type
@ -70,7 +70,7 @@ flarum-extension-manager:
install: Install a new extension install: Install a new extension
install_help: > install_help: >
Fill in the extension package name to proceed. You can specify a <semantic_link>semantic version</semantic_link> using the format <code>vendor/package-name:version</code>. Fill in the extension package name to proceed. You can specify a <semantic_link>semantic version</semantic_link> using the format <code>vendor/package-name:version</code>.
Visit {extiverse} to browse extensions. Visit {link} to browse extensions.
proceed: Proceed proceed: Proceed
remove: Uninstall remove: Uninstall
successful_install: "{extension} was installed successfully, redirecting.." successful_install: "{extension} was installed successfully, redirecting.."
@ -100,8 +100,34 @@ flarum-extension-manager:
content: This will also update any other extensions/packages with availabe updates. content: This will also update any other extensions/packages with availabe updates.
sections: sections:
control: discover:
title: Manager description: Add new features and integrations to your Flarum forum with extensions.
empty_results: Looks like there are no extensions available.
extension:
badges:
compatible: Compatible
fof: Friends Of Flarum
flarum: Flarum
incompatible: Incompatible
premium: Premium
unstable: Unstable
downloads: "{count, plural, one {{formattedCount} download} other {{formattedCount} downloads}}"
premium_extension_terms: Premium extension terms
search: Search...
server_error: Service currently unavailable, please try again later.
sort:
toggle_dropdown_accessible_label: Toggle sort options
top: Most Downloads
latest: Latest
tabs:
discover: Discover
extensions: Extensions
languages: Languages
themes: Themes
title: Extensions
party_filter:
all: All
premium: Premium
queue: queue:
columns: columns:
details: Details details: Details
@ -133,6 +159,9 @@ flarum-extension-manager:
running: Running running: Running
task_just_started: Task just started task_just_started: Task just started
title: Queue title: Queue
settings:
title: Options and Queue
description: Configure the extension manager and check operations in the queue.
settings: settings:
title: => core.ref.settings title: => core.ref.settings
@ -148,7 +177,7 @@ flarum-extension-manager:
Set to 0 to keep all tasks. Set to 0 to keep all tasks.
updater: updater:
up_to_date: Everything is up to date! up_to_date: No pending updates.
check_for_updates: Check for updates check_for_updates: Check for updates
flarum: Flarum Core flarum: Flarum Core
global_update_successful: Successfully updated all packages. global_update_successful: Successfully updated all packages.

View File

@ -0,0 +1,193 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\ExtensionManager\Api\Resource;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource\AbstractResource;
use Flarum\Api\Resource\Contracts\Countable;
use Flarum\Api\Resource\Contracts\Listable;
use Flarum\Api\Resource\Contracts\Paginatable;
use Flarum\Api\Schema;
use Flarum\ExtensionManager\Api\Schema\SortColumn;
use Flarum\ExtensionManager\Exception\CannotFetchExternalExtension;
use Flarum\ExtensionManager\External\Extension;
use Flarum\ExtensionManager\External\RequestWrapper;
use Flarum\Foundation\Application;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
use Tobyz\JsonApiServer\Schema\CustomFilter;
class ExternalExtensionResource extends AbstractResource implements Listable, Paginatable, Countable
{
protected int|null $totalResults = null;
public function __construct(
protected Repository $cache,
) {
}
public function type(): string
{
return 'external-extensions';
}
public function endpoints(): array
{
return [
Endpoint\Index::make()
->authenticated()
->admin()
->paginate(12, 20),
];
}
public function fields(): array
{
return [
Schema\Str::make('extensionId')
->get(fn (Extension $extension) => $extension->extensionId()),
Schema\Str::make('name'),
Schema\Str::make('title'),
Schema\Str::make('description'),
Schema\Str::make('iconUrl')
->property('icon_url'),
Schema\Arr::make('icon'),
Schema\Str::make('highestVersion')
->property('highest_version'),
Schema\Str::make('httpUri')
->property('http_uri'),
Schema\Str::make('discussUri')
->property('discuss_uri'),
Schema\Str::make('vendor'),
Schema\Boolean::make('isPremium')
->property('is_premium'),
Schema\Boolean::make('isLocale')
->property('is_locale'),
Schema\Str::make('locale'),
Schema\Str::make('latestFlarumVersionSupported')
->property('latest_flarum_version_supported'),
Schema\Boolean::make('compatibleWithLatestFlarum')
->property('compatible_with_latest_flarum'),
Schema\Integer::make('downloads'),
];
}
public function sorts(): array
{
return [
SortColumn::make('createdAt'),
SortColumn::make('downloads'),
];
}
public function filters(): array
{
return [
CustomFilter::make('type', function (object $query, ?string $value) {
if ($value) {
/** @var RequestWrapper $query */
$query->withQueryParams([
'filter' => [
'type' => $value,
],
]);
}
}),
CustomFilter::make('is', function (object $query, null|string|array $value) {
if ($value) {
/** @var RequestWrapper $query */
$query->withQueryParams([
'filter' => [
'is' => (array) $value,
],
]);
}
}),
CustomFilter::make('q', function (object $query, ?string $value) {
if ($value) {
/** @var RequestWrapper $query */
$query->withQueryParams([
'filter' => [
'q' => $value,
],
]);
}
}),
];
}
public function query(Context $context): object
{
return (new RequestWrapper($this->cache, 'https://flarum.org/api/extensions', 'GET', [
'Accept' => 'application/json',
]))->withQueryParams([
'filter' => [
'compatible-with' => Application::VERSION,
],
]);
}
public function paginate(object $query, OffsetPagination $pagination): void
{
/** @var RequestWrapper $query */
$query->withQueryParams([
'page' => [
'offset' => $pagination->offset,
'limit' => $pagination->limit,
],
]);
}
public function results(object $query, Context $context): iterable
{
/** @var RequestWrapper $query */
$json = $query->cache(function (RequestWrapper $query) {
try {
$response = (new Client())->send($query->getRequest());
} catch (GuzzleException) {
throw new CannotFetchExternalExtension();
}
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
throw new CannotFetchExternalExtension();
}
return json_decode($response->getBody()->getContents(), true);
});
$this->totalResults = $json['meta']['page']['total'] ?? null;
return (new Collection($json['data']))
->map(function (array $data) {
$attributes = $data['attributes'];
$attributes = array_combine(
array_map(fn ($key) => Str::snake(Str::camel($key)), array_keys($attributes)),
array_values($attributes)
);
return new Extension(array_merge([
'id' => $data['id'],
], $attributes));
});
}
public function count(object $query, Context $context): ?int
{
return $this->totalResults;
}
}

View File

@ -19,7 +19,7 @@ class TaskResource extends AbstractDatabaseResource
{ {
public function type(): string public function type(): string
{ {
return 'package-manager-tasks'; return 'extension-manager-tasks';
} }
public function model(): string public function model(): string

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\ExtensionManager\Api\Schema;
use Flarum\ExtensionManager\External\RequestWrapper;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Schema\Sort;
class SortColumn extends Sort
{
public static function make(string $name): static
{
return new static($name);
}
public function apply(object $query, string $direction, Context $context): void
{
/** @var RequestWrapper $query */
$query->withQueryParams([
'sort' => $direction === 'desc' ? "-$this->name" : $this->name,
]);
}
}

View File

@ -9,6 +9,7 @@
namespace Flarum\ExtensionManager\Command; namespace Flarum\ExtensionManager\Command;
use Flarum\Extension\Extension;
use Flarum\Extension\ExtensionManager; use Flarum\Extension\ExtensionManager;
use Flarum\ExtensionManager\Composer\ComposerAdapter; use Flarum\ExtensionManager\Composer\ComposerAdapter;
use Flarum\ExtensionManager\Composer\ComposerJson; use Flarum\ExtensionManager\Composer\ComposerJson;
@ -70,7 +71,7 @@ class CheckForUpdatesHandler
foreach ($installed as $mainPackageUpdate) { foreach ($installed as $mainPackageUpdate) {
// Skip if not an extension // Skip if not an extension
if (! $this->extensions->getExtension(Util::nameToId($mainPackageUpdate['name']))) { if (! $this->extensions->getExtension(Extension::nameToId($mainPackageUpdate['name']))) {
continue; continue;
} }

View File

@ -9,13 +9,13 @@
namespace Flarum\ExtensionManager\Command; namespace Flarum\ExtensionManager\Command;
use Flarum\Extension\Extension;
use Flarum\Extension\ExtensionManager; use Flarum\Extension\ExtensionManager;
use Flarum\ExtensionManager\Composer\ComposerAdapter; use Flarum\ExtensionManager\Composer\ComposerAdapter;
use Flarum\ExtensionManager\Exception\ComposerRequireFailedException; use Flarum\ExtensionManager\Exception\ComposerRequireFailedException;
use Flarum\ExtensionManager\Exception\ExtensionAlreadyInstalledException; use Flarum\ExtensionManager\Exception\ExtensionAlreadyInstalledException;
use Flarum\ExtensionManager\Extension\Event\Installed; use Flarum\ExtensionManager\Extension\Event\Installed;
use Flarum\ExtensionManager\RequirePackageValidator; use Flarum\ExtensionManager\RequirePackageValidator;
use Flarum\ExtensionManager\Support\Util;
use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Input\StringInput;
@ -39,7 +39,7 @@ class RequireExtensionHandler
$this->validator->assertValid(['package' => $command->package]); $this->validator->assertValid(['package' => $command->package]);
$extensionId = Util::nameToId($command->package); $extensionId = Extension::nameToId($command->package);
$extension = $this->extensions->getExtension($extensionId); $extension = $this->extensions->getExtension($extensionId);
if (! empty($extension)) { if (! empty($extension)) {

View File

@ -9,8 +9,8 @@
namespace Flarum\ExtensionManager\Composer; namespace Flarum\ExtensionManager\Composer;
use Flarum\Extension\Extension;
use Flarum\Extension\ExtensionManager; use Flarum\Extension\ExtensionManager;
use Flarum\ExtensionManager\Support\Util;
use Flarum\Foundation\Paths; use Flarum\Foundation\Paths;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -39,7 +39,7 @@ class ComposerJson
} }
// Only extensions can all be set to * versioning. // Only extensions can all be set to * versioning.
if (! $this->extensions->getExtension(Util::nameToId($packageName))) { if (! $this->extensions->getExtension(Extension::nameToId($packageName))) {
continue; continue;
} }

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\ExtensionManager\Exception;
use Exception;
use Flarum\Foundation\KnownError;
class CannotFetchExternalExtension extends Exception implements KnownError
{
public function getType(): string
{
return 'cannot_fetch_external_extension';
}
}

View File

@ -1,21 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Extension;
class ExtensionUtils
{
public static function nameToId(string $name): string
{
[$vendor, $package] = explode('/', $name);
$package = str_replace(['flarum-ext-', 'flarum-'], '', $package);
return "$vendor-$package";
}
}

View File

@ -0,0 +1,102 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\ExtensionManager\External;
/**
* @property string $name
* @property string $title
* @property string $description
* @property string $icon_url
* @property array $icon
* @property string $license
* @property string $highest_version
* @property string $http_uri
* @property string $discuss_uri
* @property string $vendor
* @property bool $is_premium
* @property bool $is_locale
* @property string $locale
* @property string $latest_flarum_version_supported
* @property bool $compatible_with_latest_flarum
* @property bool $listed_privately
* @property int $downloads
*/
class Extension
{
protected array $attributes = [];
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
protected function casts(): array
{
return [
'is_premium' => 'bool',
'is_locale' => 'bool',
'compatible_with_latest_flarum' => 'bool',
'listed_privately' => 'bool',
'downloads' => 'int',
];
}
public function extensionId(): string
{
return \Flarum\Extension\Extension::nameToId($this->name);
}
public function getAttribute(string $key): mixed
{
if (array_key_exists($key, $this->attributes)) {
return $this->castAttribute($key, $this->attributes[$key]);
}
return null;
}
public function setAttribute(string $key, mixed $value): void
{
$this->attributes[$key] = $value;
}
protected function castAttribute(string $key, mixed $value): mixed
{
if (array_key_exists($key, $this->casts())) {
$cast = $this->casts()[$key];
if (is_string($cast) && function_exists($func = $cast.'val')) {
return $func($value);
}
}
return $value;
}
public function __get(string $key): mixed
{
return $this->getAttribute($key);
}
public function __set(string $key, mixed $value): void
{
$this->setAttribute($key, $value);
}
public function __isset(string $key): bool
{
return isset($this->attributes[$key]);
}
public function __unset(string $key): void
{
unset($this->attributes[$key]);
}
}

View File

@ -0,0 +1,73 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\ExtensionManager\External;
use Closure;
use Illuminate\Contracts\Cache\Repository;
use Laminas\Diactoros\Request;
/**
* @mixin Request
*/
class RequestWrapper
{
protected Request $request;
protected array $queryParams = [];
protected static int $ttl = 300; // 5 minutes
public function __construct(
protected Repository $cache,
string $uri,
string $method,
array $headers = [],
) {
$this->request = new Request($uri, $method, 'php://temp', $headers);
}
public function withQueryParams(array $queryParams): static
{
$this->queryParams = array_merge_recursive($this->queryParams, $queryParams);
$newUri = $this->request->getUri()->withQuery(http_build_query($this->queryParams));
$new = $this->request->withUri($newUri);
$this->request = $new;
return $this;
}
public function __call(string $name, array $arguments): static
{
$new = $this->request->$name(...$arguments);
$this->request = $new;
return $this;
}
public function getRequest(): Request
{
return $this->request;
}
protected function cacheKey(): string
{
return md5($this->request->getUri()->__toString());
}
public function cache(Closure $callback): array
{
// We will not cache if there is a search query (filter[q]) in the request.
if (isset($this->queryParams['filter']['q'])) {
return $callback($this);
}
return $this->cache->remember($this->cacheKey(), static::$ttl, fn () => $callback($this));
}
}

View File

@ -15,14 +15,6 @@ use Symfony\Component\Console\Input\InputInterface;
class Util class Util
{ {
public static function nameToId(string $name): string
{
[$vendor, $package] = explode('/', $name);
$package = str_replace(['flarum-ext-', 'flarum-'], '', $package);
return "$vendor-$package";
}
public static function isMajorUpdate(string $currentVersion, string $latestVersion): bool public static function isMajorUpdate(string $currentVersion, string $latestVersion): bool
{ {
// Drop any v prefixes // Drop any v prefixes

View File

@ -9,9 +9,9 @@
namespace Flarum\ExtensionManager\Tests\integration; namespace Flarum\ExtensionManager\Tests\integration;
use Flarum\Extension\Extension;
use Flarum\ExtensionManager\Composer\ComposerAdapter; use Flarum\ExtensionManager\Composer\ComposerAdapter;
use Flarum\ExtensionManager\Composer\ComposerJson; use Flarum\ExtensionManager\Composer\ComposerJson;
use Flarum\ExtensionManager\Support\Util;
use Flarum\Foundation\Paths; use Flarum\Foundation\Paths;
use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -45,7 +45,7 @@ class TestCase extends \Flarum\Testing\integration\TestCase
return $package['type'] === 'flarum-extension'; return $package['type'] === 'flarum-extension';
}); });
$installedExtensionIds = array_map(function (string $name) { $installedExtensionIds = array_map(function (string $name) {
return Util::nameToId($name); return Extension::nameToId($name);
}, Arr::pluck($installedExtensions, 'name')); }, Arr::pluck($installedExtensions, 'name'));
if ($exists) { if ($exists) {

View File

@ -59,7 +59,7 @@ class ListTest extends TestCase
$data = json_decode($response->getBody()->getContents(), true)['data']; $data = json_decode($response->getBody()->getContents(), true)['data'];
$ids = Arr::pluck($data, 'id'); $ids = Arr::pluck($data, 'id');
$this->assertEquals(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14'], $ids); $this->assertEqualsCanonicalizing(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14'], $ids);
} }
#[Test] #[Test]

View File

@ -16,13 +16,14 @@ import MailPage from './components/MailPage';
import AdvancedPage from './components/AdvancedPage'; import AdvancedPage from './components/AdvancedPage';
import PermissionsPage from './components/PermissionsPage'; import PermissionsPage from './components/PermissionsPage';
export type Extension = { export interface Extension {
id: string; id: string;
name: string; name: string;
version: string; version: string;
description?: string; description?: string;
icon?: { icon?: {
name: string; name: string;
[key: string]: string;
}; };
links: { links: {
authors?: { authors?: {
@ -44,7 +45,7 @@ export type Extension = {
}; };
}; };
require?: Record<string, string>; require?: Record<string, string>;
}; }
export enum DatabaseDriver { export enum DatabaseDriver {
MySQL = 'MySQL', MySQL = 'MySQL',

View File

@ -19,6 +19,7 @@ import CreateUserModal from './CreateUserModal';
import Icon from '../../common/components/Icon'; import Icon from '../../common/components/Icon';
import Input from '../../common/components/Input'; import Input from '../../common/components/Input';
import GambitsAutocompleteDropdown from '../../common/components/GambitsAutocompleteDropdown'; import GambitsAutocompleteDropdown from '../../common/components/GambitsAutocompleteDropdown';
import Pagination from '../../common/components/Pagination';
type ColumnData = { type ColumnData = {
/** /**
@ -78,11 +79,6 @@ export default class UserListPage extends AdminPage {
*/ */
private pageData: User[] | undefined = undefined; private pageData: User[] | undefined = undefined;
/**
* Are there more users available?
*/
private moreData: boolean = false;
private isLoadingPage: boolean = false; private isLoadingPage: boolean = false;
oninit(vnode: Mithril.Vnode<IPageAttrs, this>) { oninit(vnode: Mithril.Vnode<IPageAttrs, this>) {
@ -160,76 +156,13 @@ export default class UserListPage extends AdminPage {
{/* Loading spinner that shows when a new page is being loaded */} {/* Loading spinner that shows when a new page is being loaded */}
{this.isLoadingPage && <LoadingIndicator size="large" />} {this.isLoadingPage && <LoadingIndicator size="large" />}
</section>, </section>,
<nav className="UserListPage-gridPagination"> <Pagination
<Button currentPage={this.pageNumber + 1}
disabled={this.pageNumber === 0} loadingPageNumber={this.loadingPageNumber + 1}
title={app.translator.trans('core.admin.users.pagination.first_page_button')} total={this.userCount}
onclick={this.goToPage.bind(this, 1)} perPage={this.numPerPage}
icon="fas fa-step-backward" onChange={this.goToPage.bind(this)}
className="Button Button--icon UserListPage-firstPageBtn" />,
/>
<Button
disabled={this.pageNumber === 0}
title={app.translator.trans('core.admin.users.pagination.back_button')}
onclick={this.previousPage.bind(this)}
icon="fas fa-chevron-left"
className="Button Button--icon UserListPage-backBtn"
/>
<span className="UserListPage-pageNumber">
{app.translator.trans('core.admin.users.pagination.page_counter', {
// https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/
current: (
<input
type="text"
inputmode="numeric"
pattern="[0-9]*"
value={this.loadingPageNumber + 1}
aria-label={extractText(app.translator.trans('core.admin.users.pagination.go_to_page_textbox_a11y_label'))}
autocomplete="off"
className="FormControl UserListPage-pageNumberInput"
onchange={(e: InputEvent) => {
const target = e.target as HTMLInputElement;
let pageNumber = parseInt(target.value);
if (isNaN(pageNumber)) {
// Invalid value, reset to current page
target.value = (this.pageNumber + 1).toString();
return;
}
if (pageNumber < 1) {
// Lower constraint
pageNumber = 1;
} else if (pageNumber > this.getTotalPageCount()) {
// Upper constraint
pageNumber = this.getTotalPageCount();
}
target.value = pageNumber.toString();
this.goToPage(pageNumber);
}}
/>
),
currentNum: this.pageNumber + 1,
total: this.getTotalPageCount(),
})}
</span>
<Button
disabled={!this.moreData}
title={app.translator.trans('core.admin.users.pagination.next_button')}
onclick={this.nextPage.bind(this)}
icon="fas fa-chevron-right"
className="Button Button--icon UserListPage-nextBtn"
/>
<Button
disabled={!this.moreData}
title={app.translator.trans('core.admin.users.pagination.last_page_button')}
onclick={this.goToPage.bind(this, this.getTotalPageCount())}
icon="fas fa-step-forward"
className="Button Button--icon UserListPage-lastPageBtn"
/>
</nav>,
]; ];
} }
@ -482,9 +415,6 @@ export default class UserListPage extends AdminPage {
}, },
}) })
.then((apiData) => { .then((apiData) => {
// Next link won't be present if there's no more data
this.moreData = !!apiData.payload?.links?.next;
let data = apiData; let data = apiData;
// @ts-ignore // @ts-ignore
@ -509,16 +439,6 @@ export default class UserListPage extends AdminPage {
}); });
} }
nextPage() {
this.isLoadingPage = true;
this.loadPage(this.pageNumber + 1);
}
previousPage() {
this.isLoadingPage = true;
this.loadPage(this.pageNumber - 1);
}
/** /**
* @param page The **1-based** page number * @param page The **1-based** page number
*/ */
@ -532,6 +452,7 @@ export default class UserListPage extends AdminPage {
const params = new URLSearchParams(search?.[1] ?? ''); const params = new URLSearchParams(search?.[1] ?? '');
params.set('page', `${pageNumber}`); params.set('page', `${pageNumber}`);
window.location.hash = search?.[0] + '?' + params.toString(); // window.location.hash = search?.[0] + '?' + params.toString();
window.history.replaceState(null, '', search?.[0] + '?' + params.toString());
} }
} }

View File

@ -4,6 +4,11 @@ import Model, { ModelData, SavedModelData } from './Model';
import GambitManager from './GambitManager'; import GambitManager from './GambitManager';
export interface MetaInformation { export interface MetaInformation {
page?: {
limit?: number;
offset?: number;
total?: number;
};
[key: string]: any; [key: string]: any;
} }

View File

@ -31,6 +31,7 @@ import './utils/patchMithril';
import './utils/classList'; import './utils/classList';
import './utils/extractText'; import './utils/extractText';
import './utils/formatNumber'; import './utils/formatNumber';
import './utils/formatAmount';
import './utils/mapRoutes'; import './utils/mapRoutes';
import './utils/withAttr'; import './utils/withAttr';
import './utils/focusTrap'; import './utils/focusTrap';

View File

@ -14,7 +14,6 @@ export interface IInputAttrs extends ComponentAttrs {
clearable?: boolean; clearable?: boolean;
clearLabel?: string; clearLabel?: string;
loading?: boolean; loading?: boolean;
inputClassName?: string;
onchange?: (value: string) => void; onchange?: (value: string) => void;
value?: string; value?: string;
stream?: Stream<string>; stream?: Stream<string>;

View File

@ -0,0 +1,94 @@
import Component, { ComponentAttrs } from '../Component';
import Button from './Button';
import app from '../../admin/app';
import extractText from '../utils/extractText';
export interface IPaginationInterface extends ComponentAttrs {
total: number;
perPage: number;
currentPage: number;
loadingPageNumber?: number;
onChange: (page: number) => void;
}
export default class Pagination<CustomAttrs extends IPaginationInterface = IPaginationInterface> extends Component<CustomAttrs> {
view() {
const { total, perPage, currentPage, loadingPageNumber, onChange } = this.attrs;
const totalPageCount = Math.ceil(total / perPage);
const moreData = totalPageCount > currentPage;
return (
<nav className="Pagination">
<Button
disabled={currentPage === 1}
title={app.translator.trans('core.admin.users.pagination.first_page_button')}
onclick={() => onChange(1)}
icon="fas fa-step-backward"
className="Button Button--icon Pagination-first"
/>
<Button
disabled={currentPage === 1}
title={app.translator.trans('core.admin.users.pagination.back_button')}
onclick={() => onChange(currentPage - 1)}
icon="fas fa-chevron-left"
className="Button Button--icon Pagination-back"
/>
<span className="Pagination-pageNumber">
{app.translator.trans('core.admin.users.pagination.page_counter', {
// https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/
current: (
<input
type="text"
inputmode="numeric"
pattern="[0-9]*"
value={loadingPageNumber ?? currentPage}
aria-label={extractText(app.translator.trans('core.admin.users.pagination.go_to_page_textbox_a11y_label'))}
autocomplete="off"
className="FormControl Pagination-input"
onchange={(e: InputEvent) => {
const target = e.target as HTMLInputElement;
let pageNumber = parseInt(target.value);
if (isNaN(pageNumber)) {
// Invalid value, reset to current page
target.value = (currentPage + 1).toString();
return;
}
if (pageNumber < 1) {
// Lower constraint
pageNumber = 1;
} else if (pageNumber > totalPageCount) {
// Upper constraint
pageNumber = totalPageCount;
}
target.value = pageNumber.toString();
onChange(pageNumber);
}}
/>
),
currentNum: currentPage,
total: totalPageCount,
})}
</span>
<Button
disabled={!moreData}
title={app.translator.trans('core.admin.users.pagination.next_button')}
onclick={() => onChange(currentPage + 1)}
icon="fas fa-chevron-right"
className="Button Button--icon Pagination-next"
/>
<Button
disabled={!moreData}
title={app.translator.trans('core.admin.users.pagination.last_page_button')}
onclick={() => onChange(totalPageCount)}
icon="fas fa-step-forward"
className="Button Button--icon Pagination-last"
/>
</nav>
);
}
}

View File

@ -126,9 +126,9 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
tabs(): JSX.Element { tabs(): JSX.Element {
return ( return (
<div className="SearchModal-tabs"> <div className="Tabs">
<div className="SearchModal-tabs-nav">{this.tabItems().toArray()}</div> <div className="Tabs-nav">{this.tabItems().toArray()}</div>
<div className="SearchModal-tabs-content">{this.activeTabItems().toArray()}</div> <div className="Tabs-content SearchModal-tabs-content">{this.activeTabItems().toArray()}</div>
</div> </div>
); );
} }

View File

@ -47,6 +47,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
protected location!: PaginationLocation; protected location!: PaginationLocation;
public pageSize: number | null; public pageSize: number | null;
public totalItems: number | null = null;
protected pages: Page<T>[] = []; protected pages: Page<T>[] = [];
protected params: P = {} as P; protected params: P = {} as P;
@ -54,6 +55,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
protected initialLoading: boolean = false; protected initialLoading: boolean = false;
protected loadingPrev: boolean = false; protected loadingPrev: boolean = false;
protected loadingNext: boolean = false; protected loadingNext: boolean = false;
protected loadingPage: boolean = false;
protected constructor(params: P = {} as P, page: number = 1, pageSize: number | null = null) { protected constructor(params: P = {} as P, page: number = 1, pageSize: number | null = null) {
this.params = params; this.params = params;
@ -139,12 +141,19 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
} }
return app.store.find<T[]>(this.type, params).then((results) => { return app.store.find<T[]>(this.type, params).then((results) => {
const usedPerPage = results.payload?.meta?.perPage;
const usedTotal = results.payload?.meta?.page?.total;
/* /*
* If this state does not rely on a preloaded API document to know the page size, * If this state does not rely on a preloaded API document to know the page size,
* then there is no initial list, and therefore the page size can be taken from subsequent requests. * then there is no initial list, and therefore the page size can be taken from subsequent requests.
*/ */
if (!this.pageSize) { if (!this.pageSize || (usedPerPage && this.pageSize !== usedPerPage)) {
this.pageSize = results.payload?.meta?.perPage || PaginatedListState.DEFAULT_PAGE_SIZE; this.pageSize = usedPerPage || PaginatedListState.DEFAULT_PAGE_SIZE;
}
if (!this.totalItems || (usedTotal && this.totalItems !== usedTotal)) {
this.totalItems = usedTotal || null;
} }
return results; return results;
@ -187,14 +196,25 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
this.clear(); this.clear();
return this.goto(page);
}
public goto(page: number): Promise<void> {
this.location = { page }; this.location = { page };
return this.loadPage() if (!this.initialLoading) {
this.loadingPage = true;
}
return this.loadPage(page)
.then((results) => { .then((results) => {
this.pages = []; this.pages = [];
this.parseResults(this.location.page, results); this.parseResults(this.location.page, results);
}) })
.finally(() => (this.initialLoading = false)); .finally(() => {
this.initialLoading = false;
this.loadingPage = false;
});
} }
public getPages(): Page<T>[] { public getPages(): Page<T>[] {
@ -205,7 +225,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
} }
public isLoading(): boolean { public isLoading(): boolean {
return this.initialLoading || this.loadingNext || this.loadingPrev; return this.initialLoading || this.loadingNext || this.loadingPrev || this.loadingPage;
} }
public isInitialLoading(): boolean { public isInitialLoading(): boolean {
return this.initialLoading; return this.initialLoading;
@ -322,18 +342,23 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
} }
changeSort(sort: string) { changeSort(sort: string) {
let currentSort: string | undefined;
if (sort === Object.keys(this.sortMap())[0]) {
currentSort = undefined;
} else {
currentSort = sort;
}
this.refreshParams( this.refreshParams(
{ {
...this.params, ...this.params,
sort: currentSort, sort: sort,
},
1
);
}
changeFilter(key: string, value: string) {
this.refreshParams(
{
...this.params,
filter: {
...this.params.filter,
[key]: value,
},
}, },
1 1
); );

View File

@ -0,0 +1,13 @@
export default function formatAmount(size: number): string {
const units = ['K', 'M', 'B'];
for (let i = units.length - 1; i >= 0; i--) {
const decimal = Math.pow(1000, i + 1);
if (size >= decimal) {
return (size / decimal).toFixed(1).replace(/\.0$/, '') + units[i];
}
}
return size.toString();
}

View File

@ -1,4 +1,6 @@
.ExtensionPage { .ExtensionPage {
padding-bottom: 30px;
&-header { &-header {
.helpText { .helpText {
margin-bottom: 5px; margin-bottom: 5px;
@ -133,7 +135,7 @@
&-body { &-body {
.InfoTile { .InfoTile {
margin-top: 4rem padding: 4rem 0;
} }
} }
} }

View File

@ -36,24 +36,7 @@
// Table refreshing overlay // Table refreshing overlay
&--loadingPage { &--loadingPage {
&::after { .loading-container();
content: "";
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(128, 128, 128, 0.2);
}
.LoadingIndicator-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
} }
&--loaded, &--loaded,
@ -88,26 +71,6 @@
} }
} }
} }
&-gridPagination {
display: grid;
grid-template-columns: auto auto 1fr auto auto;
gap: 8px;
align-items: center;
justify-content: center;
margin-top: 16px;
}
&-pageNumber {
text-align: center;
}
&-pageNumberInput {
display: inline-block;
margin: 0 8px;
width: auto;
max-width: 80px;
}
} }
// Handles styling of default UserList columns // Handles styling of default UserList columns

View File

@ -74,3 +74,6 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.Alert-container {
display: flex;
}

View File

@ -17,6 +17,10 @@
&, .Badge-icon { &, .Badge-icon {
font-size: calc(~"0.56 * var(--size)"); font-size: calc(~"0.56 * var(--size)");
} }
&-icon {
line-height: normal;
}
} }
.Badge--size(@size) { .Badge--size(@size) {
@ -51,3 +55,20 @@
.Badge--hidden { .Badge--hidden {
--badge-bg: var(--badge-hidden-bg); --badge-bg: var(--badge-hidden-bg);
} }
.Badge--flat {
box-shadow: none;
}
.Badge--square {
border-radius: var(--border-radius);
}
.Badge--danger {
--badge-color: var(--control-danger-color);
--badge-bg: var(--control-danger-bg);
}
.Badge--success {
--badge-bg: var(--success-color);
}

View File

@ -88,6 +88,12 @@
gap: 10px; gap: 10px;
} }
.Form-group--danger {
border: 2px solid var(--alert-error-bg);
border-radius: var(--border-radius);
background-color: transparent;
}
.FieldSet-items { .FieldSet-items {
width: 100%; width: 100%;
gap: 5px; gap: 5px;

View File

@ -41,6 +41,10 @@
@media @phone { @media @phone {
font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10 font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10
} }
&-alt {
background-color: var(--body-bg);
}
} }
.StackedFormControl { .StackedFormControl {

View File

@ -0,0 +1,19 @@
.Pagination {
display: grid;
grid-template-columns: auto auto 1fr auto auto;
gap: 8px;
align-items: center;
justify-content: center;
margin-top: 16px;
&-pageNumber {
text-align: center;
}
&-input {
display: inline-block;
margin: 0 8px;
width: auto;
max-width: 80px;
}
}

View File

@ -47,43 +47,15 @@
row-gap: 6px; row-gap: 6px;
} }
&-tabs { &-tabs-content {
&-nav + .Modal-divider { .Dropdown--expanded();
margin-top: 0;
.Dropdown-header {
color: var(--muted-more-color);
} }
&-nav { > .SearchModal-section:first-of-type .Modal-divider {
margin-bottom: -1px; margin-top: -1px;
display: flex;
column-gap: 4px;
padding: 0 14px;
> .Button {
border-radius: 0;
font-size: 15px;
padding: 12px 8px;
border-bottom: 2px solid;
border-color: transparent;
&[active] {
--button-color: var(--text-color);
--link-color: var(--text-color);
border-color: var(--primary-color);
font-weight: bold;
}
}
}
&-content {
.Dropdown--expanded();
.Dropdown-header {
color: var(--muted-more-color);
}
> .SearchModal-section:first-of-type .Modal-divider {
margin-top: -1px;
}
} }
} }

View File

@ -0,0 +1,32 @@
.Tabs {
&-nav + .Modal-divider {
margin-top: 0;
}
&-nav {
margin-bottom: -1px;
display: flex;
column-gap: 4px;
padding: 0 14px;
> .Button {
border-radius: 0;
font-size: 15px;
padding: 12px 8px;
border-bottom: 2px solid;
border-color: transparent;
&[active] {
--button-color: var(--text-color);
--link-color: var(--text-color);
border-color: var(--primary-color);
font-weight: bold;
}
}
}
&-divider {
margin-top: -1px;
border-width: 1px;
}
}

View File

@ -25,11 +25,13 @@
@import "LoadingIndicator"; @import "LoadingIndicator";
@import "Modal"; @import "Modal";
@import "Navigation"; @import "Navigation";
@import "Pagination";
@import "Pill"; @import "Pill";
@import "Placeholder"; @import "Placeholder";
@import "Search"; @import "Search";
@import "Select"; @import "Select";
@import "Table"; @import "Table";
@import "Tabs";
@import "TextEditor"; @import "TextEditor";
@import "ThemeMode"; @import "ThemeMode";
@import "Tooltip"; @import "Tooltip";

View File

@ -73,6 +73,29 @@ p {
margin: 0 auto; margin: 0 auto;
} }
.loading-container {
position: relative;
&::after {
content: "";
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(128, 128, 128, 0.2);
}
.LoadingIndicator-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
}
mark { mark {
background: var(--highlight-color); background: var(--highlight-color);
padding: 1px; padding: 1px;

View File

@ -143,11 +143,17 @@
@screen-desktop-max: (@screen-desktop-hd - 0.02); @screen-desktop-max: (@screen-desktop-hd - 0.02);
@screen-desktop-hd: 1100px; @screen-desktop-hd: 1100px;
@screen-desktop-xl: 1600px;
@screen-desktop-xxl: 2000px;
@screen-desktop-xxxl: 3000px;
@phone: ~"(max-width: @{screen-phone-max})"; @phone: ~"(max-width: @{screen-phone-max})";
@tablet: ~"(min-width: @{screen-tablet}) and (max-width: @{screen-tablet-max})"; @tablet: ~"(min-width: @{screen-tablet}) and (max-width: @{screen-tablet-max})";
@desktop: ~"(min-width: @{screen-desktop}) and (max-width: @{screen-desktop-max})"; @desktop: ~"(min-width: @{screen-desktop}) and (max-width: @{screen-desktop-max})";
@desktop-hd: ~"(min-width: @{screen-desktop-hd})"; @desktop-hd: ~"(min-width: @{screen-desktop-hd})";
@desktop-xl: ~"(min-width: @{screen-desktop-xl})";
@desktop-xxl: ~"(min-width: @{screen-desktop-xxl})";
@desktop-xxxl: ~"(min-width: @{screen-desktop-xxxl})";
@tablet-up: ~"(min-width: @{screen-tablet})"; @tablet-up: ~"(min-width: @{screen-tablet})";
@desktop-up: ~"(min-width: @{screen-desktop})"; @desktop-up: ~"(min-width: @{screen-desktop})";

View File

@ -15,6 +15,7 @@ use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Api\Endpoint\Concerns\IncludesData; use Flarum\Api\Endpoint\Concerns\IncludesData;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Api\Resource\AbstractResource; use Flarum\Api\Resource\AbstractResource;
use Flarum\Api\Resource\Contracts\Countable; use Flarum\Api\Resource\Contracts\Countable;
use Flarum\Api\Resource\Contracts\Listable; use Flarum\Api\Resource\Contracts\Listable;
@ -31,7 +32,6 @@ use Tobyz\JsonApiServer\Pagination\OffsetPagination;
use Tobyz\JsonApiServer\Pagination\Pagination; use Tobyz\JsonApiServer\Pagination\Pagination;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use function Tobyz\JsonApiServer\apply_filters;
use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\parse_sort_string; use function Tobyz\JsonApiServer\parse_sort_string;
@ -70,10 +70,12 @@ class Index extends Endpoint
{ {
$this->route('GET', '/') $this->route('GET', '/')
->query(function ($query, ?Pagination $pagination, Context $context): Context { ->query(function ($query, ?Pagination $pagination, Context $context): Context {
$collection = $context->collection;
// This model has a searcher API, so we'll use that instead of the default. // 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. // The searcher API allows swapping the default search engine for a custom one.
$search = $context->api->getContainer()->make(SearchManager::class); $search = $context->api->getContainer()->make(SearchManager::class);
$modelClass = $query->getModel()::class; $modelClass = $collection instanceof AbstractDatabaseResource ? $collection->model() : null;
if ($query instanceof Builder && $search->searchable($modelClass)) { if ($query instanceof Builder && $search->searchable($modelClass)) {
$actor = $context->getActor(); $actor = $context->getActor();
@ -147,6 +149,8 @@ class Index extends Endpoint
$meta = $this->serializeMeta($context); $meta = $this->serializeMeta($context);
$models = $collection->results($query, $context);
if ( if (
$collection instanceof Countable && $collection instanceof Countable &&
! is_null($total = $collection->count($query, $context)) ! is_null($total = $collection->count($query, $context))
@ -154,8 +158,6 @@ class Index extends Endpoint
$meta['page']['total'] = $total; $meta['page']['total'] = $total;
} }
$models = $collection->results($query, $context);
$models = $this->callAfterHook($context, $models); $models = $this->callAfterHook($context, $models);
$total ??= null; $total ??= null;
@ -245,14 +247,28 @@ class Index extends Endpoint
$collection = $context->collection; $collection = $context->collection;
if (! $collection instanceof \Tobyz\JsonApiServer\Resource\Listable) { if (! $collection instanceof Listable) {
throw new RuntimeException( throw new RuntimeException(
sprintf('%s must implement %s', $collection::class, \Tobyz\JsonApiServer\Resource\Listable::class), sprintf('%s must implement %s', $collection::class, \Tobyz\JsonApiServer\Resource\Listable::class),
); );
} }
try { try {
apply_filters($query, $filters, $collection, $context); $context = $context->withCollection($collection);
$availableFilters = $collection->filters();
foreach ($filters as $name => $value) {
foreach ($availableFilters as $filter) {
if ($filter->name === $name && $filter->isVisible($context)) {
$filter->apply($query, $value, $context);
continue 2;
}
}
throw (new BadRequestException("Invalid filter: $name"))->setSource([
'parameter' => "[$name]",
]);
}
} catch (Sourceable $e) { } catch (Sourceable $e) {
throw $e->prependSource(['parameter' => 'filter']); throw $e->prependSource(['parameter' => 'filter']);
} }

View File

@ -330,7 +330,7 @@ abstract class AbstractDatabaseResource extends AbstractResource implements
return new ($this->model()); return new ($this->model());
} }
public function filters(): array final public function filters(): array
{ {
throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.'); throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.');
} }

View File

@ -84,7 +84,7 @@ class Extension implements Arrayable
$this->assignId(); $this->assignId();
} }
protected static function nameToId(string $name): string public static function nameToId(string $name): string
{ {
[$vendor, $package] = explode('/', $name); [$vendor, $package] = explode('/', $name);
$package = str_replace(['flarum-ext-', 'flarum-'], '', $package); $package = str_replace(['flarum-ext-', 'flarum-'], '', $package);