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 ? (
+
+ ) : (
+
+ {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) {