From 50f89d7117afd36612634e7ed994879394c5b152 Mon Sep 17 00:00:00 2001 From: IanM Date: Thu, 26 Feb 2026 14:04:07 +0000 Subject: [PATCH 1/2] feat(admin): collapsible extension categories, health widget, abandoned package support - AdminNav: replace static category headers with collapsible groups; each group shows a count badge, category icon, and expand/collapse chevron. Categories default to collapsed; the active extension's category is pre-expanded on load. Searching auto-expands matching categories. Abandoned extensions show a warning badge on both their sidebar entry and their parent category header. - AdminApplication: fully register all extension categories (moderation, discussion, authentication, formatting, infrastructure, analytics, other) so extensions declaring these categories are correctly grouped rather than falling back to 'feature'. Add analytics category for statistics-style extensions. - ExtensionsWidget: replace the old categorised extension grid (which duplicated the sidebar) with an Extension Health Widget showing: abandoned extensions (with/without replacement), suggested packages from enabled extensions, and a compact icon grid of disabled extensions. - ExtensionLinkButton: add tooltip showing extension title and version. - ExtensionManager: add getInstalledPackageNames() to expose the full flat list of installed Composer packages, used by the health widget to filter out already- installed suggestions. - AdminPayload: apply flarum.abandoned_overrides settings key on top of the installed.json-derived abandoned status, allowing a future scheduled Packagist check (e.g. from the package manager extension) to keep abandoned data fresh without touching core's parsing logic. - composer.json (extensions): assign correct categories to all monorepo extensions (moderation, discussion, formatting, infrastructure, analytics). - locale: add all category translation keys; add extension health widget strings. - LESS: AdminNav collapsible category styles (button reset, count badge, chevron, warning icon); ExtensionWidget health widget layout. Co-Authored-By: Claude Sonnet 4.6 --- extensions/akismet/composer.json | 2 +- extensions/approval/composer.json | 2 +- extensions/bbcode/composer.json | 2 +- extensions/embed/composer.json | 2 +- extensions/emoji/composer.json | 2 +- extensions/flags/composer.json | 2 +- extensions/gdpr/composer.json | 2 +- extensions/likes/composer.json | 2 +- extensions/lock/composer.json | 2 +- extensions/markdown/composer.json | 2 +- extensions/mentions/composer.json | 2 +- extensions/messages/composer.json | 2 +- extensions/nicknames/composer.json | 2 +- extensions/pusher/composer.json | 2 +- extensions/realtime/composer.json | 2 +- extensions/statistics/composer.json | 2 +- .../admin/components/MiniStatisticsWidget.tsx | 6 +- extensions/statistics/less/admin.less | 15 +- extensions/sticky/composer.json | 2 +- extensions/subscriptions/composer.json | 2 +- extensions/suspend/composer.json | 2 +- extensions/tags/composer.json | 2 +- .../core/js/src/admin/AdminApplication.tsx | 15 +- .../core/js/src/admin/components/AdminNav.js | 119 ++++++-- .../admin/components/ExtensionLinkButton.js | 13 + .../src/admin/components/ExtensionsWidget.js | 188 +++++++++--- framework/core/less/admin/AdminNav.less | 84 +++++- .../core/less/admin/ExtensionWidget.less | 271 ++++++++++++++---- framework/core/locale/core.yml | 19 ++ .../core/src/Admin/Content/AdminPayload.php | 16 +- .../core/src/Extension/ExtensionManager.php | 18 ++ 31 files changed, 671 insertions(+), 133 deletions(-) diff --git a/extensions/akismet/composer.json b/extensions/akismet/composer.json index 9c33eed2d9..139112f797 100644 --- a/extensions/akismet/composer.json +++ b/extensions/akismet/composer.json @@ -33,7 +33,7 @@ }, "flarum-extension": { "title": "Akismet", - "category": "feature", + "category": "moderation", "icon": { "image": "icon.jpg", "backgroundSize": "cover", diff --git a/extensions/approval/composer.json b/extensions/approval/composer.json index 3873cb0058..5e756bf933 100644 --- a/extensions/approval/composer.json +++ b/extensions/approval/composer.json @@ -33,7 +33,7 @@ }, "flarum-extension": { "title": "Approval", - "category": "feature", + "category": "moderation", "icon": { "name": "fas fa-check", "backgroundColor": "#ABDC88", diff --git a/extensions/bbcode/composer.json b/extensions/bbcode/composer.json index 1486f87e75..263c9a2623 100644 --- a/extensions/bbcode/composer.json +++ b/extensions/bbcode/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "BBCode", - "category": "feature", + "category": "formatting", "icon": { "name": "fas fa-bold", "backgroundColor": "#238C59", diff --git a/extensions/embed/composer.json b/extensions/embed/composer.json index 9808d51d44..af96dbd9aa 100644 --- a/extensions/embed/composer.json +++ b/extensions/embed/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Embed", - "category": "feature", + "category": "formatting", "icon": { "name": "fas fa-code", "backgroundColor": "#B9D233", diff --git a/extensions/emoji/composer.json b/extensions/emoji/composer.json index a3ad63382b..0c20f72f53 100644 --- a/extensions/emoji/composer.json +++ b/extensions/emoji/composer.json @@ -27,7 +27,7 @@ }, "flarum-extension": { "title": "Emoji", - "category": "feature", + "category": "formatting", "icon": { "image": "icon.svg", "backgroundColor": "#FECC4D" diff --git a/extensions/flags/composer.json b/extensions/flags/composer.json index de8c26b278..9e4f0f3f60 100644 --- a/extensions/flags/composer.json +++ b/extensions/flags/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Flags", - "category": "feature", + "category": "moderation", "icon": { "name": "fas fa-flag", "backgroundColor": "#D659B5", diff --git a/extensions/gdpr/composer.json b/extensions/gdpr/composer.json index 391d00dc18..209d4389e0 100644 --- a/extensions/gdpr/composer.json +++ b/extensions/gdpr/composer.json @@ -40,7 +40,7 @@ }, "flarum-extension": { "title": "GDPR Data Management", - "category": "feature", + "category": "moderation", "icon": { "image": "resources/logo.svg", "backgroundColor": "#EBF1FD", diff --git a/extensions/likes/composer.json b/extensions/likes/composer.json index db8ae3d03c..89c89e523d 100644 --- a/extensions/likes/composer.json +++ b/extensions/likes/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Likes", - "category": "feature", + "category": "discussion", "icon": { "name": "far fa-thumbs-up", "backgroundColor": "#3A649D", diff --git a/extensions/lock/composer.json b/extensions/lock/composer.json index b83de04d32..f681d1564a 100644 --- a/extensions/lock/composer.json +++ b/extensions/lock/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Lock", - "category": "feature", + "category": "moderation", "icon": { "name": "fas fa-lock", "backgroundColor": "#ddd", diff --git a/extensions/markdown/composer.json b/extensions/markdown/composer.json index a104befb26..27ba431fff 100644 --- a/extensions/markdown/composer.json +++ b/extensions/markdown/composer.json @@ -27,7 +27,7 @@ }, "flarum-extension": { "title": "Markdown", - "category": "feature", + "category": "formatting", "icon": { "image": "icon.svg", "backgroundColor": "#000", diff --git a/extensions/mentions/composer.json b/extensions/mentions/composer.json index 3dd7fde354..9c4c58093b 100644 --- a/extensions/mentions/composer.json +++ b/extensions/mentions/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Mentions", - "category": "feature", + "category": "discussion", "optional-dependencies": [ "flarum/tags" ], diff --git a/extensions/messages/composer.json b/extensions/messages/composer.json index 9f6bcbf54f..98068b4766 100644 --- a/extensions/messages/composer.json +++ b/extensions/messages/composer.json @@ -24,7 +24,7 @@ "extra": { "flarum-extension": { "title": "Messages", - "category": "feature", + "category": "discussion", "optional-dependencies": [ "flarum/tags" ], diff --git a/extensions/nicknames/composer.json b/extensions/nicknames/composer.json index f925731371..1f30921fd4 100644 --- a/extensions/nicknames/composer.json +++ b/extensions/nicknames/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Nicknames", - "category": "feature", + "category": "discussion", "icon": { "name": "fas fa-user-tag", "backgroundColor": "#8E4529", diff --git a/extensions/pusher/composer.json b/extensions/pusher/composer.json index 51483b293f..5c1f6f6c20 100644 --- a/extensions/pusher/composer.json +++ b/extensions/pusher/composer.json @@ -36,7 +36,7 @@ }, "flarum-extension": { "title": "Pusher", - "category": "feature", + "category": "infrastructure", "icon": { "image": "icon.png", "backgroundSize": "46% 63%", diff --git a/extensions/realtime/composer.json b/extensions/realtime/composer.json index c1246f3de6..a220daac68 100644 --- a/extensions/realtime/composer.json +++ b/extensions/realtime/composer.json @@ -33,7 +33,7 @@ }, "flarum-extension": { "title": "Realtime", - "category": "feature", + "category": "infrastructure", "icon": { "image": "resources/assets/logo.svg", "backgroundColor": "#E3E7F1", diff --git a/extensions/statistics/composer.json b/extensions/statistics/composer.json index eab6dd4e44..3f945882b6 100644 --- a/extensions/statistics/composer.json +++ b/extensions/statistics/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Statistics", - "category": "feature", + "category": "analytics", "icon": { "name": "fas fa-chart-bar", "backgroundColor": "#6932d1", diff --git a/extensions/statistics/js/src/admin/components/MiniStatisticsWidget.tsx b/extensions/statistics/js/src/admin/components/MiniStatisticsWidget.tsx index 7fe7cf226c..e860f96a2a 100644 --- a/extensions/statistics/js/src/admin/components/MiniStatisticsWidget.tsx +++ b/extensions/statistics/js/src/admin/components/MiniStatisticsWidget.tsx @@ -3,6 +3,7 @@ import app from 'flarum/admin/app'; import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget'; import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; import Link from 'flarum/common/components/Link'; +import Icon from 'flarum/common/components/Icon'; import abbreviateNumber from 'flarum/common/utils/abbreviateNumber'; @@ -46,7 +47,10 @@ export default class MiniStatisticsWidget extends DashboardWidget { content() { return (
-

{app.translator.trans('flarum-statistics.admin.statistics.mini_heading')}

+

+ + {app.translator.trans('flarum-statistics.admin.statistics.mini_heading')} +

diff --git a/extensions/statistics/less/admin.less b/extensions/statistics/less/admin.less index 82a17987aa..bd052f4639 100644 --- a/extensions/statistics/less/admin.less +++ b/extensions/statistics/less/admin.less @@ -6,12 +6,23 @@ padding: 0; &--mini { - padding-top: 20px; + padding-top: 0; } &-title { - margin: 0 20px; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 16px 20px 12px; + font-size: 13px; + font-weight: 600; color: var(--muted-color); + + .icon { + font-size: 12px; + opacity: 0.7; + } } &-entities { diff --git a/extensions/sticky/composer.json b/extensions/sticky/composer.json index b72e6c8851..2b49d1109c 100644 --- a/extensions/sticky/composer.json +++ b/extensions/sticky/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Sticky", - "category": "feature", + "category": "discussion", "icon": { "name": "fas fa-thumbtack", "backgroundColor": "#D13E32", diff --git a/extensions/subscriptions/composer.json b/extensions/subscriptions/composer.json index e587485506..dbed8a4061 100644 --- a/extensions/subscriptions/composer.json +++ b/extensions/subscriptions/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Subscriptions", - "category": "feature", + "category": "discussion", "optional-dependencies": [ "flarum/approval" ], diff --git a/extensions/suspend/composer.json b/extensions/suspend/composer.json index 95241cb09d..c19b48cb21 100644 --- a/extensions/suspend/composer.json +++ b/extensions/suspend/composer.json @@ -32,7 +32,7 @@ }, "flarum-extension": { "title": "Suspend", - "category": "feature", + "category": "moderation", "icon": { "name": "fas fa-ban", "backgroundColor": "#ddd", diff --git a/extensions/tags/composer.json b/extensions/tags/composer.json index f2103eb45d..5664f60637 100644 --- a/extensions/tags/composer.json +++ b/extensions/tags/composer.json @@ -37,7 +37,7 @@ }, "flarum-extension": { "title": "Tags", - "category": "feature", + "category": "discussion", "icon": { "name": "fas fa-tags", "backgroundColor": "#F28326", diff --git a/framework/core/js/src/admin/AdminApplication.tsx b/framework/core/js/src/admin/AdminApplication.tsx index 5e81303cdd..3dd7ca8f36 100644 --- a/framework/core/js/src/admin/AdminApplication.tsx +++ b/framework/core/js/src/admin/AdminApplication.tsx @@ -45,6 +45,7 @@ export interface Extension { }; }; require?: Record; + suggest?: Record; abandoned?: boolean | string; } @@ -56,6 +57,7 @@ export enum DatabaseDriver { export interface AdminApplicationData extends ApplicationData { extensions: Record; + installedPackages: string[]; settings: Record; modelStatistics: Record; displayNameDrivers: string[]; @@ -91,9 +93,16 @@ export default class AdminApplication extends Application { registry = new AdminRegistry(); extensionCategories: Record = { - feature: 30, - theme: 20, - language: 10, + feature: 100, + moderation: 90, + discussion: 80, + authentication: 70, + formatting: 60, + infrastructure: 55, + analytics: 52, + other: 50, + theme: 40, + language: 30, }; history: IHistory = { diff --git a/framework/core/js/src/admin/components/AdminNav.js b/framework/core/js/src/admin/components/AdminNav.js index dd4fb15ca3..54f58615e5 100644 --- a/framework/core/js/src/admin/components/AdminNav.js +++ b/framework/core/js/src/admin/components/AdminNav.js @@ -7,6 +7,7 @@ import getCategorizedExtensions from '../utils/getCategorizedExtensions'; import ItemList from '../../common/utils/ItemList'; import Stream from '../../common/utils/Stream'; import Input from '../../common/components/Input'; +import Icon from '../../common/components/Icon'; import extractText from '../../common/utils/extractText'; export default class AdminNav extends Component { @@ -14,6 +15,21 @@ export default class AdminNav extends Component { super.oninit(vnode); this.query = Stream(''); + this.collapsed = {}; + + // Pre-expand the category of the currently active extension + const currentRoute = m.route.get() || ''; + const extensionMatch = currentRoute.match(/\/extensions\/([^/?]+)/); + if (extensionMatch) { + const activeId = extensionMatch[1]; + const categorized = getCategorizedExtensions(); + for (const [category, extensions] of Object.entries(categorized)) { + if (extensions.some((ext) => ext.id === activeId)) { + this.collapsed[category] = false; + break; + } + } + } } view() { @@ -54,6 +70,16 @@ export default class AdminNav extends Component { } } + isCollapsed(category) { + if (this.query()) return false; + return this.collapsed[category] !== false; + } + + toggleCollapsed(category) { + this.collapsed[category] = !this.isCollapsed(category); + m.redraw(); + } + /** * Build an item list of main links to show in the admin navigation. * @@ -137,43 +163,88 @@ export default class AdminNav extends Component { return items; } + categoryIcon(category) { + const icons = { + analytics: 'fas fa-chart-bar', + authentication: 'fas fa-lock', + discussion: 'fas fa-comments', + feature: 'fas fa-star', + formatting: 'fas fa-paragraph', + infrastructure: 'fas fa-server', + language: 'fas fa-language', + moderation: 'fas fa-shield-alt', + other: 'fas fa-cube', + theme: 'fas fa-paint-brush', + }; + return icons[category] || 'fas fa-puzzle-piece'; + } + extensionItems() { const items = new ItemList(); const categorizedExtensions = getCategorizedExtensions(); const categories = app.extensionCategories; + const query = this.query().toUpperCase(); Object.keys(categorizedExtensions).map((category) => { - if (!this.query()) { - items.add( - `category-${category}`, -

{app.translator.trans(`core.admin.nav.categories.${category}`)}

, - categories[category] - ); - } + const extensions = categorizedExtensions[category]; + const count = extensions.length; + + // When searching, only show categories that have matching results + const matchingExtensions = extensions.filter((extension) => { + if (!query) return true; + const title = extension.extra['flarum-extension'].title || ''; + const description = extension.description || ''; + return title.toUpperCase().includes(query) || description.toUpperCase().includes(query); + }); + + if (query && matchingExtensions.length === 0) return; + + const isOpen = !this.isCollapsed(category); + + const abandonedInCategory = extensions.filter((ext) => ext.abandoned); + const hasDanger = abandonedInCategory.some((ext) => typeof ext.abandoned === 'string'); + const categoryBadgeType = abandonedInCategory.length > 0 ? (hasDanger ? 'danger' : 'warning') : null; + + items.add( + `category-${category}`, + , + categories[category] + ); - categorizedExtensions[category].map((extension) => { - const query = this.query().toUpperCase(); + if (!isOpen) return; + + matchingExtensions.map((extension) => { const title = extension.extra['flarum-extension'].title || ''; const description = extension.description || ''; const isAbandoned = extension.abandoned; const hasReplacement = typeof isAbandoned === 'string'; - if (!query || title.toUpperCase().includes(query) || description.toUpperCase().includes(query)) { - items.add( - `extension-${extension.id}`, - - {title} - {isAbandoned && !} - , - categories[category] - ); - } + items.add( + `extension-${extension.id}`, + + {title} + {isAbandoned && !} + , + categories[category] + ); }); }); diff --git a/framework/core/js/src/admin/components/ExtensionLinkButton.js b/framework/core/js/src/admin/components/ExtensionLinkButton.js index d97d883899..f2cb1081fb 100644 --- a/framework/core/js/src/admin/components/ExtensionLinkButton.js +++ b/framework/core/js/src/admin/components/ExtensionLinkButton.js @@ -1,10 +1,23 @@ import app from '../../admin/app'; import isExtensionEnabled from '../utils/isExtensionEnabled'; import LinkButton from '../../common/components/LinkButton'; +import Tooltip from '../../common/components/Tooltip'; import ItemList from '../../common/utils/ItemList'; import Icon from '../../common/components/Icon'; export default class ExtensionLinkButton extends LinkButton { + view(vnode) { + const extension = app.data.extensions[this.attrs.extensionId]; + const title = extension?.extra?.['flarum-extension']?.title || this.attrs.extensionId; + const tooltipText = extension?.version ? `${title}
${extension.version}` : title; + + return ( + + {super.view(vnode)} + + ); + } + getButtonContent(children) { const content = super.getButtonContent(children); const extension = app.data.extensions[this.attrs.extensionId]; diff --git a/framework/core/js/src/admin/components/ExtensionsWidget.js b/framework/core/js/src/admin/components/ExtensionsWidget.js index 5b1386d1d6..94916da533 100644 --- a/framework/core/js/src/admin/components/ExtensionsWidget.js +++ b/framework/core/js/src/admin/components/ExtensionsWidget.js @@ -1,63 +1,187 @@ import app from '../../admin/app'; import DashboardWidget from './DashboardWidget'; import isExtensionEnabled from '../utils/isExtensionEnabled'; -import getCategorizedExtensions from '../utils/getCategorizedExtensions'; import Link from '../../common/components/Link'; -import classList from '../../common/utils/classList'; import Icon from '../../common/components/Icon'; -export default class ExtensionsWidget extends DashboardWidget { - oninit(vnode) { - super.oninit(vnode); - - this.categorizedExtensions = getCategorizedExtensions(); - } +/** + * Mirrors Extension::nameToId() from PHP. + * "flarum/mentions" → "flarum-mentions" + * "flarum/flarum-ext-suspend" → "flarum-suspend" + * "some-vendor/foo" → "some-vendor-foo" + */ +function packageNameToExtensionId(packageName) { + const [vendor, pkg] = packageName.split('/'); + const stripped = pkg.replace(/^flarum-ext-/, '').replace(/^flarum-/, ''); + return `${vendor}-${stripped}`; +} +export default class ExtensionsWidget extends DashboardWidget { className() { return 'ExtensionsWidget'; } content() { - const categories = app.extensionCategories; + const abandoned = this.abandonedExtensions(); + const suggested = this.suggestedExtensions(); + const disabled = this.disabledExtensions(); - return ( + return [ +

+ + {app.translator.trans('core.admin.extensions-health-widget.title')} +

,
- {Object.keys(categories).map((category) => !!this.categorizedExtensions[category] && this.extensionCategory(category))} + {this.renderSection('abandoned', abandoned, this.renderAbandonedItem.bind(this))} + {this.renderSection('suggested', suggested, this.renderSuggestedItem.bind(this))} + {this.renderDisabledSection(disabled)} +
, + ]; + } + + renderSection(type, items, renderItem) { + const isHealthSection = type === 'abandoned' || type === 'suggested'; + + return ( +
+

+ {app.translator.trans(`core.admin.extensions-health-widget.section_${type}_heading`)} + {!!items.length && {items.length}} +

+

{app.translator.trans(`core.admin.extensions-health-widget.section_${type}_help`)}

+ {items.length ? ( +
    {items.map(renderItem)}
+ ) : ( +

+ {isHealthSection && } + {app.translator.trans(`core.admin.extensions-health-widget.section_${type}_empty`)} +

+ )}
); } - extensionCategory(category) { + renderDisabledSection(extensions) { return ( -
-

{app.translator.trans(`core.admin.nav.categories.${category}`)}

-
    {this.categorizedExtensions[category].map((extension) => this.extensionWidget(extension))}
+
+

+ {app.translator.trans('core.admin.extensions-health-widget.section_disabled_heading')} + {!!extensions.length && {extensions.length}} +

+

{app.translator.trans('core.admin.extensions-health-widget.section_disabled_help')}

+ {extensions.length ? ( +
    {extensions.map(this.renderDisabledItem.bind(this))}
+ ) : ( +

{app.translator.trans('core.admin.extensions-health-widget.section_disabled_empty')}

+ )}
); } - extensionWidget(extension) { - const isAbandoned = extension.abandoned; - const hasReplacement = typeof isAbandoned === 'string'; + renderAbandonedItem(item) { + const { extension } = item; + const hasReplacement = typeof extension.abandoned === 'string'; + const title = extension.extra['flarum-extension'].title; + + return ( +
  • + + + {!!extension.icon && } + + + + {title} + + + + {hasReplacement + ? app.translator.trans('core.admin.extensions-health-widget.abandoned_with_replacement', { + replacement: {extension.abandoned}, + }) + : app.translator.trans('core.admin.extensions-health-widget.abandoned_no_replacement')} + + + +
  • + ); + } + + renderSuggestedItem(item) { + const { packageId, description, suggestedBy } = item; + const packagistUrl = `https://packagist.org/packages/${packageId}`; return ( -
  • +
  • + + + + {packageId} + + + + {app.translator.trans('core.admin.extensions-health-widget.suggested_by', { + extension: {suggestedBy}, + })} + {description ? ` — ${description}` : ''} + + + +
  • + ); + } + + renderDisabledItem(extension) { + const title = extension.extra['flarum-extension'].title; + + return ( +
  • -
    -
    - - {!!extension.icon && } - - {isAbandoned && ( - - - - )} -
    - {extension.extra['flarum-extension'].title} -
    + + {!!extension.icon && } + + {title}
  • ); } + + abandonedExtensions() { + return Object.values(app.data.extensions) + .filter((ext) => ext.abandoned) + .map((extension) => ({ extension })); + } + + suggestedExtensions() { + // Authoritative list of all installed composer packages (including non-extension libraries) + const installedPackageNames = new Set(app.data.installedPackages ?? []); + const suggestions = []; + const seen = new Set(); + + for (const ext of Object.values(app.data.extensions)) { + if (!ext.suggest) continue; + + for (const [packageId, description] of Object.entries(ext.suggest)) { + // Only surface vendor/package style entries (skip PHP ext-* etc.) + if (!packageId.includes('/')) continue; + if (seen.has(packageId)) continue; + + // Skip if already installed (as any composer package) + if (installedPackageNames.has(packageId)) continue; + + seen.add(packageId); + suggestions.push({ + packageId, + description, + suggestedBy: ext.extra['flarum-extension'].title, + }); + } + } + + return suggestions; + } + + disabledExtensions() { + return Object.values(app.data.extensions).filter((ext) => !isExtensionEnabled(ext.id) && !ext.abandoned); + } } diff --git a/framework/core/less/admin/AdminNav.less b/framework/core/less/admin/AdminNav.less index 502f3310fa..26936be75a 100644 --- a/framework/core/less/admin/AdminNav.less +++ b/framework/core/less/admin/AdminNav.less @@ -178,9 +178,84 @@ } .ExtensionListTitle { + // Reset button styles + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + width: 100%; + + // Layout + display: flex; + align-items: center; + gap: 6px; + margin: 12px 0 4px 0; + padding: 0 10px 0 15px; + + // Typography color: var(--muted-color); - text-transform: uppercase; - margin: 25px 0 8px 15px; + font-size: inherit; + font-weight: inherit; + + &:hover { + color: var(--text-color); + + .ExtensionListTitle-chevron { + opacity: 0.8; + } + } +} + +.ExtensionListTitle-icon { + font-size: 11px; + flex-shrink: 0; + width: 14px; + text-align: center; +} + +.ExtensionListTitle-label { + flex: 1; +} + +.ExtensionListTitle-badge { + .Badge--size(16px); + font-size: 10px; + font-weight: bold; + flex-shrink: 0; + + &.Badge--danger { + --badge-bg: @error-color; + --badge-color: var(--text-on-dark); + } + + &.Badge--warning { + --badge-bg: @alert-color; + --badge-color: var(--text-on-dark); + } +} + +.ExtensionListTitle-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--primary-color); + font-size: 10px; + font-weight: 700; + color: var(--primary-color-invert, #fff); + text-transform: none; + opacity: 0.75; +} + +.ExtensionListTitle-chevron { + font-size: 9px; + opacity: 0.5; + flex-shrink: 0; + transition: transform 0.15s ease; } .ExtensionListItem-Dot { @@ -197,7 +272,12 @@ .Button-label { max-width: ~"calc(100% - 72px)"; overflow: hidden; + } + + .Button-labelText { + overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } } diff --git a/framework/core/less/admin/ExtensionWidget.less b/framework/core/less/admin/ExtensionWidget.less index fc30021c4b..f61d1e81b8 100644 --- a/framework/core/less/admin/ExtensionWidget.less +++ b/framework/core/less/admin/ExtensionWidget.less @@ -1,85 +1,264 @@ .ExtensionsWidget { - background-color: var(--body-bg); + // Let .Widget provide the slate background and padding. + // Override padding to 0 so sections sit flush to the card edges. padding: 0; + overflow: hidden; } -.ExtensionsWidget-list { - padding: 0; - background-color: var(--body-bg); +.ExtensionsWidget-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 16px 20px 12px; + font-size: 13px; + font-weight: 600; + color: var(--muted-color); + + .icon { + font-size: 12px; + opacity: 0.7; + } +} - .ExtensionGroup { - margin-bottom: 20px; +// Healthy empty state +.ExtensionsWidget-healthy { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 20px 20px; + color: var(--muted-color); - h3 { - color: var(--muted-color); - text-transform: uppercase; - font-size: 12px; - margin: 0 0 10px; - } + .icon { + color: var(--enabled-color); + font-size: 18px; } +} - .ExtensionList { - padding: 0; - list-style: none; - display: grid; - grid-gap: 10px; - grid-template-columns: repeat(auto-fit, 90px); - margin-bottom: 0; - - > li { - text-align: left; - position: relative; - display: block; - } +// Section container +.ExtensionsWidget-list { + padding: 0; +} + +.ExtensionsWidget-section { + & + & { + border-top: 1px solid var(--body-bg); } } -.ExtensionList-Category { - background: var(--control-bg); - padding: 20px 0 20px 20px; - margin-bottom: 20px; - border-radius: var(--border-radius); +.ExtensionsWidget-sectionHeading { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 10px 20px 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted-color); + opacity: 0.75; } -.ExtensionList-Label { - margin-top: 0; +.ExtensionsWidget-sectionHelp { + margin: 0; + padding: 0 20px 6px; + font-size: 11px; color: var(--muted-color); + opacity: 0.6; + line-height: 1.4; } -.ExtensionListItem.disabled { - .ExtensionListItem-title { - opacity: 0.5; - color: var(--muted-color); +.ExtensionsWidget-sectionCount { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: var(--body-bg); + font-size: 10px; + font-weight: 700; + color: var(--muted-color); +} + +.ExtensionsWidget-section--abandoned.ExtensionsWidget-section--has-items { + .ExtensionsWidget-sectionHeading { + color: @error-color; + opacity: 1; } - .ExtensionListItem-icon { - opacity: 0.5; + .ExtensionsWidget-sectionCount { + background: fadeout(@error-color, 85%); + color: @error-color; } } -.ExtensionListItem { - transition: .15s ease-in-out; +// Item list +.ExtensionsWidget-itemList { + list-style: none; + padding: 0 0 8px; + margin: 0; +} +// Individual item +.ExtensionsWidget-item { &:hover { - transform: scale(1.05); + background: var(--body-bg); } +} + +.ExtensionsWidget-itemLink { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 20px; + text-decoration: none; + color: inherit; - a:hover { + &:hover { text-decoration: none; + color: inherit; } } -.ExtensionListItem-title { +.ExtensionsWidget-itemIcon { + flex-shrink: 0; + border-radius: 4px; +} + +.ExtensionsWidget-itemBody { + flex: 1; + min-width: 0; +} + +.ExtensionsWidget-itemName { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ExtensionsWidget-externalIcon { + font-size: 9px; + margin-left: 5px; + color: var(--primary-color); + opacity: 0.7; + vertical-align: middle; +} + +.ExtensionsWidget-itemDetail { display: block; + font-size: 11px; + color: var(--muted-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.ExtensionsWidget-itemIndicator { + display: inline; + margin-left: 5px; + font-size: 11px; + vertical-align: middle; + + .ExtensionsWidget-item--danger & { + color: @error-color; + } + + .ExtensionsWidget-item--warning & { + color: @alert-color; + } +} + +// Section empty state +.ExtensionsWidget-sectionEmpty { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 4px 20px 12px; + font-size: 12px; + color: var(--muted-color); + opacity: 0.7; + + &--ok { + opacity: 1; + color: var(--enabled-color); + } +} + +.ExtensionsWidget-okIcon { + font-size: 13px; + flex-shrink: 0; +} + +// Disabled extensions grid +.ExtensionsWidget-disabledGrid { + list-style: none; + margin: 0; + padding: 2px 12px 12px; + display: grid; + grid-template-columns: repeat(auto-fill, 80px); + gap: 2px; +} + +.ExtensionsWidget-disabledItem { text-align: center; + + a { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 6px; + border-radius: var(--border-radius); + text-decoration: none; + opacity: 0.5; + transition: opacity 0.15s, background 0.15s; + + &:hover { + opacity: 1; + background: var(--body-bg); + text-decoration: none; + } + } +} + +.ExtensionsWidget-disabledIcon { + border-radius: 8px; + flex-shrink: 0; +} + +.ExtensionsWidget-disabledName { + display: block; margin-top: 5px; + font-size: 10px; + line-height: 1.2; color: var(--text-color); + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +// Keep ExtensionIcon base styles (used in sidebar and elsewhere) .ExtensionIcon { --size: 90px; width: var(--size); height: var(--size); + + &.ExtensionsWidget-disabledIcon { + --size: 54px; + } + + &.ExtensionsWidget-itemIcon { + --size: 28px; + } background: var(--control-bg); color: var(--control-color); border-radius: 6px; @@ -91,11 +270,7 @@ vertical-align: middle; } -.ExtensionListItem-icon-wrapper { - position: relative; - display: inline-block; -} - +// Badge used on extension pages (kept for ExtensionPage use) .ExtensionListItem-badge { position: absolute; bottom: -5px; diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index fe2174de7b..dab8b70f16 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -188,6 +188,23 @@ core: toggle_advanced_page_button: Toggle Advanced Page tools_button: Tools + # These translations are used in the extensions health widget on the dashboard. + extensions-health-widget: + abandoned_no_replacement: No replacement available + abandoned_with_replacement: "Replaced by {replacement}" + all_healthy: All extensions are healthy. + section_abandoned_empty: No issues found. + section_suggested_empty: No suggestions. + section_disabled_empty: All extensions are enabled. + title: Extension Health + section_abandoned_heading: Needs Attention + section_abandoned_help: These extensions are no longer maintained or may have compatibility issues. Consider updating, replacing, or removing them. + section_suggested_heading: Suggested + section_suggested_help: Flarum extensions can add optional extra features by specifying other extensions or packages. These might add more features or integrations. + section_disabled_heading: Disabled + section_disabled_help: These extensions are installed but not currently enabled. Enable them from their settings page, or remove them if no longer needed. + suggested_by: "Suggested by {extension}" + # These translations are used in the debug warning widget. debug-warning: detail: | @@ -318,11 +335,13 @@ core: basics_button: => core.admin.basics.title basics_title: => core.admin.basics.description categories: + analytics: Analytics authentication: Authentication core: Core Configuration discussion: Discussion feature: Features formatting: Formatting + infrastructure: Infrastructure language: Languages moderation: Moderation other: Other Extensions diff --git a/framework/core/src/Admin/Content/AdminPayload.php b/framework/core/src/Admin/Content/AdminPayload.php index ac8c16bebc..02c7cde86e 100644 --- a/framework/core/src/Admin/Content/AdminPayload.php +++ b/framework/core/src/Admin/Content/AdminPayload.php @@ -53,7 +53,21 @@ public function __invoke(Document $document, Request $request): void $document->payload['settings'] = $settings; $document->payload['permissions'] = Permission::map(); - $document->payload['extensions'] = $this->extensions->getExtensions()->toArray(); + $extensions = $this->extensions->getExtensions()->toArray(); + + // Allow a stored override (e.g. written by a future scheduled Packagist check) to supplement + // or correct the abandoned status derived from installed.json at Composer install time. + // Format: JSON object mapping extension ID → false|true|"replacement/package" + // A future scheduler task can write to this settings key without touching core parsing logic. + $abandonedOverrides = json_decode($this->settings->get('flarum.abandoned_overrides', '{}'), true) ?? []; + foreach ($abandonedOverrides as $id => $status) { + if (isset($extensions[$id])) { + $extensions[$id]['abandoned'] = $status; + } + } + + $document->payload['extensions'] = $extensions; + $document->payload['installedPackages'] = $this->extensions->getInstalledPackageNames(); $document->payload['displayNameDrivers'] = array_keys($this->container->make('flarum.user.display_name.supported_drivers')); $document->payload['slugDrivers'] = array_map(array_keys(...), $this->container->make('flarum.http.slugDrivers')); diff --git a/framework/core/src/Extension/ExtensionManager.php b/framework/core/src/Extension/ExtensionManager.php index d1fd899125..7cc1b8649c 100644 --- a/framework/core/src/Extension/ExtensionManager.php +++ b/framework/core/src/Extension/ExtensionManager.php @@ -113,6 +113,24 @@ public function getExtensions(): Collection return $this->extensions; } + /** + * Returns a flat list of all installed composer package names (including non-extension packages). + * Useful for checking whether a suggested package is already installed. + * + * @return string[] + */ + public function getInstalledPackageNames(): array + { + if (! $this->filesystem->exists($this->paths->vendor.'/composer/installed.json')) { + return []; + } + + $installed = json_decode($this->filesystem->get($this->paths->vendor.'/composer/installed.json'), true); + $installed = $installed['packages'] ?? $installed; + + return array_values(array_filter(array_column($installed, 'name'))); + } + public function getExtensionsById(array $ids): Collection { return $this->getExtensions()->filter(function (Extension $extension) use ($ids) { From c3bfb9f8e47b5d32f1c663c5fa82505a150eb763 Mon Sep 17 00:00:00 2001 From: IanM Date: Thu, 26 Feb 2026 14:17:53 +0000 Subject: [PATCH 2/2] fix(admin): expand active extension category on hard reload m.route.get() returns null during oninit on a hard page reload before Mithril has processed the hash route. Fall back to window.location.hash so the correct category is pre-expanded when navigating directly to an extension URL (e.g. /admin#/extension/flarum-flags). Co-Authored-By: Claude Sonnet 4.6 --- framework/core/js/src/admin/components/AdminNav.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/framework/core/js/src/admin/components/AdminNav.js b/framework/core/js/src/admin/components/AdminNav.js index 54f58615e5..f78ae5fa80 100644 --- a/framework/core/js/src/admin/components/AdminNav.js +++ b/framework/core/js/src/admin/components/AdminNav.js @@ -17,9 +17,11 @@ export default class AdminNav extends Component { this.query = Stream(''); this.collapsed = {}; - // Pre-expand the category of the currently active extension - const currentRoute = m.route.get() || ''; - const extensionMatch = currentRoute.match(/\/extensions\/([^/?]+)/); + // Pre-expand the category of the currently active extension. + // m.route.get() may be null on a hard reload before Mithril has processed + // the hash, so fall back to parsing window.location.hash directly. + const currentRoute = m.route.get() || window.location.hash.replace(/^#/, ''); + const extensionMatch = currentRoute.match(/\/extensions?\/([^/?]+)/); if (extensionMatch) { const activeId = extensionMatch[1]; const categorized = getCategorizedExtensions();