216 lines
7.4 KiB
TypeScript
216 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import type { CSSProperties } from "react";
|
|
import { useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
|
import { getPreviewMessages } from "@/features/i18n/messages/preview";
|
|
import { useEditorStore } from "@/features/editor/state/editorStore";
|
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
|
|
|
export default function PreviewPage() {
|
|
const router = useRouter();
|
|
const locale = useAppLocale();
|
|
const m = getPreviewMessages(locale);
|
|
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);
|
|
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const checkAuth = async () => {
|
|
try {
|
|
const res = await fetch("/api/auth/me");
|
|
if (!res.ok && res.status === 401 && !cancelled) {
|
|
router.push("/login");
|
|
}
|
|
} catch {
|
|
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
|
|
}
|
|
};
|
|
|
|
void checkAuth();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [router]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const configSlugRaw = (projectConfig as any)?.slug?.trim?.();
|
|
const configSlug =
|
|
typeof configSlugRaw === "string" && configSlugRaw.length > 0 ? configSlugRaw : "";
|
|
|
|
let querySlug = "";
|
|
try {
|
|
const url = new URL(window.location.href);
|
|
const fromQuery = url.searchParams.get("slug")?.trim();
|
|
|
|
if (fromQuery) {
|
|
querySlug = fromQuery;
|
|
}
|
|
} catch {
|
|
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
|
|
}
|
|
|
|
const effectiveSlug = querySlug || configSlug;
|
|
if (!effectiveSlug) return;
|
|
|
|
const key = `pb:autosave:${effectiveSlug}`;
|
|
const raw = window.localStorage.getItem(key);
|
|
|
|
if (!raw) {
|
|
// autosave 스냅샷이 없을 때만 URL 쿼리 슬러그를 projectConfig.slug 에 동기화한다.
|
|
if (querySlug && querySlug !== configSlug) {
|
|
updateProjectConfig({ slug: querySlug } as any);
|
|
}
|
|
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);
|
|
} else if (querySlug && querySlug !== configSlug) {
|
|
// 스냅샷에 projectConfig 가 없을 때만 쿼리 슬러그를 반영한다.
|
|
updateProjectConfig({ slug: querySlug } as any);
|
|
}
|
|
|
|
// 프리뷰 진입 시 autosave 프로젝트를 복원하는 경우에도 에디터 히스토리는 새 프로젝트 기준으로만 유지되도록 초기화한다.
|
|
resetHistory();
|
|
} catch {
|
|
return;
|
|
}
|
|
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
|
|
|
const canvasStyle: CSSProperties = {};
|
|
const preset = projectConfig?.canvasPreset ?? "full";
|
|
|
|
const mainStyle: CSSProperties = {};
|
|
{
|
|
const raw = typeof projectConfig?.bodyBgColorHex === "string" ? projectConfig.bodyBgColorHex.trim() : "";
|
|
if (raw) {
|
|
mainStyle.backgroundColor = raw;
|
|
}
|
|
}
|
|
|
|
const handleExportZip = async () => {
|
|
try {
|
|
const payload = {
|
|
blocks,
|
|
projectConfig,
|
|
};
|
|
|
|
const response = await fetch("/api/export", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const slug = (projectConfig?.slug ?? "").trim() || "page-builder-export";
|
|
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${slug}.zip`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error("프리뷰 ZIP 내보내기 중 오류", error);
|
|
}
|
|
};
|
|
|
|
const widthPx =
|
|
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
|
|
? projectConfig.canvasWidthPx
|
|
: null;
|
|
|
|
const projectSlugRaw = (projectConfig as any)?.slug?.trim?.();
|
|
const projectSlug =
|
|
typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : "";
|
|
const editorHref = projectSlug
|
|
? `/editor?slug=${encodeURIComponent(projectSlug)}`
|
|
: "/editor";
|
|
|
|
if (widthPx != null) {
|
|
canvasStyle.maxWidth = `${widthPx}px`;
|
|
} else if (preset === "mobile") {
|
|
canvasStyle.maxWidth = "390px";
|
|
} else if (preset === "tablet") {
|
|
canvasStyle.maxWidth = "768px";
|
|
} else if (preset === "desktop") {
|
|
canvasStyle.maxWidth = "1200px";
|
|
}
|
|
|
|
if (projectConfig?.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
|
|
canvasStyle.backgroundColor = projectConfig.canvasBgColorHex;
|
|
}
|
|
|
|
return (
|
|
<main className="min-h-screen flex flex-col" style={mainStyle}>
|
|
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">{m.headerTitle}</h1>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
|
onClick={() => {
|
|
void handleExportZip();
|
|
}}
|
|
>
|
|
{m.exportZipButtonLabel}
|
|
</button>
|
|
<Link
|
|
href="/projects"
|
|
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-xs text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
|
>
|
|
{m.navProjects}
|
|
</Link>
|
|
<Link
|
|
href={editorHref}
|
|
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-xs text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
|
>
|
|
{m.navBackToEditor}
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링하되, projectConfig 에 따라 최대 폭을 제한한다. */}
|
|
<section className="flex flex-1 overflow-auto">
|
|
<div className="flex-1 flex justify-center px-4 py-6">
|
|
<div
|
|
data-testid="preview-canvas-inner"
|
|
className="w-full"
|
|
style={canvasStyle}
|
|
>
|
|
<PublicPageRenderer
|
|
blocks={blocks}
|
|
projectSlug={(projectConfig?.slug ?? "").trim() || undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|