"use client"; import type { CSSProperties, ReactNode } from "react"; import { useEffect, useState } from "react"; import Link from "next/link"; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, DragEndEvent, useDroppable, } 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, } 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); const selectedBlockId = useEditorStore((state) => state.selectedBlockId); 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 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 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 [editingBlockId, setEditingBlockId] = useState(null); const [editingText, setEditingText] = useState(""); 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 startEditing = (id: string, initialText: string) => { selectBlock(id); setEditingBlockId(id); setEditingText(initialText); }; 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 handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const activeId = String(active.id); const overId = String(over.id); // 컬럼 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); 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; // 같은 섹션/컬럼 내에서는 순서만 변경한다. if (activeSectionId === overSectionId && activeColumnId === overColumnId) { reorderBlocks(activeId, overId); return; } // 다른 컬럼/섹션으로 이동: 위치를 변경한 뒤, 블록 기준 드롭이면 순서 재배치까지 진행한다. moveBlock(activeId, overSectionId, overColumnId); reorderBlocks(activeId, overId); }; 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") { nextIndex = currentIndex < currentBlocks.length - 1 ? currentIndex + 1 : currentIndex; } else if (event.key === "ArrowUp") { 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 && (
)}
{blocks.length === 0 ? (
왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요.
) : ( b.id)} strategy={verticalListSortingStrategy} > {renderBlocks(rootBlocks)} )}
{activeModal === "project" && (

프로젝트 저장 / 불러오기

{projectMessage && (

{projectMessage}

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

JSON Export / Import