TDD,E2E 개선, 미적용 스타일 개선
CI / test (push) Failing after 2m54s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-07 09:52:42 +09:00
parent e726f43f7c
commit a5b432fb7b
167 changed files with 7397 additions and 663 deletions
+209 -24
View File
@@ -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) {
+14
View File
@@ -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(
+97
View File
@@ -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">
+20 -1
View File
@@ -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>
+8
View File
@@ -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
View File
@@ -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 },
+74
View File
@@ -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 }} />;
}
+8
View File
@@ -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
View File
@@ -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"
>
+12 -1
View File
@@ -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>
+7
View File
@@ -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"
+171
View File
@@ -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>
);
}