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 자체는 로그인을 처리하지 않습니다. 세션 쿠키를 설정하는 것은 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 본문 크기 제한을 피하고 실제 업로드 진행률도 얻을 수 있습니다.