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>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState, type CSSProperties } from "react";
import { useState, useMemo, type CSSProperties } from "react";
import type {
Block,
TextBlockProps,
@@ -27,11 +27,11 @@ import {
computeVideoPublicTokens,
} from "@/features/editor/utils/videoHelpers";
import { computeButtonPublicTokens, computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
import { computeTextPublicTokens } from "@/features/editor/utils/textHelpers";
import { computeTextPublicTokens, computeTextPbTokens } from "@/features/editor/utils/textHelpers";
import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers";
import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers";
import { computeListPublicTokens } from "@/features/editor/utils/listHelpers";
import { computeSectionPublicTokens } from "@/features/editor/utils/sectionHelpers";
import { computeSectionPublicTokens, computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers";
import {
computeFormInputPublicTokens,
computeFormSelectPublicTokens,
@@ -107,18 +107,217 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [formMessage, setFormMessage] = useState<string>("");
const [activeFormId, setActiveFormId] = useState<string | null>(null);
// 폼 컨트롤러 기준으로 필수 필드 맵을 구성한다.
const requiredFieldIdSet = useMemo(() => {
const set = new Set<string>();
for (const block of blocks) {
if (block.type !== "form") continue;
const props = block.props as any;
const fieldIds: string[] = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
for (const id of fieldIds) {
if (id) set.add(id);
}
}
return set;
}, [blocks]);
const submitFormByController = async (formBlock: Block) => {
const props = formBlock.props as FormBlockProps;
const tokens = computeFormControllerPublicTokens(formBlock, blocks);
const fields = tokens.fields;
const missingRequiredLabels: string[] = [];
fields.forEach((field) => {
if (!field.required) {
return;
}
const name = field.name;
if (!name) {
return;
}
let isEmpty = false;
if (field.type === "checkbox") {
const nodes = document.querySelectorAll<HTMLInputElement>(
`input[type="checkbox"][name="${name}"]`,
);
const anyChecked = Array.from(nodes).some((el) => el.checked);
isEmpty = !anyChecked;
} else if (field.type === "radio") {
const selected = document.querySelector<HTMLInputElement>(
`input[type="radio"][name="${name}"]:checked`,
);
isEmpty = !selected;
} else if (field.type === "select") {
const selectEl = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`);
if (!selectEl) {
isEmpty = true;
} else {
const value = selectEl.value;
isEmpty = value == null || `${value}`.trim() === "";
}
} else {
const inputEl = document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
const textareaEl = document.querySelector<HTMLTextAreaElement>(`textarea[name="${name}"]`);
const valueSource = (inputEl as HTMLInputElement | null) ?? (textareaEl as HTMLTextAreaElement | null);
if (!valueSource) {
isEmpty = true;
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawValue = (valueSource as any).value;
const value = typeof rawValue === "string" ? rawValue : rawValue != null ? String(rawValue) : "";
isEmpty = value.trim() === "";
}
}
if (isEmpty) {
const label = (field.label || field.name || "").trim();
if (label && !missingRequiredLabels.includes(label)) {
missingRequiredLabels.push(label);
}
}
});
if (missingRequiredLabels.length > 0) {
setActiveFormId(formBlock.id);
setFormStatus("error");
const bulletLines = missingRequiredLabels.map((label) => `- ${label}`).join("\n");
const message = `다음 필수 항목을 입력해 주세요:\n${bulletLines}`;
setFormMessage(message);
return;
}
const data = new FormData();
data.append("__config", JSON.stringify(props));
if (projectSlug && projectSlug.trim() !== "") {
data.append("__projectSlug", projectSlug.trim());
}
fields.forEach((field) => {
const name = field.name;
if (!name) {
return;
}
if (field.type === "checkbox") {
const nodes = document.querySelectorAll<HTMLInputElement>(
`input[type="checkbox"][name="${name}"]`,
);
nodes.forEach((el) => {
if (el.checked) {
data.append(name, el.value);
}
});
return;
}
if (field.type === "radio") {
const selected = document.querySelector<HTMLInputElement>(
`input[type="radio"][name="${name}"]:checked`,
);
if (selected) {
data.append(name, selected.value);
}
return;
}
if (field.type === "select") {
const selectEl = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`);
if (selectEl) {
data.append(name, selectEl.value);
}
return;
}
const inputEl = document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
const textareaEl = document.querySelector<HTMLTextAreaElement>(`textarea[name="${name}"]`);
const valueSource = (inputEl as HTMLInputElement | null) ?? (textareaEl as HTMLTextAreaElement | null);
if (valueSource) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const value = (valueSource as any).value ?? "";
data.append(name, value);
}
});
try {
setActiveFormId(formBlock.id);
setFormStatus("submitting");
setFormMessage("");
const res = await fetch("/api/forms/submit", {
method: "POST",
body: data,
});
if (res.ok) {
setFormStatus("success");
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
} else {
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
} catch (error) {
console.error("[PublicPageRenderer] form submit error", error);
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
};
const renderBlock = (block: Block) => {
if (block.type === "text") {
const props = block.props as TextBlockProps;
const tokens = computeTextPublicTokens(props);
const textStyle: CSSProperties = { ...tokens.styleOverrides };
const publicTokens = computeTextPublicTokens(props);
const text = typeof props.text === "string" ? props.text : "";
const pbTokens = computeTextPbTokens({
text,
align: props.align ?? "left",
size: props.size ?? "base",
fontSizeMode: props.fontSizeMode ?? "scale",
fontSizeScale: props.fontSizeScale ?? undefined,
fontSizeCustom: props.fontSizeCustom ?? undefined,
lineHeightMode: props.lineHeightMode ?? "scale",
lineHeightScale: props.lineHeightScale ?? undefined,
lineHeightCustom: props.lineHeightCustom ?? undefined,
fontWeightMode: props.fontWeightMode ?? "scale",
fontWeightScale: props.fontWeightScale ?? undefined,
fontWeightCustom: props.fontWeightCustom ?? undefined,
colorMode: props.colorMode ?? undefined,
colorPalette: props.colorPalette ?? undefined,
colorCustom: props.colorCustom ?? undefined,
backgroundColorCustom: (props as any).backgroundColorCustom ?? undefined,
maxWidthMode: props.maxWidthMode ?? "scale",
maxWidthScale: props.maxWidthScale ?? undefined,
underline: props.underline ?? false,
strike: props.strike ?? false,
italic: props.italic ?? false,
});
const className = [
pbTokens.alignClass,
pbTokens.sizeClass,
pbTokens.leadingClass,
pbTokens.weightClass,
pbTokens.colorClass,
pbTokens.maxWidthClass,
...pbTokens.extraClasses,
]
.filter(Boolean)
.join(" ");
const textStyle: CSSProperties = { ...publicTokens.styleOverrides };
return (
<p
key={block.id}
className={`${tokens.alignClass} ${tokens.sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
style={textStyle}
>
<p key={block.id} className={className} style={textStyle}>
{props.text}
</p>
);
@@ -127,31 +326,112 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
if (block.type === "formInput") {
const props = block.props as FormInputBlockProps;
const tokens = computeFormInputPublicTokens(props);
const isRequired = requiredFieldIdSet.has(block.id);
const baseInputClass =
"rounded border px-3 py-2 text-xs outline-none focus:border-sky-500";
const labelDisplay = (props as any).labelDisplay ?? "visible";
const wrapperClassName = `${tokens.wrapperLayoutClass} text-xs text-slate-200`;
const rawPlaceholder =
typeof props.placeholder === "string" ? props.placeholder.trim() : "";
const rawLabel = typeof props.label === "string" ? props.label.trim() : "";
// 플로팅 라벨 모드에서는 placeholder 텍스트를 인풋 안에 직접 렌더링하지 않고,
// 라벨이 인풋 안/위에서 이동하도록 사용한다. placeholder 가 라벨과 다를 경우에만
// 인풋 하단의 보조 설명 텍스트로 노출한다.
const helperText =
labelDisplay === "floating" && rawPlaceholder !== "" && rawPlaceholder !== rawLabel
? rawPlaceholder
: null;
const inputId = props.formFieldName || block.id;
if (labelDisplay === "floating") {
// builder.css 기반 플로팅 라벨 마크업: pb-form-field/pb-form-field--floating + pb-form-label + pb-input
// Export 경로와 동일한 DOM 구조를 사용해 프리뷰/퍼블릭/Export 가 모두 같은 레이아웃을 갖도록 맞춘다.
// paddingY 값을 사용해 플로팅 라벨과 인풋 높이를 함께 제어하기 위해 CSS 변수에 전달한다.
const paddingY =
typeof (props as any).paddingY === "number" && (props as any).paddingY >= 0
? (props as any).paddingY
: null;
const floatingFieldStyle: CSSProperties = {};
if (paddingY !== null) {
(floatingFieldStyle as any)["--pb-input-padding-y"] = `${paddingY / 16}rem`;
}
return (
<div
key={block.id}
data-testid="preview-form-input-wrapper"
className="pb-form-field pb-form-field--floating"
style={floatingFieldStyle}
>
{props.inputType === "textarea" ? (
<textarea
id={inputId}
data-testid="preview-form-input"
className={`pb-textarea ${tokens.widthClass}`}
style={tokens.inputStyle}
name={props.formFieldName}
required={isRequired}
// 공백 placeholder 로 라벨이 인풋 안에서 겹치지 않도록 한다.
placeholder=" "
/>
) : (
<input
id={inputId}
data-testid="preview-form-input"
className={`pb-input ${tokens.widthClass}`}
style={tokens.inputStyle}
type={props.inputType === "email" ? "email" : "text"}
name={props.formFieldName}
required={isRequired}
placeholder=" "
/>
)}
{helperText && (
<p className="mt-1 text-[10px] text-slate-400">{helperText}</p>
)}
<label htmlFor={inputId} className="pb-form-label">
{props.label}
</label>
</div>
);
}
const labelSpan =
labelDisplay === "hidden" ? (
<span className="sr-only">{props.label}</span>
) : (
<span>{props.label}</span>
);
return (
<label
key={block.id}
data-testid="preview-form-input-wrapper"
className={`${tokens.wrapperLayoutClass} text-xs text-slate-200`}
className={wrapperClassName}
style={tokens.wrapperStyle}
>
<span>{props.label}</span>
{labelSpan}
{props.inputType === "textarea" ? (
<textarea
data-testid="preview-form-input"
className={`${baseInputClass} ${tokens.widthClass}`}
className={`pb-textarea ${tokens.widthClass}`}
style={tokens.inputStyle}
name={props.formFieldName}
required={isRequired}
placeholder={props.placeholder || props.label}
/>
) : (
<input
data-testid="preview-form-input"
className={`${baseInputClass} ${tokens.widthClass}`}
className={`pb-input ${tokens.widthClass}`}
style={tokens.inputStyle}
type={props.inputType === "email" ? "email" : "text"}
name={props.formFieldName}
required={isRequired}
placeholder={props.placeholder || props.label}
/>
)}
@@ -163,17 +443,45 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
const props = block.props as FormSelectBlockProps;
const tokens = computeFormSelectPublicTokens(props);
const isRequired = requiredFieldIdSet.has(block.id);
const labelDisplay = (props as any).labelDisplay ?? "visible";
const labelLayout = (props as any).labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && labelDisplay === "visible";
const labelSpan =
labelDisplay === "hidden" ? (
<span className="sr-only">{props.label}</span>
) : (
<span className="pb-form-label">{props.label}</span>
);
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
const gapEm = pxToEm(gapPx);
const wrapperClassName = [
isInlineLayout ? "flex items-center" : "flex flex-col gap-1",
]
.filter(Boolean)
.join(" ");
const wrapperStyle: CSSProperties = isInlineLayout
? { columnGap: gapEm }
: {};
return (
<label key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
<span>{props.label}</span>
<label key={block.id} className={wrapperClassName} style={wrapperStyle}>
{labelSpan}
<div
data-testid="preview-form-select"
className={`${tokens.widthClass}`}
style={tokens.wrapperStyle}
>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
className="pb-select"
style={tokens.selectStyle}
name={props.formFieldName}
required={isRequired}
defaultValue={props.options[0]?.value ?? ""}
>
{props.options.map((opt) => (
@@ -192,14 +500,45 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
const tokens = computeFormCheckboxPublicTokens(props);
const groupLabelMode = props.groupLabelMode ?? "text";
const isRequired = requiredFieldIdSet.has(block.id);
const groupLabelDisplay = (props as any).groupLabelDisplay ?? "visible";
const labelLayout = (props as any).labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
const optionLayout = (props as any).optionLayout ?? "stacked";
const optionsLayoutClass = [
"pb-form-options",
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
].join(" ");
const groupLabelSpan =
groupLabelDisplay === "hidden" ? (
<span className="sr-only" style={tokens.groupTextStyle}>
{props.groupLabel}
</span>
) : (
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
);
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
const gapEm = pxToEm(gapPx);
const baseClass = [tokens.textSizeClass, "text-slate-200", tokens.widthClass]
.filter(Boolean)
.join(" ");
const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
const groupStyle: CSSProperties = {
...tokens.groupStyle,
...(isInlineLayout ? { columnGap: gapEm } : {}),
};
return (
<div
key={block.id}
data-testid="preview-form-checkbox-group"
className={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
.filter(Boolean)
.join(" ")}
style={tokens.groupStyle}
className={[layoutClass, baseClass].filter(Boolean).join(" ")}
style={groupStyle}
>
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -210,19 +549,26 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
style={tokens.groupTextStyle}
/>
) : (
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
groupLabelSpan
)}
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={tokens.optionsStyle}>
{props.options.map((opt) => (
<div
className={optionsLayoutClass}
data-testid="preview-form-checkbox-options"
style={tokens.optionsStyle}
>
{props.options.map((opt, index) => (
<label
key={opt.value}
data-testid="preview-form-checkbox-option-container"
className="inline-flex items-center gap-1"
className="pb-form-option"
style={tokens.optionContainerStyle}
>
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
name={props.formFieldName}
value={opt.value}
required={isRequired && index === 0}
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -249,14 +595,45 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
const tokens = computeFormRadioPublicTokens(props);
const groupLabelMode = props.groupLabelMode ?? "text";
const isRequired = requiredFieldIdSet.has(block.id);
const groupLabelDisplay = (props as any).groupLabelDisplay ?? "visible";
const labelLayout = (props as any).labelLayout ?? "stacked";
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
const optionLayout = (props as any).optionLayout ?? "stacked";
const optionsLayoutClass = [
"pb-form-options",
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
].join(" ");
const groupLabelSpan =
groupLabelDisplay === "hidden" ? (
<span className="sr-only" style={tokens.groupTextStyle}>
{props.groupLabel}
</span>
) : (
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
);
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
const gapEm = pxToEm(gapPx);
const baseClass = [tokens.textSizeClass, "text-slate-200", tokens.widthClass]
.filter(Boolean)
.join(" ");
const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
const groupStyle: CSSProperties = {
...tokens.groupStyle,
...(isInlineLayout ? { columnGap: gapEm } : {}),
};
return (
<div
key={block.id}
data-testid="preview-form-radio-group"
className={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
.filter(Boolean)
.join(" ")}
style={tokens.groupStyle}
className={[layoutClass, baseClass].filter(Boolean).join(" ")}
style={groupStyle}
>
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -267,19 +644,25 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
style={tokens.groupTextStyle}
/>
) : (
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
groupLabelSpan
)}
<div className="flex flex-col" data-testid="preview-form-radio-options" style={tokens.optionsStyle}>
{props.options.map((opt) => (
<div
className={optionsLayoutClass}
data-testid="preview-form-radio-options"
style={tokens.optionsStyle}
>
{props.options.map((opt, index) => (
<label
key={opt.value}
className="inline-flex items-center gap-1"
className="pb-form-option"
style={tokens.optionContainerStyle}
>
<input
type="radio"
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
name={props.formFieldName}
value={opt.value}
required={isRequired && index === 0}
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -339,7 +722,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
let innerContent: React.ReactNode;
if (!hasImage) {
innerContent = <span className="whitespace-pre-wrap">{props.label}</span>;
innerContent = props.label;
} else {
const src = (props as any).imageSrc as string;
const altRaw = (props as any).imageAlt as string | undefined;
@@ -372,11 +755,57 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
);
}
const owningFormBlock = blocks.find(
(b) => b.type === "form" && (b.props as FormBlockProps).submitButtonId === block.id,
);
const isSubmitButton = (() => {
if (!owningFormBlock) return false;
const tokens = computeFormControllerPublicTokens(owningFormBlock, blocks);
return (tokens.fields ?? []).length > 0;
})();
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
if (!isSubmitButton || !owningFormBlock) {
return;
}
e.preventDefault();
await submitFormByController(owningFormBlock);
};
const anchor = (
<a
href={props.href}
className={buttonClassName}
style={buttonStyle}
onClick={isSubmitButton ? handleClick : undefined}
>
{innerContent}
</a>
);
if (!isSubmitButton) {
return (
<div key={block.id} className={wrapperClass}>
{anchor}
</div>
);
}
return (
<div key={block.id} className={wrapperClass}>
<a href={props.href} className={buttonClassName} style={buttonStyle}>
{innerContent}
</a>
<div className="flex flex-col items-start gap-1">
{anchor}
{owningFormBlock && activeFormId === owningFormBlock.id && formStatus !== "idle" && formMessage ? (
<p
className={`text-xs mt-1 ${
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
}`}
>
{formMessage}
</p>
) : null}
</div>
</div>
);
}
@@ -532,10 +961,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
)}
</div>
{props.captionText && props.captionText.trim() !== "" && (
<p
data-testid="preview-video-caption"
className="mt-2 text-xs text-slate-400 hidden"
>
<p data-testid="preview-video-caption" className="pb-video-caption">
{props.captionText}
</p>
)}
@@ -545,184 +971,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
}
if (block.type === "form") {
const props = block.props as FormBlockProps;
const tokens = computeFormControllerPublicTokens(block, blocks);
const fields = tokens.fields as any[];
const hasFields = fields.length > 0;
const submitLabelBase = tokens.submitLabel ?? (hasFields ? "폼 전송" : null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
try {
setFormStatus("submitting");
setFormMessage("");
const res = await fetch("/api/forms/submit", {
method: "POST",
body: data,
});
if (res.ok) {
setFormStatus("success");
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
form.reset();
} else {
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
} catch (error) {
console.error("[PublicPageRenderer] form submit error", error);
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
};
return (
<form
key={block.id}
data-testid="preview-form-controller"
className={tokens.formClassName}
style={tokens.formStyle}
onSubmit={handleSubmit}
>
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
<input type="hidden" name="__config" value={JSON.stringify(props)} />
{projectSlug && projectSlug.trim() !== "" ? (
<input type="hidden" name="__projectSlug" value={projectSlug.trim()} />
) : null}
{hasFields && (
<div className="flex flex-col gap-2 text-xs text-slate-200">
{fields.map((field: any) => (
<label key={field.id} className="flex flex-col gap-1">
<span>{field.label}</span>
{field.type === "textarea" ? (
<textarea
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name={field.name}
placeholder={field.label}
required={field.required}
/>
) : field.type === "select" ? (
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name={field.name}
required={field.required}
defaultValue={field.options?.[0]?.value ?? ""}
>
{(field.options ?? []).map((opt: FormSelectOption) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field.type === "checkbox" ? (
<div className="flex flex-col gap-1">
{/* 그룹 타이틀: 텍스트/이미지 모드를 지원한다. alt 는 그룹 라벨 텍스트를 사용한다. */}
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={field.groupLabelImageUrl}
alt={field.label}
className="max-h-10 w-auto max-w-[200px]"
/>
) : (
<span>{field.label}</span>
)}
{(field.options ?? []).map((opt: FormCheckboxOption, index: number) => (
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
<input
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
type="checkbox"
name={field.name}
value={opt.value}
required={field.required && index === 0}
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || field.label || "체크박스 옵션"}
className="h-4 w-auto"
/>
) : (
<span>{opt.label}</span>
)}
</label>
))}
</div>
) : field.type === "radio" ? (
<div className="flex flex-col gap-1">
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={field.groupLabelImageUrl}
alt={field.label}
className="max-h-10 w-auto max-w-[200px] md:max-w-[300px] lg:max-w-[400px]"
/>
) : (
<span>{field.label}</span>
)}
{(field.options ?? []).map((opt: FormRadioOption, index: number) => (
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
<input
type="radio"
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
name={field.name}
value={opt.value}
required={field.required && index === 0}
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || field.label || "라디오 옵션"}
className="h-5 w-auto max-w-[120px]"
/>
) : (
<span>{opt.label}</span>
)}
</label>
))}
</div>
) : (
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name={field.name}
type={field.type === "email" ? "email" : "text"}
placeholder={field.label}
required={field.required}
/>
)}
</label>
))}
</div>
)}
{submitLabelBase != null && (
<button
type="submit"
disabled={formStatus === "submitting"}
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
>
{formStatus === "submitting" ? "전송 중..." : submitLabelBase}
</button>
)}
{formStatus !== "idle" && formMessage ? (
<p
className={`text-xs mt-1 ${
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
}`}
>
{formMessage}
</p>
) : null}
</form>
);
return null;
}
if (block.type === "image") {
@@ -748,18 +997,19 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
const renderSection = (section: Block) => {
const props = section.props as SectionBlockProps;
const { backgroundClass, paddingYClass, maxWidthClass, gapXClass, alignItemsClass } =
getSectionLayoutConfig(props);
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
const tokens = computeSectionPublicTokens(props);
const exportTokens = computeSectionExportTokens(props);
const alignItems =
props.alignItems === "center" ? "center" : props.alignItems === "bottom" ? "flex-end" : "flex-start";
return (
<section
key={section.id}
data-testid="preview-section"
data-section-id={section.id}
className={`${backgroundClass} ${typeof props.paddingYPx === "number" ? "" : paddingYClass}`}
className={exportTokens.sectionClasses}
style={tokens.sectionStyle}
>
{tokens.hasBackgroundVideo && tokens.backgroundVideoSrc && (
@@ -775,20 +1025,27 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
)}
<div
data-testid="preview-section-inner"
className={`mx-auto ${typeof props.maxWidthPx === "number" ? "" : maxWidthClass} px-4`}
className="pb-section-inner"
style={tokens.innerWrapperStyle}
>
<div
data-testid="preview-section-columns"
className={`flex ${typeof props.gapXPx === "number" ? "" : gapXClass} ${alignItemsClass}`}
style={tokens.columnsContainerStyle}
className="pb-section-columns"
style={{
...tokens.columnsContainerStyle,
alignItems,
}}
>
{columns.map((col) => {
const basis = `${(col.span / 12) * 100}%`;
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
return (
<div key={col.id} className="flex flex-col gap-4" style={{ flexBasis: basis }}>
<div
key={col.id}
className="pb-section-column"
style={{ flexBasis: basis, display: "flex", flexDirection: "column", rowGap: "1rem" }}
>
{columnBlocks.map((b) => (
<div key={b.id}>{renderBlock(b)}</div>
))}
@@ -803,10 +1060,10 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
return (
<div className="flex-1 flex flex-col text-slate-50">
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 pb-root 내에 페이지 상단에 노출 */}
{rootBlocks.length > 0 && (
<section className="py-12">
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
<section className="pb-root">
<div className="pb-root-inner">
{rootBlocks.map((b) => (
<div key={b.id}>{renderBlock(b)}</div>
))}
+7
View File
@@ -559,6 +559,7 @@ export interface FormFieldStyleProps {
optionGapPx?: number;
labelGapPx?: number;
labelLayout?: "stacked" | "inline";
optionLayout?: "stacked" | "inline";
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 필드 텍스트 색상/타이포그라피
fontSizeCustom?: string;
@@ -580,6 +581,7 @@ export interface FormInputBlockProps extends FormFieldStyleProps {
inputType?: "text" | "email" | "textarea";
placeholder?: string;
labelMode?: FormLabelMode;
labelDisplay?: "visible" | "hidden" | "floating";
labelImageUrl?: string;
labelImageAlt?: string;
}
@@ -596,6 +598,7 @@ export interface FormSelectBlockProps extends FormFieldStyleProps {
options: FormSelectOption[];
required?: boolean;
labelMode?: FormLabelMode;
labelDisplay?: "visible" | "hidden";
labelImageUrl?: string;
labelImageAlt?: string;
}
@@ -615,6 +618,7 @@ export interface FormCheckboxBlockProps extends FormFieldStyleProps {
required?: boolean;
// 그룹 타이틀 자체도 텍스트/이미지 모드를 가질 수 있도록 분리한다.
groupLabelMode?: FormLabelMode;
groupLabelDisplay?: "visible" | "hidden";
groupLabelImageUrl?: string;
groupLabelImageSource?: "url" | "upload";
optionImageSource?: "url" | "upload";
@@ -635,6 +639,7 @@ export interface FormRadioBlockProps extends FormFieldStyleProps {
required?: boolean;
// 라디오 그룹 타이틀의 텍스트/이미지 모드
groupLabelMode?: FormLabelMode;
groupLabelDisplay?: "visible" | "hidden";
groupLabelImageUrl?: string;
groupLabelImageSource?: "url" | "upload";
optionImageSource?: "url" | "upload";
@@ -671,6 +676,8 @@ export interface FormBlockProps {
fields?: FormFieldConfig[];
// v2 컨트롤러: 개별 폼 요소 블록과 제출 버튼을 연결하기 위한 ID 목록
fieldIds?: string[];
// v2 컨트롤러: fieldIds 로 연결된 필드 중 어떤 필드를 필수로 처리할지 관리하는 ID 목록
requiredFieldIds?: string[];
submitButtonId?: string | null;
// 폼 레이아웃
formWidthMode?: "auto" | "full" | "fixed";
+47 -8
View File
@@ -18,6 +18,9 @@ export interface ButtonStyleInput {
widthPx?: number | null | undefined;
paddingX?: number | null | undefined;
paddingY?: number | null | undefined;
fontSizeCustom?: string | null | undefined;
lineHeightCustom?: string | null | undefined;
letterSpacingCustom?: string | null | undefined;
fillColorCustom?: string | null | undefined;
strokeColorCustom?: string | null | undefined;
textColorCustom?: string | null | undefined;
@@ -68,15 +71,13 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
}
if (typeof input.paddingX === "number") {
const paddingInline = pxToEm(input.paddingX);
inlineStyles.push(`padding-left:${paddingInline}`);
inlineStyles.push(`padding-right:${paddingInline}`);
inlineStyles.push(`padding-left:${pxToEm(input.paddingX)}`);
inlineStyles.push(`padding-right:${pxToEm(input.paddingX)}`);
}
if (typeof input.paddingY === "number") {
const paddingBlock = pxToEm(input.paddingY);
inlineStyles.push(`padding-top:${paddingBlock}`);
inlineStyles.push(`padding-bottom:${paddingBlock}`);
inlineStyles.push(`padding-top:${pxToEm(input.paddingY)}`);
inlineStyles.push(`padding-bottom:${pxToEm(input.paddingY)}`);
}
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
@@ -91,6 +92,42 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
inlineStyles.push(`color:${input.textColorCustom.trim()}`);
}
if (input.fontSizeCustom && input.fontSizeCustom.trim() !== "") {
const fontSizeValue = input.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
inlineStyles.push(`font-size:${fontSizeEm}`);
}
} else {
inlineStyles.push(`font-size:${fontSizeValue}`);
}
}
if (input.lineHeightCustom && input.lineHeightCustom.trim() !== "") {
const lineHeightValue = input.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
inlineStyles.push(`line-height:${lineHeightEm}`);
}
} else {
inlineStyles.push(`line-height:${lineHeightValue}`);
}
}
if (input.letterSpacingCustom && input.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = input.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
inlineStyles.push(`letter-spacing:${letterSpacingEm}`);
}
} else {
inlineStyles.push(`letter-spacing:${letterSpacingValue}`);
}
}
return {
alignClass,
sizeClass,
@@ -155,11 +192,13 @@ export function computeButtonPublicTokens(props: ButtonBlockProps): ButtonPublic
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
styleOverrides.paddingInline = pxToEm(props.paddingX);
const paddingEm = pxToEm(props.paddingX);
styleOverrides.paddingInline = paddingEm;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
styleOverrides.paddingBlock = pxToEm(props.paddingY);
const paddingEm = pxToEm(props.paddingY);
styleOverrides.paddingBlock = paddingEm;
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
+27 -1
View File
@@ -21,7 +21,33 @@ export const computeDividerExportTokens = (
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
const marginEm = pxToEm(marginY);
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginEm} 0;`;
const widthMode = props.widthMode ?? "full";
const align = props.align ?? "left";
const styleParts: string[] = [];
styleParts.push("border:0");
styleParts.push(`border-bottom:${thickness} solid ${escapeAttr(color)}`);
styleParts.push(`margin:${marginEm} 0`);
if (widthMode === "auto") {
styleParts.push("width:50%");
} else if (widthMode === "fixed") {
const widthPx =
typeof props.widthPx === "number" && props.widthPx > 0 ? props.widthPx : 320;
styleParts.push(`width:${pxToEm(widthPx)}`);
}
if (widthMode === "auto" || widthMode === "fixed") {
if (align === "center") {
styleParts.push("margin-left:auto");
styleParts.push("margin-right:auto");
} else if (align === "right") {
styleParts.push("margin-left:auto");
styleParts.push("margin-right:0");
}
}
const style = `${styleParts.join(";")};`;
return { style };
};
+110 -53
View File
@@ -42,6 +42,12 @@ const normalizeTextColor = (raw: unknown): string => {
return trimmed !== "" ? trimmed : "";
};
// Export/Public 레이어용 모서리 둥글기 매핑
// - none: 0px
// - sm: 2px
// - lg: 6px
// - full: 9999px (완전 둥근 모서리)
// - md(기본): 4px
const computeRadiusPx = (radius: unknown): number => {
const token = typeof radius === "string" ? radius : "md";
if (token === "none") return 0;
@@ -51,6 +57,19 @@ const computeRadiusPx = (radius: unknown): number => {
return 4;
};
// Editor 레이어용 모서리 둥글기 매핑
// - none: 0px
// - sm: 4px
// - md: 8px (기본)
// - lg/full: 9999px (pill 스타일)
const computeEditorRadiusPx = (radius: unknown): number => {
const token = typeof radius === "string" ? radius : "md";
if (token === "none") return 0;
if (token === "sm") return 4;
if (token === "lg" || token === "full") return 9999;
return 8;
};
const computeWidthMode = (props: { widthMode?: string; fullWidth?: boolean }): "auto" | "full" | "fixed" => {
if (typeof props.widthMode === "string") {
return props.widthMode as "auto" | "full" | "fixed";
@@ -101,9 +120,68 @@ export const computeFormInputExportTokens = (
inputStyleParts.push(`width:${em}em`);
}
const radiusPx = computeRadiusPx(props.borderRadius);
const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
let radiusPx = 4;
if (radiusToken === "none") {
radiusPx = 0;
} else if (radiusToken === "sm") {
radiusPx = 2;
} else if (radiusToken === "lg") {
radiusPx = 6;
} else if (radiusToken === "full") {
radiusPx = 9999;
}
inputStyleParts.push(`border-radius:${radiusPx}px`);
// 폰트 관련 커스텀 값: px 단위일 경우 em 으로 변환하고, 그렇지 않으면 원문 값을 그대로 사용한다.
if (typeof props.fontSizeCustom === "string" && props.fontSizeCustom.trim() !== "") {
const raw = props.fontSizeCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`font-size:${em}em`);
}
}
} else {
inputStyleParts.push(`font-size:${escapeAttr(raw)}`);
}
}
if (typeof props.lineHeightCustom === "string" && props.lineHeightCustom.trim() !== "") {
const raw = props.lineHeightCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`line-height:${em}em`);
}
}
} else {
inputStyleParts.push(`line-height:${escapeAttr(raw)}`);
}
}
if (typeof props.letterSpacingCustom === "string" && props.letterSpacingCustom.trim() !== "") {
const raw = props.letterSpacingCustom.trim();
if (raw.endsWith("px")) {
const match = raw.match(/-?\d+(?:\.\d+)?/);
if (match) {
const px = parseFloat(match[0]);
if (Number.isFinite(px)) {
const em = px / 16;
inputStyleParts.push(`letter-spacing:${em}em`);
}
}
} else {
inputStyleParts.push(`letter-spacing:${escapeAttr(raw)}`);
}
}
const inputStyleAttr = inputStyleParts.length > 0 ? ` style="${inputStyleParts.join(";")}"` : "";
return {
@@ -254,14 +332,9 @@ export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormIn
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
const radiusPx = computeEditorRadiusPx(props.borderRadius);
fieldStyle.borderRadius = radiusPx;
// 에디터에서는 px 기반 padding/타이포 값을 그대로 fieldStyle 에 반영해 미리보기에서 변화가 보이도록 한다.
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
@@ -336,14 +409,9 @@ export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): Form
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
const selectRadiusPx = computeEditorRadiusPx(props.borderRadius);
fieldStyle.borderRadius = selectRadiusPx;
// 체크박스/라디오 그룹도 에디터에서 padding/타이포 스타일을 일부 반영해준다.
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
@@ -389,14 +457,9 @@ export const computeFormOptionGroupEditorTokens = (
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
const groupRadiusPx = computeEditorRadiusPx(props.borderRadius);
fieldStyle.borderRadius = groupRadiusPx;
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
fieldStyle.paddingInline = `${props.paddingX}px`;
@@ -535,23 +598,18 @@ export const computeFormInputPublicTokens = (props: FormInputBlockProps): FormIn
}
}
// 색상 관련 커스텀 값
// 색상 관련 커스텀 값: builder.css 의 pb-input 기본 색상을 덮어쓰지 않도록,
// 사용자가 커스텀 값을 설정했을 때에만 인라인 스타일을 적용한다.
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
inputStyle.color = props.textColorCustom.trim();
} else {
inputStyle.color = "#f9fafb";
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
inputStyle.backgroundColor = props.fillColorCustom.trim();
} else {
inputStyle.backgroundColor = "#020617";
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
inputStyle.borderColor = props.strokeColorCustom.trim();
} else {
inputStyle.borderColor = "#334155";
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
@@ -629,14 +687,12 @@ export const computeFormSelectPublicTokens = (props: FormSelectBlockProps): Form
}
}
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다.
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고,
// 없으면 builder.css 의 pb-select 기본 색상을 그대로 사용한다.
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
const colorValue = props.textColorCustom.trim();
wrapperStyle.color = colorValue;
selectStyle.color = colorValue;
} else {
wrapperStyle.color = "#f9fafb";
selectStyle.color = "#f9fafb";
}
// 배경/테두리 색상: fillColorCustom/strokeColorCustom 이 있으면 select 에 적용한다.
@@ -694,6 +750,7 @@ const computeOptionGroupPublicTokensBase = (
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
// 퍼블릭 토큰에서는 고정 너비를 em 단위로 변환해 px 단위를 피한다.
groupStyle.width = pxToEm(props.widthPx);
}
@@ -783,7 +840,12 @@ const computeOptionGroupPublicTokensBase = (
optionContainerStyle.borderRadius = `${radiusPx}px`;
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
optionsStyle.rowGap = pxToEm(props.optionGapPx);
const gapPx = props.optionGapPx;
const gapEm = pxToEm(gapPx);
optionsStyle.rowGap = gapEm;
if ((props as any).optionLayout === "inline") {
optionsStyle.columnGap = gapEm;
}
}
return {
@@ -844,6 +906,10 @@ export const computeFormControllerPublicTokens = (
.map((fieldBlock) => {
const anyProps: any = fieldBlock.props ?? {};
const isRequired = Array.isArray(props.requiredFieldIds)
? props.requiredFieldIds.includes(fieldBlock.id)
: false;
if (fieldBlock.type === "formInput") {
const name = anyProps.formFieldName ?? fieldBlock.id;
const label = anyProps.label ?? anyProps.formFieldName ?? "입력 필드";
@@ -854,7 +920,7 @@ export const computeFormControllerPublicTokens = (
label,
// 기존 PublicPageRenderer 컨트롤러 로직과 동일하게 formInput 은 항상 type "text" 로 취급한다.
type: "text",
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -869,7 +935,7 @@ export const computeFormControllerPublicTokens = (
label,
type: "select",
options,
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -886,7 +952,7 @@ export const computeFormControllerPublicTokens = (
options,
groupLabelMode: anyProps.groupLabelMode,
groupLabelImageUrl: anyProps.groupLabelImageUrl,
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
}
@@ -902,23 +968,14 @@ export const computeFormControllerPublicTokens = (
options,
groupLabelMode: anyProps.groupLabelMode,
groupLabelImageUrl: anyProps.groupLabelImageUrl,
required: Boolean(anyProps.required),
required: isRequired,
} satisfies FormControllerFieldPublicConfig;
})
: [];
const fields: FormControllerFieldPublicConfig[] =
hasControllerFields && controllerFields.length > 0
? controllerFields
: Array.isArray(props.fields) && props.fields.length > 0
? props.fields.map((field) => ({
id: field.id,
name: field.name,
label: field.label,
type: field.type,
required: field.required,
}))
: [];
// 프리뷰/퍼블릭 렌더러에서는 FormBlock 의 v1 fields 설정을 사용하지 않고,
// fieldIds 로 연결된 실제 폼 필드 블록들만 기반으로 폼 필드를 렌더링한다.
const fields: FormControllerFieldPublicConfig[] = controllerFields;
const mappedSubmitButton = blocks.find(
(b) => b.type === "button" && b.id === props.submitButtonId,
+22 -1
View File
@@ -5,6 +5,7 @@ export interface ListExportTokens {
Tag: "ul" | "ol";
items: string[];
listStyleParts: string[];
gapEm: number;
}
export const computeListExportTokens = (props: ListBlockProps): ListExportTokens => {
@@ -37,6 +38,21 @@ export const computeListExportTokens = (props: ListBlockProps): ListExportTokens
const align = alignToken === "center" ? "center" : alignToken === "right" ? "right" : "left";
const listStyleParts: string[] = [`text-align:${align}`];
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
const bulletStyle = bulletStyleRaw === "none" ? "none" : bulletStyleRaw;
listStyleParts.push(`list-style-type:${bulletStyle}`);
const gapPx =
typeof props.gapYPx === "number"
? props.gapYPx
: props.gapY === "sm"
? 4
: props.gapY === "lg"
? 16
: 8;
const gapEm = gapPx / 16;
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
}
@@ -45,6 +61,7 @@ export const computeListExportTokens = (props: ListBlockProps): ListExportTokens
Tag,
items,
listStyleParts,
gapEm,
};
};
@@ -106,7 +123,11 @@ const convertPxStringToEm = (value?: string | null) => {
export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens => {
const alignClass =
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
props.align === "center"
? "pb-text-center"
: props.align === "right"
? "pb-text-right"
: "pb-text-left";
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
+22 -2
View File
@@ -1,9 +1,13 @@
import type { CSSProperties } from "react";
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
const pxToEm = (px: number, base = 16) => `${px / base}em`;
export interface SectionExportTokens {
sectionClasses: string;
sectionStyleParts: string[];
innerWrapperStyleParts: string[];
columnsStyleParts: string[];
backgroundVideoHtml: string;
}
@@ -26,6 +30,8 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
const sectionStyleParts: string[] = [];
const innerWrapperStyleParts: string[] = [];
const columnsStyleParts: string[] = [];
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
sectionStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
@@ -65,6 +71,20 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
sectionStyleParts.push(`background-repeat:${repeat}`);
}
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
const paddingEm = pxToEm(props.paddingYPx);
sectionStyleParts.push(`padding-top:${paddingEm}`);
sectionStyleParts.push(`padding-bottom:${paddingEm}`);
}
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
innerWrapperStyleParts.push(`max-width:${pxToEm(props.maxWidthPx)}`);
}
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
columnsStyleParts.push(`column-gap:${pxToEm(props.gapXPx)}`);
}
const bgVideoSrc = props.backgroundVideoSrc;
let backgroundVideoHtml = "";
if (typeof bgVideoSrc === "string" && bgVideoSrc.trim() !== "") {
@@ -77,6 +97,8 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
return {
sectionClasses,
sectionStyleParts,
innerWrapperStyleParts,
columnsStyleParts,
backgroundVideoHtml,
};
}
@@ -89,8 +111,6 @@ export interface SectionPublicTokens {
backgroundVideoSrc: string | null;
}
const pxToEm = (px: number, base = 16) => `${px / base}em`;
export function computeSectionPublicTokens(props: SectionBlockProps): SectionPublicTokens {
const sectionStyle: CSSProperties = {};
+7 -6
View File
@@ -58,8 +58,10 @@ export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
const fontSizeMode = input.fontSizeMode ?? "scale";
const fallbackScale: TextFontSizeScale = input.size === "sm" ? "sm" : input.size === "lg" ? "lg" : "base";
const fontSizeScale: TextFontSizeScale = (input.fontSizeScale as TextFontSizeScale | null) ?? fallbackScale;
const fallbackScale: TextFontSizeScale =
input.size === "sm" ? "sm" : input.size === "lg" ? "lg" : "base";
const fontSizeScale: TextFontSizeScale =
(input.fontSizeScale as TextFontSizeScale | null) ?? fallbackScale;
const fontSizeMap: Record<TextFontSizeScale, string> = {
xs: "pb-text-xs",
@@ -71,10 +73,9 @@ export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
"3xl": "pb-text-3xl",
};
const sizeClass =
fontSizeMode === "scale" && fontSizeScale && fontSizeMap[fontSizeScale]
? fontSizeMap[fontSizeScale]
: "";
const sizeClass = fontSizeScale && fontSizeMap[fontSizeScale]
? fontSizeMap[fontSizeScale]
: "";
const lineHeightMode = input.lineHeightMode ?? "scale";
const lineHeightScale: TextLineHeightScale = (input.lineHeightScale as TextLineHeightScale | null) ?? "normal";
+1
View File
@@ -0,0 +1 @@
export { buildStaticHtml } from "@/app/api/export/route";
+25
View File
@@ -0,0 +1,25 @@
import type { Block } from "@/features/editor/state/editorStore";
interface PublicProjectSnapshot {
slug: string;
title: string;
contentJson: Block[];
}
function getStore(): Map<string, PublicProjectSnapshot> {
const g = globalThis as any;
if (!g.__PB_PUBLIC_PROJECTS) {
g.__PB_PUBLIC_PROJECTS = new Map<string, PublicProjectSnapshot>();
}
return g.__PB_PUBLIC_PROJECTS as Map<string, PublicProjectSnapshot>;
}
export function cachePublicProject(snapshot: PublicProjectSnapshot): void {
const store = getStore();
store.set(snapshot.slug, snapshot);
}
export function getCachedPublicProject(slug: string): PublicProjectSnapshot | null {
const store = getStore();
return store.get(slug) ?? null;
}
+78 -2
View File
@@ -201,6 +201,19 @@ body {
white-space: pre-wrap;
}
/* Screen reader only utility (공통 sr-only 클래스) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.pb-scroll {
scrollbar-width: thin;
scrollbar-color: #1f2937 #020617;
@@ -260,7 +273,7 @@ body {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-size: 1rem;
font-weight: 500;
border-width: 1px;
text-decoration: none;
@@ -421,11 +434,67 @@ body {
gap: 0.25rem;
}
.pb-form-field--floating {
position: relative;
margin-top: 0.5rem; /* 윗쪽 엘리먼트와는 살짝만 띄우고, 간격이 너무 넓지 않도록 조정한다. */
/* 플로팅 라벨이 사용하는 공통 세로 패딩 기준값.
값을 변경하면 인풋 높이와 라벨 위치가 함께 조정되도록 한다. */
--pb-input-padding-y: 0.5rem;
}
.pb-form-field--floating .pb-form-label {
position: absolute;
/* 인풋 내부 세로 패딩을 기준으로 라벨의 기본 위치를 계산한다. */
top: calc(var(--pb-input-padding-y) + 0.3rem);
left: 0.75rem;
font-size: 0.75rem;
color: #9ca3af;
pointer-events: none;
transform-origin: left top;
transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease;
}
.pb-form-field--floating .pb-input,
.pb-form-field--floating .pb-textarea {
margin-top: 0.25rem;
/* 기본 세로 패딩 + 라벨이 들어갈 여유 공간을 함께 적용한다. */
padding-top: calc(var(--pb-input-padding-y) + 1rem);
padding-bottom: var(--pb-input-padding-y);
/* 세로 패딩을 0으로 줄이더라도 플로팅 라벨이 지나치게 눌리지 않도록 최소 높이를 보장한다. */
min-height: 2.5rem;
}
/* 포커스되었거나 값이 입력되어 placeholder 가 사라진 경우, 라벨을 인풋 바깥(상단 보더 근처)으로 플로팅한다. */
.pb-form-field--floating .pb-input:focus ~ .pb-form-label,
.pb-form-field--floating .pb-input:not(:placeholder-shown) ~ .pb-form-label,
.pb-form-field--floating .pb-textarea:focus ~ .pb-form-label,
.pb-form-field--floating .pb-textarea:not(:placeholder-shown) ~ .pb-form-label {
top: -0.3rem; /* 인풋 상단 보더에 살짝 겹치는 정도로만 띄운다. */
font-size: 0.725rem;
color: #e5e7eb;
background-color: #020617; /* 인풋 배경과 맞춰서 보더 라인을 자연스럽게 가린다. */
padding: 0 0.25rem;
}
.pb-form-label {
font-size: 0.75rem;
color: #e5e7eb;
}
.pb-form-options {
display: flex;
font-size: 0.75rem;
}
.pb-form-options--stacked {
flex-direction: column;
}
.pb-form-options--inline {
flex-direction: row;
flex-wrap: wrap;
}
.pb-form-option {
display: flex;
align-items: center;
@@ -441,12 +510,19 @@ body {
border-radius: 0.375rem;
border: 1px solid #1f2937;
background-color: #020617;
padding: 0.5rem 0.75rem;
/* 세로 패딩은 CSS 변수로 정의해 플로팅 라벨/에디터 등이 함께 참조할 수 있게 한다. */
padding: var(--pb-input-padding-y, 0.5rem) 0.75rem;
font-size: 0.75rem;
color: #e5e7eb;
box-sizing: border-box;
}
/* 기본 인풋/셀렉트도 일정 높이 이상을 유지해, 세로 패딩을 0 에 가깝게 줄이더라도 UI 가 붕괴되지 않도록 한다. */
.pb-input,
.pb-select {
min-height: 2.5rem;
}
.pb-textarea {
min-height: 6rem;
}
+10
View File
@@ -0,0 +1,10 @@
/*
* editor.css
* - 에디터/프리뷰 상단 , 좌우 패널, 캔버스 외곽 "에디터 크롬" 전용 스타일을 정의한다.
* - 콘텐츠 블록(text/button/image/video/list/section/form ) 모양은 builder.css(pb-*) + 인라인 스타일로만 제어하고,
* 파일에서는 블록 내부 스타일을 절대 정의하지 않는다.
*/
/* 초기 상태에서는 별도 규칙 없이 파일만 생성해 두고,
* 이후 에디터 전용 레이아웃/패널 스타일을 점진적으로 이동시킨다.
*/