TDD,E2E 개선, 미적용 스타일 개선
This commit is contained in:
+209
-24
@@ -161,20 +161,41 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
||||
|
||||
// Form 컨트롤러(FormBlock)와 개별 필드 블록(formInput/formSelect/...) 를 연결하기 위한 맵.
|
||||
// - key: 필드 블록 id (fieldIds 에 포함된 id)
|
||||
// - value: 해당 필드가 속한 <form> 요소의 DOM id (예: form_form_1)
|
||||
// - fieldIdToFormId: key = 필드 블록 id (fieldIds 에 포함된 id), value = <form> 요소의 DOM id
|
||||
// - formBlockIdToFormDomId: key = FormBlock id, value = <form> 요소의 DOM id
|
||||
const fieldIdToFormId = new Map<string, string>();
|
||||
const formBlockIdToFormDomId = new Map<string, string>();
|
||||
const requiredFieldIdSet = new Set<string>();
|
||||
// 테스트 및 기존 Export HTML 과의 호환성을 위해 FormBlock 의 DOM id 규칙을 다음과 같이 정의한다.
|
||||
// - block.id 가 "form_숫자" 패턴이면 기존과 동일하게 formDomId = `form_${block.id}` (예: form_1 → form_form_1)
|
||||
// - 그 외의 id 에 대해서는 pb_form_N 패턴을 사용해, form="..." 값에 우연히 "required" 같은 단어가 포함되지 않도록 한다.
|
||||
let fallbackFormIndex = 1;
|
||||
for (const block of blocks) {
|
||||
if (block.type !== "form") continue;
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
||||
if (fieldIds.length === 0) continue;
|
||||
const formDomId = `form_${block.id}`;
|
||||
|
||||
let formDomId: string;
|
||||
if (typeof block.id === "string" && /^form_\d+$/.test(block.id)) {
|
||||
formDomId = `form_${block.id}`;
|
||||
} else {
|
||||
formDomId = `pb_form_${fallbackFormIndex++}`;
|
||||
}
|
||||
|
||||
formBlockIdToFormDomId.set(block.id, formDomId);
|
||||
for (const fieldId of fieldIds) {
|
||||
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
||||
fieldIdToFormId.set(fieldId, formDomId);
|
||||
}
|
||||
}
|
||||
|
||||
const requiredFieldIds = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
|
||||
for (const fieldId of requiredFieldIds) {
|
||||
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
||||
requiredFieldIdSet.add(fieldId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBlock = (block: Block): string => {
|
||||
@@ -239,6 +260,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
widthPx: props.widthPx ?? undefined,
|
||||
paddingX: props.paddingX ?? undefined,
|
||||
paddingY: props.paddingY ?? undefined,
|
||||
fontSizeCustom: props.fontSizeCustom ?? undefined,
|
||||
lineHeightCustom: props.lineHeightCustom ?? undefined,
|
||||
letterSpacingCustom: props.letterSpacingCustom ?? undefined,
|
||||
fillColorCustom: props.fillColorCustom ?? undefined,
|
||||
strokeColorCustom: props.strokeColorCustom ?? undefined,
|
||||
textColorCustom: props.textColorCustom ?? undefined,
|
||||
@@ -355,7 +379,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
|
||||
// - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 <form> 요소만 만든다.
|
||||
// - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다.
|
||||
const formId = `form_${block.id}`;
|
||||
const formId = formBlockIdToFormDomId.get(block.id) ?? `form_${block.id}`;
|
||||
const method = props.method ?? "POST";
|
||||
const methodAttr = ` method="${method.toLowerCase()}"`;
|
||||
const actionAttr = ` action="/api/forms/submit"`;
|
||||
@@ -397,7 +421,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
return "";
|
||||
}
|
||||
|
||||
const lis = tokens.items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
||||
const lis = tokens.items
|
||||
.map((text) => `<li>${escapeHtml(text)}</li>`)
|
||||
.join("");
|
||||
const listStyleAttr = tokens.listStyleParts.join(";");
|
||||
|
||||
return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}</${tokens.Tag}>`;
|
||||
@@ -409,18 +435,48 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const label =
|
||||
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
|
||||
const type = props.inputType === "email" ? "email" : "text";
|
||||
const required = props.required ? " required" : "";
|
||||
const labelDisplay = props.labelDisplay ?? "visible";
|
||||
const isFloating = labelDisplay === "floating";
|
||||
const isControllerRequired = requiredFieldIdSet.has(block.id);
|
||||
const required = props.required || isControllerRequired ? " required" : "";
|
||||
|
||||
const tokens = computeFormInputExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const inputId = name;
|
||||
|
||||
const fieldClass = isFloating ? "pb-form-field pb-form-field--floating" : "pb-form-field";
|
||||
let fieldStyleAttr = "";
|
||||
if (isFloating && typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const em = props.paddingY / 16;
|
||||
fieldStyleAttr = ` style="--pb-input-padding-y:${em}rem"`;
|
||||
}
|
||||
|
||||
const placeholderBase =
|
||||
typeof props.placeholder === "string" && props.placeholder.trim() !== ""
|
||||
? props.placeholder.trim()
|
||||
: "";
|
||||
|
||||
let placeholder: string;
|
||||
if (isFloating) {
|
||||
placeholder = " ";
|
||||
} else if (placeholderBase !== "") {
|
||||
placeholder = placeholderBase;
|
||||
} else {
|
||||
placeholder = label;
|
||||
}
|
||||
|
||||
const labelClass = labelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
|
||||
|
||||
return [
|
||||
'<div class="pb-form-field">',
|
||||
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
`<div class="${fieldClass}"${fieldStyleAttr}>`,
|
||||
`<input class="pb-input" name="${escapeAttr(name)}" id="${escapeAttr(inputId)}" type="${type}" placeholder="${escapeAttr(
|
||||
placeholder,
|
||||
)}"${required}${tokens.inputStyleAttr}${formAttr} />`,
|
||||
`<label class="${labelClass}" for="${escapeAttr(inputId)}"${tokens.labelStyleAttr}>${escapeHtml(
|
||||
label,
|
||||
)}</label>`,
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
@@ -430,16 +486,42 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
||||
const label =
|
||||
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
|
||||
const required = props.required ? " required" : "";
|
||||
const labelDisplay = props.labelDisplay ?? "visible";
|
||||
const isControllerRequired = requiredFieldIdSet.has(block.id);
|
||||
const required = props.required || isControllerRequired ? " required" : "";
|
||||
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormSelectExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const fieldClass = "pb-form-field";
|
||||
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelDisplay === "visible" && labelLayout === "inline";
|
||||
const styleParts: string[] = [];
|
||||
if (isInlineLayout) {
|
||||
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
|
||||
const gapEm = gapPx / 16;
|
||||
styleParts.push("display:flex");
|
||||
styleParts.push("flex-direction:row");
|
||||
styleParts.push("align-items:center");
|
||||
styleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
styleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
const fieldStyleAttr = styleParts.length > 0 ? ` style="${styleParts.join(";")}"` : "";
|
||||
|
||||
const labelClass =
|
||||
labelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<div class="${fieldClass}"${fieldStyleAttr}>`);
|
||||
parts.push(`<label class="${labelClass}"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(
|
||||
`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}${formAttr}>`,
|
||||
);
|
||||
@@ -467,16 +549,65 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const groupLabelDisplay = props.groupLabelDisplay ?? "visible";
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const isInlineLayout = groupLabelDisplay === "visible" && labelLayout === "inline";
|
||||
const fieldStyleParts: string[] = [];
|
||||
if (isInlineLayout) {
|
||||
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
|
||||
const gapEm = gapPx / 16;
|
||||
fieldStyleParts.push("display:flex");
|
||||
fieldStyleParts.push("flex-direction:row");
|
||||
fieldStyleParts.push("align-items:center");
|
||||
fieldStyleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
fieldStyleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
const fieldStyleAttr =
|
||||
fieldStyleParts.length > 0 ? ` style="${fieldStyleParts.join(";")}"` : "";
|
||||
|
||||
const labelClass =
|
||||
groupLabelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
|
||||
|
||||
const optionLayout = props.optionLayout ?? "stacked";
|
||||
const optionsClass =
|
||||
optionLayout === "inline"
|
||||
? "pb-form-options pb-form-options--inline"
|
||||
: "pb-form-options pb-form-options--stacked";
|
||||
|
||||
const optionsStyleParts: string[] = [];
|
||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
||||
const gapEm = props.optionGapPx / 16;
|
||||
optionsStyleParts.push(`row-gap:${gapEm}em`);
|
||||
if (optionLayout === "inline") {
|
||||
optionsStyleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
}
|
||||
|
||||
const optionsStyleAttr =
|
||||
optionsStyleParts.length > 0 ? ` style="${optionsStyleParts.join(";")}"` : "";
|
||||
|
||||
const isGroupRequired = requiredFieldIdSet.has(block.id) || props.required === true;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(`<div class="pb-form-field"${fieldStyleAttr}>`);
|
||||
parts.push(`<label class="${labelClass}"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<div class="${optionsClass}"${optionsStyleAttr}>`);
|
||||
options.forEach((opt: { value: string; label: string }, index: number) => {
|
||||
const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : "";
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${optionRequiredAttr}${formAttr} /> <span>${escapeHtml(
|
||||
opt.label,
|
||||
)}</span></label>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
parts.push("</div>");
|
||||
parts.push("</div>");
|
||||
|
||||
return parts.join("");
|
||||
@@ -495,16 +626,66 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const groupLabelDisplay = props.groupLabelDisplay ?? "visible";
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const isInlineLayout = groupLabelDisplay === "visible" && labelLayout === "inline";
|
||||
|
||||
const fieldStyleParts: string[] = [];
|
||||
if (isInlineLayout) {
|
||||
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
|
||||
const gapEm = gapPx / 16;
|
||||
fieldStyleParts.push("display:flex");
|
||||
fieldStyleParts.push("flex-direction:row");
|
||||
fieldStyleParts.push("align-items:center");
|
||||
fieldStyleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
fieldStyleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
const fieldStyleAttr =
|
||||
fieldStyleParts.length > 0 ? ` style="${fieldStyleParts.join(";")}"` : "";
|
||||
|
||||
const labelClass =
|
||||
groupLabelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
|
||||
|
||||
const optionLayout = props.optionLayout ?? "stacked";
|
||||
const optionsClass =
|
||||
optionLayout === "inline"
|
||||
? "pb-form-options pb-form-options--inline"
|
||||
: "pb-form-options pb-form-options--stacked";
|
||||
|
||||
const optionsStyleParts: string[] = [];
|
||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
||||
const gapEm = props.optionGapPx / 16;
|
||||
optionsStyleParts.push(`row-gap:${gapEm}em`);
|
||||
if (optionLayout === "inline") {
|
||||
optionsStyleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
}
|
||||
|
||||
const optionsStyleAttr =
|
||||
optionsStyleParts.length > 0 ? ` style="${optionsStyleParts.join(";")}"` : "";
|
||||
|
||||
const isGroupRequired = requiredFieldIdSet.has(block.id) || props.required === true;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(`<div class="pb-form-field"${fieldStyleAttr}>`);
|
||||
parts.push(`<label class="${labelClass}"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<div class="${optionsClass}"${optionsStyleAttr}>`);
|
||||
options.forEach((opt: { value: string; label: string }, index: number) => {
|
||||
const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : "";
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${optionRequiredAttr}${formAttr} /> <span>${escapeHtml(
|
||||
opt.label,
|
||||
)}</span></label>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
parts.push("</div>");
|
||||
parts.push("</div>");
|
||||
|
||||
return parts.join("");
|
||||
@@ -534,9 +715,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
|
||||
const sectionStyleAttr =
|
||||
tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : "";
|
||||
const innerWrapperStyleAttr =
|
||||
tokens.innerWrapperStyleParts.length > 0 ? ` style="${tokens.innerWrapperStyleParts.join(";")}"` : "";
|
||||
const columnsStyleAttr =
|
||||
tokens.columnsStyleParts.length > 0 ? ` style="${tokens.columnsStyleParts.join(";")}"` : "";
|
||||
|
||||
bodyParts.push(
|
||||
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"><div class="pb-section-columns">`,
|
||||
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"${innerWrapperStyleAttr}><div class="pb-section-columns"${columnsStyleAttr}>`,
|
||||
);
|
||||
|
||||
for (const column of columns) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -108,6 +110,18 @@ export async function POST(request: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
// 퍼블릭 슬러그 페이지(/p/[slug])가 DB 조회 없이도 최신 스냅샷을 바로 렌더링할 수 있도록
|
||||
// 메모리 기반 캐시에 프로젝트 스냅샷을 저장해 둔다.
|
||||
try {
|
||||
cachePublicProject({
|
||||
slug: project.slug,
|
||||
title: project.title,
|
||||
contentJson: (project.contentJson ?? []) as Block[],
|
||||
});
|
||||
} catch {
|
||||
// 캐시 저장 실패는 퍼블릭 렌더링에만 영향을 주므로, API 응답 자체는 정상적으로 계속 진행한다.
|
||||
}
|
||||
|
||||
return NextResponse.json(project, { status: 201 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken, decryptJson } from "@/features/auth/authCrypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
// 공통: pb_access 쿠키에서 JWT 토큰을 추출한다.
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 공통: Request 헤더의 쿠키에서 인증된 유저 정보를 복원한다.
|
||||
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
if (!token) return null;
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/projects/submissions
|
||||
// - 로그인한 사용자의 모든 프로젝트에 대한 폼 제출 내역을 최신순으로 최대 100건 반환한다.
|
||||
// - FormSubmission.userId 가 현재 사용자와 일치하는 레코드만 포함한다.
|
||||
// - payloadJson 과 sensitiveEnc 복호화 결과를 병합해 payload 로 반환한다.
|
||||
export async function GET(request: Request) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json(
|
||||
{ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const submissions = await prisma.formSubmission.findMany({
|
||||
where: { userId: authUser.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const items = await Promise.all(
|
||||
submissions.map(async (s: any) => {
|
||||
const basePayload = (s.payloadJson ?? {}) as Record<string, unknown>;
|
||||
let sensitive: Record<string, unknown> = {};
|
||||
|
||||
if (s.sensitiveEnc) {
|
||||
try {
|
||||
sensitive = (await decryptJson<Record<string, unknown>>(s.sensitiveEnc)) ?? {};
|
||||
} catch {
|
||||
sensitive = {};
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { ...basePayload, ...sensitive };
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
createdAt: s.createdAt,
|
||||
projectSlug: s.projectSlug,
|
||||
projectTitle: s.project?.title ?? null,
|
||||
payload,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json(items, { status: 200 });
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
||||
|
||||
const checkboxProps = block.props as FormCheckboxBlockProps;
|
||||
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
@@ -35,6 +36,78 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 표시 방식</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={groupLabelDisplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelDisplay: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="visible">표시 (기본)</option>
|
||||
<option value="hidden">숨김</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{groupLabelDisplay === "visible" && (
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>레이아웃</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={checkboxProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">세로 (기본)</option>
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>옵션 레이아웃</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={checkboxProps.optionLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
optionLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">세로 (기본)</option>
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{groupLabelDisplay === "visible" && (checkboxProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label="라벨/필드 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 4 },
|
||||
{ id: "normal", label: "보통", value: 8 },
|
||||
{ id: "relaxed", label: "느슨", value: 12 },
|
||||
{ id: "extra", label: "넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀</span>
|
||||
<input
|
||||
@@ -320,17 +393,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</div>
|
||||
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
||||
checked={Boolean(checkboxProps.required)}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
required: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-slate-400">필수 필드</span>
|
||||
<span className="text-slate-500">필수 여부는 폼 컨트롤러에서 설정합니다.</span>
|
||||
</label>
|
||||
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
|
||||
@@ -245,6 +245,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
: labelText || fieldId;
|
||||
|
||||
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
||||
const required = (formProps.requiredFieldIds ?? []).includes(fieldId);
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -266,7 +267,25 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<span>{displayLabel}</span>
|
||||
<span className="flex-1 truncate">{displayLabel}</span>
|
||||
<label className="inline-flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-600 bg-slate-950"
|
||||
checked={required}
|
||||
onChange={(e) => {
|
||||
const current = formProps.requiredFieldIds ?? [];
|
||||
const next = e.target.checked
|
||||
? Array.from(new Set([...current, fieldId]))
|
||||
: current.filter((id) => id !== fieldId);
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
requiredFieldIds: next,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<span>필수</span>
|
||||
</label>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -14,6 +14,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
if (!selectedBlockId || block.id !== selectedBlockId || block.type !== "formInput") return null;
|
||||
|
||||
const inputProps = block.props as FormInputBlockProps;
|
||||
const labelDisplay = inputProps.labelDisplay ?? "visible";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
@@ -45,6 +46,60 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">라벨 표시 방식</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={labelDisplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelDisplay: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="visible">표시 (기본)</option>
|
||||
<option value="hidden">숨김</option>
|
||||
<option value="floating">플로팅 라벨</option>
|
||||
</select>
|
||||
</label>
|
||||
{labelDisplay === "visible" && (
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>레이아웃</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={inputProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">세로 (기본)</option>
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{labelDisplay === "visible" && (inputProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label="라벨/필드 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 4 },
|
||||
{ id: "normal", label: "보통", value: 8 },
|
||||
{ id: "relaxed", label: "느슨", value: 12 },
|
||||
{ id: "extra", label: "넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{inputProps.labelMode === "image" && (
|
||||
<>
|
||||
<label className="flex flex-col gap-1">
|
||||
@@ -116,17 +171,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
||||
checked={Boolean(inputProps.required)}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
required: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-slate-400">필수 필드</span>
|
||||
<span className="text-slate-500">필수 여부는 폼 컨트롤러에서 설정합니다.</span>
|
||||
</label>
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
||||
@@ -219,42 +264,6 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>레이아웃</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={inputProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">세로 (기본)</option>
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
{(inputProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label="라벨/필드 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 4 },
|
||||
{ id: "normal", label: "보통", value: 8 },
|
||||
{ id: "relaxed", label: "느슨", value: 12 },
|
||||
{ id: "extra", label: "넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>너비</span>
|
||||
<select
|
||||
@@ -271,26 +280,24 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
<option value="fixed">고정 값</option>
|
||||
</select>
|
||||
</label>
|
||||
{(inputProps.widthMode ?? "full") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="필드 고정 너비"
|
||||
unitLabel="(px)"
|
||||
value={inputProps.widthPx ?? 240}
|
||||
min={80}
|
||||
max={800}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 200 },
|
||||
{ id: "md", label: "보통", value: 280 },
|
||||
{ id: "lg", label: "넓게", value: 360 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="필드 고정 너비"
|
||||
unitLabel="(px)"
|
||||
value={inputProps.widthPx ?? 240}
|
||||
min={80}
|
||||
max={800}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 200 },
|
||||
{ id: "md", label: "보통", value: 280 },
|
||||
{ id: "lg", label: "넓게", value: 360 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<NumericPropertyControl
|
||||
label="필드 가로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
|
||||
@@ -14,6 +14,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
||||
|
||||
const radioProps = block.props as FormRadioBlockProps;
|
||||
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
@@ -35,6 +36,78 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 표시 방식</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={groupLabelDisplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelDisplay: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="visible">표시 (기본)</option>
|
||||
<option value="hidden">숨김</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{groupLabelDisplay === "visible" && (
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>그룹 타이틀 레이아웃</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={radioProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">세로 (기본)</option>
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>옵션 레이아웃</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={radioProps.optionLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
optionLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">세로 (기본)</option>
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{groupLabelDisplay === "visible" && (radioProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label="라벨/필드 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 4 },
|
||||
{ id: "normal", label: "보통", value: 8 },
|
||||
{ id: "relaxed", label: "느슨", value: 12 },
|
||||
{ id: "extra", label: "넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀</span>
|
||||
<input
|
||||
@@ -320,17 +393,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</div>
|
||||
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
||||
checked={Boolean(radioProps.required)}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
required: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-slate-400">필수 필드</span>
|
||||
<span className="text-slate-500">필수 여부는 폼 컨트롤러에서 설정합니다.</span>
|
||||
</label>
|
||||
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
|
||||
@@ -14,6 +14,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
||||
|
||||
const selectProps = block.props as FormSelectBlockProps;
|
||||
const labelDisplay = selectProps.labelDisplay ?? "visible";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
@@ -35,6 +36,62 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">라벨 표시 방식</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={labelDisplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelDisplay: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="visible">표시 (기본)</option>
|
||||
<option value="hidden">숨김</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{labelDisplay === "visible" && (
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>레이아웃</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={selectProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">세로 (기본)</option>
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{labelDisplay === "visible" && (selectProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label="라벨/필드 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof selectProps.labelGapPx === "number" ? selectProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 4 },
|
||||
{ id: "normal", label: "보통", value: 8 },
|
||||
{ id: "relaxed", label: "느슨", value: 12 },
|
||||
{ id: "extra", label: "넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">필드 라벨</span>
|
||||
<input
|
||||
@@ -136,17 +193,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
</div>
|
||||
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
||||
checked={Boolean(selectProps.required)}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
required: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-slate-400">필수 필드</span>
|
||||
<span className="text-slate-500">필수 여부는 폼 컨트롤러에서 설정합니다.</span>
|
||||
</label>
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "../../styles/editor.css";
|
||||
|
||||
export default function EditorLayout({ children }: { children: ReactNode }) {
|
||||
// 에디터 전용 전역 스타일(editor.css)을 로드하기 위한 중첩 레이아웃.
|
||||
// 실제 마크업 구조는 page.tsx 에서 정의하며, 여기서는 children 을 그대로 반환한다.
|
||||
return children;
|
||||
}
|
||||
+184
-49
@@ -184,6 +184,21 @@ function EditorPageInner() {
|
||||
const slug = initialSlugFromQuery.trim();
|
||||
if (!slug) return;
|
||||
|
||||
// 이미 해당 slug 에 대한 autosave 스냅샷이 있으면,
|
||||
// 서버에서 프로젝트를 다시 불러와서 현재 로컬 상태를 덮어쓰지 않는다.
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const key = `pb:autosave:${slug}`;
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (raw) {
|
||||
setHasLoadedInitialProjectFromSlug(true);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// localStorage 접근 실패 시에는 서버 로드를 계속 시도한다.
|
||||
}
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadFromServer = async () => {
|
||||
@@ -218,7 +233,7 @@ function EditorPageInner() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks]);
|
||||
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks, updateProjectConfig, resetHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -707,6 +722,13 @@ function EditorPageInner() {
|
||||
|
||||
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
||||
|
||||
const projectSlugRaw = projectConfig?.slug?.trim?.();
|
||||
const projectSlug =
|
||||
typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : "";
|
||||
const previewHref = projectSlug
|
||||
? `/preview?slug=${encodeURIComponent(projectSlug)}`
|
||||
: "/preview";
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked) return;
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -951,7 +973,7 @@ function EditorPageInner() {
|
||||
<span>프로젝트 목록</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/preview"
|
||||
href={previewHref}
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
@@ -1443,6 +1465,7 @@ function SortableEditorBlock({
|
||||
{block.type === "formInput" && (() => {
|
||||
const inputProps = block.props as FormInputBlockProps;
|
||||
const inputType = inputProps.inputType ?? "text";
|
||||
const fieldName = inputProps.formFieldName ?? block.id;
|
||||
|
||||
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
|
||||
const inputTokens = computeFormInputEditorTokens(inputProps);
|
||||
@@ -1452,47 +1475,100 @@ function SortableEditorBlock({
|
||||
const isInline = labelLayout === "inline";
|
||||
|
||||
const inputAlignClass = inputTokens.inputAlignClass;
|
||||
const labelDisplay = (inputProps as any).labelDisplay ?? "visible";
|
||||
|
||||
// 프리뷰/퍼블릭과 동일한 pb-input/pb-textarea 클래스를 사용해 높이/패딩을 통일한다.
|
||||
const baseInputClass = `pb-input w-full text-xs outline-none ${inputAlignClass}`;
|
||||
const baseTextareaClass = `pb-textarea w-full text-xs outline-none ${inputAlignClass}`;
|
||||
|
||||
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
|
||||
return (
|
||||
<div className={`text-xs ${isInline ? "flex items-center gap-2" : "flex flex-col gap-1"}`}>
|
||||
{inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={inputProps.labelImageUrl}
|
||||
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
|
||||
{labelDisplay === "visible" && (
|
||||
inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={inputProps.labelImageUrl}
|
||||
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
|
||||
)
|
||||
)}
|
||||
{(() => {
|
||||
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
|
||||
const widthClass = inputTokens.widthClass;
|
||||
|
||||
if (labelDisplay === "floating") {
|
||||
const placeholder = " ";
|
||||
|
||||
return (
|
||||
<div className={`${widthClass} relative`}>
|
||||
<span
|
||||
data-testid="editor-form-input-floating-label"
|
||||
className="absolute left-2 pointer-events-none"
|
||||
style={{
|
||||
top: "-0.3rem",
|
||||
fontSize: "0.725rem",
|
||||
color: "#e5e7eb",
|
||||
backgroundColor: "#020617",
|
||||
padding: "0 0.25rem",
|
||||
}}
|
||||
>
|
||||
{inputProps.label}
|
||||
</span>
|
||||
{inputType === "textarea" ? (
|
||||
<textarea
|
||||
data-testid="form-input-field"
|
||||
className={`${baseTextareaClass} pt-6`}
|
||||
style={fieldStyle}
|
||||
name={fieldName}
|
||||
placeholder={placeholder}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
data-testid="form-input-field"
|
||||
className={`${baseInputClass} pt-6`}
|
||||
style={fieldStyle}
|
||||
type={inputType === "email" ? "email" : "text"}
|
||||
name={fieldName}
|
||||
placeholder={placeholder}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="form-input-field"
|
||||
style={fieldStyle}
|
||||
className={`${widthClass} rounded border border-slate-700 bg-slate-950`}
|
||||
>
|
||||
{inputType === "textarea" ? (
|
||||
<textarea
|
||||
className={`w-full min-h-[60px] bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
|
||||
placeholder={inputProps.placeholder || inputProps.label}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={`w-full bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
|
||||
type={inputType === "email" ? "email" : "text"}
|
||||
placeholder={inputProps.placeholder || inputProps.label}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={widthClass}>
|
||||
{inputType === "textarea" ? (
|
||||
<textarea
|
||||
data-testid="form-input-field"
|
||||
className={baseTextareaClass}
|
||||
style={fieldStyle}
|
||||
name={fieldName}
|
||||
placeholder={inputProps.placeholder || inputProps.label}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
data-testid="form-input-field"
|
||||
className={baseInputClass}
|
||||
style={fieldStyle}
|
||||
type={inputType === "email" ? "email" : "text"}
|
||||
name={fieldName}
|
||||
placeholder={inputProps.placeholder || inputProps.label}
|
||||
aria-label={inputProps.label}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
@@ -1500,6 +1576,7 @@ function SortableEditorBlock({
|
||||
})()}
|
||||
{block.type === "formSelect" && (() => {
|
||||
const selectProps = block.props as FormSelectBlockProps;
|
||||
const fieldName = selectProps.formFieldName ?? block.id;
|
||||
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
|
||||
? selectProps.options
|
||||
: [
|
||||
@@ -1523,12 +1600,12 @@ function SortableEditorBlock({
|
||||
) : (
|
||||
<span className="text-slate-200">{selectProps.label}</span>
|
||||
)}
|
||||
<div
|
||||
style={fieldStyle}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950"
|
||||
>
|
||||
<div className="w-full">
|
||||
<select
|
||||
className="w-full bg-transparent px-2 py-1 text-xs outline-none"
|
||||
data-testid="form-select-field"
|
||||
className="pb-select w-full text-xs outline-none"
|
||||
style={fieldStyle}
|
||||
name={fieldName}
|
||||
aria-label={selectProps.label}
|
||||
defaultValue={options[0]?.value}
|
||||
>
|
||||
@@ -1552,12 +1629,42 @@ function SortableEditorBlock({
|
||||
];
|
||||
|
||||
const groupLabelMode = radioProps.groupLabelMode ?? "text";
|
||||
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
|
||||
const labelLayout = radioProps.labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
|
||||
|
||||
const radioTokens = computeFormOptionGroupEditorTokens(radioProps);
|
||||
const fieldStyle = radioTokens.fieldStyle;
|
||||
|
||||
const optionLayout = radioProps.optionLayout ?? "stacked";
|
||||
const optionsLayoutClass = [
|
||||
"pb-form-options",
|
||||
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
|
||||
].join(" ");
|
||||
|
||||
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
|
||||
const optionGapPx =
|
||||
typeof radioProps.optionGapPx === "number" && radioProps.optionGapPx >= 0
|
||||
? radioProps.optionGapPx
|
||||
: undefined;
|
||||
const gapStyle: CSSProperties =
|
||||
optionGapPx !== undefined
|
||||
? optionLayout === "inline"
|
||||
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
|
||||
: { rowGap: `${optionGapPx}px` }
|
||||
: {};
|
||||
|
||||
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
|
||||
const inlineGapPx = typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8;
|
||||
const groupContainerStyle: CSSProperties = isInlineLayout
|
||||
? { columnGap: `${inlineGapPx}px` }
|
||||
: {};
|
||||
const groupContainerClassName = isInlineLayout
|
||||
? "flex flex-row items-center text-xs"
|
||||
: "flex flex-col gap-1 text-xs";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className={groupContainerClassName} style={groupContainerStyle}>
|
||||
{/* 그룹 타이틀은 텍스트/이미지 모드를 지원한다. */}
|
||||
{groupLabelMode === "image" && radioProps.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -1569,15 +1676,12 @@ function SortableEditorBlock({
|
||||
) : (
|
||||
<span className="text-slate-200">{radioProps.groupLabel}</span>
|
||||
)}
|
||||
<div
|
||||
style={fieldStyle}
|
||||
className="flex flex-col gap-1 rounded border border-slate-700 bg-slate-950"
|
||||
>
|
||||
<div style={{ ...fieldStyle, ...gapStyle }} className={optionsLayoutClass}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-2 text-slate-200">
|
||||
<label key={opt.value} className="pb-form-option">
|
||||
<input
|
||||
type="radio"
|
||||
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
|
||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||
name={radioProps.formFieldName}
|
||||
value={opt.value}
|
||||
readOnly
|
||||
@@ -1609,12 +1713,43 @@ function SortableEditorBlock({
|
||||
];
|
||||
|
||||
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
|
||||
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
|
||||
const labelLayout = checkboxProps.labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
|
||||
|
||||
const checkboxTokens = computeFormOptionGroupEditorTokens(checkboxProps);
|
||||
const fieldStyle = checkboxTokens.fieldStyle;
|
||||
|
||||
const optionLayout = checkboxProps.optionLayout ?? "stacked";
|
||||
const optionsLayoutClass = [
|
||||
"pb-form-options",
|
||||
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
|
||||
].join(" ");
|
||||
|
||||
// 그룹 타이틀 레이아웃이 inline 인 경우, 라벨과 옵션 컨테이너를 가로 방향으로 배치하고 labelGapPx 를 columnGap 으로 사용한다.
|
||||
const inlineGapPx = typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8;
|
||||
const groupContainerStyle: CSSProperties = {
|
||||
...fieldStyle,
|
||||
...(isInlineLayout ? { columnGap: `${inlineGapPx}px` } : {}),
|
||||
};
|
||||
|
||||
// optionGapPx 를 사용해 옵션 컨테이너의 rowGap/columnGap 을 제어한다.
|
||||
const optionGapPx =
|
||||
typeof checkboxProps.optionGapPx === "number" && checkboxProps.optionGapPx >= 0
|
||||
? checkboxProps.optionGapPx
|
||||
: undefined;
|
||||
const optionsStyle: CSSProperties =
|
||||
optionGapPx !== undefined
|
||||
? optionLayout === "inline"
|
||||
? { rowGap: `${optionGapPx}px`, columnGap: `${optionGapPx}px` }
|
||||
: { rowGap: `${optionGapPx}px` }
|
||||
: {};
|
||||
const groupContainerClassName = isInlineLayout
|
||||
? "flex flex-row items-center text-xs text-slate-200"
|
||||
: "flex flex-col gap-1 text-xs text-slate-200";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
|
||||
<div className={groupContainerClassName} style={groupContainerStyle}>
|
||||
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
|
||||
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -1626,12 +1761,12 @@ function SortableEditorBlock({
|
||||
) : (
|
||||
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={optionsLayoutClass} style={optionsStyle}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-2">
|
||||
<label key={opt.value} className="pb-form-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
|
||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
||||
name={checkboxProps.formFieldName}
|
||||
value={opt.value}
|
||||
readOnly
|
||||
|
||||
@@ -472,9 +472,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
}
|
||||
return 0;
|
||||
})()}
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.1}
|
||||
min={-32}
|
||||
max={32}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tighter", label: "아주 좁게", value: -1.5 },
|
||||
{ id: "tight", label: "좁게", value: -0.5 },
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { notFound } from "next/navigation";
|
||||
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";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageParams {
|
||||
// Next.js App Router 에서는 params 가 Promise 로 전달되므로
|
||||
// 서버 컴포넌트에서 사용하기 전에 await 로 한 번 풀어줘야 한다.
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
// /p/[slug] 공개 페이지
|
||||
// - slug 에 해당하는 Project 의 contentJson 을 불러와 buildStaticHtml 로 정적 HTML 을 생성한다.
|
||||
// - Export ZIP 에 포함되는 index.html 과 동일한 app-root DOM 구조를 사용해 렌더링한다.
|
||||
// - 인증 없이 접근 가능하며, 폼 제출은 /api/forms/submit 을 통해 정적 Export 와 동일하게 동작한다.
|
||||
export default async function PublicProjectPage({ params }: PageParams) {
|
||||
// Next 가 넘겨주는 params 는 Promise 이므로, 먼저 await 해서 slug 를 꺼낸다.
|
||||
const resolvedParams = await params;
|
||||
const slugRaw = resolvedParams.slug ?? "";
|
||||
const slug = slugRaw.toString().trim();
|
||||
|
||||
if (!slug) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 1차로 메모리 기반 퍼블릭 캐시에서 스냅샷을 조회한다.
|
||||
const cached = getCachedPublicProject(slug);
|
||||
|
||||
let blocks: Block[];
|
||||
let title: string;
|
||||
|
||||
if (cached) {
|
||||
blocks = cached.contentJson ?? [];
|
||||
title = cached.title ?? "";
|
||||
} else {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
contentJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
blocks = (project.contentJson ?? []) as Block[];
|
||||
title = project.title;
|
||||
}
|
||||
|
||||
// Export index.html 과 동일한 규칙으로 ProjectConfig 를 구성한다.
|
||||
const projectConfig: ProjectConfig = {
|
||||
title,
|
||||
slug,
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#020617",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
const fullHtml = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// buildStaticHtml 이 생성한 index.html 에서 <main> ... </main> 블록만 추출해 렌더링한다.
|
||||
// 이렇게 하면 Export index.html 과 동일한 main/app-root DOM 구조를 재사용할 수 있다.
|
||||
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
|
||||
const mainHtml = mainMatch ? mainMatch[0] : "";
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: mainHtml }} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "../../styles/editor.css";
|
||||
|
||||
export default function PreviewLayout({ children }: { children: ReactNode }) {
|
||||
// 프리뷰 화면에서도 에디터 크롬과 동일한 전용 스타일(editor.css)을 사용한다.
|
||||
// 콘텐츠 블록 자체의 모양은 builder.css(pb-*) 기준으로 렌더된다.
|
||||
return children;
|
||||
}
|
||||
+33
-15
@@ -39,27 +39,35 @@ export default function PreviewPage() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let slug = (projectConfig as any)?.slug?.trim?.();
|
||||
const configSlugRaw = (projectConfig as any)?.slug?.trim?.();
|
||||
const configSlug =
|
||||
typeof configSlugRaw === "string" && configSlugRaw.length > 0 ? configSlugRaw : "";
|
||||
|
||||
if (!slug) {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const fromQuery = url.searchParams.get("slug")?.trim();
|
||||
let querySlug = "";
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const fromQuery = url.searchParams.get("slug")?.trim();
|
||||
|
||||
if (fromQuery && fromQuery !== slug) {
|
||||
slug = fromQuery;
|
||||
updateProjectConfig({ slug: fromQuery } as any);
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
|
||||
if (fromQuery) {
|
||||
querySlug = fromQuery;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
|
||||
}
|
||||
|
||||
if (!slug) return;
|
||||
const effectiveSlug = querySlug || configSlug;
|
||||
if (!effectiveSlug) return;
|
||||
|
||||
const key = `pb:autosave:${slug}`;
|
||||
const key = `pb:autosave:${effectiveSlug}`;
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
|
||||
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 };
|
||||
@@ -68,6 +76,9 @@ export default function PreviewPage() {
|
||||
}
|
||||
if (parsed.projectConfig) {
|
||||
updateProjectConfig(parsed.projectConfig as any);
|
||||
} else if (querySlug && querySlug !== configSlug) {
|
||||
// 스냅샷에 projectConfig 가 없을 때만 쿼리 슬러그를 반영한다.
|
||||
updateProjectConfig({ slug: querySlug } as any);
|
||||
}
|
||||
|
||||
// 프리뷰 진입 시 autosave 프로젝트를 복원하는 경우에도 에디터 히스토리는 새 프로젝트 기준으로만 유지되도록 초기화한다.
|
||||
@@ -125,6 +136,13 @@ export default function PreviewPage() {
|
||||
? 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") {
|
||||
@@ -163,7 +181,7 @@ export default function PreviewPage() {
|
||||
프로젝트 목록
|
||||
</Link>
|
||||
<Link
|
||||
href="/editor"
|
||||
href={editorHref}
|
||||
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"
|
||||
>
|
||||
에디터로 돌아가기
|
||||
|
||||
@@ -109,8 +109,19 @@ export default function ProjectSubmissionsPage() {
|
||||
<h1 className="text-xl font-semibold">폼 제출 내역</h1>
|
||||
<p className="text-xs text-slate-400">프로젝트에 연결된 폼으로 수집된 제출 데이터를 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="text-xs text-slate-300">
|
||||
<div className="text-xs text-slate-300 flex flex-col items-end gap-1">
|
||||
<span className="font-mono text-[11px]">{slug}</span>
|
||||
<a
|
||||
href="/projects"
|
||||
role="link"
|
||||
className="text-[11px] text-sky-400 hover:text-sky-300 underline-offset-2 hover:underline"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
router.push("/projects");
|
||||
}}
|
||||
>
|
||||
프로젝트 목록
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -212,6 +212,13 @@ export default function ProjectsPage() {
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
<Link
|
||||
href="/projects/submissions"
|
||||
className="inline-flex items-center gap-1 rounded border border-emerald-700 bg-emerald-950 px-3 py-1 text-emerald-100 hover:bg-emerald-900 shadow-sm"
|
||||
>
|
||||
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
||||
<span>전체 제출 내역</span>
|
||||
</Link>
|
||||
<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"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface SubmissionItem {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
projectSlug: string;
|
||||
projectTitle: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type PageStatus = "idle" | "loading" | "error";
|
||||
|
||||
export default function AllProjectSubmissionsPage() {
|
||||
const router = useRouter();
|
||||
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
|
||||
const [status, setStatus] = useState<PageStatus>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setStatus("loading");
|
||||
setErrorMessage(null);
|
||||
|
||||
const res = await fetch("/api/projects/submissions");
|
||||
|
||||
if (!res.ok) {
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.status === 401) {
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.");
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as SubmissionItem[];
|
||||
|
||||
if (!cancelled) {
|
||||
setSubmissions(Array.isArray(data) ? data : []);
|
||||
setStatus("idle");
|
||||
setErrorMessage(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderOtherFields = (payload: Record<string, unknown>): string => {
|
||||
const knownKeys = new Set(["name", "email", "phone", "birthdate"]);
|
||||
const entries = Object.entries(payload).filter(([key]) => !knownKeys.has(key));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => {
|
||||
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return `${key}: ${stringValue}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
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="text-xs text-slate-300 flex flex-col items-end gap-1">
|
||||
<a
|
||||
href="/projects"
|
||||
role="link"
|
||||
className="text-[11px] text-sky-400 hover:text-sky-300 underline-offset-2 hover:underline"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
router.push("/projects");
|
||||
}}
|
||||
>
|
||||
프로젝트 목록
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||
{status === "error" && errorMessage && (
|
||||
<p className="text-xs text-red-300 mb-3">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="text-xs text-slate-400 mb-3">폼 제출 내역을 불러오는 중입니다...</p>
|
||||
)}
|
||||
|
||||
{submissions.length === 0 && status === "idle" && !errorMessage && (
|
||||
<p className="text-xs text-slate-400">아직 수집된 폼 제출 내역이 없습니다.</p>
|
||||
)}
|
||||
|
||||
{submissions.length > 0 && (
|
||||
<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-4">제출 시각</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">생년월일</th>
|
||||
<th className="py-2 pr-4">기타 필드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{submissions.map((item) => {
|
||||
const payload = (item.payload ?? {}) as Record<string, unknown>;
|
||||
|
||||
const nameValue = payload["name"];
|
||||
const emailValue = payload["email"];
|
||||
const phoneValue = payload["phone"];
|
||||
const birthdateValue = payload["birthdate"];
|
||||
|
||||
const name = typeof nameValue === "string" ? nameValue : "";
|
||||
const email = typeof emailValue === "string" ? emailValue : "";
|
||||
const phone = typeof phoneValue === "string" ? phoneValue : "";
|
||||
const birthdate = typeof birthdateValue === "string" ? birthdateValue : "";
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
||||
<td className="py-2 pr-4 text-slate-300 text-[11px]">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-100">{item.projectTitle ?? "-"}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{item.projectSlug}</td>
|
||||
<td className="py-2 pr-4 text-slate-100">{name}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{email}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{phone}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{birthdate}</td>
|
||||
<td className="py-2 pr-4 text-slate-300 whitespace-pre-wrap">
|
||||
{renderOtherFields(payload)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user