Native Plugins können das Admin-Panel mit benutzerdefinierten React-Seiten, Dashboard-Widgets, Feld-Widgets und Content-Listen-Spalten erweitern — Sandboxed Plugins beschreiben ihre UI stattdessen als Block Kit, da das Laden von Plugin-JavaScript in den Admin die Sandbox-Isolation brechen würde.
Wenn Ihr Plugin nur ein Einstellungsformular benötigt, deckt das automatisch generierte admin.settingsSchema-Formular (siehe Ihr erstes natives Plugin) die meisten Fälle ab, ohne React zu schreiben. Greifen Sie auf benutzerdefinierte Komponenten zurück, wenn Sie eine reichhaltigere UI benötigen als settingsSchema bietet.
Admin-Einstiegspunkt
Plugins mit Admin-UI exportieren pages- und widgets-Objekte aus einem admin-Einstiegspunkt:
import { SEOSettingsPage } from "./components/SEOSettingsPage";
import { SEODashboardWidget } from "./components/SEODashboardWidget";
export const widgets = {
"seo-overview": SEODashboardWidget,
};
export const pages = {
"/settings": SEOSettingsPage,
};
Konfigurieren Sie den Einstiegspunkt in package.json:
{
"exports": {
".": "./dist/index.js",
"./admin": "./dist/admin.js"
}
}
Referenzieren Sie ihn aus definePlugin():
definePlugin({
id: "seo",
version: "1.0.0",
admin: {
entry: "@my-org/plugin-seo/admin",
pages: [{ path: "/settings", label: "SEO Settings", icon: "settings" }],
widgets: [{ id: "seo-overview", title: "SEO Overview", size: "half" }],
},
});
Der Deskriptor benötigt einen passenden adminEntry, damit EmDash weiß, wo die Komponenten zur Build-Zeit zu finden sind:
adminEntry: "@my-org/plugin-seo/admin",
Admin-Seiten
Admin-Seiten sind React-Komponenten, die unter /_emdash/admin/plugins/<plugin-id>/<pfad> gemountet werden.
Seitendefinition
Deklarieren Sie jede Seite unter admin.pages mit einem Pfad, Label und Icon:
admin: {
pages: [
{
path: "/settings",
label: "Settings",
icon: "settings",
},
{
path: "/reports",
label: "Reports",
icon: "chart",
},
],
}
Deklarieren Sie Labels auf Englisch. Der Admin lässt sie durch seine gemeinsame Lingui-Instanz laufen, bevor er die Seitenleiste und die Befehlspalette rendert. Ein Plugin, das seinen eigenen Nachrichtenkatalog lädt — mit dem englischen Label als Nachrichten-ID — erhält kostenlos lokalisierte Navigation. Labels, die einer der Admin-eigenen Nachrichten entsprechen (Settings, Dashboard, …), übernehmen die Übersetzungen des Admins auch ohne Plugin-Katalog; Labels ohne Katalogeintrag werden wie deklariert gerendert.
import { i18n } from "@lingui/core";
const catalogs: Record<string, Record<string, string>> = {
de: { Reports: "Berichte", Settings: "Einstellungen" },
};
function mergeCatalog() {
const messages = catalogs[i18n.locale];
if (messages && !("Reports" in i18n.messages)) i18n.load(i18n.locale, messages);
}
mergeCatalog();
i18n.on("change", mergeCatalog);
Seitenkomponente
Die folgende Komponente liest und speichert Einstellungen über den Plugin-API-Hook:
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/admin";
export function SettingsPage() {
const api = usePluginAPI();
const [settings, setSettings] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get("settings").then(setSettings);
}, []);
const handleSave = async () => {
setSaving(true);
await api.post("settings/save", settings);
setSaving(false);
};
return (
<div>
<h1>Plugin-Einstellungen</h1>
<label>
Seitentitel
<input
type="text"
value={(settings.siteTitle as string) || ""}
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
/>
</label>
<button onClick={handleSave} disabled={saving}>
{saving ? "Speichert..." : "Einstellungen speichern"}
</button>
</div>
);
}
Plugin-API-Hook
usePluginAPI() ruft Ihre Plugin-Routen mit dem Plugin-ID-Präfix und dem automatisch hinzugefügten X-EmDash-Request: 1 CSRF-Header auf:
import { usePluginAPI } from "@emdash-cms/admin";
function MyComponent() {
const api = usePluginAPI();
const data = await api.get("status");
await api.post("settings/save", { enabled: true });
const result = await api.get("history?limit=50");
}
Dashboard-Widgets
Widgets erscheinen auf dem Admin-Dashboard und liefern Informationen auf einen Blick.
Widget-Definition
admin: {
widgets: [
{
id: "seo-overview",
title: "SEO Overview",
size: "half",
},
],
}
Widget-Komponente
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/admin";
export function SEOWidget() {
const api = usePluginAPI();
const [data, setData] = useState({ score: 0, issues: [] });
useEffect(() => {
api.get("analyze").then(setData);
}, []);
return (
<div className="widget-content">
<div className="score">{data.score}%</div>
<ul>
{data.issues.map((issue, i) => (
<li key={i}>{(issue as { message: string }).message}</li>
))}
</ul>
</div>
);
}
Widget-Größen
| Größe | Beschreibung |
|---|---|
full | Volle Dashboard-Breite |
half | Halbe Dashboard-Breite |
third | Ein Drittel Dashboard-Breite |
Widgets werden automatisch basierend auf der Bildschirmbreite umgebrochen.
Content-Editor-Panels
Ein vertrauenswürdiges React-Plugin kann host-gerahmte Abschnitte zur Einstellungs-Seitenleiste für gespeicherte Inhaltseinträge hinzufügen. EmDash besitzt die Abschnittsüberschrift und Platzierung, wendet Sammlungs- und Rollenfilter an und isoliert Renderfehler, sodass ein Plugin-Panel den Editor nicht demounten kann.
Exportieren Sie ein contentEditorPanels-Array aus dem Plugin-Admin-Einstiegspunkt:
import type { ContentEditorPanelContext } from "@emdash-cms/admin";
function ContentInsights({ entry, locale }: ContentEditorPanelContext) {
return (
<p>
Analyse für {entry.slug} in {locale ?? "der Standard-Locale"}
</p>
);
}
export const contentEditorPanels = [
{
id: "content-insights",
title: "Content-Insights",
component: ContentInsights,
collections: ["posts", "pages"],
minRole: 40,
order: 10,
},
];
Jedes Panel erhält den gespeicherten entry, seine collection und die aufgelöste locale. Panels werden nicht für neue, ungespeicherte Einträge gemountet. collections kann ein Array oder ein Prädikat sein und kann weggelassen werden, um jede Sammlung zu unterstützen. Niedrigere order-Werte werden zuerst unter den beigesteuerten Panels gerendert.
Panel-IDs müssen innerhalb des Plugins eindeutig sein. Halten Sie Panel-Inhalte responsiv für die schmale Einstellungs-Seitenleiste und führen Sie Autorisierung in Plugin-API-Routen durch, anstatt sich auf minRole zu verlassen, das nur die Sichtbarkeit steuert.
Content-Listen-Spalten
Vertrauenswürdige React-Plugins können schreibgeschützte Spalten zu aktiven Content-Collection-Listen hinzufügen. EmDash behält die Kontrolle über Tabelle, Paginierung, Zeilenaktionen sowie Lade- und Leerzustände; das Plugin liefert nur die Header-Metadaten und den Zelleninhalt.
import type {
ContentListColumnCellContext,
ContentListColumnExtension,
} from "@emdash-cms/admin";
import { useQuery } from "@tanstack/react-query";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
async function fetchReviewStatuses(
collection: string,
locale: string | undefined,
ids: readonly string[],
) {
const response = await apiFetch(
"/_emdash/api/plugins/editorial-workflow/review-statuses/batch",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collection, locale, ids }),
},
);
return parseApiResponse<Record<string, string>>(response, "Failed to load review statuses");
}
function ReviewStatusCell({
item,
visibleItems,
collection,
locale,
}: ContentListColumnCellContext) {
const itemIds = visibleItems.map((visibleItem) => visibleItem.id);
const { data: reviewStatuses } = useQuery({
queryKey: ["editorial-workflow", "review-statuses", collection, locale ?? null, itemIds],
queryFn: () => fetchReviewStatuses(collection, locale, itemIds),
});
const reviewStatus = reviewStatuses?.[item.id];
return reviewStatus ? <span>{reviewStatus}</span> : <span>-</span>;
}
export const contentListColumns = [
{
id: "review-status",
label: "Review-Status",
collections: ["posts", "pages"],
order: 10,
align: "end",
cell: ReviewStatusCell,
},
] satisfies readonly ContentListColumnExtension[];
Jede Zellen-Komponente wird einmal pro sichtbarer Zeile gemountet. Wenn eine Spalte Daten benötigt, die nicht bereits auf item vorhanden sind, verwenden Sie visibleItems, um die gesamte Seite in einem Batch anzufordern.
Spalten-IDs müssen nur innerhalb des Plugins eindeutig sein. Beiträge werden nach order, dann Plugin-ID und Spalten-ID geordnet. Deaktivierte oder fehlende Plugins werden weggelassen. Content-Listen-Spalten sind nur zur Anzeige.
Export-Struktur
import { SettingsPage } from "./components/SettingsPage";
import { ReportsPage } from "./components/ReportsPage";
import { StatusWidget } from "./components/StatusWidget";
import { OverviewWidget } from "./components/OverviewWidget";
import { ReviewStatusCell } from "./components/ReviewStatusCell";
export const pages = {
"/settings": SettingsPage,
"/reports": ReportsPage,
};
export const widgets = {
status: StatusWidget,
overview: OverviewWidget,
};
export const contentListColumns = [
{
id: "review-status",
label: "Review-Status",
collections: ["posts"],
cell: ReviewStatusCell,
},
];
Admin-Komponenten verwenden
EmDash bietet vorgefertigte Komponenten für häufige Muster:
import {
Card,
Button,
Input,
Select,
Toggle,
Table,
Pagination,
Alert,
Loading,
} from "@emdash-cms/admin";
function SettingsPage() {
return (
<Card title="Einstellungen">
<Input label="API-Schlüssel" type="password" />
<Toggle label="Aktiviert" defaultChecked />
<Button variant="primary">Speichern</Button>
</Card>
);
}
Automatisch generierte Einstellungs-UI
Wenn Ihr Plugin nur ein Einstellungsformular benötigt, verwenden Sie admin.settingsSchema ohne benutzerdefinierte Komponenten:
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "API Key" },
enabled: { type: "boolean", label: "Enabled", default: true },
},
},
EmDash generiert automatisch eine Einstellungsseite. Greifen Sie nur dann auf benutzerdefinierte React-Seiten zurück, wenn Sie Verhalten über ein einfaches Formular hinaus benötigen.
Navigation
Plugin-Seiten erscheinen in der Admin-Seitenleiste unter dem Plugin-Namen. Die Reihenfolge entspricht dem admin.pages-Array:
admin: {
pages: [
{ path: "/settings", label: "Settings", icon: "settings" },
{ path: "/history", label: "History", icon: "history" },
{ path: "/reports", label: "Reports", icon: "chart" },
],
}
Build-Konfiguration
Admin-Komponenten benötigen einen separaten Build-Einstiegspunkt:
tsdown
export default {
entry: {
index: "src/index.ts",
admin: "src/admin.tsx",
},
format: "esm",
dts: true,
external: ["react", "react-dom", "emdash", "@emdash-cms/admin"],
}; tsup
export default {
entry: ["src/index.ts", "src/admin.tsx"],
format: "esm",
dts: true,
external: ["react", "react-dom", "emdash", "@emdash-cms/admin"],
}; Halten Sie React und EmDash Admin als externe Abhängigkeiten, um doppeltes Bundling zu vermeiden.
Plugin aktivieren/deaktivieren
Wenn ein Plugin im Admin deaktiviert wird:
- Seitenleisten-Links werden ausgeblendet.
- Dashboard-Widgets werden nicht gerendert.
- Content-Listen-Spalten werden nicht gerendert.
- Admin-Seiten geben 404 zurück.
- Backend-Hooks werden weiterhin ausgeführt (aus Datensicherheitsgründen).
const enabled = await ctx.kv.get<boolean>("_emdash:enabled");
Vollständiges Beispiel
Das folgende Plugin definiert eine Dashboard-Seite, eine Einstellungsseite und ein Widget:
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
export function analyticsPlugin(): PluginDescriptor {
return {
id: "analytics",
version: "1.0.0",
format: "native",
entrypoint: "@my-org/plugin-analytics",
adminEntry: "@my-org/plugin-analytics/admin",
adminPages: [
{ path: "/dashboard", label: "Dashboard", icon: "chart" },
{ path: "/settings", label: "Settings", icon: "settings" },
],
adminWidgets: [{ id: "events-today", title: "Events Today", size: "third" }],
};
}
export function createPlugin() {
return definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["network:request"],
allowedHosts: ["api.analytics.example.com"],
storage: {
events: { indexes: ["type", "createdAt"] },
},
admin: {
entry: "@my-org/plugin-analytics/admin",
settingsSchema: {
trackingId: { type: "string", label: "Tracking ID" },
enabled: { type: "boolean", label: "Enabled", default: true },
},
pages: [
{ path: "/dashboard", label: "Dashboard", icon: "chart" },
{ path: "/settings", label: "Settings", icon: "settings" },
],
widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
},
routes: {
stats: {
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
const count = await ctx.storage.events.count({
createdAt: { gte: today },
});
return { today: count };
},
},
},
});
}
export default createPlugin;
import { EventsWidget } from "./components/EventsWidget";
import { DashboardPage } from "./components/DashboardPage";
import { SettingsPage } from "./components/SettingsPage";
export const widgets = {
"events-today": EventsWidget,
};
export const pages = {
"/dashboard": DashboardPage,
"/settings": SettingsPage,
};