diff --git a/package-lock.json b/package-lock.json index e8c3606..0bbfb2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@prisma/client": "^6.19.0", "dotenv": "^17.2.3", "jszip": "^3.10.1", + "lucide-react": "^0.555.0", "next": "^16.0.3", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -7089,6 +7090,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.555.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", + "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index 55bbda4..3c69f7b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@prisma/client": "^6.19.0", "dotenv": "^17.2.3", "jszip": "^3.10.1", + "lucide-react": "^0.555.0", "next": "^16.0.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/src/app/api/projects/[slug]/route.ts b/src/app/api/projects/[slug]/route.ts index 73251c6..c6c5f5b 100644 --- a/src/app/api/projects/[slug]/route.ts +++ b/src/app/api/projects/[slug]/route.ts @@ -3,14 +3,8 @@ 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; +export async function GET(_request: Request, context: { params: { slug: string } | Promise<{ slug: string }> }) { + const { slug } = await context.params; const project = await prisma.project.findUnique({ where: { slug }, @@ -22,3 +16,28 @@ export async function GET(_request: Request, { params }: Params) { return NextResponse.json(project, { status: 200 }); } + +export async function DELETE( + _request: Request, + context: { params: { slug: string } | Promise<{ slug: string }> }, +) { + const { slug } = await context.params; + + try { + await prisma.project.delete({ + where: { slug }, + }); + + return NextResponse.json({ message: "프로젝트가 삭제되었습니다." }, { status: 200 }); + } catch (error: any) { + // Prisma NotFound 에러 코드(P2025)가 떨어지는 경우 404 로 매핑한다. + if (error?.code === "P2025") { + return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 }); + } + + return NextResponse.json( + { message: "프로젝트 삭제 중 오류가 발생했습니다.", error: error?.message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 2a00d96..50e528a 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -3,6 +3,30 @@ import { PrismaClient } from "@/generated/prisma/client"; const prisma = new PrismaClient(); +export async function GET(_request: Request) { + try { + const projects = await prisma.project.findMany({ + orderBy: { createdAt: "desc" }, + take: 50, + select: { + id: true, + title: true, + slug: true, + status: true, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json(projects, { status: 200 }); + } catch (error: any) { + return NextResponse.json( + { message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message }, + { status: 500 }, + ); + } +} + export async function POST(request: Request) { const body = await request.json(); const { title, slug, contentJson } = body; @@ -12,8 +36,13 @@ export async function POST(request: Request) { } try { - const project = await prisma.project.create({ - data: { + const project = await prisma.project.upsert({ + where: { slug }, + update: { + title, + contentJson, + }, + create: { title, slug, contentJson, diff --git a/src/app/editor/EditorCanvas.tsx b/src/app/editor/EditorCanvas.tsx index db10ac0..4501223 100644 --- a/src/app/editor/EditorCanvas.tsx +++ b/src/app/editor/EditorCanvas.tsx @@ -86,7 +86,7 @@ export function EditorCanvas(props: EditorCanvasProps) { > {blocks.length === 0 ? (
- 왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요. + 왼쪽에서 "텍스트" 버튼을 눌러 블록을 추가해 보세요.
) : ( (state as any).projectConfig as ProjectConfig, ); + const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig); const [editingBlockId, setEditingBlockId] = useState(null); const [editingText, setEditingText] = useState(""); @@ -127,17 +129,17 @@ export default function EditorPage() { const [exportJson, setExportJson] = useState(""); const [importJson, setImportJson] = useState(""); - const [projectTitle, setProjectTitle] = useState(""); - const [projectSlug, setProjectSlug] = useState(""); - const [loadSlug, setLoadSlug] = useState(""); const [projectMessage, setProjectMessage] = useState(""); // 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다. const [selectedBlockIds, setSelectedBlockIds] = useState([]); + 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; + const [menuOpen, setMenuOpen] = useState(false); - const [activeModal, setActiveModal] = useState<"project" | "json" | "exportPreview" | null>(null); - const [exportPreviewHtml, setExportPreviewHtml] = useState(""); - const [exportPreviewStatus, setExportPreviewStatus] = useState<"idle" | "loading" | "error">("idle"); + const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null); const sensors = useSensors(useSensor(PointerSensor)); @@ -190,37 +192,6 @@ export default function EditorPage() { } }; - const handleRefreshExportPreview = async () => { - try { - setExportPreviewStatus("loading"); - - const payload = { - blocks, - projectConfig, - }; - - const response = await fetch("/api/export/preview", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - setExportPreviewStatus("error"); - return; - } - - const html = await response.text(); - setExportPreviewHtml(html); - setExportPreviewStatus("idle"); - } catch (error) { - console.error("Export 미리보기 요청 중 오류", error); - setExportPreviewStatus("error"); - } - }; - const commitEditing = () => { if (!editingBlockId) return; updateBlock(editingBlockId, { text: editingText }); @@ -239,7 +210,11 @@ export default function EditorPage() { const handleExportJson = () => { try { - const payload = JSON.stringify(blocks, null, 2); + const snapshot = { + blocks, + projectConfig, + }; + const payload = JSON.stringify(snapshot, null, 2); setExportJson(payload); setImportJson(payload); } catch { @@ -256,31 +231,42 @@ export default function EditorPage() { 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)); + 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 = projectSlug.trim(); + 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: projectTitle.trim() || "제목 없음", + title, contentJson: blocks, }; window.localStorage.setItem(localKey, JSON.stringify(payload)); @@ -293,7 +279,7 @@ export default function EditorPage() { "Content-Type": "application/json", }, body: JSON.stringify({ - title: projectTitle.trim() || "제목 없음", + title, slug, contentJson: blocks, }), @@ -306,6 +292,10 @@ export default function EditorPage() { const data = await response.json(); setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`); + + if (typeof window !== "undefined") { + window.location.href = "/projects"; + } } catch (error) { console.error(error); setProjectMessage("프로젝트 저장 중 오류가 발생했습니다."); @@ -313,7 +303,7 @@ export default function EditorPage() { }; const handleLoadProject = async () => { - const slug = loadSlug.trim() || projectSlug.trim(); + const slug = (projectConfig.slug ?? "").trim(); if (!slug) { setProjectMessage("불러올 주소를 입력해 주세요."); return; @@ -358,6 +348,43 @@ export default function EditorPage() { } }; + 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)); @@ -524,6 +551,48 @@ export default function EditorPage() { const rootBlocks = blocks.filter((block) => !block.sectionId); + useEffect(() => { + 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 }; + if (Array.isArray(parsed.blocks)) { + replaceBlocks(parsed.blocks as any); + } + if (parsed.projectConfig) { + updateProjectConfig(parsed.projectConfig as ProjectConfig); + } + } catch { + return; + } + }, [projectConfig?.slug, replaceBlocks, updateProjectConfig]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const slug = projectConfig?.slug?.trim(); + if (!slug) return; + + const key = `pb:autosave:${slug}`; + const payload = { + blocks, + projectConfig, + }; + + try { + window.localStorage.setItem(key, JSON.stringify(payload)); + } catch { + return; + } + }, [blocks, projectConfig]); + // 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z), // Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제, // ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다. @@ -675,25 +744,57 @@ export default function EditorPage() { return (
-
-
-

Page Editor

-

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

+
+
+ + +
+

Page Editor

+

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

+
- 프리뷰 열기 +
)} - {activeModal === "exportPreview" && ( -
-
-
-

Export 미리보기

- -
-
-
-

- 현재 캔버스 상태를 기반으로 생성된 정적 Export HTML 을 미리 확인할 수 있습니다. -

- -
- {exportPreviewStatus === "error" && ( -

- Export 미리보기 HTML 을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요. -

- )} - {exportPreviewHtml ? ( -