"use client"; import type { CSSProperties, ReactNode } from "react"; import { Fragment, Suspense, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react"; 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, VideoBlockProps, SectionBlockProps, DividerBlockProps, ListBlockProps, FormBlockProps, FormFieldConfig, FormInputBlockProps, FormSelectBlockProps, FormCheckboxBlockProps, FormRadioBlockProps, ListItemNode, ProjectConfig, } from "@/features/editor/state/editorStore"; import { normalizeVideoSourceUrl, resolveVideoPlatform, buildVideoEmbedUrl, computeVideoEditorTokens, } from "@/features/editor/utils/videoHelpers"; import { computeTextEditorTokens } from "@/features/editor/utils/textHelpers"; import { computeButtonPbTokens, computeButtonEditorTokens } from "@/features/editor/utils/buttonHelpers"; import { computeDividerEditorTokens } from "@/features/editor/utils/dividerHelpers"; import { computeListEditorTokens } from "@/features/editor/utils/listHelpers"; import { computeImageEditorTokens } from "@/features/editor/utils/imageHelpers"; import { computeSectionEditorTokens } from "@/features/editor/utils/sectionHelpers"; import { computeFormInputEditorTokens, computeFormSelectEditorTokens, computeFormOptionGroupEditorTokens, } from "@/features/editor/utils/formHelpers"; 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() { return ( ); } function EditorPageInner() { const router = useRouter(); const searchParams = useSearchParams(); 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 updateProjectConfig = useEditorStore((state) => state.updateProjectConfig); const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void); const [authUserId, setAuthUserId] = useState(null); const [authChecked, setAuthChecked] = useState(false); const [initialSlugFromQuery, setInitialSlugFromQuery] = useState(null); const [hasLoadedInitialProjectFromSlug, setHasLoadedInitialProjectFromSlug] = useState(false); 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 [projectMessage, setProjectMessage] = useState(""); // 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다. const [selectedBlockIds, setSelectedBlockIds] = useState([]); const [initializedNewProjectFromQuery, setInitializedNewProjectFromQuery] = useState(false); const historyLength = useEditorStore((state) => (state as any).history?.length ?? 0); const futureLength = useEditorStore((state) => (state as any).future?.length ?? 0); const canUndo = historyLength > 0; const canRedo = futureLength > 0; useEffect(() => { if (!searchParams) return; const slugParam = searchParams.get("slug"); if (slugParam && !initialSlugFromQuery) { setInitialSlugFromQuery(slugParam); } }, [searchParams, initialSlugFromQuery]); useEffect(() => { if (!initialSlugFromQuery) return; const trimmed = initialSlugFromQuery.trim(); if (!trimmed) return; const currentSlug = (projectConfig?.slug ?? "").trim(); if (currentSlug !== trimmed) { updateProjectConfig({ slug: trimmed }); } }, [initialSlugFromQuery, projectConfig?.slug, updateProjectConfig]); useEffect(() => { if (!searchParams) return; if (initializedNewProjectFromQuery) return; const newParam = searchParams.get("new"); if (newParam !== "1") return; if (blocks.length > 0) { replaceBlocks([]); } updateProjectConfig({ title: "새 페이지", slug: "my-landing", canvasPreset: "full", canvasBgColorHex: "#020617", bodyBgColorHex: "#020617", headHtml: "", trackingScript: "", seoTitle: "", seoDescription: "", seoOgImageUrl: "", seoCanonicalUrl: "", seoNoIndex: false, } as ProjectConfig); resetHistory(); setInitializedNewProjectFromQuery(true); }, [searchParams, initializedNewProjectFromQuery, blocks.length, replaceBlocks, updateProjectConfig, resetHistory]); useEffect(() => { if (!searchParams) return; const slugParam = searchParams.get("slug"); if (slugParam && slugParam.trim().length > 0) { // URL 에 slug 쿼리 파라미터가 있으면 기존 프로젝트 편집 중이므로, // 자동 슬러그 생성은 동작시키지 않는다. return; } const currentSlugRaw = projectConfig?.slug; const currentSlug = typeof currentSlugRaw === "string" ? currentSlugRaw.trim() : ""; // 초기 상태("my-landing") 또는 비어 있는 경우에만 자동 슬러그를 한 번 생성한다. if (currentSlug && currentSlug !== "my-landing") { return; } const randomSlug = Array.from({ length: 12 }, () => Math.floor(Math.random() * 36).toString(36), ).join(""); updateProjectConfig({ slug: randomSlug }); }, [searchParams, projectConfig?.slug, updateProjectConfig]); useEffect(() => { if (!authChecked) return; if (!initialSlugFromQuery) return; if (hasLoadedInitialProjectFromSlug) return; const slug = initialSlugFromQuery.trim(); if (!slug) return; // 이미 해당 slug 에 대한 autosave 스냅샷이 있으면, // 서버에서 프로젝트를 다시 불러와서 현재 로컬 상태를 덮어쓰지 않는다. if (typeof window !== "undefined") { try { const key = `pb:autosave:${slug}`; const raw = window.localStorage.getItem(key); if (raw) { setHasLoadedInitialProjectFromSlug(true); return; } } catch { // localStorage 접근 실패 시에는 서버 로드를 계속 시도한다. } } let cancelled = false; const loadFromServer = async () => { try { const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`); if (!res.ok || cancelled) { return; } const data = await res.json(); if (data && Array.isArray(data.contentJson)) { replaceBlocks(data.contentJson as any); if (data.title && typeof data.title === "string") { updateProjectConfig({ title: data.title, slug }); } else { updateProjectConfig({ slug }); } // 다른 slug/사용자에서 불러온 뒤에는 이전 프로젝트의 undo/redo 히스토리를 끊어준다. resetHistory(); } } catch { // 서버 로드 실패는 조용히 무시하고, 사용자가 수동으로 불러오기 할 수 있도록 둔다. } finally { if (!cancelled) { setHasLoadedInitialProjectFromSlug(true); } } }; void loadFromServer(); return () => { cancelled = true; }; }, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks, updateProjectConfig, resetHistory]); useEffect(() => { let cancelled = false; const checkAuth = async () => { try { const res = await fetch("/api/auth/me"); if (cancelled) { return; } if (res.ok) { let data: any = null; try { data = await res.json(); } catch { data = null; } if (data && typeof data.id === "string") { setAuthUserId(data.id); } else { setAuthUserId(null); } } else if (res.status === 401) { router.push("/login"); setAuthUserId(null); } } catch { if (!cancelled) { setAuthUserId(null); } } finally { if (!cancelled) { setAuthChecked(true); } } }; void checkAuth(); return () => { cancelled = true; }; }, [router]); 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 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) { const text = await response.text().catch(() => ""); const truncated = text.length > 500 ? `${text.slice(0, 500)}...` : text; console.error( "정적 파일 내보내기 실패", response.status, response.statusText, truncated, ); 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 snapshot = { blocks, projectConfig, }; const payload = JSON.stringify(snapshot, 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) as { blocks?: Block[]; projectConfig?: ProjectConfig; }; if (!parsed || typeof parsed !== "object") return; if (!Array.isArray(parsed.blocks)) return; replaceBlocks(parsed.blocks as any); if (parsed.projectConfig && typeof parsed.projectConfig === "object") { updateProjectConfig(parsed.projectConfig as ProjectConfig); } setExportJson(JSON.stringify(parsed, null, 2)); } catch { // 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능) } }; const handleSaveProject = async () => { const slug = (projectConfig.slug ?? "").trim(); if (!slug) { setProjectMessage("프로젝트 주소를 입력해 주세요."); return; } const title = (projectConfig.title ?? "").trim() || "제목 없음"; try { // 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준) if (typeof window !== "undefined") { const localKey = `pb:project:${slug}`; const payload = { title, contentJson: blocks, }; window.localStorage.setItem(localKey, JSON.stringify(payload)); // 1-1) autosave 스냅샷도 현재 저장된 상태로 동기화해, // 이후 /editor?slug 또는 /preview?slug 진입 시 서버 저장본과 동일한 블록들이 로드되도록 맞춘다. try { const autosaveKey = `pb:autosave:${slug}`; const autosavePayload = { blocks, projectConfig, savedByUserId: authUserId ?? null, }; window.localStorage.setItem(autosaveKey, JSON.stringify(autosavePayload)); } catch { // autosave 저장 실패는 치명적이지 않으므로 무시한다. } } // 2) 서버에도 저장 (백업/공유용) const response = await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ title, slug, contentJson: blocks, }), }); if (!response.ok) { if (response.status === 409) { try { const data = await response.json(); if (data && typeof (data as any).message === "string") { setProjectMessage((data as any).message as string); return; } } catch { } } setProjectMessage("프로젝트 저장에 실패했습니다."); return; } const data = await response.json(); setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`); if (typeof window !== "undefined") { window.location.href = "/projects"; } } catch (error) { console.error(error); setProjectMessage("프로젝트 저장 중 오류가 발생했습니다."); } }; const handleLoadProject = async () => { const slug = (projectConfig.slug ?? "").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); if (parsed.title && typeof parsed.title === "string") { updateProjectConfig({ title: parsed.title, slug }); } else { updateProjectConfig({ slug }); } // 다른 프로젝트(slug)를 불러왔으므로 이전 history/future 를 초기화한다. resetHistory(); 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); if (data.title && typeof data.title === "string") { updateProjectConfig({ title: data.title, slug }); } else { updateProjectConfig({ slug }); } // 서버에서 다른 프로젝트를 불러온 뒤에는 이전 history/future 를 모두 비운다. resetHistory(); setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`); } else { setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다."); } } catch (error) { console.error(error); setProjectMessage("프로젝트 불러오기 중 오류가 발생했습니다."); } }; const handleDeleteProject = async () => { const slug = (projectConfig.slug ?? "").trim(); if (!slug) { setProjectMessage("삭제할 프로젝트 주소가 없습니다."); return; } if (typeof window === "undefined") return; const confirmed = window.confirm( "현재 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)", ); if (!confirmed) return; try { const response = await fetch(`/api/projects/${encodeURIComponent(slug)}`, { method: "DELETE", }); if (!response.ok) { setProjectMessage("프로젝트 삭제에 실패했습니다."); return; } // 프로젝트 저장 및 자동저장 스냅샷을 정리한다. window.localStorage.removeItem(`pb:project:${slug}`); window.localStorage.removeItem(`pb:autosave:${slug}`); setProjectMessage("프로젝트가 삭제되었습니다."); window.location.href = "/projects"; } 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); const projectSlugRaw = projectConfig?.slug?.trim?.(); const projectSlug = typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : ""; const previewHref = projectSlug ? `/preview?slug=${encodeURIComponent(projectSlug)}` : "/preview"; useEffect(() => { if (!authChecked) return; if (typeof window === "undefined") return; const slug = projectConfig?.slug?.trim(); if (!slug) return; const key = `pb:autosave:${slug}`; const raw = window.localStorage.getItem(key); if (!raw) return; try { const parsed = JSON.parse(raw) as { blocks?: Block[]; projectConfig?: ProjectConfig; savedByUserId?: string | null; }; const savedByUserId = parsed.savedByUserId ?? null; if (authUserId) { if (savedByUserId && savedByUserId !== authUserId) { return; } } else if (savedByUserId) { return; } if (Array.isArray(parsed.blocks)) { replaceBlocks(parsed.blocks as any); } if (parsed.projectConfig) { updateProjectConfig(parsed.projectConfig as ProjectConfig); } // autosave 로 전체 프로젝트를 복원한 경우에도 이전 history/future 는 끊어준다. resetHistory(); } catch { return; } }, [authChecked, authUserId, projectConfig?.slug, replaceBlocks, updateProjectConfig]); useEffect(() => { if (typeof window === "undefined") return; const slug = projectConfig?.slug?.trim(); if (!slug) return; const key = `pb:autosave:${slug}`; try { const existing = window.localStorage.getItem(key); if (existing && !authChecked) { // 초기 렌더 시 이미 autosave 스냅샷이 있는 경우, // 인증 체크 및 복원(useEffect)이 끝나기 전까지는 기존 스냅샷을 덮어쓰지 않는다. return; } } catch { // 기존 값 조회 에러는 무시하고, 아래 저장 시도는 계속 진행한다. } const payload = { blocks, projectConfig, savedByUserId: authUserId ?? null, }; try { window.localStorage.setItem(key, JSON.stringify(payload)); } catch { return; } }, [blocks, projectConfig, authUserId, authChecked]); useEffect(() => { if (!authChecked) return; if (!authUserId) return; if (typeof window === "undefined") return; const slugRaw = projectConfig?.slug; const slug = typeof slugRaw === "string" ? slugRaw.trim() : ""; if (!slug || slug === "my-landing") return; const titleRaw = projectConfig?.title; const title = (typeof titleRaw === "string" ? titleRaw.trim() : "") || "제목 없음"; const intervalEnv = process.env.NEXT_PUBLIC_EDITOR_SERVER_AUTOSAVE_MS; const intervalMs = intervalEnv ? Number(intervalEnv) || 20000 : 20000; let cancelled = false; const saveOnce = async () => { if (cancelled) return; try { await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ title, slug, contentJson: blocks, }), }); } catch { // 서버 자동 저장 실패는 치명적이지 않으므로 조용히 무시한다. } }; const id = window.setInterval(() => { void saveOnce(); }, intervalMs); return () => { cancelled = true; window.clearInterval(id); }; }, [authChecked, authUserId, projectConfig?.slug, projectConfig?.title, blocks]); // 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z), // Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제, // ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target) { const tagName = target.tagName; const isInputLike = tagName === "INPUT" || tagName === "TEXTAREA" || (target as HTMLElement).isContentEditable; if (isInputLike) { // 입력 포커스 상태에서는 에디터 전역 단축키를 비활성화하고, // 기본 브라우저 편집 동작(삭제/커서 이동 등)을 그대로 둔다. return; } } // 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동) 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, blocks: currentBlocks } = (useEditorStore as any).getState(); // 멀티 선택이 존재하면 멀티 삭제를 우선 처리한다. if (selectedBlockIds.length > 1) { event.preventDefault(); const selectedSet = new Set(selectedBlockIds); const nextBlocks = (currentBlocks as Block[]).filter((b) => !selectedSet.has(b.id)); replaceBlocks(nextBlocks); return; } // 단일 선택만 있는 경우 기존 removeBlock 로직 사용 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, selectedBlockIds, replaceBlocks, removeBlock]); const renderBlocks = (targetBlocks: Block[]) => targetBlocks.map((block) => { const isSelected = (selectedBlockId != null && block.id === selectedBlockId) || selectedBlockIds.includes(block.id); const isEditing = block.id === editingBlockId; return ( ); }); return (

Page Editor

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

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}

)}
)} {/* Export 미리보기 기능은 제거되었습니다. */} {activeModal === "json" && (

JSON Export / Import * 에디터 내용을 가져오거나 내보낼 수 있습니다.