From 41e42382900072698e563e9095535a7b1f600f6d Mon Sep 17 00:00:00 2001 From: Jaybe Date: Thu, 27 Nov 2025 12:09:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=2016.1=20Export=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20UX=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/export/preview/route.ts | 38 ++++++ src/app/editor/page.tsx | 94 ++++++++++++++- tests/api/export.preview.spec.ts | 89 ++++++++++++++ tests/unit/EditorExportPreview.spec.tsx | 148 ++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 src/app/api/export/preview/route.ts create mode 100644 tests/api/export.preview.spec.ts create mode 100644 tests/unit/EditorExportPreview.spec.tsx diff --git a/src/app/api/export/preview/route.ts b/src/app/api/export/preview/route.ts new file mode 100644 index 0000000..3c85845 --- /dev/null +++ b/src/app/api/export/preview/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import type { Block, ProjectConfig } from "@/features/editor/state/editorStore"; +import { buildStaticHtml } from "@/app/api/export/route"; + +// /api/export/preview 라우트는 Export 결과 HTML 을 ZIP 없이 바로 반환하는 +// 경량 프리뷰용 엔드포인트이다. 에디터에서 Export 미리보기 UX 를 제공하기 위해 사용한다. + +type PreviewRequestBody = { + blocks: Block[]; + projectConfig?: ProjectConfig; +}; + +export async function POST(request: Request) { + let body: PreviewRequestBody; + + try { + // JSON 본문을 파싱해서 blocks + projectConfig 를 추출한다. + body = (await request.json()) as PreviewRequestBody; + } catch { + // 잘못된 JSON 요청에 대해서는 400 에러를 반환한다. + return NextResponse.json({ message: "잘못된 JSON 요청입니다." }, { status: 400 }); + } + + const blocks = Array.isArray(body.blocks) ? body.blocks : []; + const projectConfig = body.projectConfig; + + // Export 본문 HTML 은 기존 buildStaticHtml 을 재사용해서 생성한다. + // 이렇게 하면 /api/export ZIP 라우트와 동일한 정적 HTML 을 얻을 수 있다. + const html = buildStaticHtml(blocks, projectConfig); + + // Export 미리보기는 ZIP 이 아니라 순수 HTML 문자열을 반환한다. + return new NextResponse(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + }); +} diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 185e5ea..165c859 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -135,7 +135,9 @@ export default function EditorPage() { const [selectedBlockIds, setSelectedBlockIds] = useState([]); const [menuOpen, setMenuOpen] = useState(false); - const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null); + const [activeModal, setActiveModal] = useState<"project" | "json" | "exportPreview" | null>(null); + const [exportPreviewHtml, setExportPreviewHtml] = useState(""); + const [exportPreviewStatus, setExportPreviewStatus] = useState<"idle" | "loading" | "error">("idle"); const sensors = useSensors(useSensor(PointerSensor)); @@ -188,6 +190,37 @@ export default function EditorPage() { } }; + const handleRefreshExportPreview = async () => { + try { + setExportPreviewStatus("loading"); + + const payload = { + blocks, + projectConfig, + }; + + const response = await fetch("/api/export/preview", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + setExportPreviewStatus("error"); + return; + } + + const html = await response.text(); + setExportPreviewHtml(html); + setExportPreviewStatus("idle"); + } catch (error) { + console.error("Export 미리보기 요청 중 오류", error); + setExportPreviewStatus("error"); + } + }; + const commitEditing = () => { if (!editingBlockId) return; updateBlock(editingBlockId, { text: editingText }); @@ -685,6 +718,16 @@ export default function EditorPage() { > JSON 내보내기/불러오기 + + +
+
+

+ 현재 캔버스 상태를 기반으로 생성된 정적 Export HTML 을 미리 확인할 수 있습니다. +

+ +
+ {exportPreviewStatus === "error" && ( +

+ Export 미리보기 HTML 을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요. +

+ )} + {exportPreviewHtml ? ( +