에디터 MVP 골격 및 프로젝트 저장/로드 기능 구현
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
# Node
|
||||
node_modules/
|
||||
.next/
|
||||
|
||||
# Env/Secrets
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Docker
|
||||
**/.DS_Store
|
||||
|
||||
# Plans (exclude from Git)
|
||||
메인플랜.md
|
||||
최초플랜.md
|
||||
|
||||
/src/generated/prisma
|
||||
@@ -0,0 +1,64 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npm run dev"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=3000
|
||||
- DATABASE_URL=postgresql://app:app_password@db:5432/page_builder?schema=public
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
|
||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID}
|
||||
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET}
|
||||
- KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID}
|
||||
- KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET}
|
||||
- EMAIL_SERVER_HOST=${EMAIL_SERVER_HOST}
|
||||
- EMAIL_SERVER_PORT=${EMAIL_SERVER_PORT}
|
||||
- EMAIL_SERVER_USER=${EMAIL_SERVER_USER}
|
||||
- EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
volumes:
|
||||
- ./:/app
|
||||
- app-node-modules:/app/node_modules
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: app_password
|
||||
POSTGRES_DB: page_builder
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app -d page_builder"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:9
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD: admin1234
|
||||
ports:
|
||||
- "5050:80"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
app-node-modules:
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
appDir: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+9815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "page-builder",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"next": "^16.0.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.3",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.2.0",
|
||||
"lint-staged": "^16.2.6",
|
||||
"playwright": "^1.56.1",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"prisma": "^6.19.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
engine: "classic",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"status" "ProjectStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"contentJson" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Asset" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"hash" TEXT,
|
||||
"meta" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,44 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
slug String @unique
|
||||
status ProjectStatus @default(DRAFT)
|
||||
contentJson Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
assets Asset[]
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id @default(uuid())
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
kind String
|
||||
url String
|
||||
hash String?
|
||||
meta Json?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/app/**/*.{ts,tsx}",
|
||||
"./src/components/**/*.{ts,tsx}",
|
||||
"./src/features/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { POST as createProject } from "@/app/api/projects/route";
|
||||
import { GET as getProjectBySlug } from "@/app/api/projects/[slug]/route";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
describe("/api/projects", () => {
|
||||
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
|
||||
const payload = {
|
||||
title: "테스트 프로젝트",
|
||||
slug: `test-slug-${Date.now()}`,
|
||||
contentJson: [
|
||||
{
|
||||
id: "blk_test",
|
||||
type: "text",
|
||||
props: { text: "JSON에서 온 텍스트", align: "left", size: "base" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createResponse = await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
if (createResponse.status !== 201) {
|
||||
// 디버깅을 위해 에러 응답을 한번 출력해 둔다.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("createResponse status", createResponse.status, await createResponse.text());
|
||||
}
|
||||
expect(createResponse.status).toBe(201);
|
||||
const created = (await createResponse.json()) as any;
|
||||
expect(created.title).toBe(payload.title);
|
||||
expect(created.slug).toBe(payload.slug);
|
||||
|
||||
const getResponse = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
|
||||
{ params: { slug: payload.slug } } as any,
|
||||
);
|
||||
|
||||
expect(getResponse.status).toBe(200);
|
||||
const fetched = (await getResponse.json()) as any;
|
||||
expect(fetched.slug).toBe(payload.slug);
|
||||
expect(fetched.contentJson).toEqual(payload.contentJson);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// 최초에는 실패하는 테스트: /editor 페이지와 헤더 텍스트가 아직 없음
|
||||
|
||||
test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
await expect(canvas.getByText("새 텍스트")).toBeVisible();
|
||||
});
|
||||
|
||||
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
|
||||
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const block = canvas.getByText("새 텍스트");
|
||||
await block.dblclick();
|
||||
|
||||
// 편집 모드에서 텍스트를 변경한다.
|
||||
const editor = page.getByRole("textbox", { name: "텍스트 블록 내용 편집" });
|
||||
await editor.fill("수정된 텍스트");
|
||||
await editor.press("Enter");
|
||||
|
||||
// 변경된 텍스트가 캔버스에 반영되어야 한다.
|
||||
const canvasAfterEdit = page.getByTestId("editor-canvas");
|
||||
await expect(canvasAfterEdit.getByText("수정된 텍스트")).toBeVisible();
|
||||
});
|
||||
|
||||
test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔버스에 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
|
||||
// 캔버스 블록을 클릭해서 선택한다.
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
await canvas.getByText("새 텍스트").click();
|
||||
|
||||
// 우측 속성 패널에서 텍스트를 수정한다.
|
||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
||||
await sidebarEditor.fill("사이드바에서 수정된 텍스트");
|
||||
|
||||
// 캔버스에 수정된 텍스트가 보여야 한다.
|
||||
const canvasAfterSidebarEdit = page.getByTestId("editor-canvas");
|
||||
await expect(canvasAfterSidebarEdit.getByText("사이드바에서 수정된 텍스트")).toBeVisible();
|
||||
});
|
||||
|
||||
test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과 선택 하이라이트가 바뀌어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록 두 개를 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
const secondBlock = blocks.nth(1);
|
||||
|
||||
// 첫 번째 블록을 선택하고, 속성 패널에서 텍스트를 변경한다.
|
||||
await firstBlock.click();
|
||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
||||
await sidebarEditor.fill("첫 번째 블록");
|
||||
|
||||
// 두 번째 블록을 선택하고, 다시 속성 패널에서 텍스트를 변경한다.
|
||||
await secondBlock.click();
|
||||
await sidebarEditor.fill("두 번째 블록");
|
||||
|
||||
// 캔버스에는 두 블록의 텍스트가 모두 보여야 한다.
|
||||
await expect(canvas.getByText("첫 번째 블록")).toBeVisible();
|
||||
await expect(canvas.getByText("두 번째 블록")).toBeVisible();
|
||||
|
||||
// 현재 선택된 블록은 두 번째 블록이어야 한다.
|
||||
await expect(firstBlock).toHaveAttribute("data-selected", "false");
|
||||
await expect(secondBlock).toHaveAttribute("data-selected", "true");
|
||||
|
||||
// 속성 패널 textarea에도 두 번째 블록 텍스트가 표시되어야 한다.
|
||||
await expect(sidebarEditor).toHaveValue("두 번째 블록");
|
||||
});
|
||||
|
||||
test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경하면 캔버스 스타일이 바뀌어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 하나 추가하고 선택한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const block = canvas.getByTestId("editor-block").nth(0);
|
||||
await block.click();
|
||||
|
||||
// 속성 패널에서 정렬을 가운데로 변경한다.
|
||||
const alignSelect = page.getByRole("combobox", { name: "정렬" });
|
||||
await alignSelect.selectOption("center");
|
||||
|
||||
// 속성 패널에서 글자 크기를 크게로 변경한다.
|
||||
const sizeSelect = page.getByRole("combobox", { name: "글자 크기" });
|
||||
await sizeSelect.selectOption("lg");
|
||||
|
||||
// 캔버스 블록에 text-center와 text-lg 클래스가 적용되어야 한다.
|
||||
await expect(block).toHaveClass(/text-center/);
|
||||
await expect(block).toHaveClass(/text-lg/);
|
||||
});
|
||||
|
||||
test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한 블록 상태가 복원되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가하고 각각 다른 내용/스타일을 설정한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
const secondBlock = blocks.nth(1);
|
||||
|
||||
// 첫 번째 블록: 텍스트/정렬/크기 설정
|
||||
await firstBlock.click();
|
||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
||||
await sidebarEditor.fill("첫 번째 JSON 블록");
|
||||
const alignSelect = page.getByRole("combobox", { name: "정렬" });
|
||||
await alignSelect.selectOption("left");
|
||||
const sizeSelect = page.getByRole("combobox", { name: "글자 크기" });
|
||||
await sizeSelect.selectOption("sm");
|
||||
|
||||
// 두 번째 블록: 텍스트/정렬/크기 설정
|
||||
await secondBlock.click();
|
||||
await sidebarEditor.fill("두 번째 JSON 블록");
|
||||
await alignSelect.selectOption("right");
|
||||
await sizeSelect.selectOption("lg");
|
||||
|
||||
// 현재 상태를 JSON으로 내보낸다.
|
||||
await page.getByRole("button", { name: "JSON 내보내기" }).click();
|
||||
const exportArea = page.getByRole("textbox", { name: "에디터 상태 JSON" });
|
||||
const exportedJson = await exportArea.inputValue();
|
||||
|
||||
// 캔버스를 초기화한다.
|
||||
await page.getByRole("button", { name: "캔버스 초기화" }).click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
|
||||
|
||||
// JSON을 다시 입력하고 불러온다.
|
||||
const importArea = page.getByRole("textbox", { name: "JSON 불러오기" });
|
||||
await importArea.fill(exportedJson);
|
||||
await page.getByRole("button", { name: "JSON 적용" }).click();
|
||||
|
||||
// 복원된 캔버스에 두 블록이 모두 존재해야 한다.
|
||||
const restoredBlocks = canvas.getByTestId("editor-block");
|
||||
await expect(restoredBlocks).toHaveCount(2);
|
||||
|
||||
// 각 블록의 텍스트와 스타일이 원상 복구되었는지 확인한다.
|
||||
const restoredFirst = restoredBlocks.nth(0);
|
||||
const restoredSecond = restoredBlocks.nth(1);
|
||||
|
||||
await expect(restoredFirst).toContainText("첫 번째 JSON 블록");
|
||||
await expect(restoredFirst).toHaveClass(/text-left/);
|
||||
await expect(restoredFirst).toHaveClass(/text-sm/);
|
||||
|
||||
await expect(restoredSecond).toContainText("두 번째 JSON 블록");
|
||||
await expect(restoredSecond).toHaveClass(/text-right/);
|
||||
await expect(restoredSecond).toHaveClass(/text-lg/);
|
||||
});
|
||||
|
||||
test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태가 aria-selected로 노출되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
const secondBlock = blocks.nth(1);
|
||||
|
||||
// 각 블록 안에 드래그 핸들이 존재해야 한다.
|
||||
await expect(firstBlock.getByRole("button", { name: "블록 드래그 핸들" })).toBeVisible();
|
||||
await expect(secondBlock.getByRole("button", { name: "블록 드래그 핸들" })).toBeVisible();
|
||||
|
||||
// 초기에는 마지막으로 추가된 두 번째 블록이 선택되어 있어야 한다.
|
||||
await expect(firstBlock).toHaveAttribute("aria-selected", "false");
|
||||
await expect(secondBlock).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// 첫 번째 블록을 클릭하면 선택 상태가 전환되어야 한다.
|
||||
await firstBlock.click();
|
||||
await expect(firstBlock).toHaveAttribute("aria-selected", "true");
|
||||
await expect(secondBlock).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가하고 서로 다른 텍스트로 구분한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
const secondBlock = blocks.nth(1);
|
||||
|
||||
// 텍스트를 A/B로 설정해서 순서를 식별한다.
|
||||
await firstBlock.click();
|
||||
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
|
||||
await sidebarEditor.fill("블록 A");
|
||||
|
||||
await secondBlock.click();
|
||||
await sidebarEditor.fill("블록 B");
|
||||
|
||||
// 두 번째 블록의 드래그 핸들을 첫 번째 블록 위치로 드래그앤드롭 한다.
|
||||
const secondHandle = secondBlock.getByRole("button", { name: "블록 드래그 핸들" });
|
||||
const firstHandle = firstBlock.getByRole("button", { name: "블록 드래그 핸들" });
|
||||
await secondHandle.dragTo(firstHandle);
|
||||
|
||||
// 드래그 후에는 순서가 [블록 B, 블록 A]가 되어야 한다.
|
||||
const reorderedBlocks = canvas.getByTestId("editor-block");
|
||||
await expect(reorderedBlocks.nth(0)).toContainText("블록 B");
|
||||
await expect(reorderedBlocks.nth(1)).toContainText("블록 A");
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createEditorStore } from "@/features/editor/state/editorStore";
|
||||
|
||||
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
|
||||
|
||||
describe("editorStore", () => {
|
||||
it("addTextBlock 호출 시 텍스트 블록이 추가되고 선택되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addTextBlock();
|
||||
|
||||
const { blocks, selectedBlockId } = store.getState();
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe("text");
|
||||
expect(blocks[0].props.text).toBe("새 텍스트");
|
||||
expect(selectedBlockId).toBe(blocks[0].id);
|
||||
});
|
||||
|
||||
it("reorderBlocks 호출 시 블록 배열 순서가 변경되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
// 블록 세 개를 추가하고 각기 다른 텍스트로 수정한다.
|
||||
store.getState().addTextBlock();
|
||||
store.getState().addTextBlock();
|
||||
store.getState().addTextBlock();
|
||||
|
||||
let { blocks } = store.getState();
|
||||
|
||||
store.getState().updateBlock(blocks[0].id, { text: "블록 A" });
|
||||
store.getState().updateBlock(blocks[1].id, { text: "블록 B" });
|
||||
store.getState().updateBlock(blocks[2].id, { text: "블록 C" });
|
||||
|
||||
const [aId, bId, cId] = blocks.map((b) => b.id);
|
||||
|
||||
// B를 맨 앞으로 이동시키는 순서 변경을 수행한다.
|
||||
store.getState().reorderBlocks(bId, aId);
|
||||
|
||||
blocks = store.getState().blocks;
|
||||
|
||||
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
|
||||
expect(blocks.map((b) => b.props.text)).toEqual(["블록 B", "블록 A", "블록 C"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["tests/unit/**/*.spec.ts", "tests/api/**/*.spec.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
jsx: "automatic",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user