프로젝트 저장 및 목록
CI / test (push) Failing after 7m39s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-29 22:05:25 +09:00
parent 1f085bd64c
commit 17bfac62ed
15 changed files with 1765 additions and 179 deletions
+126 -112
View File
@@ -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">