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