에디터 MVP 골격 및 프로젝트 저장/로드 기능 구현

This commit is contained in:
2025-11-18 06:04:39 +09:00
commit 4b00e9a3f9
26 changed files with 11179 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
const prisma = new PrismaClient();
interface Params {
params: {
slug: string;
};
}
export async function GET(_request: Request, { params }: Params) {
const { slug } = params;
const project = await prisma.project.findUnique({
where: { slug },
});
if (!project) {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
return NextResponse.json(project, { status: 200 });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
const prisma = new PrismaClient();
export async function POST(request: Request) {
const body = await request.json();
const { title, slug, contentJson } = body;
if (!title || !slug || typeof title !== "string" || typeof slug !== "string") {
return NextResponse.json({ message: "title과 slug는 필수입니다." }, { status: 400 });
}
try {
const project = await prisma.project.create({
data: {
title,
slug,
contentJson,
},
});
return NextResponse.json(project, { status: 201 });
} catch (error: any) {
return NextResponse.json(
{ message: "프로젝트 생성 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
+480
View File
@@ -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>
);
}
+17
View File
@@ -0,0 +1,17 @@
import "../styles/globals.css";
import type { ReactNode } from "react";
export const metadata = {
title: "Page Builder",
description: "No-code single page builder",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body className="min-h-screen bg-slate-950 text-slate-50">
{children}
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function HomePage() {
return (
<main className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-3xl font-bold">Page Builder MVP</h1>
<p className="text-sm text-slate-400">
TDD로 .
</p>
</div>
</main>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { createStore } from "zustand";
import { create } from "zustand";
// 블록 타입 정의: 1단계에서는 텍스트 블록만 사용
export type BlockType = "text"; // 이후 image/button/section 으로 확장 예정
// 텍스트 블록 속성
export interface TextBlockProps {
text: string;
// 텍스트 정렬: 기본은 left
align: "left" | "center" | "right";
// 텍스트 크기: 기본은 base
size: "sm" | "base" | "lg";
}
// 공통 블록 모델
export interface Block {
id: string;
type: BlockType;
props: TextBlockProps;
}
// 에디터 상태 인터페이스
export interface EditorState {
blocks: Block[];
selectedBlockId: string | null;
addTextBlock: () => void;
updateBlock: (id: string, partial: Partial<TextBlockProps>) => void;
selectBlock: (id: string | null) => void;
replaceBlocks: (blocks: Block[]) => void;
reorderBlocks: (activeId: string, overId: string) => void;
}
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
let idCounter = 0;
const createId = () => `blk_${Date.now()}_${idCounter++}`;
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
const createEditorState = (set: any, get: any): EditorState => ({
blocks: [],
selectedBlockId: null,
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
addTextBlock: () => {
const id = createId();
const newBlock: Block = {
id,
type: "text",
props: {
text: "새 텍스트",
align: "left",
size: "base",
},
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 특정 블록의 텍스트 속성을 부분 업데이트
updateBlock: (id, partial) => {
set((state: EditorState) => ({
blocks: state.blocks.map((block: Block) =>
block.id === id
? {
...block,
props: { ...block.props, ...partial },
}
: block,
),
}));
},
// 선택된 블록 ID를 변경
selectBlock: (id) => {
const { blocks } = get();
// 존재하지 않는 ID를 선택하려고 할 경우, 그냥 null 로 초기화
if (id && !blocks.some((b: Block) => b.id === id)) {
set({ selectedBlockId: null });
return;
}
set({ selectedBlockId: id });
},
replaceBlocks: (blocks) => {
set({
blocks,
selectedBlockId: blocks.length > 0 ? blocks[0].id : null,
});
},
reorderBlocks: (activeId, overId) => {
set((state: EditorState) => {
const current = state.blocks;
const oldIndex = current.findIndex((b: Block) => b.id === activeId);
const newIndex = current.findIndex((b: Block) => b.id === overId);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
return { blocks: current };
}
const updated = [...current];
const [moved] = updated.splice(oldIndex, 1);
updated.splice(newIndex, 0, moved);
return { blocks: updated };
});
},
});
// React 컴포넌트에서 사용하는 전역 훅 스토어
export const useEditorStore = create<EditorState>()(createEditorState);
// 테스트 등에서 독립적인 스토어 인스턴스를 만들 때 사용하는 팩토리
export const createEditorStore = () => createStore<EditorState>(createEditorState);
+13
View File
@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
padding: 0;
margin: 0;
}
* {
box-sizing: border-box;
}