diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 64becc3..7643f4e 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -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: 해당 필드가 속한
요소의 DOM id (예: form_form_1) + // - fieldIdToFormId: key = 필드 블록 id (fieldIds 에 포함된 id), value = 요소의 DOM id + // - formBlockIdToFormDomId: key = FormBlock id, value = 요소의 DOM id const fieldIdToFormId = new Map(); + const formBlockIdToFormDomId = new Map(); + const requiredFieldIdSet = new Set(); + // 테스트 및 기존 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/메서드/액션만 가지는 최소한의 요소만 만든다. // - 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) => `
  • ${escapeHtml(text)}
  • `).join(""); + const lis = tokens.items + .map((text) => `
  • ${escapeHtml(text)}
  • `) + .join(""); const listStyleAttr = tokens.listStyleParts.join(";"); return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}`; @@ -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 [ - '
    ', - ``, - ``, + ``, + ``, "
    ", ].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('
    '); - parts.push(``); + parts.push(`
    `); + parts.push(``); parts.push( ` ${escapeHtml(opt.label)}`, + )}" value="${escapeAttr(opt.value)}"${optionRequiredAttr}${formAttr} /> ${escapeHtml( + opt.label, + )}`, ); - } + }); + parts.push("
    "); parts.push("
    "); 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('
    '); - parts.push(``); - for (const opt of options) { + parts.push(`
    `); + parts.push(``); + parts.push(`
    `); + options.forEach((opt: { value: string; label: string }, index: number) => { + const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : ""; parts.push( ``, + )}" value="${escapeAttr(opt.value)}"${optionRequiredAttr}${formAttr} /> ${escapeHtml( + opt.label, + )}`, ); - } + }); + parts.push("
    "); parts.push("
    "); 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( - `
    ${tokens.backgroundVideoHtml}
    `, + `
    ${tokens.backgroundVideoHtml}
    `, ); for (const column of columns) { diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index f722bc1..5a27473 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -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( diff --git a/src/app/api/projects/submissions/route.ts b/src/app/api/projects/submissions/route.ts new file mode 100644 index 0000000..63365b3 --- /dev/null +++ b/src/app/api/projects/submissions/route.ts @@ -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 { + 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; + let sensitive: Record = {}; + + if (s.sensitiveEnc) { + try { + sensitive = (await decryptJson>(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 }); +} diff --git a/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx b/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx index 7f116df..4a99103 100644 --- a/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx +++ b/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx @@ -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 (
    @@ -35,6 +36,78 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc + + + {groupLabelDisplay === "visible" && ( + + )} + + + + {groupLabelDisplay === "visible" && (checkboxProps.labelLayout ?? "stacked") === "inline" && ( + + updateBlock(selectedBlockId, { + labelGapPx: v, + } as any) + } + /> + )} +