Los plugins nativos pueden extender el panel admin con páginas React personalizadas y widgets de dashboard — los plugins sandbox describen su UI como Block Kit en su lugar, porque cargar JavaScript de plugin en el admin rompería el aislamiento del sandbox.
Si tu plugin solo necesita un formulario de configuración, el formulario admin.settingsSchema generado automáticamente (ver Tu primer plugin nativo) cubre la mayoría de los casos sin escribir React. Usa componentes personalizados solo cuando necesites una UI más rica que la que settingsSchema proporciona.
Punto de entrada admin
Los plugins con UI admin exportan objetos pages y widgets desde un punto de entrada admin:
import { SEOSettingsPage } from "./components/SEOSettingsPage";
import { SEODashboardWidget } from "./components/SEODashboardWidget";
export const widgets = {
"seo-overview": SEODashboardWidget,
};
export const pages = {
"/settings": SEOSettingsPage,
};
Configura el punto de entrada en package.json:
{
"exports": {
".": "./dist/index.js",
"./admin": "./dist/admin.js"
}
}
Referéncialo desde 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" }],
},
});
El descriptor necesita un adminEntry correspondiente para que EmDash sepa dónde encontrar los componentes en tiempo de compilación:
adminEntry: "@my-org/plugin-seo/admin",
Páginas admin
Las páginas admin son componentes React que se montan en /_emdash/admin/plugins/<plugin-id>/<path>.
Definición de página
Declara cada página bajo admin.pages con una ruta, etiqueta e icono:
admin: {
pages: [
{
path: "/settings",
label: "Settings",
icon: "settings",
},
{
path: "/reports",
label: "Reports",
icon: "chart",
},
],
}
Declara las etiquetas en inglés. El admin las pasa por su instancia compartida de Lingui antes de renderizar la barra lateral y la paleta de comandos, así que un plugin que carga su propio catálogo de mensajes — con la etiqueta en inglés como id de mensaje — obtiene navegación localizada gratis. Las etiquetas que coinciden con uno de los mensajes propios del admin (Settings, Dashboard, …) toman las traducciones del admin incluso sin un catálogo de plugin; las etiquetas sin entrada de catálogo se renderizan como fueron declaradas.
import { i18n } from "@lingui/core";
// Fusionar el catálogo compilado del plugin en la instancia i18n del admin.
// "Reports" ahora se renderiza como "Informes" cuando el locale del admin es español.
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();
// El selector de locale del admin *reemplaza* el catálogo al cambiar, así que re-fusionar.
// La verificación sentinel arriba evita la recursión (load() dispara "change").
i18n.on("change", mergeCatalog);
Componente de página
El siguiente componente lee y guarda configuraciones a través del hook de API del plugin:
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>Configuración del plugin</h1>
<label>
Título del sitio
<input
type="text"
value={(settings.siteTitle as string) || ""}
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
/>
</label>
<button onClick={handleSave} disabled={saving}>
{saving ? "Guardando..." : "Guardar configuración"}
</button>
</div>
);
}
Hook de API del plugin
usePluginAPI() llama a las rutas de tu plugin con el prefijo de id del plugin y el header CSRF X-EmDash-Request: 1 añadido automáticamente:
import { usePluginAPI } from "@emdash-cms/admin";
function MyComponent() {
const api = usePluginAPI();
const data = await api.get("status"); // GET /_emdash/api/plugins/<id>/status
await api.post("settings/save", { enabled: true }); // POST con body JSON
const result = await api.get("history?limit=50"); // parámetros de consulta soportados
}
Widgets del dashboard
Los widgets aparecen en el dashboard del admin y proporcionan información de un vistazo.
Definición del widget
Declara cada widget bajo admin.widgets con un id, título y tamaño:
admin: {
widgets: [
{
id: "seo-overview",
title: "SEO Overview",
size: "half", // "full" | "half" | "third"
},
],
}
Componente del widget
El siguiente componente obtiene sus datos al montar y renderiza un resumen compacto:
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>
);
}
Tamaños de widget
| Tamaño | Descripción |
|---|---|
full | Ancho completo del dashboard |
half | Mitad del ancho del dashboard |
third | Un tercio del ancho del dashboard |
Los widgets se envuelven automáticamente según el ancho de pantalla.
Estructura de exportación
El punto de entrada admin exporta dos objetos:
import { SettingsPage } from "./components/SettingsPage";
import { ReportsPage } from "./components/ReportsPage";
import { StatusWidget } from "./components/StatusWidget";
import { OverviewWidget } from "./components/OverviewWidget";
export const pages = {
"/settings": SettingsPage,
"/reports": ReportsPage,
};
export const widgets = {
status: StatusWidget,
overview: OverviewWidget,
};
Usar componentes admin
EmDash proporciona componentes preconstruidos para patrones comunes:
import {
Card,
Button,
Input,
Select,
Toggle,
Table,
Pagination,
Alert,
Loading,
} from "@emdash-cms/admin";
function SettingsPage() {
return (
<Card title="Configuración">
<Input label="Clave API" type="password" />
<Toggle label="Habilitado" defaultChecked />
<Button variant="primary">Guardar</Button>
</Card>
);
}
UI de configuración auto-generada
Si tu plugin solo necesita un formulario de configuración, usa admin.settingsSchema sin componentes personalizados:
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "API Key" },
enabled: { type: "boolean", label: "Enabled", default: true },
},
},
EmDash genera una página de configuración automáticamente. Usa páginas React personalizadas solo cuando necesites comportamiento más allá de un formulario básico.
Navegación
Las páginas del plugin aparecen en la barra lateral del admin bajo el nombre del plugin. El orden coincide con el array admin.pages, como se muestra a continuación:
admin: {
pages: [
{ path: "/settings", label: "Settings", icon: "settings" }, // primera
{ path: "/history", label: "History", icon: "history" }, // segunda
{ path: "/reports", label: "Reports", icon: "chart" }, // tercera
],
}
Configuración de compilación
Los componentes admin necesitan un punto de entrada de compilación separado. La siguiente configuración del bundler compila tanto los puntos de entrada del servidor como del admin:
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"],
}; Mantén React y EmDash admin como dependencias externas para evitar duplicados en el bundle.
Activar/desactivar plugin
Cuando un plugin se desactiva en el admin:
- Los enlaces de la barra lateral se ocultan.
- Los widgets del dashboard no se renderizan.
- Las páginas admin devuelven 404.
- Los hooks del backend siguen ejecutándose (por seguridad de datos).
Los plugins pueden verificar su estado de activación:
const enabled = await ctx.kv.get<boolean>("_emdash:enabled");
Ejemplo completo
El siguiente plugin define una página de dashboard, una página de configuración y un widget, con los puntos de entrada de runtime y admin en archivos separados. El archivo src/index.ts contiene el descriptor y el runtime:
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;
El archivo src/admin.tsx mapea rutas de página e ids de widget a sus componentes React:
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,
};