EmDash の管理画面は、Astro サイトに埋め込まれた React のシングルページアプリケーションです。編集者と管理者向けに、コンテンツ管理の UI を一通り提供します。
アーキテクチャの概要
┌────────────────────────────────────────────────────────────────┐
│ Astro シェル │
│ /_emdash/admin/[...path].astro │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ React SPA │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ TanStack │ │ TanStack │ │ Kumo │ │ │
│ │ │ Router │ │ Query │ │ コンポーネント │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ REST API クライアント │ │ │
│ │ │ /_emdash/api/* │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
管理画面は React の「大きなアイランド」です。Astro がシェルと認証を担当し、管理画面内のナビゲーションとレンダリングはすべてクライアント側で行われます。
技術スタック
| レイヤー | 技術 | 役割 |
|---|---|---|
| ルーティング | TanStack Router | 型安全なクライアント側ルーティング |
| データ | TanStack Query | サーバー状態、キャッシュ、ミューテーション |
| UI | Kumo | アクセシブルなコンポーネント(Base UI + Tailwind) |
| テーブル | TanStack Table | 並べ替え、フィルタ、ページネーション |
| フォーム | React Hook Form + Zod | サーバースキーマに沿ったバリデーション |
| アイコン | Phosphor | 一貫したアイコン |
| エディタ | TipTap | リッチテキスト編集(Portable Text) |
ルート構成
管理画面は /_emdash/admin/ にマウントされ、クライアント側ルーティングを使います。
| パス | 画面 |
|---|---|
/ | ダッシュボード |
/content/:collection | コンテンツ一覧 |
/content/:collection/:id | コンテンツエディタ |
/content/:collection/new | 新規エントリ |
/media | メディアライブラリ |
/content-types | スキーマビルダー(管理者のみ) |
/menus | ナビゲーションメニュー |
/widgets | ウィジェットエリア |
/taxonomies | カテゴリ/タグの管理 |
/settings | サイト設定 |
/plugins/:pluginId/* | プラグインページ |
マニフェスト駆動の UI
管理画面はコレクションやプラグインをハードコードしません。サーバーからマニフェストを取得します。
GET /_emdash/api/manifest
レスポンス:
{
"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"
}
管理画面はこのマニフェストだけからナビゲーション、フォーム、エディタを組み立てます。利点は次のとおりです。
- スキーマ変更がすぐ反映される — 管理画面の再ビルドは不要
- プラグイン UI が自動統合される — マニフェスト由来のページとウィジェット
- 境界で型安全 — Zod スキーマはサーバー側に留まる
データの流れ
- 管理 SPA の読み込み — TanStack Router が初期化される
- マニフェスト取得 — TanStack Query がコレクション/プラグインのメタデータをキャッシュ
- ナビゲーション構築 — サイドバーはマニフェストから生成
- ユーザーが移動 — クライアント側ルーティング、ページ再読み込みなし
- データ取得 — TanStack Query が REST API からコンテンツを要求
- フォーム描画 — マニフェストのフィールド記述からフィールドエディタを生成
- 変更の送信 — TanStack Query 経由のミューテーション、楽観的更新
- サーバーで検証 — サーバー上の Zod、エラーは JSON で返却
REST API エンドポイント
管理画面は REST API のみで通信します。
コンテンツ API
| メソッド | エンドポイント | 目的 |
|---|---|---|
GET | /api/content/:collection | エントリ一覧 |
POST | /api/content/:collection | エントリ作成 |
GET | /api/content/:collection/:id | エントリ取得 |
PUT | /api/content/:collection/:id | エントリ更新 |
DELETE | /api/content/:collection/:id | ソフトデリート |
GET | /api/content/:collection/:id/revisions | リビジョン一覧 |
POST | /api/content/:collection/:id/preview-url | プレビュー URL 生成 |
スキーマ API
| メソッド | エンドポイント | 目的 |
|---|---|---|
GET | /api/schema | スキーマ全体のエクスポート |
GET | /api/schema/collections | コレクション一覧 |
POST | /api/schema/collections | コレクション作成 |
PUT | /api/schema/collections/:slug | コレクション更新 |
DELETE | /api/schema/collections/:slug | コレクション削除 |
POST | /api/schema/collections/:slug/fields | フィールド追加 |
PUT | /api/schema/collections/:slug/fields/:field | フィールド更新 |
DELETE | /api/schema/collections/:slug/fields/:field | フィールド削除 |
メディア API
| メソッド | エンドポイント | 目的 |
|---|---|---|
GET | /api/media | メディア一覧 |
POST | /api/media/upload-url | 署名付きアップロード URL |
POST | /api/media/:id/confirm | アップロード完了の確認 |
DELETE | /api/media/:id | メディア削除 |
GET | /api/media/file/:key | メディアファイルの配信 |
その他の API
| エンドポイント | 目的 |
|---|---|
/api/settings | サイト設定(GET/POST) |
/api/menus/* | ナビゲーションメニュー |
/api/widget-areas/* | ウィジェット管理 |
/api/taxonomies/* | タクソノミー用語 |
/api/admin/plugins/* | プラグイン状態 |
ページネーション
一覧系エンドポイントはすべてカーソルベースのページネーションを使います。
{
"items": [...],
"nextCursor": "eyJpZCI6IjAxSjEyMzQ1NiJ9"
}
次のページを取得:
GET /api/content/posts?cursor=eyJpZCI6IjAxSjEyMzQ1NiJ9
プラグインの管理 UI
プラグインはページやダッシュボードのウィジェットで管理画面を拡張できます。統合処理が静的 import 付きの仮想モジュールを生成します。
// virtual:emdash/plugin-admins(生成)
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,
};
プラグインページ
プラグインページは /_emdash/admin/plugins/:pluginId/* 以下にマウントされます。
// @emdash-cms/plugin-seo/src/admin.tsx
export const pages = [
{
path: "settings",
component: SEOSettingsPage,
label: "SEO Settings",
},
];
表示 URL: /_emdash/admin/plugins/seo/settings
ダッシュボードウィジェット
プラグインはダッシュボードにウィジェットを追加できます。
export const widgets = [
{
id: "seo-overview",
component: SEOWidget,
title: "SEO Overview",
size: "half", // "full" | "half" | "third"
},
];
認証
管理シェルルートは Astro のミドルウェアで認証を強制します。
// ミドルウェアの簡略化した例
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();
}
管理 SPA 自体はログインを扱いません。セッション Cookie を設定するのは Astro のページです。
ロールベースのアクセス
ロールごとに見える管理画面の範囲が異なります。
| ロール | 表示されるセクション |
|---|---|
| Editor | ダッシュボード、割り当てコレクション、メディア |
| Admin | +コンテンツタイプ、全コレクション、設定 |
| Developer | +CLI アクセス、生成された型 |
マニフェストのエンドポイントは、リクエストユーザーのロールに応じてコレクションと機能を絞り込みます。
コンテンツエディタ
コンテンツエディタはフィールド定義に基づきフォームを動的に生成します。
// エディタ描画の簡略例
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>
);
}
フィールド型ごとに対応するウィジェットがあります。
| フィールド型 | ウィジェット |
|---|---|
string | テキスト入力 |
text | テキストエリア |
number | 数値入力 |
boolean | トグル |
datetime | 日時ピッカー |
select | ドロップダウン |
multiSelect | 複数選択 |
portableText | TipTap エディタ |
image | メディアピッカー |
reference | エントリピッカー |
リッチテキストエディタ
Portable Text フィールドの編集には TipTap(ProseMirror)を使います。
ユーザー入力 → TipTap(ProseMirror JSON)→ 保存 → Portable Text(DB)
読み込み → Portable Text(DB)→ TipTap(ProseMirror JSON)→ 表示
変換は読み込み/保存の境界で portableTextToProsemirror() と prosemirrorToPortableText() により行われます。
サポートされるブロック:
- 段落、見出し(H1–H6)
- 箇条書き・番号付きリスト
- 引用、コードブロック
- 画像(メディアライブラリから)
- リンク
プラグインやインポート由来の未知のブロックは、読み取り専用のプレースホルダとして保持されます。
メディアライブラリ
メディアライブラリでは次が利用できます。
- グリッド表示とリスト表示
- 種類・日付での検索とフィルタ
- ドラッグ&ドロップでのアップロード
- メタデータ付きの画像プレビュー
- 一括選択と削除
アップロードは署名付き URL でクライアントからストレージへ直接送ります。
- アップロード URL を要求 —
POST /api/media/upload-url - 直接アップロード — クライアントが署名付き URL(R2/S3)へファイルを PUT
- アップロードの確認 —
POST /api/media/:id/confirm - サーバーがメタデータを抽出 — 寸法、MIME タイプなど
この方式により Workers のボディサイズ制限を回避し、実際のアップロード進捗も得られます。