에디터 MVP 골격 및 프로젝트 저장/로드 기능 구현
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} 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 } from "@/features/editor/state/editorStore";
|
||||
|
||||
export default function EditorPage() {
|
||||
const blocks = useEditorStore((state) => state.blocks);
|
||||
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
||||
const addTextBlock = useEditorStore((state) => state.addTextBlock);
|
||||
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 [editingBlockId, setEditingBlockId] = useState<string | null>(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 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 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 {
|
||||
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 {
|
||||
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;
|
||||
reorderBlocks(String(active.id), String(over.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Page Editor</h1>
|
||||
<p className="text-xs text-slate-400">빌더 MVP용 에디터 페이지 골격</p>
|
||||
</header>
|
||||
<section className="flex flex-1 overflow-hidden">
|
||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
|
||||
<h2 className="font-medium">블록</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={addTextBlock}
|
||||
>
|
||||
텍스트 블록 추가
|
||||
</button>
|
||||
<p className="text-xs text-slate-500">
|
||||
Text / Image / Button / Section 블록을 이 영역에서 추가할 수 있습니다.
|
||||
</p>
|
||||
</aside>
|
||||
<div
|
||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
|
||||
data-testid="editor-canvas"
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
|
||||
왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요.
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={blocks.map((b) => b.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{blocks.map((block) => {
|
||||
const isSelected = block.id === selectedBlockId;
|
||||
const isEditing = block.id === editingBlockId;
|
||||
|
||||
return (
|
||||
<SortableEditorBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
isSelected={isSelected}
|
||||
isEditing={isEditing}
|
||||
editingText={editingText}
|
||||
startEditing={startEditing}
|
||||
commitEditing={commitEditing}
|
||||
setEditingText={setEditingText}
|
||||
selectBlock={selectBlock}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
<aside className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto">
|
||||
<h2 className="font-medium mb-2">속성 패널</h2>
|
||||
{selectedBlockId ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-400">선택한 텍스트 블록 내용</p>
|
||||
<textarea
|
||||
className="w-full min-h-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="선택한 텍스트 블록 내용"
|
||||
value={
|
||||
blocks.find((b) => b.id === selectedBlockId)?.props.text ?? ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, { text: value });
|
||||
// 인라인 편집과의 상태 차이를 막기 위해, 현재 인라인 편집 중인 블록이라면 로컬 상태도 동기화한다.
|
||||
if (editingBlockId === selectedBlockId) {
|
||||
setEditingText(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>정렬</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="정렬"
|
||||
value={
|
||||
blocks.find((b) => b.id === selectedBlockId)?.props.align ?? "left"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as "left" | "center" | "right";
|
||||
updateBlock(selectedBlockId, { align: value });
|
||||
}}
|
||||
>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>글자 크기</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="글자 크기"
|
||||
value={
|
||||
blocks.find((b) => b.id === selectedBlockId)?.props.size ?? "base"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as "sm" | "base" | "lg";
|
||||
updateBlock(selectedBlockId, { size: value });
|
||||
}}
|
||||
>
|
||||
<option value="sm">작게</option>
|
||||
<option value="base">보통</option>
|
||||
<option value="lg">크게</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
캔버스에서 블록을 선택하면 이곳에서 내용을 수정하고 스타일을 바꿀 수 있습니다.
|
||||
</p>
|
||||
)}
|
||||
<div className="border-t border-slate-800 pt-4 space-y-3 text-xs">
|
||||
<h3 className="font-medium mb-1 text-slate-200">프로젝트 저장 / 불러오기</h3>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">프로젝트 제목</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={projectTitle}
|
||||
onChange={(e) => setProjectTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">슬러그</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={projectSlug}
|
||||
onChange={(e) => setProjectSlug(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-sky-700 bg-sky-950 px-2 py-1 text-sky-100 hover:bg-sky-900"
|
||||
onClick={handleSaveProject}
|
||||
>
|
||||
프로젝트 저장
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
||||
onClick={handleLoadProject}
|
||||
>
|
||||
프로젝트 불러오기
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">불러올 슬러그 (비워두면 위 슬러그 사용)</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={loadSlug}
|
||||
onChange={(e) => setLoadSlug(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{projectMessage && (
|
||||
<p className="text-[11px] text-slate-300">{projectMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-slate-800 pt-4 space-y-2 text-xs">
|
||||
<h3 className="font-medium mb-1 text-slate-200">JSON Export / Import</h3>
|
||||
<div className="flex gap-2 mb-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 hover:bg-slate-800"
|
||||
onClick={handleExportJson}
|
||||
>
|
||||
JSON 내보내기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-700 bg-red-950 px-2 py-1 text-red-100 hover:bg-red-900"
|
||||
onClick={handleClearCanvas}
|
||||
>
|
||||
캔버스 초기화
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">에디터 상태 JSON</span>
|
||||
<textarea
|
||||
className="w-full h-24 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[10px] font-mono outline-none focus:border-sky-500"
|
||||
aria-label="에디터 상태 JSON"
|
||||
value={exportJson}
|
||||
onChange={(e) => setExportJson(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">JSON 불러오기</span>
|
||||
<textarea
|
||||
className="w-full h-24 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[10px] font-mono outline-none focus:border-sky-500"
|
||||
aria-label="JSON 불러오기"
|
||||
value={importJson}
|
||||
onChange={(e) => setImportJson(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 rounded border border-sky-700 bg-sky-950 px-2 py-1 text-sky-100 hover:bg-sky-900"
|
||||
onClick={handleApplyImportJson}
|
||||
>
|
||||
JSON 적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableEditorBlockProps {
|
||||
block: Block;
|
||||
isSelected: boolean;
|
||||
isEditing: boolean;
|
||||
editingText: string;
|
||||
startEditing: (id: string, initialText: string) => void;
|
||||
commitEditing: () => void;
|
||||
setEditingText: (value: string) => void;
|
||||
selectBlock: (id: string) => void;
|
||||
}
|
||||
|
||||
function SortableEditorBlock({
|
||||
block,
|
||||
isSelected,
|
||||
isEditing,
|
||||
editingText,
|
||||
startEditing,
|
||||
commitEditing,
|
||||
setEditingText,
|
||||
selectBlock,
|
||||
}: SortableEditorBlockProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: block.id,
|
||||
});
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const alignClass =
|
||||
block.props.align === "center"
|
||||
? "text-center"
|
||||
: block.props.align === "right"
|
||||
? "text-right"
|
||||
: "text-left";
|
||||
|
||||
const sizeClass =
|
||||
block.props.size === "sm"
|
||||
? "text-sm"
|
||||
: block.props.size === "lg"
|
||||
? "text-lg"
|
||||
: "text-base";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-testid="editor-block"
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
aria-selected={isSelected ? "true" : "false"}
|
||||
className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${
|
||||
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
|
||||
}`}
|
||||
onClick={() => selectBlock(block.id)}
|
||||
onDoubleClick={() => startEditing(block.id, block.props.text)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 h-4 w-4 rounded border border-slate-700 bg-slate-900 text-[10px] flex items-center justify-center text-slate-400 hover:bg-slate-800"
|
||||
aria-label="블록 드래그 핸들"
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
≡
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
className="w-full bg-transparent outline-none resize-none text-sm"
|
||||
aria-label="텍스트 블록 내용 편집"
|
||||
value={editingText}
|
||||
onChange={(e) => setEditingText(e.target.value)}
|
||||
onBlur={commitEditing}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
commitEditing();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div>{block.props.text}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user