From a5b432fb7bc02a5ded4521ebdeeae8ed9dd12439 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sun, 7 Dec 2025 09:52:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?TDD,E2E=20=EA=B0=9C=EC=84=A0,=20=EB=AF=B8?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/export/route.ts | 233 +++- src/app/api/projects/route.ts | 14 + src/app/api/projects/submissions/route.ts | 97 ++ .../forms/FormCheckboxPropertiesPanel.tsx | 85 +- src/app/editor/forms/FormControllerPanel.tsx | 21 +- .../editor/forms/FormInputPropertiesPanel.tsx | 141 +- .../editor/forms/FormRadioPropertiesPanel.tsx | 85 +- .../forms/FormSelectPropertiesPanel.tsx | 69 +- src/app/editor/layout.tsx | 8 + src/app/editor/page.tsx | 233 +++- .../editor/panels/ButtonPropertiesPanel.tsx | 6 +- src/app/p/[slug]/page.tsx | 74 ++ src/app/preview/layout.tsx | 8 + src/app/preview/page.tsx | 48 +- src/app/projects/[slug]/submissions/page.tsx | 13 +- src/app/projects/page.tsx | 7 + src/app/projects/submissions/page.tsx | 171 +++ .../editor/components/PublicPageRenderer.tsx | 721 ++++++---- src/features/editor/state/editorStore.ts | 7 + src/features/editor/utils/buttonHelpers.ts | 55 +- src/features/editor/utils/dividerHelpers.ts | 28 +- src/features/editor/utils/formHelpers.ts | 163 ++- src/features/editor/utils/listHelpers.ts | 23 +- src/features/editor/utils/sectionHelpers.ts | 24 +- src/features/editor/utils/textHelpers.ts | 13 +- src/features/export/buildStaticHtml.ts | 1 + src/features/projects/publicCache.ts | 25 + src/styles/builder.css | 80 +- src/styles/editor.css | 10 + tests/api/export.spec.ts | 520 ++++++++ tests/api/projectSubmissions.spec.ts | 129 +- tests/e2e/block-styles-regression.spec.ts | 1167 ++++++++++++++++ tests/e2e/editor.spec.ts | 23 +- tests/e2e/form-editor-preview-parity.spec.ts | 350 +++++ tests/e2e/form-submission-flow.spec.ts | 37 +- tests/e2e/preview.spec.ts | 12 - tests/e2e/projects.spec.ts | 7 +- tests/unit/AllProjectSubmissionsPage.spec.tsx | 156 +++ tests/unit/ButtonPropertiesPanel.spec.tsx | 220 +++ tests/unit/DividerPropertiesPanel.spec.tsx | 40 + tests/unit/EditorExportPreview.spec.tsx | 8 + tests/unit/EditorFormBlocksStyle.spec.tsx | 382 +++++- tests/unit/FormFieldPropertiesPanels.spec.tsx | 1176 ++++++++++++++++- tests/unit/ImagePropertiesPanel.spec.tsx | 154 +++ tests/unit/ListPropertiesPanel.spec.tsx | 186 +++ tests/unit/PreviewAutosave.spec.tsx | 45 + tests/unit/ProjectSubmissionsPage.spec.tsx | 24 + tests/unit/ProjectsPage.spec.tsx | 32 + ...PublicPageRendererBlockBackground.spec.tsx | 9 +- ...PublicPageRendererFormFieldStyles.spec.tsx | 483 +++++++ ...licPageRendererFormSubmitMessages.spec.tsx | 189 ++- .../PublicPageRendererSectionLayout.spec.tsx | 38 +- .../PublicPageRendererTextStyles.spec.tsx | 14 +- tests/unit/SectionPropertiesPanel.spec.tsx | 23 + tests/unit/dividerHelpers.spec.ts | 16 + tests/unit/formHelpers.spec.ts | 12 +- tests/unit/listHelpers.spec.ts | 8 +- tests/unit/sectionHelpers.spec.ts | 28 + uploads/024a6d52-b3b6-4e47-838f-993e298b8cc4 | 1 + uploads/0dcbdce9-b21b-4fe1-80dc-7e370eafa2b9 | 1 + uploads/0ee4d32a-cd9d-4051-89c4-fa3448cad429 | 1 + uploads/1332002d-e920-4d18-a337-09f3dc4f166c | 1 + uploads/2cf1cca5-a9b8-44b3-8865-f712ed76a5ae | 1 + uploads/3356ec8a-5de4-4cc2-91d1-cd71e21b2cf3 | 1 + uploads/3e10e8d0-b9cf-4b10-8106-0f2e62dd038d | 1 + uploads/509d145c-7826-45e1-b54d-ce07f1cd86bb | 1 + uploads/58c84807-c36f-4277-a439-7550483b8ab4 | 1 + uploads/5bf824c0-cd99-4e23-a388-f177442f51b8 | 1 + uploads/606e1fad-9331-4794-88e3-3ac16f5ab4c9 | 1 + uploads/73ca2938-db54-48be-a0a0-ca3bfb922bdc | 1 + uploads/74eb8997-68a7-4cc4-9ac8-9bcb8fbb4d5f | 1 + uploads/7c43661e-0383-47f1-8002-736bb4855648 | 1 + uploads/8ce3a123-62ac-4dcf-b2e6-4cc4de14aa7c | 1 + uploads/8f278435-2797-4b1a-92d2-c4c8b5156cbf | 1 + uploads/8fca223f-fda4-4504-8808-1e4213c4d397 | 1 + uploads/945bbd4d-b369-4088-aa73-64fc435fd958 | 1 + uploads/94ecf769-f8a2-4161-945d-eb188306e0ba | 1 + uploads/954273ad-c2cc-48c8-bbbb-f3aa10245a75 | 1 + uploads/973b0f8d-3c0f-4b3b-a57b-6286dbf50bf4 | 1 + uploads/985a81f4-c18a-450c-a174-dd1ab5d8e70e | 1 + uploads/9e80973a-67e0-473b-a2e5-0c9398ef9a9d | 1 + uploads/a7a581c0-307d-4f91-a70c-bec467994719 | 1 + uploads/af916702-ea24-4cb6-ac25-2b5bd90de064 | 1 + uploads/bbefb266-b279-4044-bac8-bbc748b4974c | 1 + uploads/c9c0c8fb-e541-4aa3-a4c6-126b5c189fc8 | 1 + uploads/cf6d7855-26ce-45fc-9262-7c3092015434 | 1 + uploads/d2c60582-febf-4fba-af87-6df18e1699ed | 1 + uploads/df0f179b-f872-4b11-b600-874401bf8d73 | 1 + uploads/ec5eb630-141a-47ed-bebd-87425c13e331 | 1 + uploads/f182d17d-536e-45e1-ac91-5a9361b4ffac | 1 + uploads/f65645f9-4321-44f4-a0a3-365ac9908841 | 1 + uploads/f77f6600-a364-43ce-a2f2-e463e2477702 | 1 + uploads/test-image-1764651816626 | 1 + uploads/test-image-1764679205355 | 1 + uploads/test-image-1764679767710 | 1 + uploads/test-image-1764685822135 | 1 + uploads/test-image-1764723073453 | 1 + uploads/test-image-1764725854480 | 1 + uploads/test-image-1764744172231 | 1 + uploads/test-image-1764914447284 | 1 + uploads/test-image-1764916875397 | 1 + uploads/test-image-1764918783589 | 1 + uploads/test-image-1765037975281 | 1 + uploads/test-image-1765038740036 | 1 + uploads/test-image-1765039112201 | 1 + uploads/test-image-1765039284214 | 1 + uploads/test-image-1765063251345 | 1 + uploads/test-section-bg-1764651816637 | 1 + uploads/test-section-bg-1764679205381 | 1 + uploads/test-section-bg-1764679767729 | 1 + uploads/test-section-bg-1764685822139 | 1 + uploads/test-section-bg-1764723073459 | 1 + uploads/test-section-bg-1764725854496 | 1 + uploads/test-section-bg-1764744172240 | 1 + uploads/test-section-bg-1764914447291 | 1 + uploads/test-section-bg-1764916875410 | 1 + uploads/test-section-bg-1764918783602 | 1 + uploads/test-section-bg-1765037975314 | 1 + uploads/test-section-bg-1765038740112 | 1 + uploads/test-section-bg-1765039112224 | 1 + uploads/test-section-bg-1765039284264 | 1 + uploads/test-section-bg-1765063251354 | 1 + uploads/test-section-bg-video-1764651816672 | 1 + uploads/test-section-bg-video-1764679205463 | 1 + uploads/test-section-bg-video-1764679767959 | 1 + uploads/test-section-bg-video-1764685822144 | 1 + uploads/test-section-bg-video-1764723073468 | 1 + uploads/test-section-bg-video-1764725854532 | 1 + uploads/test-section-bg-video-1764744172256 | 1 + uploads/test-section-bg-video-1764914447306 | 1 + uploads/test-section-bg-video-1764916875420 | 1 + uploads/test-section-bg-video-1764918783615 | 1 + uploads/test-section-bg-video-1765037975366 | 1 + uploads/test-section-bg-video-1765038740129 | 1 + uploads/test-section-bg-video-1765039112525 | 1 + uploads/test-section-bg-video-1765039284319 | 1 + uploads/test-section-bg-video-1765063251365 | 1 + uploads/test-video-1764651816661 | 1 + uploads/test-video-1764679205455 | 1 + uploads/test-video-1764679767891 | 1 + uploads/test-video-1764685822143 | 1 + uploads/test-video-1764723073466 | 1 + uploads/test-video-1764725854519 | 1 + uploads/test-video-1764744172253 | 1 + uploads/test-video-1764914447304 | 1 + uploads/test-video-1764916875417 | 1 + uploads/test-video-1764918783612 | 1 + uploads/test-video-1765037975353 | 1 + uploads/test-video-1765038740126 | 1 + uploads/test-video-1765039112481 | 1 + uploads/test-video-1765039284296 | 1 + uploads/test-video-1765063251362 | 1 + uploads/test-video-poster-1764651816631 | 1 + uploads/test-video-poster-1764679205365 | 1 + uploads/test-video-poster-1764679767718 | 1 + uploads/test-video-poster-1764685822137 | 1 + uploads/test-video-poster-1764723073457 | 1 + uploads/test-video-poster-1764725854485 | 1 + uploads/test-video-poster-1764744172233 | 1 + uploads/test-video-poster-1764914447287 | 1 + uploads/test-video-poster-1764916875401 | 1 + uploads/test-video-poster-1764918783594 | 1 + uploads/test-video-poster-1765037975292 | 1 + uploads/test-video-poster-1765038740088 | 1 + uploads/test-video-poster-1765039112207 | 1 + uploads/test-video-poster-1765039284229 | 1 + uploads/test-video-poster-1765063251348 | 1 + 167 files changed, 7397 insertions(+), 663 deletions(-) create mode 100644 src/app/api/projects/submissions/route.ts create mode 100644 src/app/editor/layout.tsx create mode 100644 src/app/p/[slug]/page.tsx create mode 100644 src/app/preview/layout.tsx create mode 100644 src/app/projects/submissions/page.tsx create mode 100644 src/features/export/buildStaticHtml.ts create mode 100644 src/features/projects/publicCache.ts create mode 100644 src/styles/editor.css create mode 100644 tests/e2e/block-styles-regression.spec.ts create mode 100644 tests/e2e/form-editor-preview-parity.spec.ts create mode 100644 tests/unit/AllProjectSubmissionsPage.spec.tsx create mode 100644 tests/unit/ListPropertiesPanel.spec.tsx create mode 100644 uploads/024a6d52-b3b6-4e47-838f-993e298b8cc4 create mode 100644 uploads/0dcbdce9-b21b-4fe1-80dc-7e370eafa2b9 create mode 100644 uploads/0ee4d32a-cd9d-4051-89c4-fa3448cad429 create mode 100644 uploads/1332002d-e920-4d18-a337-09f3dc4f166c create mode 100644 uploads/2cf1cca5-a9b8-44b3-8865-f712ed76a5ae create mode 100644 uploads/3356ec8a-5de4-4cc2-91d1-cd71e21b2cf3 create mode 100644 uploads/3e10e8d0-b9cf-4b10-8106-0f2e62dd038d create mode 100644 uploads/509d145c-7826-45e1-b54d-ce07f1cd86bb create mode 100644 uploads/58c84807-c36f-4277-a439-7550483b8ab4 create mode 100644 uploads/5bf824c0-cd99-4e23-a388-f177442f51b8 create mode 100644 uploads/606e1fad-9331-4794-88e3-3ac16f5ab4c9 create mode 100644 uploads/73ca2938-db54-48be-a0a0-ca3bfb922bdc create mode 100644 uploads/74eb8997-68a7-4cc4-9ac8-9bcb8fbb4d5f create mode 100644 uploads/7c43661e-0383-47f1-8002-736bb4855648 create mode 100644 uploads/8ce3a123-62ac-4dcf-b2e6-4cc4de14aa7c create mode 100644 uploads/8f278435-2797-4b1a-92d2-c4c8b5156cbf create mode 100644 uploads/8fca223f-fda4-4504-8808-1e4213c4d397 create mode 100644 uploads/945bbd4d-b369-4088-aa73-64fc435fd958 create mode 100644 uploads/94ecf769-f8a2-4161-945d-eb188306e0ba create mode 100644 uploads/954273ad-c2cc-48c8-bbbb-f3aa10245a75 create mode 100644 uploads/973b0f8d-3c0f-4b3b-a57b-6286dbf50bf4 create mode 100644 uploads/985a81f4-c18a-450c-a174-dd1ab5d8e70e create mode 100644 uploads/9e80973a-67e0-473b-a2e5-0c9398ef9a9d create mode 100644 uploads/a7a581c0-307d-4f91-a70c-bec467994719 create mode 100644 uploads/af916702-ea24-4cb6-ac25-2b5bd90de064 create mode 100644 uploads/bbefb266-b279-4044-bac8-bbc748b4974c create mode 100644 uploads/c9c0c8fb-e541-4aa3-a4c6-126b5c189fc8 create mode 100644 uploads/cf6d7855-26ce-45fc-9262-7c3092015434 create mode 100644 uploads/d2c60582-febf-4fba-af87-6df18e1699ed create mode 100644 uploads/df0f179b-f872-4b11-b600-874401bf8d73 create mode 100644 uploads/ec5eb630-141a-47ed-bebd-87425c13e331 create mode 100644 uploads/f182d17d-536e-45e1-ac91-5a9361b4ffac create mode 100644 uploads/f65645f9-4321-44f4-a0a3-365ac9908841 create mode 100644 uploads/f77f6600-a364-43ce-a2f2-e463e2477702 create mode 100644 uploads/test-image-1764651816626 create mode 100644 uploads/test-image-1764679205355 create mode 100644 uploads/test-image-1764679767710 create mode 100644 uploads/test-image-1764685822135 create mode 100644 uploads/test-image-1764723073453 create mode 100644 uploads/test-image-1764725854480 create mode 100644 uploads/test-image-1764744172231 create mode 100644 uploads/test-image-1764914447284 create mode 100644 uploads/test-image-1764916875397 create mode 100644 uploads/test-image-1764918783589 create mode 100644 uploads/test-image-1765037975281 create mode 100644 uploads/test-image-1765038740036 create mode 100644 uploads/test-image-1765039112201 create mode 100644 uploads/test-image-1765039284214 create mode 100644 uploads/test-image-1765063251345 create mode 100644 uploads/test-section-bg-1764651816637 create mode 100644 uploads/test-section-bg-1764679205381 create mode 100644 uploads/test-section-bg-1764679767729 create mode 100644 uploads/test-section-bg-1764685822139 create mode 100644 uploads/test-section-bg-1764723073459 create mode 100644 uploads/test-section-bg-1764725854496 create mode 100644 uploads/test-section-bg-1764744172240 create mode 100644 uploads/test-section-bg-1764914447291 create mode 100644 uploads/test-section-bg-1764916875410 create mode 100644 uploads/test-section-bg-1764918783602 create mode 100644 uploads/test-section-bg-1765037975314 create mode 100644 uploads/test-section-bg-1765038740112 create mode 100644 uploads/test-section-bg-1765039112224 create mode 100644 uploads/test-section-bg-1765039284264 create mode 100644 uploads/test-section-bg-1765063251354 create mode 100644 uploads/test-section-bg-video-1764651816672 create mode 100644 uploads/test-section-bg-video-1764679205463 create mode 100644 uploads/test-section-bg-video-1764679767959 create mode 100644 uploads/test-section-bg-video-1764685822144 create mode 100644 uploads/test-section-bg-video-1764723073468 create mode 100644 uploads/test-section-bg-video-1764725854532 create mode 100644 uploads/test-section-bg-video-1764744172256 create mode 100644 uploads/test-section-bg-video-1764914447306 create mode 100644 uploads/test-section-bg-video-1764916875420 create mode 100644 uploads/test-section-bg-video-1764918783615 create mode 100644 uploads/test-section-bg-video-1765037975366 create mode 100644 uploads/test-section-bg-video-1765038740129 create mode 100644 uploads/test-section-bg-video-1765039112525 create mode 100644 uploads/test-section-bg-video-1765039284319 create mode 100644 uploads/test-section-bg-video-1765063251365 create mode 100644 uploads/test-video-1764651816661 create mode 100644 uploads/test-video-1764679205455 create mode 100644 uploads/test-video-1764679767891 create mode 100644 uploads/test-video-1764685822143 create mode 100644 uploads/test-video-1764723073466 create mode 100644 uploads/test-video-1764725854519 create mode 100644 uploads/test-video-1764744172253 create mode 100644 uploads/test-video-1764914447304 create mode 100644 uploads/test-video-1764916875417 create mode 100644 uploads/test-video-1764918783612 create mode 100644 uploads/test-video-1765037975353 create mode 100644 uploads/test-video-1765038740126 create mode 100644 uploads/test-video-1765039112481 create mode 100644 uploads/test-video-1765039284296 create mode 100644 uploads/test-video-1765063251362 create mode 100644 uploads/test-video-poster-1764651816631 create mode 100644 uploads/test-video-poster-1764679205365 create mode 100644 uploads/test-video-poster-1764679767718 create mode 100644 uploads/test-video-poster-1764685822137 create mode 100644 uploads/test-video-poster-1764723073457 create mode 100644 uploads/test-video-poster-1764725854485 create mode 100644 uploads/test-video-poster-1764744172233 create mode 100644 uploads/test-video-poster-1764914447287 create mode 100644 uploads/test-video-poster-1764916875401 create mode 100644 uploads/test-video-poster-1764918783594 create mode 100644 uploads/test-video-poster-1765037975292 create mode 100644 uploads/test-video-poster-1765038740088 create mode 100644 uploads/test-video-poster-1765039112207 create mode 100644 uploads/test-video-poster-1765039284229 create mode 100644 uploads/test-video-poster-1765063251348 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) + } + /> + )} +