프로젝트 저장 및 목록
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+126
-112
@@ -117,6 +117,7 @@ export default function EditorPage() {
|
||||
const projectConfig = useEditorStore(
|
||||
(state) => (state as any).projectConfig as ProjectConfig,
|
||||
);
|
||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||
|
||||
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
@@ -127,17 +128,12 @@ 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<string[]>([]);
|
||||
|
||||
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 +186,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 +204,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 +225,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 +273,7 @@ export default function EditorPage() {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: projectTitle.trim() || "제목 없음",
|
||||
title,
|
||||
slug,
|
||||
contentJson: blocks,
|
||||
}),
|
||||
@@ -306,6 +286,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 +297,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 +342,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 +545,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을 이용한 선택 블록 이동을 처리한다.
|
||||
@@ -681,6 +744,12 @@ export default function EditorPage() {
|
||||
<p className="text-xs text-slate-400">빌더 MVP용 에디터 페이지 골격</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
프로젝트 목록
|
||||
</Link>
|
||||
<Link
|
||||
href="/preview"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
@@ -720,13 +789,13 @@ export default function EditorPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
|
||||
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setActiveModal("exportPreview");
|
||||
void handleDeleteProject();
|
||||
}}
|
||||
>
|
||||
Export 미리보기
|
||||
프로젝트 삭제
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -806,16 +875,16 @@ export default function EditorPage() {
|
||||
<span className="text-slate-400">프로젝트 제목</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={projectTitle}
|
||||
onChange={(e) => setProjectTitle(e.target.value)}
|
||||
value={projectConfig.title ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">프로젝트 주소 (예: my-landing)</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={projectSlug}
|
||||
onChange={(e) => setProjectSlug(e.target.value)}
|
||||
value={projectConfig.slug ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -834,14 +903,6 @@ export default function EditorPage() {
|
||||
불러오기
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">불러올 주소 (비우면 위 주소 사용)</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
value={loadSlug}
|
||||
onChange={(e) => setLoadSlug(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{projectMessage && (
|
||||
<p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p>
|
||||
)}
|
||||
@@ -850,54 +911,7 @@ export default function EditorPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeModal === "exportPreview" && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-4xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">Export 미리보기</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-slate-100 text-sm"
|
||||
onClick={() => setActiveModal(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-slate-400">
|
||||
현재 캔버스 상태를 기반으로 생성된 정적 Export HTML 을 미리 확인할 수 있습니다.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-sky-700 bg-sky-950 px-3 py-1 text-[11px] text-sky-100 hover:bg-sky-900 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
onClick={handleRefreshExportPreview}
|
||||
disabled={exportPreviewStatus === "loading"}
|
||||
>
|
||||
{exportPreviewStatus === "loading" ? "로딩 중..." : "미리보기 새로고침"}
|
||||
</button>
|
||||
</div>
|
||||
{exportPreviewStatus === "error" && (
|
||||
<p className="text-[11px] text-red-300">
|
||||
Export 미리보기 HTML 을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.
|
||||
</p>
|
||||
)}
|
||||
{exportPreviewHtml ? (
|
||||
<iframe
|
||||
title="Export preview"
|
||||
data-testid="export-preview-frame"
|
||||
className="w-full h-[480px] rounded border border-slate-700 bg-slate-950"
|
||||
srcDoc={exportPreviewHtml}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-[200px] rounded border border-dashed border-slate-700 bg-slate-950 flex items-center justify-center text-[11px] text-slate-500">
|
||||
아직 Export 미리보기 HTML 이 없습니다. "미리보기 새로고침" 버튼을 눌러 생성해 보세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Export 미리보기 기능은 제거되었습니다. */}
|
||||
|
||||
{activeModal === "json" && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
@@ -8,6 +9,31 @@ import { PublicPageRenderer } from "@/features/editor/components/PublicPageRende
|
||||
export default function PreviewPage() {
|
||||
const blocks = useEditorStore((state) => state.blocks);
|
||||
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
|
||||
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
|
||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const slug = (projectConfig as any)?.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?: any[]; projectConfig?: any };
|
||||
if (Array.isArray(parsed.blocks)) {
|
||||
replaceBlocks(parsed.blocks as any);
|
||||
}
|
||||
if (parsed.projectConfig) {
|
||||
updateProjectConfig(parsed.projectConfig as any);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
||||
|
||||
const canvasStyle: CSSProperties = {};
|
||||
const preset = projectConfig?.canvasPreset ?? "full";
|
||||
@@ -88,6 +114,12 @@ export default function PreviewPage() {
|
||||
>
|
||||
페이지 파일로 내보내기 (ZIP)
|
||||
</button>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
프로젝트 목록
|
||||
</Link>
|
||||
<Link
|
||||
href="/editor"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FilePlus2,
|
||||
Trash2,
|
||||
Eye,
|
||||
Pencil,
|
||||
ListChecks,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ProjectListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<ProjectListItem[]>([]);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setStatus("loading");
|
||||
|
||||
const res = await fetch("/api/projects");
|
||||
if (!res.ok) {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as ProjectListItem[];
|
||||
if (!cancelled) {
|
||||
setProjects(Array.isArray(data) ? data : []);
|
||||
setStatus("idle");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
|
||||
setCurrentPage((prev) => Math.min(prev, totalPages));
|
||||
}, [projects.length, pageSize]);
|
||||
|
||||
const handleDelete = async (slug: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
"이 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects((prev) => prev.filter((p) => p.slug !== slug));
|
||||
setSelectedSlugs((prev) => prev.filter((s) => s !== slug));
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const totalCount = projects.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const pageProjects = projects.slice(startIndex, endIndex);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const slugs = selectedSlugs;
|
||||
if (slugs.length === 0) return;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
"선택한 프로젝트를 모두 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
slugs.map(async (slug) => {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
||||
return { slug, ok: res.ok };
|
||||
}),
|
||||
);
|
||||
|
||||
const okSlugs = results.filter((r) => r.ok).map((r) => r.slug);
|
||||
|
||||
if (okSlugs.length === 0) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects((prev) => prev.filter((p) => !okSlugs.includes(p.slug)));
|
||||
setSelectedSlugs((prev) => prev.filter((s) => !okSlugs.includes(s)));
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
for (const slug of okSlugs) {
|
||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okSlugs.length !== slugs.length) {
|
||||
setStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
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 bg-slate-950/80 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">프로젝트 목록</h1>
|
||||
<p className="text-xs text-slate-400">저장된 프로젝트들을 한 눈에 볼 수 있는 목록</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Link
|
||||
href="/editor"
|
||||
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
|
||||
>
|
||||
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span>새 프로젝트 만들기</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||
{status === "error" && (
|
||||
<p className="text-xs text-red-300 mb-3">프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.</p>
|
||||
)}
|
||||
{projects.length === 0 && status === "idle" && (
|
||||
<p className="text-xs text-slate-400">아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.</p>
|
||||
)}
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2 text-[11px] text-slate-300">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
|
||||
<ListChecks className="w-3 h-3 text-slate-400" aria-hidden="true" />
|
||||
<span>
|
||||
선택된 프로젝트: <span className="font-semibold">{selectedSlugs.length}</span>개
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-red-700 px-2 py-1 text-red-200 hover:bg-red-900/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={selectedSlugs.length === 0}
|
||||
onClick={() => {
|
||||
void handleBulkDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>선택 삭제</span>
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full text-xs text-left border-collapse mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 text-slate-400">
|
||||
<th className="py-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
|
||||
aria-label="전체 선택"
|
||||
checked={
|
||||
pageProjects.length > 0 && pageProjects.every((p) => selectedSlugs.includes(p.slug))
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
const slugsOnPage = pageProjects.map((p) => p.slug);
|
||||
setSelectedSlugs((prev) => Array.from(new Set([...prev, ...slugsOnPage])));
|
||||
} else {
|
||||
const slugsOnPage = pageProjects.map((p) => p.slug);
|
||||
setSelectedSlugs((prev) => prev.filter((s) => !slugsOnPage.includes(s)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="py-2 pr-4">제목</th>
|
||||
<th className="py-2 pr-4">주소(slug)</th>
|
||||
<th className="py-2 pr-4">상태</th>
|
||||
<th className="py-2 pr-4">생성일</th>
|
||||
<th className="py-2 pr-4">수정일</th>
|
||||
<th className="py-2 pr-4 text-right">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageProjects.map((project) => {
|
||||
const checked = selectedSlugs.includes(project.slug);
|
||||
return (
|
||||
<tr key={project.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
||||
<td className="py-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
|
||||
aria-label={`${project.title} 선택`}
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSlugs((prev) =>
|
||||
prev.includes(project.slug) ? prev : [...prev, project.slug],
|
||||
);
|
||||
} else {
|
||||
setSelectedSlugs((prev) => prev.filter((s) => s !== project.slug));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-100">{project.title}</td>
|
||||
<td className="py-2 pr-4 text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{project.status}</td>
|
||||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||||
{new Date(project.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||||
{new Date(project.updatedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pl-4 pr-0 text-right text-[11px]">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Link
|
||||
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline"
|
||||
>
|
||||
<Pencil className="w-3 h-3" aria-hidden="true" />
|
||||
<span>편집</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-slate-300 hover:text-slate-100 underline-offset-2 hover:underline"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>미리보기</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
|
||||
onClick={() => {
|
||||
void handleDelete(project.slug);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>삭제</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-300">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
|
||||
전체 {totalCount}개 중{" "}
|
||||
<span className="font-semibold">
|
||||
{totalCount === 0 ? 0 : startIndex + 1}–{Math.min(endIndex, totalCount)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||
const page = idx + 1;
|
||||
const isActive = page === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`w-6 h-6 rounded-full text-[10px] flex items-center justify-center border transition-colors ${
|
||||
isActive
|
||||
? "bg-sky-600 text-white border-sky-500 shadow-sm"
|
||||
: "bg-slate-900 text-slate-300 border-slate-700 hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === 1}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
|
||||
<span>이전</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === totalPages}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
<span>다음</span>
|
||||
<ChevronRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user