mirror of
https://github.com/flarum/framework.git
synced 2024-11-22 07:21:49 +08:00
feat: extension list UI (#4066)
This commit is contained in:
parent
b0e8f5ca36
commit
0107c96fb7
|
@ -10,7 +10,9 @@
|
|||
namespace Flarum\ExtensionManager;
|
||||
|
||||
use Flarum\Extend;
|
||||
use Flarum\ExtensionManager\Api\Resource\ExternalExtensionResource;
|
||||
use Flarum\ExtensionManager\Api\Resource\TaskResource;
|
||||
use Flarum\ExtensionManager\Exception\CannotFetchExternalExtension;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\Frontend\Document;
|
||||
use Illuminate\Contracts\Queue\Queue;
|
||||
|
@ -29,6 +31,7 @@ return [
|
|||
->post('/extension-manager/composer', 'extension-manager.composer', Api\Controller\ConfigureComposerController::class),
|
||||
|
||||
new Extend\ApiResource(TaskResource::class),
|
||||
new Extend\ApiResource(ExternalExtensionResource::class),
|
||||
|
||||
(new Extend\Frontend('admin'))
|
||||
->css(__DIR__.'/less/admin.less')
|
||||
|
@ -62,8 +65,10 @@ return [
|
|||
->handler(Exception\ComposerRequireFailedException::class, Exception\ExceptionHandler::class)
|
||||
->handler(Exception\ComposerUpdateFailedException::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_not_installed', 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),
|
||||
];
|
||||
|
|
|
@ -15,12 +15,7 @@ export default class ControlSection extends Component<ComponentAttrs> {
|
|||
|
||||
view() {
|
||||
return (
|
||||
<div className="ExtensionPage-permissions 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="ExtensionPage-settings ExtensionManager-controlSection">
|
||||
<div className="container">
|
||||
{app.data['flarum-extension-manager.writable_dirs'] ? (
|
||||
<Form>
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
<div className="helpText">
|
||||
{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" />,
|
||||
code: <code />,
|
||||
})}
|
||||
|
|
|
@ -7,7 +7,7 @@ import Alert from 'flarum/common/components/Alert';
|
|||
|
||||
import { UpdatedPackage, UpdateState } from '../states/ControlSectionState';
|
||||
import WhyNotModal from './WhyNotModal';
|
||||
import ExtensionItem from './ExtensionItem';
|
||||
import ExtensionCard from './ExtensionCard';
|
||||
import classList from 'flarum/common/utils/classList';
|
||||
|
||||
export interface MajorUpdaterAttrs extends ComponentAttrs {
|
||||
|
@ -27,7 +27,6 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
|
|||
}
|
||||
|
||||
view(): Mithril.Children {
|
||||
// @todo move Form-group--danger class to core for reuse
|
||||
return (
|
||||
<div
|
||||
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 ? (
|
||||
<div className="ExtensionManager-majorUpdate-incompatibleExtensions ExtensionManager-extensions-grid">
|
||||
{this.updateState.incompatibleExtensions.map((extension: string) => (
|
||||
<ExtensionItem
|
||||
<ExtensionCard
|
||||
extension={app.data.extensions[extension.replace('flarum-', '').replace('flarum-ext-', '').replace('/', '-')]}
|
||||
updates={{}}
|
||||
onClickUpdate={null}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -9,12 +9,13 @@ import Icon from 'flarum/common/components/Icon';
|
|||
import ItemList from 'flarum/common/utils/ItemList';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
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 TaskOutputModal from './TaskOutputModal';
|
||||
import humanDuration from '../utils/humanDuration';
|
||||
import Task, { TaskOperations } from '../models/Task';
|
||||
import Pagination from './Pagination';
|
||||
|
||||
interface QueueTableColumn extends ComponentAttrs {
|
||||
label: string;
|
||||
|
@ -30,7 +31,7 @@ export default class QueueSection extends Component<{}> {
|
|||
|
||||
view() {
|
||||
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="container">
|
||||
<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 (
|
||||
<>
|
||||
<table className="Table ExtensionManager-queueTable">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.toArray().map((item, index) => (
|
||||
<th key={index}>{item.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task, index) => (
|
||||
<tr key={index}>
|
||||
{columns.toArray().map((item, index) => {
|
||||
const { label, content, ...attrs } = item;
|
||||
|
||||
return (
|
||||
<td key={index} {...attrs}>
|
||||
{content(task)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={classList('Table-container', {
|
||||
'loading-container': tasks && app.extensionManager.queue.isLoading(),
|
||||
})}
|
||||
>
|
||||
<table className="Table ExtensionManager-queueTable">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.toArray().map((item, index) => (
|
||||
<th key={index}>{item.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,36 +6,32 @@ import ItemList from 'flarum/common/utils/ItemList';
|
|||
import QueueSection from './QueueSection';
|
||||
import ControlSection from './ControlSection';
|
||||
import ConfigureComposer from './ConfigureComposer';
|
||||
import Alert from 'flarum/common/components/Alert';
|
||||
import listItems from 'flarum/common/helpers/listItems';
|
||||
import ConfigureAuth from './ConfigureAuth';
|
||||
import DiscoverSection from './DiscoverSection';
|
||||
|
||||
export default class SettingsPage extends ExtensionPage {
|
||||
content() {
|
||||
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 (
|
||||
<div className="ExtensionPage-settings">
|
||||
<div className="container">
|
||||
<div className="ExtensionManager-warnings Form-group">
|
||||
<Alert className="ExtensionManager-primaryWarning" type="warning" dismissible={false}>
|
||||
<ul>{listItems(warnings)}</ul>
|
||||
</Alert>
|
||||
</div>
|
||||
{settings ? (
|
||||
<div className="FormSectionGroup ExtensionManager-SettingsGroups">
|
||||
<div className="FormSection">
|
||||
<label>{app.translator.trans('flarum-extension-manager.admin.settings.title')}</label>
|
||||
<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>
|
||||
[
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('flarum-extension-manager.admin.sections.settings.title')}</label>
|
||||
<div className="helpText">{app.translator.trans('flarum-extension-manager.admin.sections.settings.description')}</div>
|
||||
</div>,
|
||||
<div className="FormSectionGroup ExtensionManager-SettingsGroups">
|
||||
<div className="FormSection">
|
||||
<label>{app.translator.trans('flarum-extension-manager.admin.settings.title')}</label>
|
||||
<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>
|
||||
)}
|
||||
|
@ -47,9 +43,11 @@ export default class SettingsPage extends ExtensionPage {
|
|||
sections(vnode: Mithril.VnodeDOM<ExtensionPageAttrs, this>): ItemList<unknown> {
|
||||
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']) {
|
||||
items.add('queue', <QueueSection />, 5);
|
||||
|
|
|
@ -4,9 +4,9 @@ import Button from 'flarum/common/components/Button';
|
|||
import humanTime from 'flarum/common/helpers/humanTime';
|
||||
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
|
||||
import MajorUpdater from './MajorUpdater';
|
||||
import ExtensionItem from './ExtensionItem';
|
||||
import { Extension } from 'flarum/admin/AdminApplication';
|
||||
import ItemList from 'flarum/common/utils/ItemList';
|
||||
import InfoTile from 'flarum/common/components/InfoTile';
|
||||
import ExtensionCard from './ExtensionCard';
|
||||
|
||||
export interface IUpdaterAttrs extends ComponentAttrs {}
|
||||
|
||||
|
@ -59,8 +59,8 @@ export default class Updater extends Component<IUpdaterAttrs> {
|
|||
|
||||
if (!(state.extensionUpdates.length || hasMinorCoreUpdate)) {
|
||||
return (
|
||||
<div className="ExtensionManager-extensions">
|
||||
<span className="helpText">{app.translator.trans('flarum-extension-manager.admin.updater.up_to_date')}</span>
|
||||
<div className="ExtensionManager-extensions ExtensionManager-extensions--empty">
|
||||
<InfoTile icon="fas fa-plug-circle-check">{app.translator.trans('flarum-extension-manager.admin.updater.up_to_date')}</InfoTile>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ export default class Updater extends Component<IUpdaterAttrs> {
|
|||
<div className="ExtensionManager-extensions">
|
||||
<div className="ExtensionManager-extensions-grid">
|
||||
{hasMinorCoreUpdate ? (
|
||||
<ExtensionItem
|
||||
<ExtensionCard
|
||||
extension={state.coreUpdate!.extension}
|
||||
updates={state.coreUpdate!.package}
|
||||
isCore={true}
|
||||
|
@ -77,8 +77,8 @@ export default class Updater extends Component<IUpdaterAttrs> {
|
|||
whyNotWarning={state.lastUpdateRun.limitedPackages().includes('flarum/core')}
|
||||
/>
|
||||
) : null}
|
||||
{state.extensionUpdates.map((extension: Extension) => (
|
||||
<ExtensionItem
|
||||
{state.extensionUpdates.map((extension) => (
|
||||
<ExtensionCard
|
||||
extension={extension}
|
||||
updates={state.packageUpdates[extension.id]}
|
||||
onClickUpdate={{
|
||||
|
|
|
@ -2,8 +2,14 @@ import Extend from 'flarum/common/extenders';
|
|||
import app from 'flarum/admin/app';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import Task from './models/Task';
|
||||
import ExternalExtension from './models/ExternalExtension';
|
||||
|
||||
export default [
|
||||
new Extend.Store() //
|
||||
.add('extension-manager-tasks', Task)
|
||||
.add('external-extensions', ExternalExtension),
|
||||
|
||||
new Extend.Admin()
|
||||
.setting(() => ({
|
||||
setting: 'flarum-extension-manager.queue_jobs',
|
||||
|
|
|
@ -4,7 +4,6 @@ import ExtensionPage from 'flarum/admin/components/ExtensionPage';
|
|||
import Button from 'flarum/common/components/Button';
|
||||
import LoadingModal from 'flarum/admin/components/LoadingModal';
|
||||
import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled';
|
||||
import Task from './models/Task';
|
||||
import jumpToQueue from './utils/jumpToQueue';
|
||||
import { AsyncBackendResponse } from './shims';
|
||||
import ExtensionManagerState from './states/ExtensionManagerState';
|
||||
|
@ -12,8 +11,6 @@ import ExtensionManagerState from './states/ExtensionManagerState';
|
|||
export { default as extend } from './extend';
|
||||
|
||||
app.initializers.add('flarum-extension-manager', (app) => {
|
||||
app.store.models['extension-manager-tasks'] = Task;
|
||||
|
||||
app.extensionManager = new ExtensionManagerState();
|
||||
|
||||
if (app.data['flarum-extension-manager.using_sync_queue']) {
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import QueueState from './QueueState';
|
||||
import ControlSectionState from './ControlSectionState';
|
||||
import ExtensionListState from './ExtensionListState';
|
||||
|
||||
export default class ExtensionManagerState {
|
||||
public queue: QueueState = new QueueState();
|
||||
public control: ControlSectionState = new ControlSectionState();
|
||||
public extensions: ExtensionListState = new ExtensionListState();
|
||||
}
|
||||
|
|
|
@ -8,9 +8,10 @@ export default class QueueState {
|
|||
private limit = 20;
|
||||
private offset = 0;
|
||||
private total = 0;
|
||||
private loading = false;
|
||||
|
||||
load(params?: ApiQueryParamsPlural, actionTaken = false): Promise<Task[]> {
|
||||
this.tasks = null;
|
||||
this.loading = true;
|
||||
params = {
|
||||
page: {
|
||||
limit: this.limit,
|
||||
|
@ -22,7 +23,7 @@ export default class QueueState {
|
|||
|
||||
return app.store.find<Task[]>('extension-manager-tasks', params || {}).then((data) => {
|
||||
this.tasks = data;
|
||||
this.total = data.payload.meta?.total || 0;
|
||||
this.total = data.payload.meta?.page?.total || 0;
|
||||
|
||||
m.redraw();
|
||||
|
||||
|
@ -40,14 +41,24 @@ export default class QueueState {
|
|||
app.extensionManager.control.setLoading(null);
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
getItems() {
|
||||
return this.tasks;
|
||||
}
|
||||
|
||||
getTotalItems() {
|
||||
return this.total;
|
||||
}
|
||||
|
||||
getTotalPages(): number {
|
||||
return Math.ceil(this.total / this.limit);
|
||||
}
|
||||
|
@ -56,6 +67,10 @@ export default class QueueState {
|
|||
return Math.ceil(this.offset / this.limit);
|
||||
}
|
||||
|
||||
getPerPage() {
|
||||
return this.limit;
|
||||
}
|
||||
|
||||
hasPrev(): boolean {
|
||||
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 {
|
||||
if (this.polling) {
|
||||
clearTimeout(this.polling);
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
@import "admin/TaskOutputModal";
|
||||
@import "admin/QueueSection";
|
||||
@import "admin/ControlSection";
|
||||
@import "admin/DiscoverSection";
|
||||
@import "admin/ExtensionCard";
|
||||
|
||||
.ExtensionManager-controlSection {
|
||||
> .container {
|
||||
|
@ -16,12 +18,6 @@
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.Form-group--danger {
|
||||
border: 2px solid var(--alert-error-bg);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ButtonGroup--full {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
@ -47,6 +43,7 @@
|
|||
column-count: 3;
|
||||
column-gap: 30px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 24px;
|
||||
|
||||
.FormSection {
|
||||
min-width: 300px;
|
||||
|
@ -63,11 +60,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionManager-warnings {
|
||||
margin-bottom: 24px;
|
||||
|
||||
> .Alert {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
--space: 16px;
|
||||
padding: var(--space);
|
||||
|
@ -169,3 +113,8 @@
|
|||
.ExtensionManager-primaryWarning ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ExtensionManager-extensions--empty {
|
||||
border: 1px solid var(--control-bg);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
|
51
extensions/package-manager/less/admin/DiscoverSection.less
Normal file
51
extensions/package-manager/less/admin/DiscoverSection.less
Normal 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;
|
||||
}
|
||||
}
|
128
extensions/package-manager/less/admin/ExtensionCard.less
Normal file
128
extensions/package-manager/less/admin/ExtensionCard.less
Normal 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);
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ flarum-extension-manager:
|
|||
add_label: New authentication method
|
||||
add_modal:
|
||||
host_label: Host
|
||||
host_placeholder: "example: extiverse.com"
|
||||
host_placeholder: "example: bearer.flarum.org"
|
||||
submit_button: Submit
|
||||
token_label: Token
|
||||
type_label: Type
|
||||
|
@ -70,7 +70,7 @@ flarum-extension-manager:
|
|||
install: Install a new extension
|
||||
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>.
|
||||
Visit {extiverse} to browse extensions.
|
||||
Visit {link} to browse extensions.
|
||||
proceed: Proceed
|
||||
remove: Uninstall
|
||||
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.
|
||||
|
||||
sections:
|
||||
control:
|
||||
title: Manager
|
||||
discover:
|
||||
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:
|
||||
columns:
|
||||
details: Details
|
||||
|
@ -133,6 +159,9 @@ flarum-extension-manager:
|
|||
running: Running
|
||||
task_just_started: Task just started
|
||||
title: Queue
|
||||
settings:
|
||||
title: Options and Queue
|
||||
description: Configure the extension manager and check operations in the queue.
|
||||
|
||||
settings:
|
||||
title: => core.ref.settings
|
||||
|
@ -148,7 +177,7 @@ flarum-extension-manager:
|
|||
Set to 0 to keep all tasks.
|
||||
|
||||
updater:
|
||||
up_to_date: Everything is up to date!
|
||||
up_to_date: No pending updates.
|
||||
check_for_updates: Check for updates
|
||||
flarum: Flarum Core
|
||||
global_update_successful: Successfully updated all packages.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -19,7 +19,7 @@ class TaskResource extends AbstractDatabaseResource
|
|||
{
|
||||
public function type(): string
|
||||
{
|
||||
return 'package-manager-tasks';
|
||||
return 'extension-manager-tasks';
|
||||
}
|
||||
|
||||
public function model(): string
|
||||
|
|
30
extensions/package-manager/src/Api/Schema/SortColumn.php
Normal file
30
extensions/package-manager/src/Api/Schema/SortColumn.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
namespace Flarum\ExtensionManager\Command;
|
||||
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\ExtensionManager\Composer\ComposerAdapter;
|
||||
use Flarum\ExtensionManager\Composer\ComposerJson;
|
||||
|
@ -70,7 +71,7 @@ class CheckForUpdatesHandler
|
|||
|
||||
foreach ($installed as $mainPackageUpdate) {
|
||||
// Skip if not an extension
|
||||
if (! $this->extensions->getExtension(Util::nameToId($mainPackageUpdate['name']))) {
|
||||
if (! $this->extensions->getExtension(Extension::nameToId($mainPackageUpdate['name']))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
@ -9,13 +9,13 @@
|
|||
|
||||
namespace Flarum\ExtensionManager\Command;
|
||||
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\ExtensionManager\Composer\ComposerAdapter;
|
||||
use Flarum\ExtensionManager\Exception\ComposerRequireFailedException;
|
||||
use Flarum\ExtensionManager\Exception\ExtensionAlreadyInstalledException;
|
||||
use Flarum\ExtensionManager\Extension\Event\Installed;
|
||||
use Flarum\ExtensionManager\RequirePackageValidator;
|
||||
use Flarum\ExtensionManager\Support\Util;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Symfony\Component\Console\Input\StringInput;
|
||||
|
||||
|
@ -39,7 +39,7 @@ class RequireExtensionHandler
|
|||
|
||||
$this->validator->assertValid(['package' => $command->package]);
|
||||
|
||||
$extensionId = Util::nameToId($command->package);
|
||||
$extensionId = Extension::nameToId($command->package);
|
||||
$extension = $this->extensions->getExtension($extensionId);
|
||||
|
||||
if (! empty($extension)) {
|
||||
|
|
|
@ -9,8 +9,8 @@
|
|||
|
||||
namespace Flarum\ExtensionManager\Composer;
|
||||
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\ExtensionManager\Support\Util;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Str;
|
||||
|
@ -39,7 +39,7 @@ class ComposerJson
|
|||
}
|
||||
|
||||
// Only extensions can all be set to * versioning.
|
||||
if (! $this->extensions->getExtension(Util::nameToId($packageName))) {
|
||||
if (! $this->extensions->getExtension(Extension::nameToId($packageName))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
102
extensions/package-manager/src/External/Extension.php
vendored
Normal file
102
extensions/package-manager/src/External/Extension.php
vendored
Normal 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]);
|
||||
}
|
||||
}
|
73
extensions/package-manager/src/External/RequestWrapper.php
vendored
Normal file
73
extensions/package-manager/src/External/RequestWrapper.php
vendored
Normal 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));
|
||||
}
|
||||
}
|
|
@ -15,14 +15,6 @@ use Symfony\Component\Console\Input\InputInterface;
|
|||
|
||||
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
|
||||
{
|
||||
// Drop any v prefixes
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
|
||||
namespace Flarum\ExtensionManager\Tests\integration;
|
||||
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\ExtensionManager\Composer\ComposerAdapter;
|
||||
use Flarum\ExtensionManager\Composer\ComposerJson;
|
||||
use Flarum\ExtensionManager\Support\Util;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Illuminate\Support\Arr;
|
||||
|
@ -45,7 +45,7 @@ class TestCase extends \Flarum\Testing\integration\TestCase
|
|||
return $package['type'] === 'flarum-extension';
|
||||
});
|
||||
$installedExtensionIds = array_map(function (string $name) {
|
||||
return Util::nameToId($name);
|
||||
return Extension::nameToId($name);
|
||||
}, Arr::pluck($installedExtensions, 'name'));
|
||||
|
||||
if ($exists) {
|
||||
|
|
|
@ -59,7 +59,7 @@ class ListTest extends TestCase
|
|||
$data = json_decode($response->getBody()->getContents(), true)['data'];
|
||||
|
||||
$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]
|
||||
|
|
|
@ -16,13 +16,14 @@ import MailPage from './components/MailPage';
|
|||
import AdvancedPage from './components/AdvancedPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
|
||||
export type Extension = {
|
||||
export interface Extension {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
icon?: {
|
||||
name: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
links: {
|
||||
authors?: {
|
||||
|
@ -44,7 +45,7 @@ export type Extension = {
|
|||
};
|
||||
};
|
||||
require?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export enum DatabaseDriver {
|
||||
MySQL = 'MySQL',
|
||||
|
|
|
@ -19,6 +19,7 @@ import CreateUserModal from './CreateUserModal';
|
|||
import Icon from '../../common/components/Icon';
|
||||
import Input from '../../common/components/Input';
|
||||
import GambitsAutocompleteDropdown from '../../common/components/GambitsAutocompleteDropdown';
|
||||
import Pagination from '../../common/components/Pagination';
|
||||
|
||||
type ColumnData = {
|
||||
/**
|
||||
|
@ -78,11 +79,6 @@ export default class UserListPage extends AdminPage {
|
|||
*/
|
||||
private pageData: User[] | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Are there more users available?
|
||||
*/
|
||||
private moreData: boolean = false;
|
||||
|
||||
private isLoadingPage: boolean = false;
|
||||
|
||||
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 */}
|
||||
{this.isLoadingPage && <LoadingIndicator size="large" />}
|
||||
</section>,
|
||||
<nav className="UserListPage-gridPagination">
|
||||
<Button
|
||||
disabled={this.pageNumber === 0}
|
||||
title={app.translator.trans('core.admin.users.pagination.first_page_button')}
|
||||
onclick={this.goToPage.bind(this, 1)}
|
||||
icon="fas fa-step-backward"
|
||||
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>,
|
||||
<Pagination
|
||||
currentPage={this.pageNumber + 1}
|
||||
loadingPageNumber={this.loadingPageNumber + 1}
|
||||
total={this.userCount}
|
||||
perPage={this.numPerPage}
|
||||
onChange={this.goToPage.bind(this)}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -482,9 +415,6 @@ export default class UserListPage extends AdminPage {
|
|||
},
|
||||
})
|
||||
.then((apiData) => {
|
||||
// Next link won't be present if there's no more data
|
||||
this.moreData = !!apiData.payload?.links?.next;
|
||||
|
||||
let data = apiData;
|
||||
|
||||
// @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
|
||||
*/
|
||||
|
@ -532,6 +452,7 @@ export default class UserListPage extends AdminPage {
|
|||
const params = new URLSearchParams(search?.[1] ?? '');
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,11 @@ import Model, { ModelData, SavedModelData } from './Model';
|
|||
import GambitManager from './GambitManager';
|
||||
|
||||
export interface MetaInformation {
|
||||
page?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
total?: number;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ import './utils/patchMithril';
|
|||
import './utils/classList';
|
||||
import './utils/extractText';
|
||||
import './utils/formatNumber';
|
||||
import './utils/formatAmount';
|
||||
import './utils/mapRoutes';
|
||||
import './utils/withAttr';
|
||||
import './utils/focusTrap';
|
||||
|
|
|
@ -14,7 +14,6 @@ export interface IInputAttrs extends ComponentAttrs {
|
|||
clearable?: boolean;
|
||||
clearLabel?: string;
|
||||
loading?: boolean;
|
||||
inputClassName?: string;
|
||||
onchange?: (value: string) => void;
|
||||
value?: string;
|
||||
stream?: Stream<string>;
|
||||
|
|
94
framework/core/js/src/common/components/Pagination.tsx
Normal file
94
framework/core/js/src/common/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -126,9 +126,9 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
|
|||
|
||||
tabs(): JSX.Element {
|
||||
return (
|
||||
<div className="SearchModal-tabs">
|
||||
<div className="SearchModal-tabs-nav">{this.tabItems().toArray()}</div>
|
||||
<div className="SearchModal-tabs-content">{this.activeTabItems().toArray()}</div>
|
||||
<div className="Tabs">
|
||||
<div className="Tabs-nav">{this.tabItems().toArray()}</div>
|
||||
<div className="Tabs-content SearchModal-tabs-content">{this.activeTabItems().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -47,6 +47,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||
|
||||
protected location!: PaginationLocation;
|
||||
public pageSize: number | null;
|
||||
public totalItems: number | null = null;
|
||||
|
||||
protected pages: Page<T>[] = [];
|
||||
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 loadingPrev: boolean = false;
|
||||
protected loadingNext: boolean = false;
|
||||
protected loadingPage: boolean = false;
|
||||
|
||||
protected constructor(params: P = {} as P, page: number = 1, pageSize: number | null = null) {
|
||||
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) => {
|
||||
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,
|
||||
* then there is no initial list, and therefore the page size can be taken from subsequent requests.
|
||||
*/
|
||||
if (!this.pageSize) {
|
||||
this.pageSize = results.payload?.meta?.perPage || PaginatedListState.DEFAULT_PAGE_SIZE;
|
||||
if (!this.pageSize || (usedPerPage && this.pageSize !== usedPerPage)) {
|
||||
this.pageSize = usedPerPage || PaginatedListState.DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
if (!this.totalItems || (usedTotal && this.totalItems !== usedTotal)) {
|
||||
this.totalItems = usedTotal || null;
|
||||
}
|
||||
|
||||
return results;
|
||||
|
@ -187,14 +196,25 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||
|
||||
this.clear();
|
||||
|
||||
return this.goto(page);
|
||||
}
|
||||
|
||||
public goto(page: number): Promise<void> {
|
||||
this.location = { page };
|
||||
|
||||
return this.loadPage()
|
||||
if (!this.initialLoading) {
|
||||
this.loadingPage = true;
|
||||
}
|
||||
|
||||
return this.loadPage(page)
|
||||
.then((results) => {
|
||||
this.pages = [];
|
||||
this.parseResults(this.location.page, results);
|
||||
})
|
||||
.finally(() => (this.initialLoading = false));
|
||||
.finally(() => {
|
||||
this.initialLoading = false;
|
||||
this.loadingPage = false;
|
||||
});
|
||||
}
|
||||
|
||||
public getPages(): Page<T>[] {
|
||||
|
@ -205,7 +225,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||
}
|
||||
|
||||
public isLoading(): boolean {
|
||||
return this.initialLoading || this.loadingNext || this.loadingPrev;
|
||||
return this.initialLoading || this.loadingNext || this.loadingPrev || this.loadingPage;
|
||||
}
|
||||
public isInitialLoading(): boolean {
|
||||
return this.initialLoading;
|
||||
|
@ -322,18 +342,23 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
|||
}
|
||||
|
||||
changeSort(sort: string) {
|
||||
let currentSort: string | undefined;
|
||||
|
||||
if (sort === Object.keys(this.sortMap())[0]) {
|
||||
currentSort = undefined;
|
||||
} else {
|
||||
currentSort = sort;
|
||||
}
|
||||
|
||||
this.refreshParams(
|
||||
{
|
||||
...this.params,
|
||||
sort: currentSort,
|
||||
sort: sort,
|
||||
},
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
changeFilter(key: string, value: string) {
|
||||
this.refreshParams(
|
||||
{
|
||||
...this.params,
|
||||
filter: {
|
||||
...this.params.filter,
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
1
|
||||
);
|
||||
|
|
13
framework/core/js/src/common/utils/formatAmount.ts
Normal file
13
framework/core/js/src/common/utils/formatAmount.ts
Normal 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();
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
.ExtensionPage {
|
||||
padding-bottom: 30px;
|
||||
|
||||
&-header {
|
||||
.helpText {
|
||||
margin-bottom: 5px;
|
||||
|
@ -133,7 +135,7 @@
|
|||
|
||||
&-body {
|
||||
.InfoTile {
|
||||
margin-top: 4rem
|
||||
padding: 4rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,24 +36,7 @@
|
|||
|
||||
// Table refreshing overlay
|
||||
&--loadingPage {
|
||||
&::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;
|
||||
}
|
||||
.loading-container();
|
||||
}
|
||||
|
||||
&--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
|
||||
|
|
|
@ -74,3 +74,6 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.Alert-container {
|
||||
display: flex;
|
||||
}
|
||||
|
|
|
@ -17,6 +17,10 @@
|
|||
&, .Badge-icon {
|
||||
font-size: calc(~"0.56 * var(--size)");
|
||||
}
|
||||
|
||||
&-icon {
|
||||
line-height: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.Badge--size(@size) {
|
||||
|
@ -51,3 +55,20 @@
|
|||
.Badge--hidden {
|
||||
--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);
|
||||
}
|
||||
|
|
|
@ -88,6 +88,12 @@
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.Form-group--danger {
|
||||
border: 2px solid var(--alert-error-bg);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.FieldSet-items {
|
||||
width: 100%;
|
||||
gap: 5px;
|
||||
|
|
|
@ -41,6 +41,10 @@
|
|||
@media @phone {
|
||||
font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10
|
||||
}
|
||||
|
||||
&-alt {
|
||||
background-color: var(--body-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.StackedFormControl {
|
||||
|
|
19
framework/core/less/common/Pagination.less
Normal file
19
framework/core/less/common/Pagination.less
Normal 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;
|
||||
}
|
||||
}
|
|
@ -47,43 +47,15 @@
|
|||
row-gap: 6px;
|
||||
}
|
||||
|
||||
&-tabs {
|
||||
&-nav + .Modal-divider {
|
||||
margin-top: 0;
|
||||
&-tabs-content {
|
||||
.Dropdown--expanded();
|
||||
|
||||
.Dropdown-header {
|
||||
color: var(--muted-more-color);
|
||||
}
|
||||
|
||||
&-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
.Dropdown--expanded();
|
||||
|
||||
.Dropdown-header {
|
||||
color: var(--muted-more-color);
|
||||
}
|
||||
|
||||
> .SearchModal-section:first-of-type .Modal-divider {
|
||||
margin-top: -1px;
|
||||
}
|
||||
> .SearchModal-section:first-of-type .Modal-divider {
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
32
framework/core/less/common/Tabs.less
Normal file
32
framework/core/less/common/Tabs.less
Normal 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;
|
||||
}
|
||||
}
|
|
@ -25,11 +25,13 @@
|
|||
@import "LoadingIndicator";
|
||||
@import "Modal";
|
||||
@import "Navigation";
|
||||
@import "Pagination";
|
||||
@import "Pill";
|
||||
@import "Placeholder";
|
||||
@import "Search";
|
||||
@import "Select";
|
||||
@import "Table";
|
||||
@import "Tabs";
|
||||
@import "TextEditor";
|
||||
@import "ThemeMode";
|
||||
@import "Tooltip";
|
||||
|
|
|
@ -73,6 +73,29 @@ p {
|
|||
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 {
|
||||
background: var(--highlight-color);
|
||||
padding: 1px;
|
||||
|
|
|
@ -143,11 +143,17 @@
|
|||
@screen-desktop-max: (@screen-desktop-hd - 0.02);
|
||||
|
||||
@screen-desktop-hd: 1100px;
|
||||
@screen-desktop-xl: 1600px;
|
||||
@screen-desktop-xxl: 2000px;
|
||||
@screen-desktop-xxxl: 3000px;
|
||||
|
||||
@phone: ~"(max-width: @{screen-phone-max})";
|
||||
@tablet: ~"(min-width: @{screen-tablet}) and (max-width: @{screen-tablet-max})";
|
||||
@desktop: ~"(min-width: @{screen-desktop}) and (max-width: @{screen-desktop-max})";
|
||||
@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})";
|
||||
@desktop-up: ~"(min-width: @{screen-desktop})";
|
||||
|
|
|
@ -15,6 +15,7 @@ use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
|
|||
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
|
||||
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
|
||||
use Flarum\Api\Endpoint\Concerns\IncludesData;
|
||||
use Flarum\Api\Resource\AbstractDatabaseResource;
|
||||
use Flarum\Api\Resource\AbstractResource;
|
||||
use Flarum\Api\Resource\Contracts\Countable;
|
||||
use Flarum\Api\Resource\Contracts\Listable;
|
||||
|
@ -31,7 +32,6 @@ use Tobyz\JsonApiServer\Pagination\OffsetPagination;
|
|||
use Tobyz\JsonApiServer\Pagination\Pagination;
|
||||
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
|
||||
|
||||
use function Tobyz\JsonApiServer\apply_filters;
|
||||
use function Tobyz\JsonApiServer\json_api_response;
|
||||
use function Tobyz\JsonApiServer\parse_sort_string;
|
||||
|
||||
|
@ -70,10 +70,12 @@ class Index extends Endpoint
|
|||
{
|
||||
$this->route('GET', '/')
|
||||
->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.
|
||||
// The searcher API allows swapping the default search engine for a custom one.
|
||||
$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)) {
|
||||
$actor = $context->getActor();
|
||||
|
@ -147,6 +149,8 @@ class Index extends Endpoint
|
|||
|
||||
$meta = $this->serializeMeta($context);
|
||||
|
||||
$models = $collection->results($query, $context);
|
||||
|
||||
if (
|
||||
$collection instanceof Countable &&
|
||||
! is_null($total = $collection->count($query, $context))
|
||||
|
@ -154,8 +158,6 @@ class Index extends Endpoint
|
|||
$meta['page']['total'] = $total;
|
||||
}
|
||||
|
||||
$models = $collection->results($query, $context);
|
||||
|
||||
$models = $this->callAfterHook($context, $models);
|
||||
|
||||
$total ??= null;
|
||||
|
@ -245,14 +247,28 @@ class Index extends Endpoint
|
|||
|
||||
$collection = $context->collection;
|
||||
|
||||
if (! $collection instanceof \Tobyz\JsonApiServer\Resource\Listable) {
|
||||
if (! $collection instanceof Listable) {
|
||||
throw new RuntimeException(
|
||||
sprintf('%s must implement %s', $collection::class, \Tobyz\JsonApiServer\Resource\Listable::class),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
throw $e->prependSource(['parameter' => 'filter']);
|
||||
}
|
||||
|
|
|
@ -330,7 +330,7 @@ abstract class AbstractDatabaseResource extends AbstractResource implements
|
|||
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.');
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ class Extension implements Arrayable
|
|||
$this->assignId();
|
||||
}
|
||||
|
||||
protected static function nameToId(string $name): string
|
||||
public static function nameToId(string $name): string
|
||||
{
|
||||
[$vendor, $package] = explode('/', $name);
|
||||
$package = str_replace(['flarum-ext-', 'flarum-'], '', $package);
|
||||
|
|
Loading…
Reference in New Issue
Block a user