리팩터링
This commit is contained in:
@@ -145,11 +145,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
);
|
||||
}
|
||||
|
||||
if (seoCanonicalRaw) {
|
||||
seoHeadParts.push(
|
||||
` <link rel="canonical" href="${escapeAttr(seoCanonicalRaw)}" />`,
|
||||
);
|
||||
}
|
||||
// canonical URL 은 Export HTML 에서는 meta property="og:url" 로만 사용하고,
|
||||
// 별도의 <link rel="canonical"> 태그는 생성하지 않는다.
|
||||
|
||||
if (seoNoIndex) {
|
||||
seoHeadParts.push(
|
||||
|
||||
@@ -5,6 +5,22 @@ import { encryptJson } from "@/features/auth/authCrypto";
|
||||
|
||||
const prisma = new PrismaClient() as any;
|
||||
|
||||
function resolveProjectSlug(formData: FormData, config: FormBlockProps | null): string | null {
|
||||
const projectSlugRaw = formData.get("__projectSlug");
|
||||
let projectSlug: string | null = null;
|
||||
|
||||
if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") {
|
||||
projectSlug = projectSlugRaw.trim();
|
||||
} else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") {
|
||||
const v = config.extraParams.projectSlug.trim();
|
||||
if (v !== "") {
|
||||
projectSlug = v;
|
||||
}
|
||||
}
|
||||
|
||||
return projectSlug;
|
||||
}
|
||||
|
||||
function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string> {
|
||||
const result = new Set<string>();
|
||||
const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : [];
|
||||
@@ -55,17 +71,7 @@ function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string>
|
||||
}
|
||||
|
||||
async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) {
|
||||
const projectSlugRaw = formData.get("__projectSlug");
|
||||
let projectSlug: string | null = null;
|
||||
|
||||
if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") {
|
||||
projectSlug = projectSlugRaw.trim();
|
||||
} else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") {
|
||||
const v = config.extraParams.projectSlug.trim();
|
||||
if (v !== "") {
|
||||
projectSlug = v;
|
||||
}
|
||||
}
|
||||
const projectSlug = resolveProjectSlug(formData, config);
|
||||
|
||||
const sensitiveNames = buildSensitiveFieldNameSet(config);
|
||||
|
||||
@@ -167,6 +173,44 @@ export async function POST(req: Request) {
|
||||
const successMessage = config?.successMessage;
|
||||
const errorMessage = config?.errorMessage;
|
||||
|
||||
const projectSlug = resolveProjectSlug(formData, config);
|
||||
let projectStatus: string | null = null;
|
||||
|
||||
if (projectSlug) {
|
||||
try {
|
||||
const project = await prisma.project.findUnique({ where: { slug: projectSlug } });
|
||||
if (project && typeof (project as any).status === "string") {
|
||||
projectStatus = (project as any).status as string;
|
||||
}
|
||||
} catch {
|
||||
// 상태 조회 실패는 제출 자체를 막지 않는다. (projectSlug 기반 저장은 계속 허용)
|
||||
}
|
||||
}
|
||||
|
||||
const isProjectClosed = (status: string | null): boolean => {
|
||||
if (!status) return false;
|
||||
if (status === "ARCHIVED") return true;
|
||||
if (status === "DRAFT") return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isProjectClosed(projectStatus)) {
|
||||
const message =
|
||||
errorMessage ??
|
||||
(projectStatus === "DRAFT"
|
||||
? "아직 공개되지 않은 프로젝트입니다."
|
||||
: "이 프로젝트는 현재 폼 제출이 중단된 상태입니다.");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "project_closed",
|
||||
message,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (submitTarget === "internal") {
|
||||
await persistFormSubmission(formData, config);
|
||||
console.log("[forms/submit][internal]", { name, email, message });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaClient, ProjectStatus } from "@prisma/client";
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
import { cachePublicProject, parseProjectContentJson } from "@/features/projects/publicCache";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -98,3 +99,70 @@ export async function DELETE(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||
) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ message: "프로젝트를 수정하려면 로그인이 필요합니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const { slug } = await context.params;
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ message: "잘못된 요청 본문입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const nextStatus = body?.status as ProjectStatus | undefined;
|
||||
const allowedStatuses: ProjectStatus[] = ["DRAFT", "PUBLISHED", "ARCHIVED"];
|
||||
|
||||
if (!nextStatus || !allowedStatuses.includes(nextStatus)) {
|
||||
return NextResponse.json({ message: "유효하지 않은 프로젝트 상태입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
const updated = await prisma.project.update({
|
||||
where: { slug },
|
||||
data: { status: nextStatus },
|
||||
});
|
||||
|
||||
// 퍼블릭 캐시 스냅샷도 함께 갱신해 /p/[slug] 가 최신 상태를 사용할 수 있도록 한다.
|
||||
try {
|
||||
const { blocks, projectConfig } = parseProjectContentJson((updated as any).contentJson);
|
||||
|
||||
cachePublicProject({
|
||||
slug: updated.slug,
|
||||
title: updated.title,
|
||||
status: (updated as any).status ?? nextStatus,
|
||||
blocks,
|
||||
projectConfig,
|
||||
});
|
||||
} catch {
|
||||
// 캐시 갱신 실패는 퍼블릭 렌더링에만 영향을 주므로 무시한다.
|
||||
}
|
||||
|
||||
return NextResponse.json(updated, { status: 200 });
|
||||
} catch (error: any) {
|
||||
if (error?.code === "P2025") {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "프로젝트 수정 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
import { cachePublicProject } from "@/features/projects/publicCache";
|
||||
import { cachePublicProject, parseProjectContentJson } from "@/features/projects/publicCache";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -62,7 +61,80 @@ export async function GET(request: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(projects, { status: 200 });
|
||||
if (projects.length === 0) {
|
||||
return NextResponse.json(
|
||||
projects.map((p) => ({
|
||||
...p,
|
||||
todaySubmissions: 0,
|
||||
totalSubmissions: 0,
|
||||
})),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
|
||||
const submissions = await prisma.formSubmission.findMany({
|
||||
where: {
|
||||
userId: authUser.id,
|
||||
projectId: { in: projectIds },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
|
||||
const statsByProjectId = new Map<
|
||||
string,
|
||||
{
|
||||
todaySubmissions: number;
|
||||
totalSubmissions: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const project of projects) {
|
||||
statsByProjectId.set(project.id, { todaySubmissions: 0, totalSubmissions: 0 });
|
||||
}
|
||||
|
||||
for (const submission of submissions) {
|
||||
const projectId = submission.projectId;
|
||||
if (!projectId) continue;
|
||||
|
||||
const stat = statsByProjectId.get(projectId);
|
||||
if (!stat) continue;
|
||||
|
||||
stat.totalSubmissions += 1;
|
||||
|
||||
const createdAt =
|
||||
submission.createdAt instanceof Date
|
||||
? submission.createdAt
|
||||
: new Date((submission.createdAt as unknown as string) ?? "");
|
||||
|
||||
if (createdAt >= startOfToday) {
|
||||
stat.todaySubmissions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = projects.map((project) => {
|
||||
const stat = statsByProjectId.get(project.id) ?? {
|
||||
todaySubmissions: 0,
|
||||
totalSubmissions: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
...project,
|
||||
todaySubmissions: stat.todaySubmissions,
|
||||
totalSubmissions: stat.totalSubmissions,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message },
|
||||
@@ -112,17 +184,15 @@ export async function POST(request: Request) {
|
||||
|
||||
// 퍼블릭 슬러그 페이지(/p/[slug])가 DB 조회 없이도 최신 스냅샷을 바로 렌더링할 수 있도록
|
||||
// 메모리 기반 캐시에 프로젝트 스냅샷을 저장해 둔다.
|
||||
const rawContent = project.contentJson;
|
||||
let contentBlocks: Block[] = [];
|
||||
if (Array.isArray(rawContent)) {
|
||||
contentBlocks = rawContent as unknown as Block[];
|
||||
}
|
||||
const { blocks, projectConfig } = parseProjectContentJson((project as any).contentJson);
|
||||
|
||||
try {
|
||||
cachePublicProject({
|
||||
slug: project.slug,
|
||||
title: project.title,
|
||||
contentJson: contentBlocks,
|
||||
status: (project as any).status ?? "DRAFT",
|
||||
blocks,
|
||||
projectConfig,
|
||||
});
|
||||
} catch {
|
||||
// 캐시 저장 실패는 퍼블릭 렌더링에만 영향을 주므로, API 응답 자체는 정상적으로 계속 진행한다.
|
||||
|
||||
+79
-12
@@ -282,9 +282,27 @@ function EditorPageInner() {
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data && Array.isArray(data.contentJson)) {
|
||||
replaceBlocks(data.contentJson as any);
|
||||
if (data.title && typeof data.title === "string") {
|
||||
const rawContent = (data as any)?.contentJson;
|
||||
let nextBlocks: Block[] | null = null;
|
||||
let nextProjectConfig: ProjectConfig | null = null;
|
||||
|
||||
if (Array.isArray(rawContent)) {
|
||||
nextBlocks = rawContent as Block[];
|
||||
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
|
||||
nextBlocks = rawContent.blocks as Block[];
|
||||
if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") {
|
||||
nextProjectConfig = rawContent.projectConfig as ProjectConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextBlocks) {
|
||||
replaceBlocks(nextBlocks as any);
|
||||
if (nextProjectConfig) {
|
||||
updateProjectConfig({
|
||||
...(nextProjectConfig as ProjectConfig),
|
||||
slug,
|
||||
});
|
||||
} else if (data.title && typeof data.title === "string") {
|
||||
updateProjectConfig({ title: data.title, slug });
|
||||
} else {
|
||||
updateProjectConfig({ slug });
|
||||
@@ -483,9 +501,13 @@ function EditorPageInner() {
|
||||
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
|
||||
if (typeof window !== "undefined") {
|
||||
const localKey = `pb:project:${slug}`;
|
||||
const snapshot = {
|
||||
blocks,
|
||||
projectConfig,
|
||||
};
|
||||
const payload = {
|
||||
title,
|
||||
contentJson: blocks,
|
||||
contentJson: snapshot,
|
||||
};
|
||||
window.localStorage.setItem(localKey, JSON.stringify(payload));
|
||||
|
||||
@@ -505,6 +527,10 @@ function EditorPageInner() {
|
||||
}
|
||||
|
||||
// 2) 서버에도 저장 (백업/공유용)
|
||||
const snapshotForServer = {
|
||||
blocks,
|
||||
projectConfig,
|
||||
};
|
||||
const response = await fetch("/api/projects", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -513,7 +539,7 @@ function EditorPageInner() {
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
slug,
|
||||
contentJson: blocks,
|
||||
contentJson: snapshotForServer,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -559,10 +585,33 @@ function EditorPageInner() {
|
||||
const raw = window.localStorage.getItem(localKey);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.contentJson)) {
|
||||
replaceBlocks(parsed.contentJson as any);
|
||||
if (parsed.title && typeof parsed.title === "string") {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
title?: string;
|
||||
contentJson?: any;
|
||||
};
|
||||
|
||||
const rawContent = (parsed as any)?.contentJson;
|
||||
let nextBlocks: Block[] | null = null;
|
||||
let nextProjectConfig: ProjectConfig | null = null;
|
||||
|
||||
if (Array.isArray(rawContent)) {
|
||||
nextBlocks = rawContent as Block[];
|
||||
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
|
||||
nextBlocks = rawContent.blocks as Block[];
|
||||
if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") {
|
||||
nextProjectConfig = rawContent.projectConfig as ProjectConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextBlocks) {
|
||||
replaceBlocks(nextBlocks as any);
|
||||
|
||||
if (nextProjectConfig) {
|
||||
updateProjectConfig({
|
||||
...(nextProjectConfig as ProjectConfig),
|
||||
slug,
|
||||
});
|
||||
} else if (parsed.title && typeof parsed.title === "string") {
|
||||
updateProjectConfig({ title: parsed.title, slug });
|
||||
} else {
|
||||
updateProjectConfig({ slug });
|
||||
@@ -588,9 +637,27 @@ function EditorPageInner() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data && Array.isArray(data.contentJson)) {
|
||||
replaceBlocks(data.contentJson as any);
|
||||
if (data.title && typeof data.title === "string") {
|
||||
const rawContent = (data as any)?.contentJson;
|
||||
let nextBlocks: Block[] | null = null;
|
||||
let nextProjectConfig: ProjectConfig | null = null;
|
||||
|
||||
if (Array.isArray(rawContent)) {
|
||||
nextBlocks = rawContent as Block[];
|
||||
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
|
||||
nextBlocks = rawContent.blocks as Block[];
|
||||
if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") {
|
||||
nextProjectConfig = rawContent.projectConfig as ProjectConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextBlocks) {
|
||||
replaceBlocks(nextBlocks as any);
|
||||
if (nextProjectConfig) {
|
||||
updateProjectConfig({
|
||||
...(nextProjectConfig as ProjectConfig),
|
||||
slug,
|
||||
});
|
||||
} else if (data.title && typeof data.title === "string") {
|
||||
updateProjectConfig({ title: data.title, slug });
|
||||
} else {
|
||||
updateProjectConfig({ slug });
|
||||
|
||||
@@ -5,9 +5,10 @@ import { getPublicPageRendererMessages } from "@/features/i18n/messages/publicPa
|
||||
|
||||
interface PublicProjectPageClientProps {
|
||||
html: string;
|
||||
canSubmit?: boolean;
|
||||
}
|
||||
|
||||
export default function PublicProjectPageClient({ html }: PublicProjectPageClientProps) {
|
||||
export default function PublicProjectPageClient({ html, canSubmit = true }: PublicProjectPageClientProps) {
|
||||
const m = getPublicPageRendererMessages(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -52,6 +53,12 @@ export default function PublicProjectPageClient({ html }: PublicProjectPageClien
|
||||
}
|
||||
}
|
||||
|
||||
if (!canSubmit) {
|
||||
const errorMsg = (config && config.errorMessage) || m.submitErrorDefault;
|
||||
showToast(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const action = form.getAttribute("action") || "/api/forms/submit";
|
||||
|
||||
fetch(action, { method: "POST", body: formData })
|
||||
|
||||
+161
-13
@@ -1,9 +1,10 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { buildStaticHtml } from "@/features/export/buildStaticHtml";
|
||||
import { getCachedPublicProject } from "@/features/projects/publicCache";
|
||||
import { getCachedPublicProject, parseProjectContentJson } from "@/features/projects/publicCache";
|
||||
import PublicProjectPageClient from "./PublicProjectPageClient";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -14,6 +15,113 @@ interface PageParams {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
async function resolveSlugParam(rawParams: { slug: string } | Promise<{ slug: string }>): Promise<string> {
|
||||
const resolved = await rawParams;
|
||||
const slugRaw = resolved?.slug ?? "";
|
||||
return slugRaw.toString().trim();
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props:
|
||||
| { params: { slug: string } }
|
||||
| {
|
||||
params: Promise<{ slug: string }>;
|
||||
},
|
||||
): Promise<Metadata> {
|
||||
const slug = await resolveSlugParam((props as any).params);
|
||||
if (!slug) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const cached = getCachedPublicProject(slug);
|
||||
|
||||
let status: string | null = null;
|
||||
let projectConfigFromContent: ProjectConfig | null = null;
|
||||
let fallbackTitle: string | null = null;
|
||||
|
||||
if (cached) {
|
||||
status = (cached as any).status ?? null;
|
||||
projectConfigFromContent = (cached as any).projectConfig ?? null;
|
||||
fallbackTitle = cached.title ?? null;
|
||||
} else {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
title: true,
|
||||
status: true,
|
||||
contentJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return {};
|
||||
}
|
||||
|
||||
status = (project as any).status ?? null;
|
||||
const { projectConfig } = parseProjectContentJson((project as any).contentJson);
|
||||
projectConfigFromContent = projectConfig;
|
||||
fallbackTitle = project.title ?? null;
|
||||
}
|
||||
|
||||
if (status === "DRAFT") {
|
||||
// DRAFT 프로젝트는 404 처리되므로 별도의 메타데이터를 노출하지 않는다.
|
||||
return {};
|
||||
}
|
||||
|
||||
const baseConfig: ProjectConfig = {
|
||||
...(projectConfigFromContent ?? {}),
|
||||
title: (projectConfigFromContent?.title ?? fallbackTitle ?? slug) as any,
|
||||
slug,
|
||||
} as ProjectConfig;
|
||||
|
||||
const baseTitleRaw = (baseConfig.title ?? "").trim() || "Page Builder";
|
||||
const seoTitleRaw = (baseConfig as any).seoTitle
|
||||
? String((baseConfig as any).seoTitle).trim()
|
||||
: "";
|
||||
const pageTitle = seoTitleRaw || baseTitleRaw;
|
||||
|
||||
const seoDescriptionRaw = (baseConfig as any).seoDescription
|
||||
? String((baseConfig as any).seoDescription).trim()
|
||||
: "";
|
||||
const seoCanonicalRaw = (baseConfig as any).seoCanonicalUrl
|
||||
? String((baseConfig as any).seoCanonicalUrl).trim()
|
||||
: "";
|
||||
const seoOgImageRaw = (baseConfig as any).seoOgImageUrl
|
||||
? String((baseConfig as any).seoOgImageUrl).trim()
|
||||
: "";
|
||||
const seoNoIndex = Boolean((baseConfig as any).seoNoIndex);
|
||||
|
||||
const description = seoDescriptionRaw || undefined;
|
||||
const ogUrl = seoCanonicalRaw || undefined;
|
||||
const ogImage = seoOgImageRaw || undefined;
|
||||
|
||||
const metadata: Metadata = {
|
||||
title: pageTitle,
|
||||
description,
|
||||
openGraph: {
|
||||
title: pageTitle,
|
||||
description,
|
||||
type: "website",
|
||||
url: ogUrl,
|
||||
images: ogImage ? [ogImage] : undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: pageTitle,
|
||||
description,
|
||||
images: ogImage ? [ogImage] : undefined,
|
||||
},
|
||||
robots: seoNoIndex
|
||||
? {
|
||||
index: false,
|
||||
follow: false,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// /p/[slug] 공개 페이지
|
||||
// - slug 에 해당하는 Project 의 contentJson 을 불러와 buildStaticHtml 로 정적 HTML 을 생성한다.
|
||||
// - Export ZIP 에 포함되는 index.html 과 동일한 app-root DOM 구조를 사용해 렌더링한다.
|
||||
@@ -33,16 +141,21 @@ export default async function PublicProjectPage({ params }: PageParams) {
|
||||
|
||||
let blocks: Block[];
|
||||
let title: string;
|
||||
let status: string | null = null;
|
||||
let projectConfigFromContent: ProjectConfig | null = null;
|
||||
|
||||
if (cached) {
|
||||
blocks = cached.contentJson ?? [];
|
||||
blocks = cached.blocks ?? [];
|
||||
title = cached.title ?? "";
|
||||
status = (cached as any).status ?? null;
|
||||
projectConfigFromContent = (cached as any).projectConfig ?? null;
|
||||
} else {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
contentJson: true,
|
||||
},
|
||||
});
|
||||
@@ -51,24 +164,40 @@ export default async function PublicProjectPage({ params }: PageParams) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const rawContent = project.contentJson;
|
||||
let contentBlocks: Block[] = [];
|
||||
if (Array.isArray(rawContent)) {
|
||||
contentBlocks = rawContent as unknown as Block[];
|
||||
}
|
||||
blocks = contentBlocks;
|
||||
const { blocks: parsedBlocks, projectConfig } = parseProjectContentJson(
|
||||
(project as any).contentJson,
|
||||
);
|
||||
blocks = parsedBlocks;
|
||||
title = project.title;
|
||||
status = (project as any).status ?? null;
|
||||
projectConfigFromContent = projectConfig;
|
||||
}
|
||||
|
||||
if (status === "DRAFT") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Export index.html 과 동일한 규칙으로 ProjectConfig 를 구성한다.
|
||||
const projectConfig: ProjectConfig = {
|
||||
const baseConfig: ProjectConfig = {
|
||||
...(projectConfigFromContent ?? {}),
|
||||
title,
|
||||
slug,
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#020617",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
if (!baseConfig.canvasPreset) {
|
||||
baseConfig.canvasPreset = "full" as ProjectConfig["canvasPreset"];
|
||||
}
|
||||
if (!baseConfig.canvasBgColorHex) {
|
||||
baseConfig.canvasBgColorHex = "#020617";
|
||||
}
|
||||
if (!baseConfig.bodyBgColorHex) {
|
||||
baseConfig.bodyBgColorHex = "#020617";
|
||||
}
|
||||
|
||||
const projectConfig: ProjectConfig = baseConfig;
|
||||
|
||||
const canSubmit = status === "ARCHIVED" ? false : true;
|
||||
|
||||
const fullHtml = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// buildStaticHtml 이 생성한 index.html 에서 <main> ... </main> 블록만 추출해 렌더링한다.
|
||||
@@ -76,5 +205,24 @@ export default async function PublicProjectPage({ params }: PageParams) {
|
||||
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
|
||||
const mainHtml = mainMatch ? mainMatch[0] : "";
|
||||
|
||||
return <PublicProjectPageClient html={mainHtml} />;
|
||||
return (
|
||||
<>
|
||||
{(projectConfig as any).headHtml ? (
|
||||
<div
|
||||
// headHtml 은 meta/script 태그 등을 포함하는 임의의 HTML 조각으로 가정하고,
|
||||
// SSR 시 그대로 문서에 포함되도록 body 안에 삽입한다.
|
||||
dangerouslySetInnerHTML={{ __html: String((projectConfig as any).headHtml) }}
|
||||
/>
|
||||
) : null}
|
||||
{(projectConfig as any).trackingScript ? (
|
||||
<div
|
||||
// trackingScript 는 HTML 문자열(일반적으로 <script>...</script>) 이므로 그대로 삽입한다.
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: String((projectConfig as any).trackingScript),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<PublicProjectPageClient html={mainHtml} canSubmit={canSubmit} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+114
-18
@@ -15,6 +15,8 @@ import {
|
||||
FolderKanban,
|
||||
SunMoon,
|
||||
BarChart2,
|
||||
HelpCircle,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||||
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
||||
@@ -27,6 +29,8 @@ interface ProjectListItem {
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
todaySubmissions?: number;
|
||||
totalSubmissions?: number;
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
@@ -41,6 +45,7 @@ export default function ProjectsPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [emailVerified, setEmailVerified] = useState<boolean | null>(null);
|
||||
const [isStatusHelpOpen, setIsStatusHelpOpen] = useState(false);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,6 +122,50 @@ export default function ProjectsPage() {
|
||||
setCurrentPage((prev) => Math.min(prev, totalPages));
|
||||
}, [projects.length, pageSize]);
|
||||
|
||||
const handleStatusChange = async (
|
||||
slug: string,
|
||||
nextStatus: "DRAFT" | "PUBLISHED" | "ARCHIVED",
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setErrorMessage(p.errorFetchGeneric);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = (await res.json().catch(() => null)) as
|
||||
| (ProjectListItem & { status?: string })
|
||||
| null;
|
||||
|
||||
setProjects((prev) =>
|
||||
prev.map((project) => {
|
||||
if (project.slug !== slug) return project;
|
||||
const updatedStatus = updated?.status ?? nextStatus;
|
||||
const updatedAt = (updated as any)?.updatedAt ?? project.updatedAt;
|
||||
return {
|
||||
...project,
|
||||
status: updatedStatus,
|
||||
updatedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setStatus("idle");
|
||||
setErrorMessage(null);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setErrorMessage(p.errorFetchGeneric);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (slug: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(p.confirmDeleteSingle);
|
||||
@@ -391,7 +440,30 @@ export default function ProjectsPage() {
|
||||
</th>
|
||||
<th className="py-2 pr-4">{p.tableHeaderTitle}</th>
|
||||
<th className="py-2 pr-4">{p.tableHeaderSlug}</th>
|
||||
<th className="py-2 pr-4">{p.tableHeaderStatus}</th>
|
||||
<th className="py-2 pr-4 relative">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>{p.tableHeaderStatus}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={p.tableHeaderStatusHelpLabel}
|
||||
aria-pressed={isStatusHelpOpen}
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-slate-300 text-slate-500 bg-white hover:bg-slate-100 dark:border-slate-600 dark:text-slate-300 dark:bg-slate-900 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setIsStatusHelpOpen((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
{isStatusHelpOpen && (
|
||||
<div className="absolute z-20 mt-1 w-72 rounded-md border border-slate-200 bg-white p-2 text-[10px] text-slate-700 shadow-md dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200">
|
||||
<pre className="whitespace-pre-wrap font-sans text-[10px] leading-snug">
|
||||
{p.tableHeaderStatusHelpTooltip}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
<th className="py-2 pr-4">{p.tableHeaderSubmissions}</th>
|
||||
<th className="py-2 pr-4">{p.tableHeaderCreatedAt}</th>
|
||||
<th className="py-2 pr-4">{p.tableHeaderUpdatedAt}</th>
|
||||
<th className="py-2 pr-4 text-right">{p.tableHeaderActions}</th>
|
||||
@@ -424,7 +496,36 @@ export default function ProjectsPage() {
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{project.title}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{project.status}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">
|
||||
<select
|
||||
value={project.status}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
if (value !== project.status) {
|
||||
void handleStatusChange(project.slug, value);
|
||||
}
|
||||
}}
|
||||
className="border border-slate-300 rounded px-1.5 py-1 text-[11px] bg-white text-slate-700 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-200"
|
||||
>
|
||||
<option value="DRAFT">{p.statusDraftLabel}</option>
|
||||
<option value="PUBLISHED">{p.statusPublishedLabel}</option>
|
||||
<option value="ARCHIVED">{p.statusArchivedLabel}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 text-[11px]">
|
||||
<Link
|
||||
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
||||
className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
|
||||
>
|
||||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||
<span>
|
||||
{(project.todaySubmissions ?? 0).toString()}/{
|
||||
(project.totalSubmissions ?? 0).toString()
|
||||
}
|
||||
</span>
|
||||
<span className="sr-only">{p.actionViewSubmissions}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-500 dark:text-slate-400 text-[11px]">
|
||||
{new Date(project.createdAt).toLocaleString()}
|
||||
</td>
|
||||
@@ -432,37 +533,32 @@ export default function ProjectsPage() {
|
||||
{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">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<Link
|
||||
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-sky-600 hover:text-sky-700 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
||||
>
|
||||
<Pencil className="w-3 h-3" aria-hidden="true" />
|
||||
<span>{p.actionEdit}</span>
|
||||
<Pencil className="w-4 h-4" aria-hidden="true" />
|
||||
<span className="sr-only">{p.actionEdit}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-800 underline-offset-2 hover:underline dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>{p.actionPreview}</span>
|
||||
<Eye className="w-4 h-4" aria-hidden="true" />
|
||||
<span className="sr-only">{p.actionPreview}</span>
|
||||
</Link>
|
||||
{emailVerified !== false && (
|
||||
<Link
|
||||
href={`/p/${encodeURIComponent(project.slug)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>{t.projectOpenPublicPageLink}</span>
|
||||
<ExternalLink className="w-4 h-4" aria-hidden="true" />
|
||||
<span className="sr-only">{t.projectOpenPublicPageLink}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
||||
className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
|
||||
>
|
||||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||
<span>{p.actionViewSubmissions}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 underline-offset-2 hover:underline dark:text-red-300 dark:hover:text-red-200"
|
||||
@@ -470,8 +566,8 @@ export default function ProjectsPage() {
|
||||
void handleDelete(project.slug);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>{p.actionDelete}</span>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span className="sr-only">{p.actionDelete}</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -92,8 +92,9 @@ const EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES: Record<AppLocale, EditorProjectP
|
||||
seoOgImageUrlAria: "OG/Twitter image URL",
|
||||
seoOgImageUrlPlaceholder: "e.g. https://example.com/og-image.png",
|
||||
|
||||
seoCanonicalUrlLabel: "Canonical URL",
|
||||
seoCanonicalUrlAria: "Canonical URL",
|
||||
// Canonical 은 더 이상 <link rel=\"canonical\"> 로 쓰지 않고, 공유 URL(og:url) 설정 용도로만 사용한다.
|
||||
seoCanonicalUrlLabel: "Share URL (og:url)",
|
||||
seoCanonicalUrlAria: "Share URL (og:url)",
|
||||
seoCanonicalUrlPlaceholder: "e.g. https://example.com/landing",
|
||||
|
||||
seoNoIndexAria: "Hide from search engines (noindex)",
|
||||
@@ -145,8 +146,9 @@ const EDITOR_PROJECT_PROPERTIES_PANEL_MESSAGES: Record<AppLocale, EditorProjectP
|
||||
seoOgImageUrlAria: "OG/Twitter 이미지 URL",
|
||||
seoOgImageUrlPlaceholder: "예: https://example.com/og-image.png",
|
||||
|
||||
seoCanonicalUrlLabel: "Canonical URL",
|
||||
seoCanonicalUrlAria: "Canonical URL",
|
||||
// Canonical 은 더 이상 <link rel=\"canonical\"> 로 쓰지 않고, 공유 URL(og:url) 설정 용도로만 사용한다.
|
||||
seoCanonicalUrlLabel: "공유 URL (og:url)",
|
||||
seoCanonicalUrlAria: "공유 URL (og:url)",
|
||||
seoCanonicalUrlPlaceholder: "예: https://example.com/landing",
|
||||
|
||||
seoNoIndexAria: "검색 엔진에 노출하지 않기 (noindex)",
|
||||
|
||||
@@ -19,6 +19,14 @@ export type ProjectsMessages = {
|
||||
tableHeaderTitle: string;
|
||||
tableHeaderSlug: string;
|
||||
tableHeaderStatus: string;
|
||||
tableHeaderStatusHelpLabel: string;
|
||||
tableHeaderStatusHelpTooltip: string;
|
||||
statusDraftLabel: string;
|
||||
statusPublishedLabel: string;
|
||||
statusArchivedLabel: string;
|
||||
tableHeaderSubmissions: string;
|
||||
tableHeaderSubmissionsHelpLabel: string;
|
||||
tableHeaderSubmissionsHelpTooltip: string;
|
||||
tableHeaderCreatedAt: string;
|
||||
tableHeaderUpdatedAt: string;
|
||||
tableHeaderActions: string;
|
||||
@@ -60,6 +68,18 @@ const PROJECTS_MESSAGES: Record<AppLocale, ProjectsMessages> = {
|
||||
tableHeaderTitle: "Title",
|
||||
tableHeaderSlug: "Address (slug)",
|
||||
tableHeaderStatus: "Status",
|
||||
tableHeaderStatusHelpLabel: "Project status help",
|
||||
tableHeaderStatusHelpTooltip:
|
||||
"Draft: Public page is blocked (404) and form submissions are not accepted.\n" +
|
||||
"Published: Public page is live and form submissions are accepted.\n" +
|
||||
"Archived: Public page is live but new form submissions are blocked.",
|
||||
statusDraftLabel: "Draft (blocked)",
|
||||
statusPublishedLabel: "Published (live)",
|
||||
statusArchivedLabel: "Archived (read-only)",
|
||||
tableHeaderSubmissions: "Submissions (today/total)",
|
||||
tableHeaderSubmissionsHelpLabel: "Submission counts help",
|
||||
tableHeaderSubmissionsHelpTooltip:
|
||||
"Shows form submission counts as [icon] today/total (today's submissions / all submissions).",
|
||||
tableHeaderCreatedAt: "Created at",
|
||||
tableHeaderUpdatedAt: "Updated at",
|
||||
tableHeaderActions: "Actions",
|
||||
@@ -100,6 +120,18 @@ const PROJECTS_MESSAGES: Record<AppLocale, ProjectsMessages> = {
|
||||
tableHeaderTitle: "제목",
|
||||
tableHeaderSlug: "주소(slug)",
|
||||
tableHeaderStatus: "상태",
|
||||
tableHeaderStatusHelpLabel: "프로젝트 상태 설명",
|
||||
tableHeaderStatusHelpTooltip:
|
||||
"차단: 퍼블릭 페이지가 404로 차단되고 폼 제출을 받지 않습니다.\n" +
|
||||
"운영 중: 퍼블릭 페이지에 노출되고 폼 제출을 정상적으로 받습니다.\n" +
|
||||
"제출 중단(오픈): 퍼블릭 페이지는 열리지만 새 폼 제출은 차단됩니다.",
|
||||
statusDraftLabel: "차단",
|
||||
statusPublishedLabel: "운영 중",
|
||||
statusArchivedLabel: "제출 중단(오픈)",
|
||||
tableHeaderSubmissions: "제출 내역 (오늘/전체)",
|
||||
tableHeaderSubmissionsHelpLabel: "제출 내역 수치 설명",
|
||||
tableHeaderSubmissionsHelpTooltip:
|
||||
"아이콘 옆 숫자는 '오늘 제출 수 / 전체 누적 제출 수'를 의미합니다.",
|
||||
tableHeaderCreatedAt: "생성일",
|
||||
tableHeaderUpdatedAt: "수정일",
|
||||
tableHeaderActions: "액션",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
interface PublicProjectSnapshot {
|
||||
slug: string;
|
||||
title: string;
|
||||
contentJson: Block[];
|
||||
status: string;
|
||||
blocks: Block[];
|
||||
projectConfig: ProjectConfig | null;
|
||||
}
|
||||
|
||||
function getStore(): Map<string, PublicProjectSnapshot> {
|
||||
@@ -23,3 +25,26 @@ export function getCachedPublicProject(slug: string): PublicProjectSnapshot | nu
|
||||
const store = getStore();
|
||||
return store.get(slug) ?? null;
|
||||
}
|
||||
|
||||
export function parseProjectContentJson(rawContent: any): {
|
||||
blocks: Block[];
|
||||
projectConfig: ProjectConfig | null;
|
||||
} {
|
||||
let blocks: Block[] = [];
|
||||
let projectConfig: ProjectConfig | null = null;
|
||||
|
||||
if (Array.isArray(rawContent)) {
|
||||
blocks = rawContent as Block[];
|
||||
} else if (rawContent && typeof rawContent === "object") {
|
||||
const maybeBlocks = (rawContent as any).blocks;
|
||||
if (Array.isArray(maybeBlocks)) {
|
||||
blocks = maybeBlocks as Block[];
|
||||
const maybeConfig = (rawContent as any).projectConfig;
|
||||
if (maybeConfig && typeof maybeConfig === "object") {
|
||||
projectConfig = maybeConfig as ProjectConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blocks, projectConfig };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user