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..f78ae5fa80 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,23 @@ export default class AdminNav extends Component { super.oninit(vnode); this.query = Stream(''); + this.collapsed = {}; + + // 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(); + for (const [category, extensions] of Object.entries(categorized)) { + if (extensions.some((ext) => ext.id === activeId)) { + this.collapsed[category] = false; + break; + } + } + } } view() { @@ -54,6 +72,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 +165,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) {