"use client"; import type { CSSProperties, ReactNode } from "react"; import { Fragment, useEffect, useState } from "react"; import Link from "next/link"; import { DndContext, PointerSensor, closestCenter, rectIntersection, useSensor, useSensors, DragEndEvent, DragStartEvent, DragOverlay, useDroppable, useDndContext, } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useEditorStore } from "@/features/editor/state/editorStore"; import type { Block, TextBlockProps, ButtonBlockProps, ImageBlockProps, SectionBlockProps, DividerBlockProps, ListBlockProps, FormBlockProps, FormFieldConfig, FormInputBlockProps, FormSelectBlockProps, FormCheckboxBlockProps, FormRadioBlockProps, ListItemNode, ProjectConfig, } 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"; import { BlocksSidebar } from "./panels/BlocksSidebar"; import { PropertiesSidebar } from "./panels/PropertiesSidebar"; import { EditorCanvas } from "./EditorCanvas"; export default function EditorPage() { const blocks = useEditorStore((state) => state.blocks); const selectedBlockId = useEditorStore((state) => state.selectedBlockId); const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined); const addTextBlock = useEditorStore((state) => state.addTextBlock); const addButtonBlock = useEditorStore((state) => state.addButtonBlock); const addImageBlock = useEditorStore((state) => state.addImageBlock); 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 addFormInputBlock = useEditorStore((state) => (state as any).addFormInputBlock); const addFormSelectBlock = useEditorStore((state) => (state as any).addFormSelectBlock); const addFormCheckboxBlock = useEditorStore((state) => (state as any).addFormCheckboxBlock); const addFormRadioBlock = useEditorStore((state) => (state as any).addFormRadioBlock); const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection); const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection); const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection); const addFaqTemplateSection = useEditorStore((state) => state.addFaqTemplateSection); const addPricingTemplateSection = useEditorStore((state) => state.addPricingTemplateSection); const addTestimonialsTemplateSection = useEditorStore((state) => state.addTestimonialsTemplateSection); const addBlogTemplateSection = useEditorStore((state) => state.addBlogTemplateSection); const addTeamTemplateSection = useEditorStore((state) => state.addTeamTemplateSection); const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection); const updateBlock = useEditorStore((state) => state.updateBlock); const selectBlock = useEditorStore((state) => state.selectBlock); const selectListItem = useEditorStore((state) => (state as any).selectListItem as (id: string | null) => void); const indentSelectedListItem = useEditorStore( (state) => (state as any).indentSelectedListItem as (blockId: string) => void, ); const outdentSelectedListItem = useEditorStore( (state) => (state as any).outdentSelectedListItem as (blockId: string) => void, ); const moveSelectedListItemUp = useEditorStore( (state) => (state as any).moveSelectedListItemUp as (blockId: string) => void, ); const moveSelectedListItemDown = useEditorStore( (state) => (state as any).moveSelectedListItemDown as (blockId: string) => void, ); const replaceBlocks = useEditorStore((state) => state.replaceBlocks); const reorderBlocks = useEditorStore((state) => state.reorderBlocks); const moveBlock = useEditorStore((state) => state.moveBlock); const undo = useEditorStore((state) => state.undo); const redo = useEditorStore((state) => state.redo); const removeBlock = useEditorStore((state) => state.removeBlock); const duplicateBlock = useEditorStore((state) => state.duplicateBlock); const projectConfig = useEditorStore( (state) => (state as any).projectConfig as ProjectConfig, ); const [editingBlockId, setEditingBlockId] = useState(null); const [editingText, setEditingText] = useState(""); // 드래그 중인 블록 ID를 추적하여 DragOverlay에 일관된 고스트 카드를 렌더링한다. const [activeDragId, setActiveDragId] = useState(null); const [exportJson, setExportJson] = useState(""); const [importJson, setImportJson] = useState(""); const [projectTitle, setProjectTitle] = useState(""); const [projectSlug, setProjectSlug] = useState(""); 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 editorMainStyle: CSSProperties = {}; if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) { editorMainStyle.backgroundColor = projectConfig.bodyBgColorHex; } const startEditing = (id: string, initialText: string) => { selectBlock(id); setEditingBlockId(id); setEditingText(initialText); }; const handleExportZip = async () => { try { const payload = { blocks, projectConfig, }; const response = await fetch("/api/export", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); if (!response.ok) { console.error("정적 파일 내보내기 실패", await response.text().catch(() => "")); return; } const blob = await response.blob(); const url = URL.createObjectURL(blob); const slug = (projectConfig.slug ?? "").trim() || "page-builder-export"; const link = document.createElement("a"); link.href = url; link.download = `${slug}.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } catch (error) { console.error("정적 파일 내보내기 중 오류", error); } }; const commitEditing = () => { if (!editingBlockId) return; updateBlock(editingBlockId, { text: editingText }); 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); setExportJson(payload); setImportJson(payload); } catch { // JSON 직렬화 실패 시는 조용히 무시 (순수 클라이언트 상태라 치명적이지 않음) } }; const handleClearCanvas = () => { replaceBlocks([]); setExportJson(""); setImportJson(""); }; const handleApplyImportJson = () => { if (!importJson.trim()) return; try { const parsed = JSON.parse(importJson); if (!Array.isArray(parsed)) return; // 최소한의 구조 검증: id, type, props.text 가 있는 블록만 사용 const safeBlocks = parsed.filter((b) => b && typeof b.id === "string" && b.type === "text" && b.props && typeof b.props.text === "string"); replaceBlocks(safeBlocks); // 내보내기 영역도 최신 상태로 업데이트 setExportJson(JSON.stringify(safeBlocks, null, 2)); } catch { // 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능) } }; const handleSaveProject = async () => { const slug = projectSlug.trim(); if (!slug) { 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: { "Content-Type": "application/json", }, body: JSON.stringify({ title: projectTitle.trim() || "제목 없음", slug, contentJson: blocks, }), }); if (!response.ok) { setProjectMessage("프로젝트 저장에 실패했습니다."); return; } const data = await response.json(); setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`); } catch (error) { console.error(error); setProjectMessage("프로젝트 저장 중 오류가 발생했습니다."); } }; const handleLoadProject = async () => { const slug = loadSlug.trim() || projectSlug.trim(); if (!slug) { 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("프로젝트를 불러오지 못했습니다."); return; } const data = await response.json(); if (data && Array.isArray(data.contentJson)) { replaceBlocks(data.contentJson as any); setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`); } else { setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다."); } } catch (error) { console.error(error); setProjectMessage("프로젝트 불러오기 중 오류가 발생했습니다."); } }; const handleDragStart = (event: DragStartEvent) => { const { active } = event; setActiveDragId(String(active.id)); }; const handleDragEnd = (event: DragEndEvent) => { // 드래그가 끝나면 고스트 카드를 숨긴다. setActiveDragId(null); const { active, over } = event; if (!over || active.id === over.id) return; const activeId = String(active.id); const overId = String(over.id); // 드롭존(블록 사이/최하단 가이드 영역)에 드롭한 경우: 컨테이너 기준으로 위치를 조정한다. if (overId.startsWith("dropzone-before:")) { const [, targetId] = overId.split(":"); if (targetId) { const activeBlock = blocks.find((b) => b.id === activeId); const targetBlock = blocks.find((b) => b.id === targetId); if (!activeBlock || !targetBlock) { reorderBlocks(activeId, targetId); return; } const activeSectionId = activeBlock.sectionId ?? null; const activeColumnId = activeBlock.columnId ?? null; const targetSectionId = targetBlock.sectionId ?? null; const targetColumnId = targetBlock.columnId ?? null; // 섹션 안 블록을 루트 드롭존으로 끌어낸 경우: 루트로 이동 후 순서 재배치 if (activeSectionId && !targetSectionId && !targetColumnId && targetBlock.type !== "section") { moveBlock(activeId, null, null); reorderBlocks(activeId, targetId); return; } // 같은 컨테이너(루트 또는 동일 섹션/컬럼) 내에서는 순서만 변경 if ( (!activeSectionId && !activeColumnId && !targetSectionId && !targetColumnId) || (activeSectionId === targetSectionId && activeColumnId === targetColumnId) ) { reorderBlocks(activeId, targetId); return; } // 다른 섹션/컬럼으로의 이동: 컨테이너를 변경한 뒤 순서 재배치 moveBlock(activeId, targetSectionId, targetColumnId); reorderBlocks(activeId, targetId); } return; } // 최하단 드롭존에 드롭한 경우: 컨테이너의 맨 아래로 이동한다. if (overId === "dropzone-after-root" || overId.startsWith("dropzone-after:")) { const activeBlock = blocks.find((b) => b.id === activeId); if (!activeBlock) return; // 루트 최하단 드롭존 if (overId === "dropzone-after-root") { const rootBlocksForDrop = blocks.filter((b) => !b.sectionId); const lastRoot = rootBlocksForDrop[rootBlocksForDrop.length - 1]; if (!lastRoot) { // 아직 루트 블록이 없다면, 섹션 안 블록을 루트로만 이동시킨다. if (activeBlock.sectionId || activeBlock.columnId) { moveBlock(activeId, null, null); } return; } // 섹션 안에서 루트 최하단으로 끌어낸 경우: 먼저 루트로 이동 후 마지막 루트 블록 뒤로 재배치 if (activeBlock.sectionId || activeBlock.columnId) { moveBlock(activeId, null, null); } if (lastRoot.id !== activeId) { reorderBlocks(activeId, lastRoot.id); } return; } // 섹션 컬럼 최하단 드롭존: id 형식은 dropzone-after:sectionId:columnId const [, sectionId, columnId] = overId.split(":"); const columnBlocks = blocks.filter( (b) => b.sectionId === sectionId && b.columnId === columnId, ); const lastInColumn = columnBlocks[columnBlocks.length - 1]; // 다른 컨테이너에서 이 컬럼으로 이동하는 경우: 먼저 컨테이너 이동 if (activeBlock.sectionId !== sectionId || activeBlock.columnId !== columnId) { moveBlock(activeId, sectionId || null, columnId || null); } if (lastInColumn && lastInColumn.id !== activeId) { reorderBlocks(activeId, lastInColumn.id); } return; } // 1) 컬럼 droppable에 드롭한 경우: 해당 섹션/컬럼으로 이동만 수행한다. if (overId.startsWith("column:")) { const [, sectionId, columnId] = overId.split(":"); moveBlock(activeId, sectionId || null, columnId || null); return; } const activeBlock = blocks.find((b) => b.id === activeId); const overBlock = blocks.find((b) => b.id === overId); // 2) 드롭 대상 블록을 찾지 못한 경우: 단순 순서 변경만 시도한다. if (!activeBlock || !overBlock) { reorderBlocks(activeId, overId); return; } const activeSectionId = activeBlock.sectionId ?? null; const activeColumnId = activeBlock.columnId ?? null; const overSectionId = overBlock.sectionId ?? null; const overColumnId = overBlock.columnId ?? null; // B-1) 루트 블록을 섹션(박스) 안으로 집어넣기: // 섹션 블록 위로 드랍했고, 드래그 중인 블록은 루트(섹션/컬럼이 없음)인 경우 if (overBlock.type === "section" && !activeSectionId && !activeColumnId) { const sectionProps = overBlock.props as SectionBlockProps; const targetColumnId = sectionProps.columns?.[0]?.id ?? null; moveBlock(activeId, overBlock.id, targetColumnId); // 섹션 블록 주변에서 순서가 자연스럽게 보이도록 배열 순서도 함께 재배치 reorderBlocks(activeId, overId); return; } // B-2) 섹션(박스) 안 블록을 루트로 꺼내기: // 드래그 중인 블록은 섹션 안에 있고, 드랍 대상은 루트 블록(섹션/컬럼이 없음)인 경우 if (activeSectionId && !overSectionId && !overColumnId && overBlock.type !== "section") { moveBlock(activeId, null, null); reorderBlocks(activeId, overId); return; } // 3) 루트 블록들끼리 드래그하는 경우: 순서만 변경한다. if (!activeSectionId && !activeColumnId && !overSectionId && !overColumnId) { reorderBlocks(activeId, overId); return; } // 4) 같은 섹션/컬럼 내에서는 순서만 변경한다. if (activeSectionId === overSectionId && activeColumnId === overColumnId) { reorderBlocks(activeId, overId); return; } // 5) 다른 컬럼/섹션으로 이동: 위치를 변경한 뒤, 블록 기준 드롭이면 순서 재배치까지 진행한다. moveBlock(activeId, overSectionId, overColumnId); reorderBlocks(activeId, overId); }; const handleDragCancel = () => { // 드래그가 취소된 경우에도 고스트 카드를 정리한다. setActiveDragId(null); }; const rootBlocks = blocks.filter((block) => !block.sectionId); // 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z), // Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제, // ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동) if (event.key === "ArrowDown" || event.key === "ArrowUp") { const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } = (useEditorStore as any).getState(); if (!currentBlocks.length) return; const currentIndex = currentSelectedId ? currentBlocks.findIndex((b: Block) => b.id === currentSelectedId) : -1; let nextIndex = currentIndex; if (event.key === "ArrowDown") { // 선택이 없거나(자동 선택 상태를 무시) 0보다 큰 인덱스에서 시작하면 첫 번째 블록으로 이동한다. if (currentIndex === -1 || currentIndex > 0) { nextIndex = 0; } else { // 이미 0번을 선택 중이면 1번으로 한 칸 내려간다. nextIndex = currentBlocks.length > 1 ? 1 : 0; } } else if (event.key === "ArrowUp") { // 선택이 없을 때는 마지막 블록을 선택한다. if (currentIndex === -1) { nextIndex = currentBlocks.length - 1; } else { nextIndex = currentIndex > 0 ? currentIndex - 1 : currentIndex; } } if (nextIndex !== -1 && nextIndex !== currentIndex) { event.preventDefault(); const nextId = currentBlocks[nextIndex]?.id; if (nextId) { storeSelectBlock(nextId); } } return; } const isMac = navigator.platform.toLowerCase().includes("mac"); const metaKey = isMac ? event.metaKey : event.ctrlKey; // Delete / Backspace 로 선택된 블록 삭제 if (event.key === "Delete" || event.key === "Backspace") { const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState(); if (currentSelectedId) { event.preventDefault(); removeBlock(currentSelectedId); } return; } // Cmd/Ctrl 없는 경우에는 여기서 종료한다. if (!metaKey) return; // Cmd/Ctrl + D → 현재 선택된 블록 복제 if (event.key.toLowerCase() === "d") { const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState(); if (currentSelectedId) { event.preventDefault(); duplicateBlock(currentSelectedId); } return; } // Cmd/Ctrl + Shift + Z → Redo if (event.key.toLowerCase() === "z" && event.shiftKey) { event.preventDefault(); redo(); return; } // Cmd/Ctrl + Z → Undo if (event.key.toLowerCase() === "z") { event.preventDefault(); undo(); } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [undo, redo]); const renderBlocks = (targetBlocks: Block[]) => targetBlocks.map((block) => { const isSelected = block.id === selectedBlockId; const isEditing = block.id === editingBlockId; return ( ); }); return (

Page Editor

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

프리뷰 열기
{menuOpen && (
)}
selectBlock(null)} renderBlocks={renderBlocks} handleDragStart={handleDragStart} handleDragEnd={handleDragEnd} handleDragCancel={handleDragCancel} projectConfig={projectConfig} /> updateBlock(id, partial as any)} removeBlock={removeBlock} duplicateBlock={duplicateBlock} editingBlockId={editingBlockId} setEditingText={setEditingText} onMoveSelectedItemUp={(blockId) => moveSelectedListItemUp(blockId)} onMoveSelectedItemDown={(blockId) => moveSelectedListItemDown(blockId)} onIndentSelectedItem={(blockId) => indentSelectedListItem(blockId)} onOutdentSelectedItem={(blockId) => outdentSelectedListItem(blockId)} />
{activeModal === "project" && (

프로젝트 저장 / 불러오기

{projectMessage && (

{projectMessage}

)}
)} {activeModal === "json" && (

JSON Export / Import