diff --git a/extensions/package-manager/extend.php b/extensions/package-manager/extend.php index 871b2619d..7b6986c89 100755 --- a/extensions/package-manager/extend.php +++ b/extensions/package-manager/extend.php @@ -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), ]; diff --git a/extensions/package-manager/js/src/admin/components/ControlSection.tsx b/extensions/package-manager/js/src/admin/components/ControlSection.tsx index 41fd38c8b..33ac417bc 100644 --- a/extensions/package-manager/js/src/admin/components/ControlSection.tsx +++ b/extensions/package-manager/js/src/admin/components/ControlSection.tsx @@ -15,12 +15,7 @@ export default class ControlSection extends Component { view() { return ( -
-
-
-

{app.translator.trans('flarum-extension-manager.admin.sections.control.title')}

-
-
+
{app.data['flarum-extension-manager.writable_dirs'] ? (
diff --git a/extensions/package-manager/js/src/admin/components/DiscoverSection.tsx b/extensions/package-manager/js/src/admin/components/DiscoverSection.tsx new file mode 100644 index 000000000..cfcb99d70 --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/DiscoverSection.tsx @@ -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 extends Component { + protected search = Stream(''); + protected warningsDismissed = Stream(false); + + oninit(vnode: Mithril.Vnode) { + 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 ( +
+
+ +
+ +
+ {app.translator.trans('flarum-extension-manager.admin.sections.discover.description')} + {this.warningsDismissed() && ( +
+
+ {!this.warningsDismissed() && ( +
+ this.setWarningDismissed(true)}> +
    {listItems(this.warningItems().toArray())}
+
+
+ )} +
+
{this.tabItems().toArray()}
+
+
+
+
{this.toolbarPrimaryItems().toArray()}
+
{this.toolbarSecondaryItems().toArray()}
+
+ {this.extensionList()} +
{this.footerItems().toArray()}
+
+
+ +
+
+ ); + } + + tabFilters(): Record 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, + + ); + }); + + return items; + } + + warningItems() { + const items = new ItemList(); + + 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', + { + 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', + 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 ( + + ); + })} + + ); + + const is = app.extensionManager.extensions.getParams().filter?.is?.[0] ?? null; + const activeType = is || 'all'; + + items.add( + 'party', + + {['all', 'premium'].map((party) => ( + + ))} + + ); + + return items; + } + + extensionList() { + if (!app.extensionManager.extensions.hasItems() && app.extensionManager.extensions.isLoading()) { + return ; + } + + if (!app.extensionManager.extensions.hasItems()) { + return ( +
+ + {app.translator.trans('flarum-extension-manager.admin.sections.discover.empty_results')} + +
+ ); + } + + return ( +
+
+ {app.extensionManager.extensions + .getPages() + .map((page) => page.items.map((extension: ExternalExtension) => ))} +
+ {app.extensionManager.extensions.hasItems() && app.extensionManager.extensions.isLoading() && } +
+ ); + } + + footerItems() { + const items = new ItemList(); + + items.add( + 'pagination', + { + const current = app.extensionManager.extensions.getLocation().page; + + if (current === page) { + return; + } + + this.load(page); + }} + /> + ); + + items.add( + 'premiumTermsLink', + + {app.translator.trans('flarum-extension-manager.admin.sections.discover.premium_extension_terms')} + + ); + + return items; + } + + private setWarningDismissed(dismissed: boolean) { + this.warningsDismissed(dismissed); + localStorage.setItem('flarum-extension-manager.warningsDismissed', dismissed ? 'true' : 'false'); + } +} diff --git a/extensions/package-manager/js/src/admin/components/ExtensionCard.tsx b/extensions/package-manager/js/src/admin/components/ExtensionCard.tsx new file mode 100644 index 000000000..97f2a13f2 --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/ExtensionCard.tsx @@ -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 extends Component { + 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 ( +
+
+ {this.icon()} + +

{extension.extra['flarum-extension'].title}

+
+ {this.attrs.extension instanceof ExternalExtension &&
{this.badges().toArray()}
} +
{this.actionItems().toArray()}
+
+
+

{extension.description}

+
+
+
{this.metaItems().toArray()}
+
+
+ ); + } + + 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 ( + + {extension.icon?.name ? : null} + + ); + } + + badges() { + const items = new ItemList(); + + const extension = this.attrs.extension as ExternalExtension; + + if (extension.isSupported()) { + items.add( + 'compatible', + + ); + } else { + items.add( + 'incompatible', + + ); + } + + if (extension.isPremium()) { + items.add( + 'premium', + + ); + } + + if (!extension.isStable()) { + items.add( + 'unstable', + + ); + } + + if (extension.name().split('/')[0] === 'fof') { + items.add( + 'fof', + + ); + } + + if (extension.name().split('/')[0] === 'flarum') { + items.add( + 'flarum', + + ); + } + + return items; + } + + metaItems() { + const items = new ItemList(); + + 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', + + + {app.translator.trans('flarum-extension-manager.admin.sections.discover.extension.downloads', { + count: this.attrs.extension.downloads(), + formattedCount: formatAmount(this.attrs.extension.downloads()), + })} + + ); + } else { + items.add( + 'version', +
+ {this.version(updates!['version'])} + {latestVersion ? ( + <> + + + + ) : null} +
+ ); + } + + if (this.attrs.extension instanceof ExternalExtension) { + items.add('version',
v{this.version(this.attrs.extension.highestVersion())}
); + + items.add( + 'link', + + ); + } + + return items; + } + + actionItems() { + const items = new ItemList(); + + const { updates, extension, onClickUpdate, whyNotWarning } = this.attrs; + + if (extension instanceof ExternalExtension) { + if (!(extension.extensionId() in app.data.extensions)) { + items.add( + 'install', + + + + ); + } + + if (whyNotWarning) + items.add( + 'whyNot', + + - - - ) : null} - {whyNotWarning ? ( - -
-
- ); - } - - version(v: string): string { - return v.charAt(0) === 'v' ? v.substring(1) : v; - } -} diff --git a/extensions/package-manager/js/src/admin/components/Installer.tsx b/extensions/package-manager/js/src/admin/components/Installer.tsx index ac31fc7cc..eb59e7de8 100755 --- a/extensions/package-manager/js/src/admin/components/Installer.tsx +++ b/extensions/package-manager/js/src/admin/components/Installer.tsx @@ -23,7 +23,7 @@ export default class Installer extends Component {
{app.translator.trans('flarum-extension-manager.admin.extensions.install_help', { - extiverse: extiverse.com, + link: flarum.org, semantic_link: , code: , })} diff --git a/extensions/package-manager/js/src/admin/components/MajorUpdater.tsx b/extensions/package-manager/js/src/admin/components/MajorUpdater.tsx index 7c376f335..a07901872 100644 --- a/extensions/package-manager/js/src/admin/components/MajorUpdater.tsx +++ b/extensions/package-manager/js/src/admin/components/MajorUpdater.tsx @@ -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 {this.updateState.incompatibleExtensions.map((extension: string) => ( - { - view() { - return ( - - ); - } -} diff --git a/extensions/package-manager/js/src/admin/components/QueueSection.tsx b/extensions/package-manager/js/src/admin/components/QueueSection.tsx index e4380d8bb..19a41eac2 100644 --- a/extensions/package-manager/js/src/admin/components/QueueSection.tsx +++ b/extensions/package-manager/js/src/admin/components/QueueSection.tsx @@ -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 ( -
+

{app.translator.trans('flarum-extension-manager.admin.sections.queue.title')}

@@ -174,32 +175,43 @@ export default class QueueSection extends Component<{}> { return ( <> - - - - {columns.toArray().map((item, index) => ( - - ))} - - - - {tasks.map((task, index) => ( - - {columns.toArray().map((item, index) => { - const { label, content, ...attrs } = item; - - return ( - - ); - })} +
+
{item.label}
- {content(task)} -
+ + + {columns.toArray().map((item, index) => ( + + ))} - ))} - -
{item.label}
+ + + {tasks.map((task, index) => ( + + {columns.toArray().map((item, index) => { + const { label, content, ...attrs } = item; - + return ( + + {content(task)} + + ); + })} + + ))} + + + {tasks && app.extensionManager.queue.isLoading() && } +
+ app.extensionManager.queue.goto(page)} + /> ); } diff --git a/extensions/package-manager/js/src/admin/components/SettingsPage.tsx b/extensions/package-manager/js/src/admin/components/SettingsPage.tsx index 214ce8dce..ec3344605 100644 --- a/extensions/package-manager/js/src/admin/components/SettingsPage.tsx +++ b/extensions/package-manager/js/src/admin/components/SettingsPage.tsx @@ -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 (
-
- -
    {listItems(warnings)}
-
-
{settings ? ( -
-
- -
{settings.map(this.buildSettingComponent.bind(this))}
-
{this.submitButton()}
-
- - -
+ [ +
+ +
{app.translator.trans('flarum-extension-manager.admin.sections.settings.description')}
+
, +
+
+ +
{settings.map(this.buildSettingComponent.bind(this))}
+
{this.submitButton()}
+
+ + +
, + ] ) : (

{app.translator.trans('core.admin.extension.no_settings')}

)} @@ -47,9 +43,11 @@ export default class SettingsPage extends ExtensionPage { sections(vnode: Mithril.VnodeDOM): ItemList { const items = super.sections(vnode); - items.setPriority('content', 10); + items.add('discover', , 15); - items.add('control', , 8); + items.add('control', , 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', , 5); diff --git a/extensions/package-manager/js/src/admin/components/Updater.tsx b/extensions/package-manager/js/src/admin/components/Updater.tsx index 568df46d0..57c14a9ca 100755 --- a/extensions/package-manager/js/src/admin/components/Updater.tsx +++ b/extensions/package-manager/js/src/admin/components/Updater.tsx @@ -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 { if (!(state.extensionUpdates.length || hasMinorCoreUpdate)) { return ( -
- {app.translator.trans('flarum-extension-manager.admin.updater.up_to_date')} +
+ {app.translator.trans('flarum-extension-manager.admin.updater.up_to_date')}
); } @@ -69,7 +69,7 @@ export default class Updater extends Component {
{hasMinorCoreUpdate ? ( - { whyNotWarning={state.lastUpdateRun.limitedPackages().includes('flarum/core')} /> ) : null} - {state.extensionUpdates.map((extension: Extension) => ( - ( + ({ setting: 'flarum-extension-manager.queue_jobs', diff --git a/extensions/package-manager/js/src/admin/index.tsx b/extensions/package-manager/js/src/admin/index.tsx index 4e09ebd6b..9aaaf40d4 100755 --- a/extensions/package-manager/js/src/admin/index.tsx +++ b/extensions/package-manager/js/src/admin/index.tsx @@ -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']) { diff --git a/extensions/package-manager/js/src/admin/models/ExternalExtension.ts b/extensions/package-manager/js/src/admin/models/ExternalExtension.ts new file mode 100644 index 000000000..895b3cf42 --- /dev/null +++ b/extensions/package-manager/js/src/admin/models/ExternalExtension.ts @@ -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('extensionId'); + name = Model.attribute('name'); + title = Model.attribute('title'); + description = Model.attribute('description'); + iconUrl = Model.attribute('iconUrl'); + icon = Model.attribute<{ + name: string; + [key: string]: string; + }>('icon'); + highestVersion = Model.attribute('highestVersion'); + httpUri = Model.attribute('httpUri'); + discussUri = Model.attribute('discussUri'); + vendor = Model.attribute('vendor'); + isPremium = Model.attribute('isPremium'); + isLocale = Model.attribute('isLocale'); + locale = Model.attribute('locale'); + latestFlarumVersionSupported = Model.attribute('latestFlarumVersionSupported'); + downloads = Model.attribute('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(), + }, + }, + }; + } +} diff --git a/extensions/package-manager/js/src/admin/states/ExtensionListState.ts b/extensions/package-manager/js/src/admin/states/ExtensionListState.ts new file mode 100644 index 000000000..1eff1ca68 --- /dev/null +++ b/extensions/package-manager/js/src/admin/states/ExtensionListState.ts @@ -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 { + 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), + }, + }; + } +} diff --git a/extensions/package-manager/js/src/admin/states/ExtensionManagerState.ts b/extensions/package-manager/js/src/admin/states/ExtensionManagerState.ts index 62073aa71..3b8fbc55b 100644 --- a/extensions/package-manager/js/src/admin/states/ExtensionManagerState.ts +++ b/extensions/package-manager/js/src/admin/states/ExtensionManagerState.ts @@ -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(); } diff --git a/extensions/package-manager/js/src/admin/states/QueueState.ts b/extensions/package-manager/js/src/admin/states/QueueState.ts index 5b54efffd..d9ea399d2 100644 --- a/extensions/package-manager/js/src/admin/states/QueueState.ts +++ b/extensions/package-manager/js/src/admin/states/QueueState.ts @@ -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 { - this.tasks = null; + this.loading = true; params = { page: { limit: this.limit, @@ -22,7 +23,7 @@ export default class QueueState { return app.store.find('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); diff --git a/extensions/package-manager/less/admin.less b/extensions/package-manager/less/admin.less index c58a01d7f..f8470996f 100755 --- a/extensions/package-manager/less/admin.less +++ b/extensions/package-manager/less/admin.less @@ -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%; - } -} diff --git a/extensions/package-manager/less/admin/ControlSection.less b/extensions/package-manager/less/admin/ControlSection.less index 99273f271..d5fa35453 100644 --- a/extensions/package-manager/less/admin/ControlSection.less +++ b/extensions/package-manager/less/admin/ControlSection.less @@ -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); +} diff --git a/extensions/package-manager/less/admin/DiscoverSection.less b/extensions/package-manager/less/admin/DiscoverSection.less new file mode 100644 index 000000000..9eb738557 --- /dev/null +++ b/extensions/package-manager/less/admin/DiscoverSection.less @@ -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; + } +} diff --git a/extensions/package-manager/less/admin/ExtensionCard.less b/extensions/package-manager/less/admin/ExtensionCard.less new file mode 100644 index 000000000..4686a9961 --- /dev/null +++ b/extensions/package-manager/less/admin/ExtensionCard.less @@ -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); + } +} diff --git a/extensions/package-manager/locale/en.yml b/extensions/package-manager/locale/en.yml index 54d93e731..b7e0816bf 100755 --- a/extensions/package-manager/locale/en.yml +++ b/extensions/package-manager/locale/en.yml @@ -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 version using the format vendor/package-name:version. - 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. diff --git a/extensions/package-manager/src/Api/Resource/ExternalExtensionResource.php b/extensions/package-manager/src/Api/Resource/ExternalExtensionResource.php new file mode 100644 index 000000000..b0a90ee8d --- /dev/null +++ b/extensions/package-manager/src/Api/Resource/ExternalExtensionResource.php @@ -0,0 +1,193 @@ +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; + } +} diff --git a/extensions/package-manager/src/Api/Resource/TaskResource.php b/extensions/package-manager/src/Api/Resource/TaskResource.php index d132d29f7..67bfab588 100644 --- a/extensions/package-manager/src/Api/Resource/TaskResource.php +++ b/extensions/package-manager/src/Api/Resource/TaskResource.php @@ -19,7 +19,7 @@ class TaskResource extends AbstractDatabaseResource { public function type(): string { - return 'package-manager-tasks'; + return 'extension-manager-tasks'; } public function model(): string diff --git a/extensions/package-manager/src/Api/Schema/SortColumn.php b/extensions/package-manager/src/Api/Schema/SortColumn.php new file mode 100644 index 000000000..875870008 --- /dev/null +++ b/extensions/package-manager/src/Api/Schema/SortColumn.php @@ -0,0 +1,30 @@ +withQueryParams([ + 'sort' => $direction === 'desc' ? "-$this->name" : $this->name, + ]); + } +} diff --git a/extensions/package-manager/src/Command/CheckForUpdatesHandler.php b/extensions/package-manager/src/Command/CheckForUpdatesHandler.php index f87d814dc..82bbc9c4d 100755 --- a/extensions/package-manager/src/Command/CheckForUpdatesHandler.php +++ b/extensions/package-manager/src/Command/CheckForUpdatesHandler.php @@ -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; } diff --git a/extensions/package-manager/src/Command/RequireExtensionHandler.php b/extensions/package-manager/src/Command/RequireExtensionHandler.php index b3b8180cf..180b3abdf 100755 --- a/extensions/package-manager/src/Command/RequireExtensionHandler.php +++ b/extensions/package-manager/src/Command/RequireExtensionHandler.php @@ -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)) { diff --git a/extensions/package-manager/src/Composer/ComposerJson.php b/extensions/package-manager/src/Composer/ComposerJson.php index 342f56d05..ee0f9b916 100644 --- a/extensions/package-manager/src/Composer/ComposerJson.php +++ b/extensions/package-manager/src/Composer/ComposerJson.php @@ -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; } diff --git a/extensions/package-manager/src/Exception/CannotFetchExternalExtension.php b/extensions/package-manager/src/Exception/CannotFetchExternalExtension.php new file mode 100644 index 000000000..7459cafff --- /dev/null +++ b/extensions/package-manager/src/Exception/CannotFetchExternalExtension.php @@ -0,0 +1,21 @@ +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]); + } +} diff --git a/extensions/package-manager/src/External/RequestWrapper.php b/extensions/package-manager/src/External/RequestWrapper.php new file mode 100644 index 000000000..48d381d02 --- /dev/null +++ b/extensions/package-manager/src/External/RequestWrapper.php @@ -0,0 +1,73 @@ +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)); + } +} diff --git a/extensions/package-manager/src/Support/Util.php b/extensions/package-manager/src/Support/Util.php index 426ef0a46..dddd0ea55 100755 --- a/extensions/package-manager/src/Support/Util.php +++ b/extensions/package-manager/src/Support/Util.php @@ -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 diff --git a/extensions/package-manager/tests/integration/TestCase.php b/extensions/package-manager/tests/integration/TestCase.php index 9da5731c2..7175ff3f1 100644 --- a/extensions/package-manager/tests/integration/TestCase.php +++ b/extensions/package-manager/tests/integration/TestCase.php @@ -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) { diff --git a/extensions/tags/tests/integration/api/tags/ListTest.php b/extensions/tags/tests/integration/api/tags/ListTest.php index ac937e686..68d311aa7 100644 --- a/extensions/tags/tests/integration/api/tags/ListTest.php +++ b/extensions/tags/tests/integration/api/tags/ListTest.php @@ -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] diff --git a/framework/core/js/src/admin/AdminApplication.tsx b/framework/core/js/src/admin/AdminApplication.tsx index 753b6299a..fd00ac806 100644 --- a/framework/core/js/src/admin/AdminApplication.tsx +++ b/framework/core/js/src/admin/AdminApplication.tsx @@ -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; -}; +} export enum DatabaseDriver { MySQL = 'MySQL', diff --git a/framework/core/js/src/admin/components/UserListPage.tsx b/framework/core/js/src/admin/components/UserListPage.tsx index 9c4cad77e..e2b64583c 100644 --- a/framework/core/js/src/admin/components/UserListPage.tsx +++ b/framework/core/js/src/admin/components/UserListPage.tsx @@ -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) { @@ -160,76 +156,13 @@ export default class UserListPage extends AdminPage { {/* Loading spinner that shows when a new page is being loaded */} {this.isLoadingPage && }
, - , + , ]; } @@ -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()); } } diff --git a/framework/core/js/src/common/Store.ts b/framework/core/js/src/common/Store.ts index 09051d18a..0fb22b164 100644 --- a/framework/core/js/src/common/Store.ts +++ b/framework/core/js/src/common/Store.ts @@ -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; } diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index 35b5a9245..82a6dc861 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -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'; diff --git a/framework/core/js/src/common/components/Input.tsx b/framework/core/js/src/common/components/Input.tsx index a09348bdf..da4eaddb5 100644 --- a/framework/core/js/src/common/components/Input.tsx +++ b/framework/core/js/src/common/components/Input.tsx @@ -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; diff --git a/framework/core/js/src/common/components/Pagination.tsx b/framework/core/js/src/common/components/Pagination.tsx new file mode 100644 index 000000000..1ce146df4 --- /dev/null +++ b/framework/core/js/src/common/components/Pagination.tsx @@ -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 extends Component { + view() { + const { total, perPage, currentPage, loadingPageNumber, onChange } = this.attrs; + + const totalPageCount = Math.ceil(total / perPage); + const moreData = totalPageCount > currentPage; + + return ( + + ); + } +} diff --git a/framework/core/js/src/common/components/SearchModal.tsx b/framework/core/js/src/common/components/SearchModal.tsx index 0fdafa3de..fe636981e 100644 --- a/framework/core/js/src/common/components/SearchModal.tsx +++ b/framework/core/js/src/common/components/SearchModal.tsx @@ -126,9 +126,9 @@ export default class SearchModal -
{this.tabItems().toArray()}
-
{this.activeTabItems().toArray()}
+
+
{this.tabItems().toArray()}
+
{this.activeTabItems().toArray()}
); } diff --git a/framework/core/js/src/common/states/PaginatedListState.ts b/framework/core/js/src/common/states/PaginatedListState.ts index 015e6fd4b..87ce55b09 100644 --- a/framework/core/js/src/common/states/PaginatedListState.ts +++ b/framework/core/js/src/common/states/PaginatedListState.ts @@ -47,6 +47,7 @@ export default abstract class PaginatedListState[] = []; protected params: P = {} as P; @@ -54,6 +55,7 @@ export default abstract class PaginatedListState(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 { 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[] { @@ -205,7 +225,7 @@ export default abstract class PaginatedListState= 0; i--) { + const decimal = Math.pow(1000, i + 1); + + if (size >= decimal) { + return (size / decimal).toFixed(1).replace(/\.0$/, '') + units[i]; + } + } + + return size.toString(); +} diff --git a/framework/core/less/admin/ExtensionPage.less b/framework/core/less/admin/ExtensionPage.less index f89ca446c..356193ebb 100644 --- a/framework/core/less/admin/ExtensionPage.less +++ b/framework/core/less/admin/ExtensionPage.less @@ -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; } } } diff --git a/framework/core/less/admin/UsersListPage.less b/framework/core/less/admin/UsersListPage.less index 3f92b72ab..2ca8f6457 100644 --- a/framework/core/less/admin/UsersListPage.less +++ b/framework/core/less/admin/UsersListPage.less @@ -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 diff --git a/framework/core/less/common/Alert.less b/framework/core/less/common/Alert.less index 4c2d844d8..d66d72302 100644 --- a/framework/core/less/common/Alert.less +++ b/framework/core/less/common/Alert.less @@ -74,3 +74,6 @@ align-items: center; gap: 8px; } +.Alert-container { + display: flex; +} diff --git a/framework/core/less/common/Badge.less b/framework/core/less/common/Badge.less index a1f94183c..aa025a941 100644 --- a/framework/core/less/common/Badge.less +++ b/framework/core/less/common/Badge.less @@ -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); +} diff --git a/framework/core/less/common/Form.less b/framework/core/less/common/Form.less index 5b8477965..41c8ca23e 100644 --- a/framework/core/less/common/Form.less +++ b/framework/core/less/common/Form.less @@ -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; diff --git a/framework/core/less/common/FormControl.less b/framework/core/less/common/FormControl.less index b5eded09d..08aac891f 100644 --- a/framework/core/less/common/FormControl.less +++ b/framework/core/less/common/FormControl.less @@ -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 { diff --git a/framework/core/less/common/Pagination.less b/framework/core/less/common/Pagination.less new file mode 100644 index 000000000..3c00ef0d3 --- /dev/null +++ b/framework/core/less/common/Pagination.less @@ -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; + } +} diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index 5daa435f2..4353866b3 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -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; } } diff --git a/framework/core/less/common/Tabs.less b/framework/core/less/common/Tabs.less new file mode 100644 index 000000000..eb5c1f9c3 --- /dev/null +++ b/framework/core/less/common/Tabs.less @@ -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; + } +} diff --git a/framework/core/less/common/common.less b/framework/core/less/common/common.less index 5063fe100..7ab01a78d 100644 --- a/framework/core/less/common/common.less +++ b/framework/core/less/common/common.less @@ -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"; diff --git a/framework/core/less/common/scaffolding.less b/framework/core/less/common/scaffolding.less index 58bb3e47d..bfa02fa37 100644 --- a/framework/core/less/common/scaffolding.less +++ b/framework/core/less/common/scaffolding.less @@ -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; diff --git a/framework/core/less/common/variables.less b/framework/core/less/common/variables.less index 04d1d4598..259b5ba35 100644 --- a/framework/core/less/common/variables.less +++ b/framework/core/less/common/variables.less @@ -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})"; diff --git a/framework/core/src/Api/Endpoint/Index.php b/framework/core/src/Api/Endpoint/Index.php index 92b31972a..c6f815f7c 100644 --- a/framework/core/src/Api/Endpoint/Index.php +++ b/framework/core/src/Api/Endpoint/Index.php @@ -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']); } diff --git a/framework/core/src/Api/Resource/AbstractDatabaseResource.php b/framework/core/src/Api/Resource/AbstractDatabaseResource.php index 4fb659f20..1968d6d82 100644 --- a/framework/core/src/Api/Resource/AbstractDatabaseResource.php +++ b/framework/core/src/Api/Resource/AbstractDatabaseResource.php @@ -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.'); } diff --git a/framework/core/src/Extension/Extension.php b/framework/core/src/Extension/Extension.php index 6250c8a88..c44f1e153 100644 --- a/framework/core/src/Extension/Extension.php +++ b/framework/core/src/Extension/Extension.php @@ -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);