O painel de administração EmDash é uma aplicação de página única (SPA) em React incorporada ao seu site Astro. Oferece uma interface completa de gestão de conteúdo para editoras e administradoras.
Visão geral da arquitetura
┌────────────────────────────────────────────────────────────────┐
│ Shell Astro │
│ /_emdash/admin/[...path].astro │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SPA React │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ TanStack │ │ TanStack │ │ Kumo │ │ │
│ │ │ Router │ │ Query │ │ Componentes │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Cliente API REST │ │ │
│ │ │ /_emdash/api/* │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
O admin é uma «grande ilha» React. O Astro gere o shell e a autenticação; toda a navegação e renderização dentro do admin são no cliente.
Pilha tecnológica
| Camada | Tecnologia | Finalidade |
|---|---|---|
| Encaminhamento | TanStack Router | Encaminhamento no cliente com tipos seguros |
| Dados | TanStack Query | Estado do servidor, cache, mutações |
| UI | Kumo | Componentes acessíveis (Base UI + Tailwind) |
| Tabelas | TanStack Table | Ordenação, filtragem, paginação |
| Formulários | React Hook Form + Zod | Validação alinhada ao esquema do servidor |
| Ícones | Phosphor | Iconografia coerente |
| Editor | TipTap | Edição de texto rico (Portable Text) |
Estrutura de rotas
O admin monta-se em /_emdash/admin/ e usa encaminhamento no cliente:
| Rota | Ecrã |
|---|---|
/ | Painel principal |
/content/:collection | Lista de conteúdo |
/content/:collection/:id | Editor de conteúdo |
/content/:collection/new | Nova entrada |
/media | Biblioteca de mídia |
/content-types | Construtor de esquema (só admin) |
/menus | Menus de navegação |
/widgets | Áreas de widgets |
/taxonomies | Gestão de categorias/etiquetas |
/settings | Configurações do site |
/plugins/:pluginId/* | Páginas de plugins |
UI baseada em manifesto
O admin não codifica manualmente coleções nem plugins. Obtém um manifesto do servidor:
GET /_emdash/api/manifest
Resposta:
{
"collections": [
{
"slug": "posts",
"label": "Blog Posts",
"labelSingular": "Post",
"icon": "file-text",
"supports": ["drafts", "revisions", "preview"],
"fields": [
{ "slug": "title", "type": "string", "required": true },
{ "slug": "content", "type": "portableText" }
]
}
],
"plugins": [
{
"id": "audit-log",
"label": "Audit Log",
"adminPages": [{ "path": "history", "label": "Audit History" }],
"widgets": [{ "id": "recent-activity", "title": "Recent Activity" }]
}
],
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
"version": "abc123"
}
O admin constrói navegação, formulários e editores apenas a partir deste manifesto. Vantagens:
- Alterações de esquema aparecem na hora — Não é preciso reconstruir o admin
- A UI de plugins integra-se automaticamente — Páginas e widgets a partir do manifesto
- Tipos na fronteira — Esquemas Zod permanecem no servidor
Fluxo de dados
- A SPA do admin carrega — TanStack Router inicializa 2. Obter manifesto — TanStack Query em cache os metadados de coleções/plugins 3. Construir navegação — Barra lateral gerada a partir do manifesto 4. A utilizadora navega — Encaminhamento no cliente, sem recarregar a página 5. Obter dados — TanStack Query pede conteúdo às APIs REST 6. Renderizar formulários — Editores de campo gerados a partir dos descritores do manifesto 7. Enviar alterações — Mutações via TanStack Query, atualizações otimistas 8. O servidor valida — Esquemas Zod no servidor, erros em JSON
Endpoints REST
O admin comunica exclusivamente via APIs REST:
API de conteúdo
| Método | Endpoint | Finalidade |
|---|---|---|
GET | /api/content/:collection | Listar entradas |
POST | /api/content/:collection | Criar entrada |
GET | /api/content/:collection/:id | Obter entrada |
PUT | /api/content/:collection/:id | Atualizar entrada |
DELETE | /api/content/:collection/:id | Exclusão lógica da entrada |
GET | /api/content/:collection/:id/revisions | Listar revisões |
POST | /api/content/:collection/:id/preview-url | Gerar URL de pré-visualização |
API de esquema
| Método | Endpoint | Finalidade |
|---|---|---|
GET | /api/schema | Exportar esquema completo |
GET | /api/schema/collections | Listar coleções |
POST | /api/schema/collections | Criar coleção |
PUT | /api/schema/collections/:slug | Atualizar coleção |
DELETE | /api/schema/collections/:slug | Eliminar coleção |
POST | /api/schema/collections/:slug/fields | Adicionar campo |
PUT | /api/schema/collections/:slug/fields/:field | Atualizar campo |
DELETE | /api/schema/collections/:slug/fields/:field | Eliminar campo |
API de mídia
| Método | Endpoint | Finalidade |
|---|---|---|
GET | /api/media | Listar itens de mídia |
POST | /api/media/upload-url | Obter URL de upload assinada |
POST | /api/media/:id/confirm | Confirmar upload concluído |
DELETE | /api/media/:id | Eliminar item de mídia |
GET | /api/media/file/:key | Servir ficheiro de mídia |
Outras APIs
| Endpoint | Finalidade |
|---|---|
/api/settings | Configurações do site (GET/POST) |
/api/menus/* | Menus de navegação |
/api/widget-areas/* | Gestão de widgets |
/api/taxonomies/* | Termos de taxonomia |
/api/admin/plugins/* | Estado do plugin |
Paginação
Todos os endpoints de lista usam paginação por cursor:
{
"items": [...],
"nextCursor": "eyJpZCI6IjAxSjEyMzQ1NiJ9"
}
Obter a página seguinte:
GET /api/content/posts?cursor=eyJpZCI6IjAxSjEyMzQ1NiJ9
UI de admin de plugins
Os plugins podem estender o admin com páginas e widgets do painel. A integração gera um módulo virtual com imports estáticos:
// virtual:emdash/plugin-admins (gerado)
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
import * as pluginAdmin1 from "@emdash-cms/plugin-analytics/admin";
export const pluginAdmins = {
seo: pluginAdmin0,
analytics: pluginAdmin1,
};
Páginas de plugin
As páginas de plugin montam-se sob /_emdash/admin/plugins/:pluginId/*:
// @emdash-cms/plugin-seo/src/admin.tsx
export const pages = [
{
path: "settings",
component: SEOSettingsPage,
label: "SEO Settings",
},
];
Renderiza em: /_emdash/admin/plugins/seo/settings
Widgets do painel
Os plugins podem adicionar widgets ao painel:
export const widgets = [
{
id: "seo-overview",
component: SEOWidget,
title: "SEO Overview",
size: "half", // "full" | "half" | "third"
},
];
Autenticação
A rota shell do admin exige autenticação via middleware Astro:
// Lógica simplificada do middleware
export async function onRequest({ request, locals }, next) {
const session = await getSession(request);
if (request.url.includes("/_emdash/admin")) {
if (!session?.user) {
return redirect("/_emdash/admin/login");
}
locals.user = session.user;
}
return next();
}
A SPA do admin não trata do login: isso é uma página Astro que define o cookie de sessão.
Acesso por funções
Cada função vê partes diferentes do admin:
| Função | Secções visíveis |
|---|---|
| Editor | Painel, coleções atribuídas, mídia |
| Admin | + Tipos de conteúdo, todas as coleções, configurações |
| Developer | + Acesso CLI, tipos gerados |
O endpoint do manifesto filtra coleções e recursos conforme a função da utilizadora.
Editor de conteúdo
O editor de conteúdo gera formulários dinamicamente a partir das definições de campo:
// Renderização simplificada do editor
function ContentEditor({ collection, fields }) {
return (
<form>
{fields.map((field) => (
<FieldWidget
key={field.slug}
type={field.type}
label={field.label}
required={field.required}
options={field.options}
/>
))}
</form>
);
}
Cada tipo de campo tem um widget correspondente:
| Tipo de campo | Widget |
|---|---|
string | Campo de texto |
text | Área de texto |
number | Campo numérico |
boolean | Interruptor |
datetime | Seletor de data/hora |
select | Lista pendente |
multiSelect | Seleção múltipla |
portableText | Editor TipTap |
image | Seletor de mídia |
reference | Seletor de entradas |
Editor de texto rico
Os campos Portable Text usam TipTap (ProseMirror):
A utilizadora escreve → TipTap (JSON ProseMirror) → Guardar → Portable Text (BD)
Carregar → Portable Text (BD) → TipTap (JSON ProseMirror) → Mostrar
A conversão ocorre nos limites de carregar/guardar com portableTextToProsemirror() e prosemirrorToPortableText().
Blocos suportados:
- Parágrafos, títulos (H1–H6)
- Listas com marcadores e numeradas
- Citações, blocos de código
- Imagens (da biblioteca de mídia)
- Ligações
Blocos desconhecidos de plugins ou importações mantêm-se como marcadores só de leitura.
Biblioteca de mídia
A biblioteca de mídia oferece:
- Vista em grelha e lista
- Pesquisa e filtro por tipo e data
- Upload por arrastar e largar
- Pré-visualização de imagem com metadados
- Seleção e eliminação em lote
Os uploads usam URLs assinadas para upload direto cliente → armazenamento:
- Pedir URL de upload —
POST /api/media/upload-url2. Carregar diretamente — O cliente faz PUT do ficheiro para a URL assinada (R2/S3) 3. Confirmar upload —POST /api/media/:id/confirm4. O servidor extrai metadados — Dimensões, tipo MIME, etc.
Isto contorna limites de tamanho do corpo nos Workers e mostra progresso real de upload.