Files
EpicNext-Cms/src/app/admin/plugins/client.tsx
T
openhands 127d5dcfa6
Local Build and Deploy / deploy (push) Failing after 48s
Upgrade plugin system: lifecycle hooks, dependency checks, enabled gating
- 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
2026-07-18 14:38:56 +02:00

240 lines
7.9 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { pluginRegistry } from "@/lib/plugins/registry";
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();
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 {}
}, []);
useEffect(() => {
load();
}, [load]);
const toggleEnabled = async (id: string, current: boolean) => {
setToggling(id);
try {
await fetch(`/api/admin/plugins`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, enabled: !current }),
});
load();
} catch {}
setToggling(null);
};
const saveConfig = async (id: string, config: Record<string, string | boolean>) => {
try {
await fetch(`/api/admin/plugins`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, config }),
});
setEditing(null);
load();
} catch {}
};
const hasDependencyIssues = (p: PluginWithState) =>
p.dependencies?.length && p.missingDeps.length > 0;
return (
<div className="space-y-3">
{plugins.length === 0 && (
<div className="text-center py-12 text-sm text-[var(--admin-text-muted)]">
No plugins registered.
</div>
)}
{plugins.map((plugin) => (
<div
key={plugin.id}
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 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}
</p>
<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 && (
<div className="mt-3">
{editing === plugin.id ? (
<ConfigForm
fields={plugin.configFields}
initial={plugin.config}
onSave={(cfg) => saveConfig(plugin.id, cfg)}
onCancel={() => setEditing(null)}
/>
) : (
<button
type="button"
onClick={() => setEditing(plugin.id)}
className="text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/10 text-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/20 transition-colors"
>
Configure
</button>
)}
</div>
)}
</div>
<div className="flex-none">
<label className="relative inline-flex items-center cursor-pointer">
<input
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 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>
))}
</div>
);
}
function ConfigForm({
fields,
initial,
onSave,
onCancel,
}: {
fields: PluginConfigField[];
initial: Record<string, string | boolean>;
onSave: (config: Record<string, string | boolean>) => void;
onCancel: () => void;
}) {
const [values, setValues] = useState<Record<string, string | boolean>>(initial);
const update = (key: string, value: string | boolean) => {
setValues((prev) => ({ ...prev, [key]: value }));
};
return (
<div className="space-y-2 p-3 rounded-lg bg-[var(--admin-surface)]/50 border border-[var(--admin-border)]">
{fields.map((field) => (
<div key={field.key}>
<label className="text-[0.6rem] font-medium text-[var(--admin-text-muted)] block mb-1">
{field.label}
</label>
{field.type === "text" && (
<input
type="text"
value={(values[field.key] as string) ?? ""}
onChange={(e) => update(field.key, e.target.value)}
className="w-full px-2 py-1 text-xs rounded-md border border-[var(--admin-border)] bg-transparent text-[var(--admin-text)]"
/>
)}
{field.type === "boolean" && (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={(values[field.key] as boolean) ?? false}
onChange={(e) => update(field.key, e.target.checked)}
/>
<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" />
</label>
)}
{field.type === "select" && field.options && (
<select
value={(values[field.key] as string) ?? ""}
onChange={(e) => update(field.key, e.target.value)}
className="w-full px-2 py-1 text-xs rounded-md border border-[var(--admin-border)] bg-transparent text-[var(--admin-text)]"
>
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)}
</div>
))}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={() => onSave(values)}
className="text-[0.6rem] font-bold px-2.5 py-1 rounded-full bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
>
Save
</button>
<button
type="button"
onClick={onCancel}
className="text-[0.6rem] font-bold px-2.5 py-1 rounded-full border border-[var(--admin-border)] text-[var(--admin-text-muted)]"
>
Cancel
</button>
</div>
</div>
);
}