Upgrade plugin system: lifecycle hooks, dependency checks, enabled gating
Local Build and Deploy / deploy (push) Failing after 48s
Local Build and Deploy / deploy (push) Failing after 48s
- PluginDefinition: add license, homepage, dependencies, onEnable, onDisable - store.ts: call onEnable/onDisable hooks on toggle, fix circular dependency - admin-nav.ts: async allEnabledHubs/allEnabledNavGroups filters by enabled state - layout.tsx: pass filtered nav groups to sidebar - client.tsx: show Disabled/Missing deps badges, dim disabled plugins - API: return dependency info, lifecycle hooks on PATCH - Radio depends on API, Events depends on Notifications
This commit is contained in:
1 parent
d56c48a0b3
commit
127d5dcfa6
9 files changed
+197
-78
No files matched your search
@@ -10,7 +10,7 @@ import { AdminTopbar } from "@/components/admin/admin-topbar";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { allNavGroups } from "@/lib/admin-nav";
|
||||
import { allEnabledNavGroups } from "@/lib/admin-nav";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -32,8 +32,10 @@ export default async function AdminLayout({
|
||||
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
|
||||
}
|
||||
|
||||
const navGroups = await allEnabledNavGroups();
|
||||
|
||||
return (
|
||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} navGroups={navGroups} />}>
|
||||
<div
|
||||
data-admin
|
||||
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
|
||||
@@ -50,8 +52,10 @@ export default async function AdminLayout({
|
||||
|
||||
async function Sidebar({
|
||||
staff,
|
||||
navGroups,
|
||||
}: {
|
||||
staff: { id: number; username: string; rank: number };
|
||||
navGroups: import("@/lib/admin-nav").AdminNavGroup[];
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.nav");
|
||||
const initial = staff.username.charAt(0).toUpperCase();
|
||||
@@ -72,7 +76,7 @@ async function Sidebar({
|
||||
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">
|
||||
{staff.username}
|
||||
</div>
|
||||
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
|
||||
<span className="inline-block mt-0.5 text-[0.55rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
|
||||
{t("rankLabel", { rank: staff.rank })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -83,7 +87,7 @@ async function Sidebar({
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
{allNavGroups().map((group, gi) => {
|
||||
{navGroups.map((group, gi) => {
|
||||
const GroupIcon = group.icon;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -7,17 +7,39 @@ import type { PluginDefinition, PluginConfigField } from "@/lib/plugins/types";
|
||||
interface PluginWithState extends PluginDefinition {
|
||||
enabled: boolean;
|
||||
config: Record<string, string | boolean>;
|
||||
missingDeps: string[];
|
||||
allDepIds: string[];
|
||||
}
|
||||
|
||||
export function PluginsClient() {
|
||||
const [plugins, setPlugins] = useState<PluginWithState[]>([]);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/plugins");
|
||||
const data = await res.json();
|
||||
setPlugins(data.plugins ?? []);
|
||||
const allDepIds = new Set<string>();
|
||||
for (const p of data.plugins ?? []) {
|
||||
if (p.dependencies) {
|
||||
for (const d of p.dependencies) allDepIds.add(d);
|
||||
}
|
||||
}
|
||||
const items: PluginWithState[] = (data.plugins ?? []).map(
|
||||
(p: PluginDefinition & { enabled: boolean; config: Record<string, string | boolean> }) => {
|
||||
const missing =
|
||||
p.dependencies?.filter(
|
||||
(d: string) => !allDepIds.has(d),
|
||||
) ?? [];
|
||||
return {
|
||||
...p,
|
||||
missingDeps: missing,
|
||||
allDepIds: Array.from(allDepIds),
|
||||
};
|
||||
},
|
||||
);
|
||||
setPlugins(items);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
@@ -26,6 +48,7 @@ export function PluginsClient() {
|
||||
}, [load]);
|
||||
|
||||
const toggleEnabled = async (id: string, current: boolean) => {
|
||||
setToggling(id);
|
||||
try {
|
||||
await fetch(`/api/admin/plugins`, {
|
||||
method: "PATCH",
|
||||
@@ -34,6 +57,7 @@ export function PluginsClient() {
|
||||
});
|
||||
load();
|
||||
} catch {}
|
||||
setToggling(null);
|
||||
};
|
||||
|
||||
const saveConfig = async (id: string, config: Record<string, string | boolean>) => {
|
||||
@@ -48,6 +72,9 @@ export function PluginsClient() {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const hasDependencyIssues = (p: PluginWithState) =>
|
||||
p.dependencies?.length && p.missingDeps.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{plugins.length === 0 && (
|
||||
@@ -58,16 +85,26 @@ export function PluginsClient() {
|
||||
{plugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="admin-card p-4 flex items-start justify-between gap-4"
|
||||
className={`admin-card p-4 flex items-start justify-between gap-4 ${!plugin.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text)]">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<span className="text-[0.55rem] font-bold px-1.5 py-0.5 rounded-full bg-[var(--admin-accent)]/12 text-[var(--admin-accent)]">
|
||||
v{plugin.version}
|
||||
</span>
|
||||
{!plugin.enabled && (
|
||||
<span className="text-[0.55rem] font-bold px-1.5 py-0.5 rounded-full" style={{ backgroundColor: "rgba(245,158,11,0.15)", color: "var(--admin-text)" }}>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
{hasDependencyIssues(plugin) && (
|
||||
<span className="text-[0.55rem] font-bold px-1.5 py-0.5 rounded-full" style={{ backgroundColor: "rgba(239,68,68,0.15)", color: "var(--admin-text)" }} title={`Missing: ${plugin.missingDeps.join(", ")}`}>
|
||||
Missing deps
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
||||
{plugin.description}
|
||||
@@ -75,6 +112,10 @@ export function PluginsClient() {
|
||||
<div className="flex items-center gap-3 mt-2 text-[0.6rem] text-[var(--admin-text-muted)]">
|
||||
<span>ID: {plugin.id}</span>
|
||||
{plugin.author && <span>Author: {plugin.author}</span>}
|
||||
{plugin.license && <span>License: {plugin.license}</span>}
|
||||
{plugin.dependencies && plugin.dependencies.length > 0 && (
|
||||
<span>Deps: {plugin.dependencies.join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plugin.configFields && plugin.configFields.length > 0 && (
|
||||
@@ -105,9 +146,12 @@ export function PluginsClient() {
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={plugin.enabled}
|
||||
disabled={toggling === plugin.id}
|
||||
onChange={() => toggleEnabled(plugin.id, plugin.enabled)}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-[var(--admin-text-muted)]/30 rounded-full peer peer-checked:bg-[var(--admin-accent)] after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
|
||||
<div
|
||||
className={`w-9 h-5 rounded-full peer peer-checked:bg-[var(--admin-accent)] after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full ${toggling === plugin.id ? "bg-[var(--admin-accent)]/50" : "bg-[var(--admin-text-muted)]/30"}`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getAllPluginStates,
|
||||
setPluginEnabled,
|
||||
setPluginConfig,
|
||||
isPluginEnabled,
|
||||
} from "@/lib/plugins/store";
|
||||
|
||||
async function checkAuth() {
|
||||
@@ -25,16 +26,25 @@ export async function GET() {
|
||||
const all = pluginRegistry.getAll();
|
||||
const states = await getAllPluginStates();
|
||||
|
||||
const plugins = all.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
version: p.version,
|
||||
author: p.author,
|
||||
configFields: p.configFields ?? [],
|
||||
enabled: states.enabled[p.id] ?? true,
|
||||
config: states.configs[p.id] ?? {},
|
||||
}));
|
||||
const allDepIds = new Set(all.map((p) => p.id));
|
||||
|
||||
const plugins = all.map((p) => {
|
||||
const missing = (p.dependencies ?? []).filter((d) => !allDepIds.has(d));
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
version: p.version,
|
||||
author: p.author,
|
||||
license: p.license,
|
||||
homepage: p.homepage,
|
||||
dependencies: p.dependencies ?? [],
|
||||
configFields: p.configFields ?? [],
|
||||
enabled: states.enabled[p.id] ?? p.enabled ?? true,
|
||||
config: states.configs[p.id] ?? {},
|
||||
missingDeps: missing,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ plugins });
|
||||
}
|
||||
|
||||
+54
-27
@@ -18,6 +18,7 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { pluginRegistry } from "@/lib/plugins";
|
||||
import { getAllPluginStates } from "@/lib/plugins/store";
|
||||
import "@/lib/plugins/loader";
|
||||
|
||||
export interface AdminHubTab {
|
||||
@@ -346,25 +347,33 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function allHubs(): AdminHubDefinition[] {
|
||||
const pluginHubs: AdminHubDefinition[] = pluginRegistry.getHubs().map((h) => ({
|
||||
id: h.id,
|
||||
titleKey: h.titleKey,
|
||||
subtitleKey: h.subtitleKey,
|
||||
icon: h.icon,
|
||||
prefixes: h.prefixes,
|
||||
tabs: h.tabs.map((t) => ({
|
||||
href: t.href,
|
||||
labelKey: t.labelKey,
|
||||
match: t.match,
|
||||
group: t.group,
|
||||
})),
|
||||
}));
|
||||
export function allHubs(enabled?: Record<string, boolean>): AdminHubDefinition[] {
|
||||
const allReg = pluginRegistry.getHubs();
|
||||
const pluginHubs: AdminHubDefinition[] = [];
|
||||
for (const h of allReg) {
|
||||
const hubPlugin = pluginRegistry
|
||||
.getAll()
|
||||
.find((p) => (p.hubs ?? []).some((ph) => ph.id === h.id));
|
||||
if (hubPlugin && enabled && enabled[hubPlugin.id] === false) continue;
|
||||
pluginHubs.push({
|
||||
id: h.id,
|
||||
titleKey: h.titleKey,
|
||||
subtitleKey: h.subtitleKey,
|
||||
icon: h.icon,
|
||||
prefixes: h.prefixes,
|
||||
tabs: h.tabs.map((t) => ({
|
||||
href: t.href,
|
||||
labelKey: t.labelKey,
|
||||
match: t.match,
|
||||
group: t.group,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return [...ADMIN_HUBS, ...pluginHubs];
|
||||
}
|
||||
|
||||
export function findAdminHub(pathname: string): AdminHubDefinition | null {
|
||||
const hubs = allHubs();
|
||||
export function findAdminHub(pathname: string, enabled?: Record<string, boolean>): AdminHubDefinition | null {
|
||||
const hubs = allHubs(enabled);
|
||||
return (
|
||||
hubs.find((hub) =>
|
||||
hub.prefixes.some((p) => pathname === p || pathname.startsWith(`${p}/`)),
|
||||
@@ -372,17 +381,25 @@ export function findAdminHub(pathname: string): AdminHubDefinition | null {
|
||||
);
|
||||
}
|
||||
|
||||
export function allNavGroups(): AdminNavGroup[] {
|
||||
const pluginGroups: AdminNavGroup[] = pluginRegistry.getNavGroups().map((g) => ({
|
||||
labelKey: g.labelKey,
|
||||
icon: g.icon,
|
||||
items: g.items.map((item) => ({
|
||||
href: item.href,
|
||||
labelKey: item.labelKey,
|
||||
icon: item.icon,
|
||||
matchPrefixes: item.matchPrefixes,
|
||||
})),
|
||||
}));
|
||||
export function allNavGroups(enabled?: Record<string, boolean>): AdminNavGroup[] {
|
||||
const allReg = pluginRegistry.getNavGroups();
|
||||
const pluginGroups: AdminNavGroup[] = [];
|
||||
for (const g of allReg) {
|
||||
const groupPlugin = pluginRegistry
|
||||
.getAll()
|
||||
.find((p) => (p.navGroups ?? []).some((pg) => pg.labelKey === g.labelKey));
|
||||
if (groupPlugin && enabled && enabled[groupPlugin.id] === false) continue;
|
||||
pluginGroups.push({
|
||||
labelKey: g.labelKey,
|
||||
icon: g.icon,
|
||||
items: g.items.map((item) => ({
|
||||
href: item.href,
|
||||
labelKey: item.labelKey,
|
||||
icon: item.icon,
|
||||
matchPrefixes: item.matchPrefixes,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (pluginGroups.length === 0) return ADMIN_NAV_GROUPS;
|
||||
return [
|
||||
...ADMIN_NAV_GROUPS,
|
||||
@@ -394,6 +411,16 @@ export function allNavGroups(): AdminNavGroup[] {
|
||||
];
|
||||
}
|
||||
|
||||
export async function allEnabledHubs(): Promise<AdminHubDefinition[]> {
|
||||
const states = await getAllPluginStates();
|
||||
return allHubs(states.enabled);
|
||||
}
|
||||
|
||||
export async function allEnabledNavGroups(): Promise<AdminNavGroup[]> {
|
||||
const states = await getAllPluginStates();
|
||||
return allNavGroups(states.enabled);
|
||||
}
|
||||
|
||||
function hubPrefixes(id: string): string[] {
|
||||
return ADMIN_HUBS.find((h) => h.id === id)?.prefixes ?? [];
|
||||
}
|
||||
|
||||
+35
-27
@@ -10,38 +10,16 @@ interface PluginStoreData {
|
||||
configs: Record<string, Record<string, string | boolean>>;
|
||||
}
|
||||
|
||||
function defaultStore(): PluginStoreData {
|
||||
const plugins = pluginRegistry.getAll();
|
||||
const enabled: Record<string, boolean> = {};
|
||||
const configs: Record<string, Record<string, string | boolean>> = {};
|
||||
for (const p of plugins) {
|
||||
enabled[p.id] = p.enabled ?? true;
|
||||
if (p.configFields) {
|
||||
const cfg: Record<string, string | boolean> = {};
|
||||
for (const field of p.configFields) {
|
||||
cfg[field.key] = field.default;
|
||||
}
|
||||
configs[p.id] = cfg;
|
||||
}
|
||||
}
|
||||
return { enabled, configs };
|
||||
}
|
||||
|
||||
async function load(): Promise<PluginStoreData> {
|
||||
try {
|
||||
const row = await prisma.websiteSetting.findUnique({
|
||||
where: { key: STORE_KEY },
|
||||
});
|
||||
if (row?.value) {
|
||||
const parsed = JSON.parse(row.value) as PluginStoreData;
|
||||
const defs = defaultStore();
|
||||
return {
|
||||
enabled: { ...defs.enabled, ...parsed.enabled },
|
||||
configs: { ...defs.configs, ...parsed.configs },
|
||||
};
|
||||
return JSON.parse(row.value) as PluginStoreData;
|
||||
}
|
||||
} catch {}
|
||||
return defaultStore();
|
||||
return { enabled: {}, configs: {} };
|
||||
}
|
||||
|
||||
async function save(data: PluginStoreData): Promise<void> {
|
||||
@@ -56,7 +34,12 @@ async function save(data: PluginStoreData): Promise<void> {
|
||||
|
||||
export async function isPluginEnabled(pluginId: string): Promise<boolean> {
|
||||
const data = await load();
|
||||
return data.enabled[pluginId] ?? true;
|
||||
const saved = data.enabled[pluginId];
|
||||
if (saved === undefined) {
|
||||
const def = pluginRegistry.get(pluginId);
|
||||
return def?.enabled ?? true;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(
|
||||
@@ -64,15 +47,33 @@ export async function setPluginEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const data = await load();
|
||||
|
||||
const prev = data.enabled[pluginId];
|
||||
if (prev === enabled) return;
|
||||
|
||||
data.enabled[pluginId] = enabled;
|
||||
await save(data);
|
||||
|
||||
const plugin = pluginRegistry.get(pluginId);
|
||||
if (enabled) {
|
||||
await plugin?.onEnable?.();
|
||||
} else {
|
||||
await plugin?.onDisable?.();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPluginConfig(
|
||||
pluginId: string,
|
||||
): Promise<Record<string, string | boolean>> {
|
||||
const data = await load();
|
||||
return data.configs[pluginId] ?? {};
|
||||
const saved = data.configs[pluginId] ?? {};
|
||||
const plugin = pluginRegistry.get(pluginId);
|
||||
if (!plugin?.configFields) return saved;
|
||||
const defaults: Record<string, string | boolean> = {};
|
||||
for (const f of plugin.configFields) {
|
||||
defaults[f.key] = f.default;
|
||||
}
|
||||
return { ...defaults, ...saved };
|
||||
}
|
||||
|
||||
export async function setPluginConfig(
|
||||
@@ -88,5 +89,12 @@ export async function getAllPluginStates(): Promise<{
|
||||
enabled: Record<string, boolean>;
|
||||
configs: Record<string, Record<string, string | boolean>>;
|
||||
}> {
|
||||
return load();
|
||||
const data = await load();
|
||||
const out = data;
|
||||
for (const p of pluginRegistry.getAll()) {
|
||||
if (out.enabled[p.id] === undefined) {
|
||||
out.enabled[p.id] = p.enabled ?? true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -66,7 +66,10 @@ export interface PluginDefinition {
|
||||
description: string;
|
||||
version: string;
|
||||
author?: string;
|
||||
license?: string;
|
||||
homepage?: string;
|
||||
enabled?: boolean;
|
||||
dependencies?: string[];
|
||||
hubs?: PluginHub[];
|
||||
navGroups?: PluginNavGroup[];
|
||||
permissions?: PluginPermission[];
|
||||
@@ -77,4 +80,6 @@ export interface PluginDefinition {
|
||||
|
||||
onLoad?: () => void | Promise<void>;
|
||||
onUnload?: () => void | Promise<void>;
|
||||
onEnable?: () => void | Promise<void>;
|
||||
onDisable?: () => void | Promise<void>;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ const plugin: PluginDefinition = {
|
||||
name: "Events & Polls",
|
||||
description: "Hotel events, calendar, polls and giveaways",
|
||||
version: "1.0.0",
|
||||
author: "Atom CMS",
|
||||
license: "MIT",
|
||||
dependencies: ["notifications"],
|
||||
enabled: true,
|
||||
configFields: [
|
||||
{ key: "enable_events", label: "Enable hotel events", type: "boolean", default: true },
|
||||
|
||||
@@ -6,6 +6,9 @@ const radioPlugin: PluginDefinition = {
|
||||
name: "Radio",
|
||||
description: "Radio stream, DJ tools, song requests and giveaways",
|
||||
version: "1.0.0",
|
||||
author: "Atom CMS",
|
||||
license: "MIT",
|
||||
dependencies: ["api"],
|
||||
enabled: true,
|
||||
configFields: [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user