From a6ef5f01cdfa17e8e321c1e31c125f6b12fb571a Mon Sep 17 00:00:00 2001 From: Jaybe Date: Thu, 20 Nov 2025 00:53:39 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8/=EC=86=8D=EC=84=B1=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EB=B0=8F=20=ED=8F=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MAIN_PLAN.md | 79 -- src/app/api/forms/submit/route.ts | 14 + src/app/editor/page.tsx | 1040 ++++++++++------- .../editor/panels/ButtonPropertiesPanel.tsx | 295 +++++ .../editor/panels/DividerPropertiesPanel.tsx | 51 + .../editor/panels/ImagePropertiesPanel.tsx | 42 + src/app/editor/panels/ListPropertiesPanel.tsx | 65 ++ .../editor/panels/SectionPropertiesPanel.tsx | 55 + src/app/editor/panels/TextPropertiesPanel.tsx | 505 ++++++++ src/app/editor/templates/blogTemplate.ts | 78 ++ src/app/editor/templates/ctaTemplate.ts | 68 ++ src/app/editor/templates/faqTemplate.ts | 85 ++ src/app/editor/templates/featuresTemplate.ts | 70 ++ src/app/editor/templates/footerTemplate.ts | 63 + src/app/editor/templates/heroTemplate.ts | 90 ++ src/app/editor/templates/pricingTemplate.ts | 78 ++ src/app/editor/templates/teamTemplate.ts | 93 ++ .../editor/templates/testimonialsTemplate.ts | 78 ++ src/app/layout.tsx | 1 + .../editor/components/ColorPickerField.tsx | 167 +++ .../components/NumericPropertyControl.tsx | 97 ++ .../editor/components/PropertySliderField.tsx | 70 ++ .../editor/components/PublicPageRenderer.tsx | 107 +- src/features/editor/state/editorStore.ts | 685 +++-------- src/styles/builder.css | 300 +++++ src/styles/globals.css | 4 +- tests/e2e/editor.spec.ts | 28 + tests/unit/PropertyControls.spec.ts | 94 ++ tests/unit/editorStore.spec.ts | 21 + 29 files changed, 3371 insertions(+), 1052 deletions(-) create mode 100644 src/app/api/forms/submit/route.ts create mode 100644 src/app/editor/panels/ButtonPropertiesPanel.tsx create mode 100644 src/app/editor/panels/DividerPropertiesPanel.tsx create mode 100644 src/app/editor/panels/ImagePropertiesPanel.tsx create mode 100644 src/app/editor/panels/ListPropertiesPanel.tsx create mode 100644 src/app/editor/panels/SectionPropertiesPanel.tsx create mode 100644 src/app/editor/panels/TextPropertiesPanel.tsx create mode 100644 src/app/editor/templates/blogTemplate.ts create mode 100644 src/app/editor/templates/ctaTemplate.ts create mode 100644 src/app/editor/templates/faqTemplate.ts create mode 100644 src/app/editor/templates/featuresTemplate.ts create mode 100644 src/app/editor/templates/footerTemplate.ts create mode 100644 src/app/editor/templates/heroTemplate.ts create mode 100644 src/app/editor/templates/pricingTemplate.ts create mode 100644 src/app/editor/templates/teamTemplate.ts create mode 100644 src/app/editor/templates/testimonialsTemplate.ts create mode 100644 src/features/editor/components/ColorPickerField.tsx create mode 100644 src/features/editor/components/NumericPropertyControl.tsx create mode 100644 src/features/editor/components/PropertySliderField.tsx create mode 100644 src/styles/builder.css create mode 100644 tests/unit/PropertyControls.spec.ts diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 00ef7da..e69de29 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -1,79 +0,0 @@ -# 메인 플랜 (Page Builder) - -## 현재 완료된 단계 -- **builder-1 ~ builder-8**: 기본 에디터, 텍스트/버튼/섹션 블록, DnD, Undo/Redo, 블록 삭제/복제 등 핵심 UX 구현 -- **builder-9-templates-advanced** (현재 브랜치): - - Hero / Features / CTA / FAQ / Pricing / Testimonials / Blog / Team / Footer 템플릿 섹션 - - 프리뷰 페이지 및 모바일 뷰포트 E2E - - CI 환경에서 Prisma Client generate + API 테스트용 DATABASE_URL 설정 - - API 테스트에서 Prisma를 메모리 기반 목으로 교체하여 DB 없이 테스트 통과 - -## 다음 단계 개요 -1. **builder-10-block-types** - - 더 다양한 블록 타입 추가 (예: 이미지, 아이콘이 있는 카드, 구분선, 리스트 등) - - Zustand store에 타입/props 정의 및 액션 추가 - - 에디터 UI에 새 블록 버튼 및 속성 패널 연동 - - 새 블록 타입에 대한 유닛 테스트 + E2E 테스트 추가 - -2. **builder-11-editor-ux-advanced** - - 드래그앤드롭 UX 고도화 (섹션/컬럼 이동 UX 개선, 드롭 힌트 강화) - - 키보드 숏컷 확장 및 포커스 관리 개선 - - 인라인 편집 UX 다듬기 - -3. **builder-12-theme-output** - - Tailwind 기반 디자인 시스템 정리 (색상/타이포/간격 스케일) - - Public/Preview 렌더링 품질 개선 (반응형, 여백, 타이포그래피) - - 샘플 테마 프리셋 추가 - ---- - -## builder-10-block-types 상세 플랜 - -### 1) 대상 블록 타입 정의 -- **이미지 블록(image)** - - props: `src`, `alt`, `align`, `width`, `borderRadius` 등 -- **구분선(divider)** - - props: `style` (solid/dashed), `thickness`, `color`, `marginY` -- **리스트(list)** - - props: `items` (문자열 배열), `ordered` (true/false), `align` -- (필요시) **카드(card)** - - props: `title`, `description`, `imageSrc`, `align` - -※ 실제 구현 범위는 TDD를 진행하면서 우선순위 높은 것부터 차례대로 확장한다. - -### 2) TDD 순서 -1. **유닛 테스트부터 작성 (실패 상태로 시작)** - - `tests/unit/editorStore.spec.ts` - - `addImageBlock`, `addDividerBlock`, `addListBlock` 등 새 액션 테스트 추가 - - 블록이 올바른 기본 props 와 함께 `blocks` 배열에 추가되는지 확인 - - 섹션/컬럼 내부에 배치되는 경우 위치 정보(`sectionId`, `columnId`) 테스트 -2. **스토어 구현 (editorStore.ts)** - - `Block` 타입에 새 `type` 과 `props` 구조 추가 - - `EditorState`에 새 액션 시그니처 추가 - - `createEditorState` 내부에 실제 로직 구현 (createId 활용, 기존 텍스트/버튼/섹션 패턴 재사용) -3. **에디터 UI 연동 (editor/page.tsx)** - - 사이드바에 새 블록 버튼 추가 (이미지, 구분선, 리스트 등) - - 캔버스 렌더링 분기에 새 블록 타입별 렌더링 로직 추가 - - 속성 패널에서 최소한의 필수 props 편집 가능하도록 구현 -4. **E2E 테스트 추가 (Playwright)** - - `tests/e2e/editor.spec.ts` - - 새 블록 타입 버튼 클릭 → 캔버스에 올바르게 렌더링되는지 확인 - - 속성 패널에서 값 수정 시 캔버스 반영 확인 (예: 이미지 src, 리스트 아이템 수정) -5. **리팩터링 & 안정화** - - 중복되는 렌더링/props 처리 로직 정리 - - 필요시 추가 유닛 테스트/E2E로 회귀 방지 - ---- - -## 디버깅/CI 관련 기록 (요약) -- CI에서 `prisma generate` 단계가 `DATABASE_URL` 없음으로 실패 → 워크플로에서 더미 `DATABASE_URL` 주입으로 해결 -- 유닛 테스트에서 API 테스트가 실제 DB 접속 시도 → - - CI: `Run unit tests` 스텝에도 더미 `DATABASE_URL` 추가 - - 테스트 코드: PrismaClient를 메모리 기반 목으로 교체, 라우트를 동적 import 하도록 수정 - -- Playwright E2E (에디터): - - 레이아웃 상 사이드바/속성 패널이 캔버스 블록 클릭을 가로채면서 `locator.click` 타임아웃이 발생하는 이슈 확인 - - divider/list 블록용 E2E는 블록 추가 직후 자동 선택 상태와 속성 패널 조작만 검증하도록 작성해 부분적으로 우회 - - 기존 테스트들(블록 삭제, 복제, 키보드 이동 등)은 동일한 클릭 간섭 문제 영향 범위에 있어 후속 단계에서 레이아웃/테스트 전략 리팩터링 필요 - -이후 단계(`builder-11-editor-ux-advanced`, `builder-12-theme-output`)에서도 모든 기능 추가는 TDD(실패 테스트 → 구현 → 리팩터링) 순서를 유지한다. diff --git a/src/app/api/forms/submit/route.ts b/src/app/api/forms/submit/route.ts new file mode 100644 index 0000000..b0bf126 --- /dev/null +++ b/src/app/api/forms/submit/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + const formData = await req.formData(); + + const name = formData.get("name"); + const email = formData.get("email"); + const message = formData.get("message"); + + // TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다. + console.log("[forms/submit]", { name, email, message }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index e49fd0e..d0dd638 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -27,7 +27,14 @@ import type { SectionBlockProps, DividerBlockProps, ListBlockProps, + FormBlockProps, } from "@/features/editor/state/editorStore"; +import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel"; +import { TextPropertiesPanel } from "./panels/TextPropertiesPanel"; +import { ListPropertiesPanel } from "./panels/ListPropertiesPanel"; +import { DividerPropertiesPanel } from "./panels/DividerPropertiesPanel"; +import { ImagePropertiesPanel } from "./panels/ImagePropertiesPanel"; +import { SectionPropertiesPanel } from "./panels/SectionPropertiesPanel"; export default function EditorPage() { const blocks = useEditorStore((state) => state.blocks); @@ -38,6 +45,7 @@ export default function EditorPage() { const addDividerBlock = useEditorStore((state) => state.addDividerBlock); const addListBlock = useEditorStore((state) => state.addListBlock); const addSectionBlock = useEditorStore((state) => state.addSectionBlock); + const addFormBlock = useEditorStore((state) => state.addFormBlock); const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection); const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection); const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection); @@ -68,6 +76,9 @@ export default function EditorPage() { const [loadSlug, setLoadSlug] = useState(""); const [projectMessage, setProjectMessage] = useState(""); + const [menuOpen, setMenuOpen] = useState(false); + const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null); + const sensors = useSensors(useSensor(PointerSensor)); const startEditing = (id: string, initialText: string) => { @@ -82,6 +93,16 @@ export default function EditorPage() { setEditingBlockId(null); }; + const cancelEditing = () => { + if (!editingBlockId) return; + const current = blocks.find((b) => b.id === editingBlockId); + if (current && current.type === "text") { + const textProps = current.props as TextBlockProps; + setEditingText(textProps.text); + } + setEditingBlockId(null); + }; + const handleExportJson = () => { try { const payload = JSON.stringify(blocks, null, 2); @@ -116,11 +137,22 @@ export default function EditorPage() { const handleSaveProject = async () => { const slug = projectSlug.trim(); if (!slug) { - setProjectMessage("슬러그를 입력해 주세요."); + setProjectMessage("프로젝트 주소를 입력해 주세요."); return; } try { + // 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준) + if (typeof window !== "undefined") { + const localKey = `pb:project:${slug}`; + const payload = { + title: projectTitle.trim() || "제목 없음", + contentJson: blocks, + }; + window.localStorage.setItem(localKey, JSON.stringify(payload)); + } + + // 2) 서버에도 저장 (백업/공유용) const response = await fetch("/api/projects", { method: "POST", headers: { @@ -149,11 +181,30 @@ export default function EditorPage() { const handleLoadProject = async () => { const slug = loadSlug.trim() || projectSlug.trim(); if (!slug) { - setProjectMessage("불러올 슬러그를 입력해 주세요."); + setProjectMessage("불러올 주소를 입력해 주세요."); return; } try { + // 1) 로컬스토리지에서 먼저 조회 + if (typeof window !== "undefined") { + const localKey = `pb:project:${slug}`; + const raw = window.localStorage.getItem(localKey); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.contentJson)) { + replaceBlocks(parsed.contentJson as any); + setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`); + return; + } + } catch { + // 로컬 파싱 에러는 무시하고 서버 시도 + } + } + } + + // 2) 로컬에 없으면 서버에서 조회 const response = await fetch(`/api/projects/${slug}`); if (!response.ok) { setProjectMessage("프로젝트를 불러오지 못했습니다."); @@ -307,27 +358,109 @@ export default function EditorPage() { editingText={editingText} startEditing={startEditing} commitEditing={commitEditing} + cancelEditing={cancelEditing} setEditingText={setEditingText} selectBlock={selectBlock} allBlocks={blocks} renderBlocks={renderBlocks} + updateBlock={updateBlock} /> ); }); return (
-
+

Page Editor

빌더 MVP용 에디터 페이지 골격

- - 프리뷰 열기 - +
+ + 프리뷰 열기 + +
+ + {menuOpen && ( +
+ + +
+ )} + {block.type === "form" && (() => { + const formProps = block.props as FormBlockProps; + + const handleSubmitEditor = (e: React.FormEvent) => { + e.preventDefault(); + // 에디터에서는 실제 전송 대신 데모만 수행한다. + }; + + return ( +
+
+ 이름 + +
+
+ 이메일 + +
+
+ 메시지 +