Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6004a5caf | |||
| c0ddb39562 | |||
| 278195772a | |||
| 0cf67b5619 | |||
| 17bfac62ed | |||
| 1f085bd64c | |||
| c9a62d479c | |||
| c7b6097bd9 | |||
| 6e5e229ae5 | |||
| af84bcbe8d | |||
| 55906f9d3d | |||
| 23bc96b3a1 | |||
| d4cf2f0225 | |||
| c5c9d17e8b | |||
| 1405280ed3 | |||
| 41e4238290 | |||
| e179035fbc | |||
| e1c91b1668 | |||
| 860eff514a | |||
| e16f8298ab | |||
| 48e13d2a75 | |||
| 672cca5271 |
+4
-23
@@ -5,24 +5,17 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- feature/**
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.56.1-jammy
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
@@ -39,6 +32,7 @@ jobs:
|
||||
npx prisma generate
|
||||
|
||||
- name: Run unit tests
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
# API 유닛 테스트에서도 PrismaClient가 필요하므로,
|
||||
# generate 단계와 동일한 더미 DATABASE_URL 을 사용한다.
|
||||
@@ -48,21 +42,8 @@ jobs:
|
||||
run: |
|
||||
npm test
|
||||
|
||||
# Playwright 브라우저는 용량이 크기 때문에 캐시를 사용해 설치 시간을 단축한다.
|
||||
- name: Cache Playwright browsers
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
|
||||
run: |
|
||||
npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
npx playwright test
|
||||
|
||||
|
||||
Generated
+10
@@ -14,6 +14,7 @@
|
||||
"@prisma/client": "^6.19.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "^16.0.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -7089,6 +7090,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.555.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
|
||||
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@prisma/client": "^6.19.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "^16.0.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
// /api/export/preview 라우트는 Export 결과 HTML 을 ZIP 없이 바로 반환하는
|
||||
// 경량 프리뷰용 엔드포인트이다. 에디터에서 Export 미리보기 UX 를 제공하기 위해 사용한다.
|
||||
|
||||
type PreviewRequestBody = {
|
||||
blocks: Block[];
|
||||
projectConfig?: ProjectConfig;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: PreviewRequestBody;
|
||||
|
||||
try {
|
||||
// JSON 본문을 파싱해서 blocks + projectConfig 를 추출한다.
|
||||
body = (await request.json()) as PreviewRequestBody;
|
||||
} catch {
|
||||
// 잘못된 JSON 요청에 대해서는 400 에러를 반환한다.
|
||||
return NextResponse.json({ message: "잘못된 JSON 요청입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
|
||||
const projectConfig = body.projectConfig;
|
||||
|
||||
// Export 본문 HTML 은 기존 buildStaticHtml 을 재사용해서 생성한다.
|
||||
// 이렇게 하면 /api/export ZIP 라우트와 동일한 정적 HTML 을 얻을 수 있다.
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// Export 미리보기는 ZIP 이 아니라 순수 HTML 문자열을 반환한다.
|
||||
return new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
+378
-301
@@ -10,7 +10,28 @@ import type {
|
||||
TextBlockProps,
|
||||
ButtonBlockProps,
|
||||
SectionBlockProps,
|
||||
VideoBlockProps,
|
||||
ListBlockProps,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
import {
|
||||
normalizeVideoSourceUrl,
|
||||
resolveVideoPlatform,
|
||||
buildVideoEmbedUrl,
|
||||
computeVideoExportTokens,
|
||||
} from "@/features/editor/utils/videoHelpers";
|
||||
import { computeTextPbTokens } from "@/features/editor/utils/textHelpers";
|
||||
import { computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
|
||||
import { computeImageExportStyles } from "@/features/editor/utils/imageHelpers";
|
||||
import { computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers";
|
||||
import { computeListExportTokens } from "@/features/editor/utils/listHelpers";
|
||||
import {
|
||||
computeFormInputExportTokens,
|
||||
computeFormSelectExportTokens,
|
||||
computeFormCheckboxExportTokens,
|
||||
computeFormRadioExportTokens,
|
||||
computeFormBlockExportTokens,
|
||||
} from "@/features/editor/utils/formHelpers";
|
||||
import { computeDividerExportTokens } from "@/features/editor/utils/dividerHelpers";
|
||||
|
||||
type ExportRequestBody = {
|
||||
blocks: Block[];
|
||||
@@ -31,7 +52,9 @@ const escapeHtml = (value: string): string =>
|
||||
const escapeAttr = (value: string): string => escapeHtml(value);
|
||||
|
||||
export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): string => {
|
||||
const pageTitleRaw = (projectConfig?.title ?? "").trim() || "Page Builder Export";
|
||||
const baseTitleRaw = (projectConfig?.title ?? "").trim() || "Page Builder Export";
|
||||
const seoTitleRaw = (projectConfig?.seoTitle ?? "").trim();
|
||||
const pageTitleRaw = seoTitleRaw || baseTitleRaw;
|
||||
const pageTitle = escapeHtml(pageTitleRaw);
|
||||
|
||||
const headExtraRaw = (projectConfig?.headHtml ?? "").trim();
|
||||
@@ -63,108 +86,141 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const trackingRaw = (projectConfig?.trackingScript ?? "").trim();
|
||||
const trackingHtml = trackingRaw ? `\n${trackingRaw}\n` : "";
|
||||
|
||||
const seoDescriptionRaw = (projectConfig?.seoDescription ?? "").trim();
|
||||
const seoOgImageRaw = (projectConfig?.seoOgImageUrl ?? "").trim();
|
||||
const seoCanonicalRaw = (projectConfig?.seoCanonicalUrl ?? "").trim();
|
||||
const seoNoIndex = projectConfig?.seoNoIndex === true;
|
||||
|
||||
const seoHeadParts: string[] = [];
|
||||
|
||||
if (seoDescriptionRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pageTitleRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta property="og:title" content="${escapeAttr(pageTitleRaw)}" />`,
|
||||
);
|
||||
}
|
||||
if (seoDescriptionRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta property="og:description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
||||
);
|
||||
}
|
||||
seoHeadParts.push(` <meta property="og:type" content="website" />`);
|
||||
|
||||
if (seoOgImageRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta property="og:image" content="${escapeAttr(seoOgImageRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
if (seoCanonicalRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta property="og:url" content="${escapeAttr(seoCanonicalRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
// Twitter 카드 메타 태그
|
||||
seoHeadParts.push(
|
||||
` <meta name="twitter:card" content="summary_large_image" />`,
|
||||
);
|
||||
if (pageTitleRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="twitter:title" content="${escapeAttr(pageTitleRaw)}" />`,
|
||||
);
|
||||
}
|
||||
if (seoDescriptionRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="twitter:description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
||||
);
|
||||
}
|
||||
if (seoOgImageRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="twitter:image" content="${escapeAttr(seoOgImageRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
if (seoCanonicalRaw) {
|
||||
seoHeadParts.push(
|
||||
` <link rel="canonical" href="${escapeAttr(seoCanonicalRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
if (seoNoIndex) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="robots" content="noindex, nofollow" />`,
|
||||
);
|
||||
}
|
||||
|
||||
const seoHeadHtml = seoHeadParts.length > 0 ? `\n${seoHeadParts.join("\n")}` : "";
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
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)
|
||||
const fieldIdToFormId = new Map<string, string>();
|
||||
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}`;
|
||||
for (const fieldId of fieldIds) {
|
||||
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
||||
fieldIdToFormId.set(fieldId, formDomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBlock = (block: Block): string => {
|
||||
if (block.type === "text") {
|
||||
const props = (block.props ?? {}) as TextBlockProps;
|
||||
const text = typeof props.text === "string" ? props.text : "";
|
||||
|
||||
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
||||
const alignClass =
|
||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
||||
|
||||
const fontSizeMode = props.fontSizeMode ?? "scale";
|
||||
const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = props.fontSizeScale ?? fallbackScale;
|
||||
const fontSizeMap: Record<NonNullable<TextBlockProps["fontSizeScale"]>, string> = {
|
||||
xs: "pb-text-xs",
|
||||
sm: "pb-text-sm",
|
||||
base: "pb-text-base",
|
||||
lg: "pb-text-lg",
|
||||
xl: "pb-text-xl",
|
||||
"2xl": "pb-text-2xl",
|
||||
"3xl": "pb-text-3xl",
|
||||
};
|
||||
const sizeClass =
|
||||
fontSizeMode === "scale" && fontSizeScale && fontSizeMap[fontSizeScale]
|
||||
? fontSizeMap[fontSizeScale]
|
||||
: "";
|
||||
|
||||
const lineHeightMode = props.lineHeightMode ?? "scale";
|
||||
const lineHeightScale = props.lineHeightScale ?? "normal";
|
||||
const leadingMap: Record<NonNullable<TextBlockProps["lineHeightScale"]>, string> = {
|
||||
tight: "pb-leading-tight",
|
||||
snug: "pb-leading-snug",
|
||||
normal: "pb-leading-normal",
|
||||
relaxed: "pb-leading-relaxed",
|
||||
loose: "pb-leading-loose",
|
||||
};
|
||||
const leadingClass =
|
||||
lineHeightMode === "scale" && lineHeightScale && leadingMap[lineHeightScale]
|
||||
? leadingMap[lineHeightScale]
|
||||
: "";
|
||||
|
||||
const fontWeightMode = props.fontWeightMode ?? "scale";
|
||||
const fontWeightScale = props.fontWeightScale ?? "normal";
|
||||
const weightMap: Record<NonNullable<TextBlockProps["fontWeightScale"]>, string> = {
|
||||
normal: "pb-font-normal",
|
||||
medium: "pb-font-medium",
|
||||
semibold: "pb-font-semibold",
|
||||
bold: "pb-font-bold",
|
||||
};
|
||||
const weightClass =
|
||||
fontWeightMode === "scale" && fontWeightScale && weightMap[fontWeightScale]
|
||||
? weightMap[fontWeightScale]
|
||||
: "";
|
||||
|
||||
let colorClass = "";
|
||||
if (props.colorMode === "palette") {
|
||||
const palette = props.colorPalette ?? "default";
|
||||
const paletteMap: Record<NonNullable<TextBlockProps["colorPalette"]>, string> = {
|
||||
default: "pb-text-color-default",
|
||||
muted: "pb-text-color-muted",
|
||||
strong: "pb-text-color-strong",
|
||||
accent: "pb-text-color-accent",
|
||||
danger: "pb-text-color-danger",
|
||||
success: "pb-text-color-success",
|
||||
warning: "pb-text-color-warning",
|
||||
info: "pb-text-color-info",
|
||||
neutral: "pb-text-color-neutral",
|
||||
};
|
||||
colorClass = paletteMap[palette] ?? "";
|
||||
}
|
||||
|
||||
let maxWidthClass = "";
|
||||
const maxWidthMode = props.maxWidthMode ?? "scale";
|
||||
const maxWidthScale = props.maxWidthScale ?? "none";
|
||||
if (maxWidthMode === "scale") {
|
||||
if (maxWidthScale === "prose") {
|
||||
maxWidthClass = "pb-text-maxw-prose";
|
||||
} else if (maxWidthScale === "narrow") {
|
||||
maxWidthClass = "pb-text-maxw-narrow";
|
||||
}
|
||||
}
|
||||
|
||||
const decoClasses: string[] = [];
|
||||
if (props.underline) decoClasses.push("pb-underline");
|
||||
if (props.strike) decoClasses.push("pb-line-through");
|
||||
if (props.italic) decoClasses.push("pb-italic");
|
||||
const tokens = 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 classes = [
|
||||
alignClass,
|
||||
sizeClass,
|
||||
leadingClass,
|
||||
weightClass,
|
||||
colorClass,
|
||||
maxWidthClass,
|
||||
...decoClasses,
|
||||
tokens.alignClass,
|
||||
tokens.sizeClass,
|
||||
tokens.leadingClass,
|
||||
tokens.weightClass,
|
||||
tokens.colorClass,
|
||||
tokens.maxWidthClass,
|
||||
...tokens.extraClasses,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return `<p class="${classes}">${escapeHtml(text)}</p>`;
|
||||
const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
||||
|
||||
return `<p class="${classes}"${styleAttr}>${escapeHtml(text)}</p>`;
|
||||
}
|
||||
|
||||
if (block.type === "button") {
|
||||
@@ -172,221 +228,161 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const href = typeof props.href === "string" ? props.href : "#";
|
||||
const label = typeof props.label === "string" ? props.label : "";
|
||||
|
||||
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
||||
const alignClass =
|
||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
||||
const tokens = computeButtonPbTokens({
|
||||
align: props.align ?? "left",
|
||||
size: props.size ?? "md",
|
||||
variant: props.variant ?? "solid",
|
||||
colorPalette: props.colorPalette ?? "primary",
|
||||
borderRadius: props.borderRadius ?? "md",
|
||||
fullWidth: props.fullWidth ?? false,
|
||||
widthMode: props.widthMode ?? undefined,
|
||||
widthPx: props.widthPx ?? undefined,
|
||||
paddingX: props.paddingX ?? undefined,
|
||||
paddingY: props.paddingY ?? undefined,
|
||||
fillColorCustom: props.fillColorCustom ?? undefined,
|
||||
strokeColorCustom: props.strokeColorCustom ?? undefined,
|
||||
textColorCustom: props.textColorCustom ?? undefined,
|
||||
});
|
||||
|
||||
const sizeToken = props.size ?? "md";
|
||||
const sizeMap: Record<NonNullable<ButtonBlockProps["size"]>, string> = {
|
||||
xs: "pb-btn-size-xs",
|
||||
sm: "pb-btn-size-sm",
|
||||
md: "pb-btn-size-md",
|
||||
lg: "pb-btn-size-lg",
|
||||
xl: "pb-btn-size-xl",
|
||||
};
|
||||
const sizeClass = sizeMap[sizeToken] ?? "pb-btn-size-md";
|
||||
const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
||||
|
||||
const variant = props.variant ?? "solid";
|
||||
const palette = props.colorPalette ?? "primary";
|
||||
const variantClass = `pb-btn-variant-${variant}-${palette}`;
|
||||
|
||||
const radiusToken = props.borderRadius ?? "md";
|
||||
const radiusMap: Record<NonNullable<ButtonBlockProps["borderRadius"]>, string> = {
|
||||
none: "pb-btn-radius-none",
|
||||
sm: "pb-btn-radius-sm",
|
||||
md: "pb-btn-radius-md",
|
||||
lg: "pb-btn-radius-lg",
|
||||
full: "pb-btn-radius-full",
|
||||
};
|
||||
const radiusClass = radiusMap[radiusToken] ?? "";
|
||||
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
|
||||
const styleParts: string[] = [];
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
styleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
if (typeof props.paddingX === "number") {
|
||||
styleParts.push(`padding-left:${props.paddingX}px`);
|
||||
styleParts.push(`padding-right:${props.paddingX}px`);
|
||||
}
|
||||
if (typeof props.paddingY === "number") {
|
||||
styleParts.push(`padding-top:${props.paddingY}px`);
|
||||
styleParts.push(`padding-bottom:${props.paddingY}px`);
|
||||
}
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
styleParts.push(`background-color:${props.fillColorCustom}`);
|
||||
}
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
styleParts.push(`border-color:${props.strokeColorCustom}`);
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
styleParts.push(`color:${props.textColorCustom}`);
|
||||
}
|
||||
const styleAttr = styleParts.length > 0 ? ` style="${styleParts.join(";")}"` : "";
|
||||
|
||||
const btnClasses = ["pb-btn-base", sizeClass, variantClass, radiusClass]
|
||||
const btnClasses = ["pb-btn-base", tokens.sizeClass, tokens.variantClass, tokens.radiusClass]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return `<div class="${alignClass}"><a href="${escapeAttr(
|
||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
||||
href,
|
||||
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></div>`;
|
||||
}
|
||||
|
||||
if (block.type === "video") {
|
||||
const props: any = block.props ?? {};
|
||||
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
|
||||
|
||||
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
|
||||
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
|
||||
|
||||
const tokens = computeVideoExportTokens(props, { escapeAttr });
|
||||
|
||||
const outerStyleAttr =
|
||||
tokens.outerStyleParts.length > 0 ? ` style="${tokens.outerStyleParts.join(";")}"` : "";
|
||||
const wrapperStyleAttr =
|
||||
tokens.wrapperStyleParts.length > 0 ? ` style="${tokens.wrapperStyleParts.join(";")}"` : "";
|
||||
const videoStyleAttr =
|
||||
tokens.videoStyleParts.length > 0 ? ` style="${tokens.videoStyleParts.join(";")}"` : "";
|
||||
|
||||
const isEmbed = platform === "youtube" || platform === "vimeo";
|
||||
|
||||
if (isEmbed) {
|
||||
const titleRaw = typeof (props as any).titleText === "string" ? (props as any).titleText.trim() : "";
|
||||
const iframeTitle = titleRaw !== "" ? titleRaw : "비디오";
|
||||
|
||||
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><iframe title="${escapeAttr(
|
||||
iframeTitle,
|
||||
)}" src="${escapeAttr(
|
||||
embedUrl,
|
||||
)}" class="pb-video-frame" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe></div></div>`;
|
||||
}
|
||||
|
||||
const controlsAttr = props.controls === false ? "" : " controls";
|
||||
const autoplayAttr = props.autoplay ? " autoplay" : "";
|
||||
const loopAttr = props.loop ? " loop" : "";
|
||||
const mutedAttr = props.muted ? " muted" : "";
|
||||
const posterRaw = typeof (props as any).posterImageSrc === "string" ? (props as any).posterImageSrc.trim() : "";
|
||||
const posterAttr = posterRaw !== "" ? ` poster="${escapeAttr(posterRaw)}"` : "";
|
||||
const ariaRaw = typeof (props as any).ariaLabel === "string" ? (props as any).ariaLabel.trim() : "";
|
||||
const ariaAttr = ariaRaw !== "" ? ` aria-label="${escapeAttr(ariaRaw)}"` : "";
|
||||
const startDataAttr =
|
||||
typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0
|
||||
? ` data-start-seconds="${(props as any).startTimeSec}"`
|
||||
: "";
|
||||
const endDataAttr =
|
||||
typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0
|
||||
? ` data-end-seconds="${(props as any).endTimeSec}"`
|
||||
: "";
|
||||
const captionRaw = typeof (props as any).captionText === "string" ? (props as any).captionText.trim() : "";
|
||||
const captionHtml = captionRaw !== "" ? `<p class="pb-video-caption">${escapeHtml(captionRaw)}</p>` : "";
|
||||
|
||||
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><video class="pb-video-frame" src="${escapeAttr(
|
||||
rawUrl,
|
||||
)}"${posterAttr}${ariaAttr}${startDataAttr}${endDataAttr}${videoStyleAttr}${controlsAttr}${autoplayAttr}${loopAttr}${mutedAttr}></video></div>${captionHtml}</div>`;
|
||||
}
|
||||
|
||||
if (block.type === "image") {
|
||||
const props: any = block.props ?? {};
|
||||
const src = typeof props.src === "string" ? props.src : "";
|
||||
const alt = typeof props.alt === "string" ? props.alt : "";
|
||||
return `<div><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}" /></div>`;
|
||||
|
||||
const styles = computeImageExportStyles({
|
||||
backgroundColorCustom:
|
||||
typeof props.backgroundColorCustom === "string" ? props.backgroundColorCustom : undefined,
|
||||
widthMode: props.widthMode ?? "auto",
|
||||
widthPx: typeof props.widthPx === "number" ? props.widthPx : undefined,
|
||||
borderRadius: props.borderRadius ?? "md",
|
||||
borderRadiusPx: typeof props.borderRadiusPx === "number" ? props.borderRadiusPx : undefined,
|
||||
});
|
||||
|
||||
const wrapperStyleAttr =
|
||||
styles.wrapperStyleParts.length > 0 ? ` style="${styles.wrapperStyleParts.join(";")}"` : "";
|
||||
const imgStyleAttr = styles.imgStyleParts.length > 0 ? ` style="${styles.imgStyleParts.join(";")}"` : "";
|
||||
|
||||
return `<div${wrapperStyleAttr}><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${imgStyleAttr} /></div>`;
|
||||
}
|
||||
|
||||
if (block.type === "form") {
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const tokens = computeFormBlockExportTokens(block, blocks);
|
||||
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
|
||||
|
||||
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
||||
const controllerFields = fieldIds
|
||||
.map((id) => blocks.find((b) => b.id === id))
|
||||
.filter((b): b is Block => Boolean(b))
|
||||
.filter(
|
||||
(b) =>
|
||||
b.type === "formInput" ||
|
||||
b.type === "formSelect" ||
|
||||
b.type === "formCheckbox" ||
|
||||
b.type === "formRadio",
|
||||
);
|
||||
const hasControllerFields = controllerFields.length > 0;
|
||||
|
||||
const fallbackFields = Array.isArray(props.fields) ? props.fields : [];
|
||||
|
||||
const fields = controllerFields.length > 0 ? controllerFields : null;
|
||||
|
||||
const formParts: string[] = [];
|
||||
formParts.push(`<form class="pb-form" method="post">`);
|
||||
|
||||
if (fields) {
|
||||
for (const fieldBlock of fields) {
|
||||
const anyProps: any = fieldBlock.props ?? {};
|
||||
const name = typeof anyProps.formFieldName === "string" ? anyProps.formFieldName : fieldBlock.id;
|
||||
const label =
|
||||
typeof anyProps.label === "string" || typeof anyProps.groupLabel === "string"
|
||||
? (anyProps.label ?? anyProps.groupLabel)
|
||||
: name;
|
||||
|
||||
formParts.push(`<div class="pb-form-field">`);
|
||||
formParts.push(`<label class="pb-form-label">${escapeHtml(label ?? "")}</label>`);
|
||||
|
||||
if (fieldBlock.type === "formInput") {
|
||||
const type = anyProps.inputType === "email" ? "email" : "text";
|
||||
const required = anyProps.required ? " required" : "";
|
||||
formParts.push(
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label ?? "",
|
||||
)}"${required} />`,
|
||||
);
|
||||
} else if (fieldBlock.type === "formSelect") {
|
||||
const required = anyProps.required ? " required" : "";
|
||||
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}>`);
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||
);
|
||||
}
|
||||
formParts.push(`</select>`);
|
||||
} else if (fieldBlock.type === "formCheckbox") {
|
||||
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<label class="pb-form-option"><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
} else if (fieldBlock.type === "formRadio") {
|
||||
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<label class="pb-form-option"><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
formParts.push(`</div>`);
|
||||
}
|
||||
} else if (fallbackFields.length > 0) {
|
||||
for (const field of fallbackFields) {
|
||||
const required = field.required ? " required" : "";
|
||||
const label = field.label ?? field.name;
|
||||
formParts.push(`<div class="pb-form-field">`);
|
||||
formParts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
||||
if (field.type === "textarea") {
|
||||
formParts.push(
|
||||
`<textarea class="pb-textarea" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
)}"${required}></textarea>`,
|
||||
);
|
||||
} else {
|
||||
formParts.push(
|
||||
`<input class="pb-input" type="${field.type}" name="${escapeAttr(
|
||||
field.name,
|
||||
)}" placeholder="${escapeAttr(label)}"${required} />`,
|
||||
);
|
||||
}
|
||||
formParts.push(`</div>`);
|
||||
}
|
||||
// FormBlock 이 컨트롤러 필드 정보를 전혀 가지고 있지 않으면
|
||||
// Export 레이어에서는 아무 것도 렌더하지 않는다 (fallback fields 기반 pb-form 도 생성하지 않음).
|
||||
if (!hasControllerFields) {
|
||||
return "";
|
||||
}
|
||||
|
||||
formParts.push(`<button type="submit" class="pb-btn-base pb-btn-size-md pb-btn-variant-solid-primary">제출</button>`);
|
||||
formParts.push(`</form>`);
|
||||
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
|
||||
// - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 <form> 요소만 만든다.
|
||||
// - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다.
|
||||
const formId = `form_${block.id}`;
|
||||
const method = props.method ?? "POST";
|
||||
const methodAttr = ` method="${method.toLowerCase()}"`;
|
||||
const actionAttr = ` action="/api/forms/submit"`;
|
||||
|
||||
return formParts.join("");
|
||||
// 정적 Export 에서도 FormBlock 설정(전송 대상/웹훅 설정 등)을 함께 전달할 수 있도록
|
||||
// preview 렌더러와 동일하게 __config hidden 필드에 FormBlockProps 전체를 JSON 으로 담아둔다.
|
||||
const configJson = JSON.stringify(props ?? {});
|
||||
const escapedConfig = escapeAttr(configJson);
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
|
||||
);
|
||||
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
|
||||
parts.push("</form>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
if (block.type === "divider") {
|
||||
const props: any = block.props ?? {};
|
||||
const thickness = props.thickness === "medium" ? "2px" : "1px";
|
||||
const color = typeof props.colorHex === "string" && props.colorHex.trim() !== "" ? props.colorHex : "#475569";
|
||||
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
|
||||
return `<hr class="pb-divider" style="border:0;border-bottom:${thickness} solid ${escapeAttr(
|
||||
color,
|
||||
)};margin:${marginY}px 0;" />`;
|
||||
const tokens = computeDividerExportTokens(props, { escapeAttr });
|
||||
return `<hr class="pb-divider" style="${tokens.style}" />`;
|
||||
}
|
||||
|
||||
if (block.type === "list") {
|
||||
const props: any = block.props ?? {};
|
||||
const ordered = Boolean(props.ordered);
|
||||
const Tag = ordered ? "ol" : "ul";
|
||||
const props = (block.props ?? {}) as ListBlockProps;
|
||||
|
||||
const items: string[] = [];
|
||||
if (Array.isArray(props.itemsTree) && props.itemsTree.length > 0) {
|
||||
const walk = (nodes: any[]) => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.text === "string" && node.text.trim() !== "") {
|
||||
items.push(node.text);
|
||||
}
|
||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(props.itemsTree);
|
||||
} else if (Array.isArray(props.items)) {
|
||||
for (const raw of props.items) {
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
items.push(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
const tokens = computeListExportTokens(props);
|
||||
|
||||
if (items.length === 0) {
|
||||
if (tokens.items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
||||
const lis = items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
||||
return `<${Tag} class="pb-list" style="text-align:${align};">${lis}</${Tag}>`;
|
||||
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}>`;
|
||||
}
|
||||
|
||||
if (block.type === "formInput") {
|
||||
@@ -397,12 +393,16 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const type = props.inputType === "email" ? "email" : "text";
|
||||
const required = props.required ? " required" : "";
|
||||
|
||||
const tokens = computeFormInputExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
return [
|
||||
'<div class="pb-form-field">',
|
||||
`<label class="pb-form-label">${escapeHtml(label)}</label>`,
|
||||
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
)}"${required} />`,
|
||||
)}"${required}${tokens.inputStyleAttr}${formAttr} />`,
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
@@ -415,10 +415,16 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const required = props.required ? " 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 parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
||||
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(
|
||||
`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}${formAttr}>`,
|
||||
);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||
@@ -439,14 +445,18 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
: name;
|
||||
const options = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormCheckboxExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<label class="pb-form-option"><input type="checkbox" name="${escapeAttr(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
@@ -463,14 +473,18 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
: name;
|
||||
const options = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormRadioExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<label class="pb-form-option"><input type="radio" name="${escapeAttr(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
@@ -498,33 +512,26 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
? props.columns
|
||||
: [{ id: `${section.id}_col`, span: 12 }];
|
||||
|
||||
const bgToken = props.background ?? "default";
|
||||
const bgClassMap: Record<SectionBlockProps["background"], string> = {
|
||||
default: "pb-section-bg-default",
|
||||
muted: "pb-section-bg-muted",
|
||||
primary: "pb-section-bg-primary",
|
||||
};
|
||||
const bgClass = bgClassMap[bgToken] ?? "pb-section-bg-default";
|
||||
const tokens = computeSectionExportTokens(props);
|
||||
|
||||
const pyToken = props.paddingY ?? "md";
|
||||
const pyClassMap: Record<SectionBlockProps["paddingY"], string> = {
|
||||
sm: "pb-section-py-sm",
|
||||
md: "pb-section-py-md",
|
||||
lg: "pb-section-py-lg",
|
||||
};
|
||||
const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md";
|
||||
const sectionStyleAttr =
|
||||
tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : "";
|
||||
|
||||
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
||||
bodyParts.push(
|
||||
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"><div class="pb-section-columns">`,
|
||||
);
|
||||
|
||||
bodyParts.push(`<section class="${sectionClasses}"><div class="pb-section-inner"><div class="pb-section-columns">`);
|
||||
for (const col of columns) {
|
||||
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
||||
for (const column of columns) {
|
||||
const columnBlocks = blocks.filter(
|
||||
(b) => b.sectionId === section.id && b.columnId === column.id,
|
||||
);
|
||||
bodyParts.push('<div class="pb-section-column">');
|
||||
for (const block of columnBlocks) {
|
||||
bodyParts.push(renderBlock(block));
|
||||
}
|
||||
bodyParts.push("</div>");
|
||||
}
|
||||
|
||||
bodyParts.push("</div></div></section>");
|
||||
}
|
||||
|
||||
@@ -536,7 +543,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${pageTitle}</title>
|
||||
<link rel="stylesheet" href="./builder.css" />${headExtra}
|
||||
<link rel="stylesheet" href="./builder.css" />${seoHeadHtml}${headExtra}
|
||||
</head>
|
||||
<body style="${bodyStyle}">
|
||||
<main>
|
||||
@@ -565,7 +572,7 @@ export async function POST(request: Request) {
|
||||
const zip = new JSZip();
|
||||
|
||||
const imageIds = new Set<string>();
|
||||
const imageRegex = /\/api\/image\/([^"'>\s]+)/g;
|
||||
const imageRegex = /\/api\/image\/([^"'>\s)]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = imageRegex.exec(html)) !== null) {
|
||||
@@ -588,6 +595,30 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const videoIds = new Set<string>();
|
||||
const videoRegex = /\/api\/video\/([^"'>\s]+)/g;
|
||||
let videoMatch: RegExpExecArray | null;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((videoMatch = videoRegex.exec(html)) !== null) {
|
||||
if (videoMatch[1]) {
|
||||
videoIds.add(videoMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of videoIds) {
|
||||
const filePath = path.join(UPLOAD_DIR, id);
|
||||
try {
|
||||
const bytes = await fs.readFile(filePath);
|
||||
zip.file(`videos/${id}`, bytes);
|
||||
|
||||
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const srcRegex = new RegExp(`/api/video/${escapedId}`, "g");
|
||||
html = html.replace(srcRegex, `./videos/${id}`);
|
||||
} catch {
|
||||
// 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다.
|
||||
}
|
||||
}
|
||||
|
||||
zip.file("index.html", html);
|
||||
|
||||
try {
|
||||
@@ -598,7 +629,53 @@ export async function POST(request: Request) {
|
||||
zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n");
|
||||
}
|
||||
|
||||
const mainJs = `console.log("Page Builder static export loaded");`;
|
||||
const mainJs = [
|
||||
"(function(){",
|
||||
" function pbInitFormControllers(){",
|
||||
" var forms = document.querySelectorAll(\"form.pb-form-controller\");",
|
||||
" if (!forms || !forms.length) { return; }",
|
||||
" forms.forEach(function(form){",
|
||||
" form.addEventListener(\"submit\", function(event){",
|
||||
" if (!form) { return; }",
|
||||
" if (event && typeof event.preventDefault === 'function') { event.preventDefault(); }",
|
||||
" var formData = new FormData(form);",
|
||||
" var configInput = form.querySelector('input[name=\"__config\"]');",
|
||||
" var config = null;",
|
||||
" if (configInput && configInput.value) {",
|
||||
" try { config = JSON.parse(configInput.value); } catch (e) { config = null; }",
|
||||
" }",
|
||||
" var action = form.getAttribute('action') || '/api/forms/submit';",
|
||||
" fetch(action, { method: 'POST', body: formData })",
|
||||
" .then(function(res){ return res.json().catch(function(){ return {}; }); })",
|
||||
" .then(function(data){",
|
||||
" var ok = data && data.ok;",
|
||||
" var message = data && data.message;",
|
||||
" if (ok) {",
|
||||
" var success = message || (config && config.successMessage) || '성공적으로 전송되었습니다.';",
|
||||
" if (typeof alert === 'function') { alert(success); }",
|
||||
" try { form.reset(); } catch (e) {}",
|
||||
" } else {",
|
||||
" var errorMsg = message || (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
|
||||
" if (typeof alert === 'function') { alert(errorMsg); }",
|
||||
" }",
|
||||
" })",
|
||||
" .catch(function(){",
|
||||
" var errorMsg = (config && config.errorMessage) || '전송 중 오류가 발생했습니다.';",
|
||||
" if (typeof alert === 'function') { alert(errorMsg); }",
|
||||
" });",
|
||||
" });",
|
||||
" });",
|
||||
" }",
|
||||
" if (typeof document !== 'undefined') {",
|
||||
" if (document.readyState === 'loading') {",
|
||||
" document.addEventListener('DOMContentLoaded', pbInitFormControllers);",
|
||||
" } else {",
|
||||
" pbInitFormControllers();",
|
||||
" }",
|
||||
" }",
|
||||
" console.log('Page Builder static export loaded');",
|
||||
"})();",
|
||||
].join("\n");
|
||||
zip.file("main.js", mainJs);
|
||||
|
||||
const zipArrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
|
||||
|
||||
@@ -21,19 +21,28 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const submitTarget = config?.submitTarget ?? "internal";
|
||||
const successMessage = config?.successMessage;
|
||||
const errorMessage = config?.errorMessage;
|
||||
|
||||
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
|
||||
if (submitTarget === "internal") {
|
||||
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
|
||||
console.log("[forms/submit][internal]", { name, email, message });
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: true, message: successMessage });
|
||||
}
|
||||
|
||||
if (submitTarget === "both") {
|
||||
console.log("[forms/submit][internal]", { name, email, message });
|
||||
}
|
||||
|
||||
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
|
||||
if (submitTarget === "webhook") {
|
||||
if (submitTarget === "webhook" || submitTarget === "both") {
|
||||
const destinationUrl = config?.destinationUrl;
|
||||
if (!destinationUrl) {
|
||||
return NextResponse.json({ ok: false, error: "destinationUrl_missing" }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "destinationUrl_missing", message: errorMessage },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const payloadFormat = config?.payloadFormat ?? "form";
|
||||
@@ -87,19 +96,25 @@ export async function POST(req: Request) {
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("[forms/submit][webhook] remote error", res.status, await res.text().catch(() => ""));
|
||||
return NextResponse.json({ ok: false, error: "webhook_failed" }, { status: 502 });
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "webhook_failed", message: errorMessage },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[forms/submit][webhook] fetch error", error);
|
||||
return NextResponse.json({ ok: false, error: "webhook_exception" }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "webhook_exception", message: errorMessage },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[forms/submit][webhook] forwarded", { name, email, message, destinationUrl });
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: true, message: successMessage });
|
||||
}
|
||||
|
||||
// 그 외 알 수 없는 모드는 internal 과 동일하게 처리
|
||||
console.warn("[forms/submit] unknown submitTarget, fallback to internal", submitTarget);
|
||||
console.log("[forms/submit][internal]", { name, email, message });
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: true, message: successMessage });
|
||||
}
|
||||
|
||||
@@ -6,35 +6,31 @@ import { PrismaClient } from "@/generated/prisma/client";
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 기본 MIME 타입으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
export async function GET(request: Request) {
|
||||
// Next.js 15 이후 params 가 Promise 로 전달되는 경고를 피하기 위해,
|
||||
// 동적 세그먼트는 request.url 의 path 에서 직접 파싱한다.
|
||||
let id: string | undefined;
|
||||
|
||||
export async function GET(request: Request, ctx: Params) {
|
||||
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
||||
let id: string | undefined = ctx?.params?.id;
|
||||
if (!id) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "image", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "image") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "image", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "image") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
|
||||
@@ -8,13 +8,15 @@ import { PrismaClient } from "@/generated/prisma/client";
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
if (process.env.DATABASE_URL && process.env.DATABASE_URL.trim() !== "") {
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -3,14 +3,8 @@ import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const { slug } = params;
|
||||
export async function GET(_request: Request, context: { params: { slug: string } | Promise<{ slug: string }> }) {
|
||||
const { slug } = await context.params;
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
@@ -22,3 +16,28 @@ export async function GET(_request: Request, { params }: Params) {
|
||||
|
||||
return NextResponse.json(project, { status: 200 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await context.params;
|
||||
|
||||
try {
|
||||
await prisma.project.delete({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "프로젝트가 삭제되었습니다." }, { status: 200 });
|
||||
} catch (error: any) {
|
||||
// Prisma NotFound 에러 코드(P2025)가 떨어지는 경우 404 로 매핑한다.
|
||||
if (error?.code === "P2025") {
|
||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "프로젝트 삭제 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,30 @@ import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(_request: Request) {
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(projects, { status: 200 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { title, slug, contentJson } = body;
|
||||
@@ -12,8 +36,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
const project = await prisma.project.upsert({
|
||||
where: { slug },
|
||||
update: {
|
||||
title,
|
||||
contentJson,
|
||||
},
|
||||
create: {
|
||||
title,
|
||||
slug,
|
||||
contentJson,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 비디오 MIME 타입은 기본값으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request, ctx: Params) {
|
||||
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
||||
let id: string | undefined = ctx?.params?.id;
|
||||
if (!id) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "video", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "video") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ message: "id 파라미터가 필요합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Referer 기반으로 간단한 핫링크 방지: 동일 호스트에서 온 요청만 허용한다.
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
const referer = request.headers.get("referer");
|
||||
|
||||
if (!referer) {
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
|
||||
const refererUrl = new URL(referer);
|
||||
if (refererUrl.host !== requestUrl.host) {
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("비디오 Referer 검사 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, id);
|
||||
|
||||
try {
|
||||
const bytes = await fs.readFile(filePath);
|
||||
|
||||
// 기본 MIME 타입은 mp4 로 두되, 가능하면 Asset 메타데이터에서 실제 타입을 읽어온다.
|
||||
let mimeType = "video/mp4";
|
||||
if (prisma) {
|
||||
try {
|
||||
const asset = await prisma.asset.findUnique({ where: { id } });
|
||||
const meta: any = asset?.meta ?? null;
|
||||
const candidate = typeof meta?.mimeType === "string" ? meta.mimeType.trim() : "";
|
||||
if (candidate) {
|
||||
mimeType = candidate;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("비디오 메타데이터 조회 실패 - 기본 MIME 타입으로 응답합니다.", error);
|
||||
}
|
||||
}
|
||||
|
||||
return new NextResponse(bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error && typeof error === "object" && (error as any).code === "ENOENT") {
|
||||
return NextResponse.json({ message: "비디오를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
console.error("비디오 파일 읽기 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 로드 중 오류가 발생했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
// 업로드된 비디오 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 비디오 업로드는 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!file || typeof (file as any).arrayBuffer !== "function") {
|
||||
return NextResponse.json({ message: "file 필드는 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 디렉터리 생성 실패", error);
|
||||
return NextResponse.json({ message: "업로드 디렉터리를 생성할 수 없습니다." }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await (file as any).arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
const id = randomUUID();
|
||||
|
||||
// 파일 경로는 uploads/<id> 형태로 저장한다.
|
||||
const relativePath = path.join("uploads", id);
|
||||
const filePath = path.join(process.cwd(), relativePath);
|
||||
|
||||
await fs.writeFile(filePath, bytes);
|
||||
|
||||
// 가능하다면 Asset 테이블에도 메타데이터를 남긴다.
|
||||
if (prisma) {
|
||||
try {
|
||||
const project = await prisma.project.upsert({
|
||||
where: { slug: "assets-global" },
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
title: "Assets Global",
|
||||
slug: "assets-global",
|
||||
contentJson: [],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.asset.create({
|
||||
data: {
|
||||
id,
|
||||
projectId: project.id,
|
||||
kind: "video",
|
||||
url: relativePath,
|
||||
meta: {
|
||||
sourceType: "uploaded",
|
||||
originalName: (file as any).name ?? "upload",
|
||||
mimeType: (file as any).type ?? "application/octet-stream",
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("비디오 Asset DB 저장 실패 - 파일 시스템만 사용합니다.", error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id,
|
||||
servedUrl: `/api/video/${id}`,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 처리 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 업로드 중 오류가 발생했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
||||
projectConfig,
|
||||
} = props;
|
||||
|
||||
const canvasOuterStyle: CSSProperties = {};
|
||||
const canvasInnerStyle: CSSProperties = {};
|
||||
const preset = projectConfig.canvasPreset ?? "full";
|
||||
const widthPx =
|
||||
@@ -59,14 +60,19 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
||||
canvasInnerStyle.maxWidth = "1200px";
|
||||
}
|
||||
|
||||
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
|
||||
canvasOuterStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
||||
}
|
||||
|
||||
if (projectConfig.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
|
||||
canvasInnerStyle.backgroundColor = projectConfig.canvasBgColorHex;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
|
||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto pb-scroll"
|
||||
data-testid="editor-canvas"
|
||||
style={canvasOuterStyle}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onCanvasEmptyClick();
|
||||
@@ -80,7 +86,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
|
||||
왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요.
|
||||
왼쪽에서 "텍스트" 버튼을 눌러 블록을 추가해 보세요.
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
|
||||
@@ -130,7 +130,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 레이아웃</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>폼 너비 모드</span>
|
||||
@@ -139,27 +139,27 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
value={formProps.formWidthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
formWidthMode: e.target.value,
|
||||
formWidthMode: e.target.value as FormBlockProps["formWidthMode"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">내용에 맞춤</option>
|
||||
<option value="auto">자동</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 폭 (px)</option>
|
||||
<option value="fixed">고정 값</option>
|
||||
</select>
|
||||
</label>
|
||||
{(formProps.formWidthMode ?? "auto") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="폼 고정 너비 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 360}
|
||||
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 480}
|
||||
min={160}
|
||||
max={960}
|
||||
max={1200}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "좁게", value: 280 },
|
||||
{ id: "md", label: "보통", value: 360 },
|
||||
{ id: "lg", label: "넓게", value: 480 },
|
||||
{ id: "sm", label: "좁게", value: 320 },
|
||||
{ id: "md", label: "보통", value: 480 },
|
||||
{ id: "lg", label: "넓게", value: 640 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -171,14 +171,15 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
<NumericPropertyControl
|
||||
label="폼 위/아래 여백 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 16}
|
||||
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 24}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
max={160}
|
||||
step={4}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: 8 },
|
||||
{ id: "normal", label: "보통", value: 16 },
|
||||
{ id: "relaxed", label: "넓게", value: 32 },
|
||||
{ id: "none", label: "없음", value: 0 },
|
||||
{ id: "compact", label: "좁게", value: 16 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "spacious", label: "넓게", value: 40 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -188,6 +189,36 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 메시지</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>성공 메시지</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
placeholder="예: 성공적으로 전송되었습니다."
|
||||
value={formProps.successMessage ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
successMessage: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>에러 메시지</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
placeholder="예: 전송 중 오류가 발생했습니다."
|
||||
value={formProps.errorMessage ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
errorMessage: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 컨트롤러</h3>
|
||||
|
||||
|
||||
+455
-393
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
||||
import {
|
||||
FilePlus2,
|
||||
ListChecks,
|
||||
Type,
|
||||
MousePointerClick,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Minus,
|
||||
List,
|
||||
LayoutTemplate,
|
||||
Pencil,
|
||||
FileText,
|
||||
ListFilter,
|
||||
CircleDot,
|
||||
SquareCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
// 좌측 블록/폼/템플릿 추가 사이드바를 분리한 컴포넌트
|
||||
export function BlocksSidebar() {
|
||||
const addTextBlock = useEditorStore((state) => state.addTextBlock);
|
||||
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
|
||||
const addImageBlock = useEditorStore((state) => state.addImageBlock);
|
||||
const addVideoBlock = useEditorStore((state) => (state as any).addVideoBlock);
|
||||
const addDividerBlock = useEditorStore((state) => state.addDividerBlock);
|
||||
const addListBlock = useEditorStore((state) => state.addListBlock);
|
||||
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
|
||||
@@ -32,6 +49,7 @@ export function BlocksSidebar() {
|
||||
const handleAddText = useCallback(() => addTextBlock(), [addTextBlock]);
|
||||
const handleAddButton = useCallback(() => addButtonBlock(), [addButtonBlock]);
|
||||
const handleAddImage = useCallback(() => addImageBlock(), [addImageBlock]);
|
||||
const handleAddVideo = useCallback(() => addVideoBlock(), [addVideoBlock]);
|
||||
const handleAddDivider = useCallback(() => addDividerBlock(), [addDividerBlock]);
|
||||
const handleAddList = useCallback(() => addListBlock(), [addListBlock]);
|
||||
const handleAddSection = useCallback(() => addSectionBlock(), [addSectionBlock]);
|
||||
@@ -67,156 +85,465 @@ export function BlocksSidebar() {
|
||||
[addFooterTemplateSection],
|
||||
);
|
||||
|
||||
const [isBlocksOpen, setIsBlocksOpen] = useState(true);
|
||||
const [isFormsOpen, setIsFormsOpen] = useState(true);
|
||||
const [isTemplatesOpen, setIsTemplatesOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
|
||||
<h2 className="font-medium">블록</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddText}
|
||||
>
|
||||
텍스트 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddButton}
|
||||
>
|
||||
버튼 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddImage}
|
||||
>
|
||||
이미지 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddDivider}
|
||||
>
|
||||
구분선 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddList}
|
||||
>
|
||||
리스트 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddSection}
|
||||
>
|
||||
섹션 블록 추가
|
||||
</button>
|
||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll bg-slate-950/40">
|
||||
<h2 className="font-medium flex items-center gap-2 text-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
aria-expanded={isBlocksOpen}
|
||||
onClick={() => setIsBlocksOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
|
||||
<span>블록</span>
|
||||
</span>
|
||||
</button>
|
||||
</h2>
|
||||
{isBlocksOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddText}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Type className="w-3 h-3" aria-hidden="true" />
|
||||
<span>텍스트</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddButton}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<MousePointerClick className="w-3 h-3" aria-hidden="true" />
|
||||
<span>버튼</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddImage}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ImageIcon className="w-3 h-3" aria-hidden="true" />
|
||||
<span>이미지</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddVideo}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Video className="w-3 h-3" aria-hidden="true" />
|
||||
<span>비디오</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddDivider}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Minus className="w-3 h-3" aria-hidden="true" />
|
||||
<span>구분선</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddList}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<List className="w-3 h-3" aria-hidden="true" />
|
||||
<span>리스트</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddSection}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<LayoutTemplate className="w-3 h-3" aria-hidden="true" />
|
||||
<span>섹션</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
|
||||
<h3 className="text-[11px] font-medium text-slate-300">폼 요소</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
|
||||
onClick={handleAddFormBlock}
|
||||
>
|
||||
폼 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormInput}
|
||||
>
|
||||
폼 입력 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormSelect}
|
||||
>
|
||||
폼 셀렉트 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormRadio}
|
||||
>
|
||||
폼 라디오 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormCheckbox}
|
||||
>
|
||||
폼 체크박스 추가
|
||||
</button>
|
||||
<h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
aria-expanded={isFormsOpen}
|
||||
onClick={() => setIsFormsOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||
<span>폼 요소</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
{isFormsOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
|
||||
onClick={handleAddFormBlock}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>폼 컨트롤러</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormInput}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FileText className="w-3 h-3" aria-hidden="true" />
|
||||
<span>입력(Input)</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormSelect}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ListFilter className="w-3 h-3" aria-hidden="true" />
|
||||
<span>셀렉트(Select)</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormRadio}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<CircleDot className="w-3 h-3" aria-hidden="true" />
|
||||
<span>단일 선택(Radio)</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormCheckbox}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<SquareCheck className="w-3 h-3" aria-hidden="true" />
|
||||
<span>다중 선택(Checkbox)</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
|
||||
<h3 className="text-[11px] font-medium text-slate-300">템플릿</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddHeroTemplate}
|
||||
>
|
||||
Hero 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFeaturesTemplate}
|
||||
>
|
||||
Features 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddCtaTemplate}
|
||||
>
|
||||
CTA 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFaqTemplate}
|
||||
>
|
||||
FAQ 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddPricingTemplate}
|
||||
>
|
||||
Pricing 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTestimonialsTemplate}
|
||||
>
|
||||
Testimonials 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddBlogTemplate}
|
||||
>
|
||||
Blog 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTeamTemplate}
|
||||
>
|
||||
Team 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFooterTemplate}
|
||||
>
|
||||
Footer 템플릿 추가
|
||||
</button>
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
|
||||
<h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
aria-expanded={isTemplatesOpen}
|
||||
onClick={() => setIsTemplatesOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>템플릿</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
{isTemplatesOpen && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">히어로 · CTA</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddHeroTemplate}
|
||||
>
|
||||
Hero 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-hero"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/70" />
|
||||
<div className="h-[2px] w-1/2 bg-slate-700/50" />
|
||||
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddCtaTemplate}
|
||||
>
|
||||
CTA 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-cta"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex h-full w-full items-center gap-[2px]">
|
||||
<div className="flex-1 flex flex-col justify-center gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">콜투액션(CTA) 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">콘텐츠 섹션</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFeaturesTemplate}
|
||||
>
|
||||
기능 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-features"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">3컬럼 기능 소개 섹션</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFaqTemplate}
|
||||
>
|
||||
FAQ 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-faq"
|
||||
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="w-2 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[2px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">자주 묻는 질문(FAQ) 섹션</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddPricingTemplate}
|
||||
>
|
||||
상품 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-pricing"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
<div className="h-[1px] w-3/4 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-sky-700/50 bg-sky-900/20 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-sky-500/70" />
|
||||
<div className="h-[1px] w-3/4 bg-sky-500/50" />
|
||||
<div className="h-[2px] w-3/4 bg-sky-500/70 mt-[1px] rounded-[1px]" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
<div className="h-[1px] w-3/4 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">요금제/플랜 소개 섹션</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddBlogTemplate}
|
||||
>
|
||||
블로그 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-blog"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">블로그 포스트 목록 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">신뢰/소개</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTestimonialsTemplate}
|
||||
>
|
||||
후기 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-testimonials"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">고객 후기(Testimonials) 섹션</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTeamTemplate}
|
||||
>
|
||||
Team 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-team"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">팀 소개 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">푸터/기타</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFooterTemplate}
|
||||
>
|
||||
Footer 템플릿
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-footer"
|
||||
className="shrink-0 flex h-6 w-10 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 h-[2px] bg-slate-700/70" />
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 h-[1px] bg-slate-700/40" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">페이지 푸터 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -341,18 +341,6 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>가로 전체 사용</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={buttonProps.fullWidth ?? false}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { fullWidth: e.target.checked } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
|
||||
export type ImagePropertiesPanelProps = {
|
||||
imageProps: ImageBlockProps;
|
||||
@@ -127,6 +128,21 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">이미지 스타일</h4>
|
||||
|
||||
{/* 카드 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="카드 배경색"
|
||||
ariaLabelColorInput="이미지 카드 배경색 피커"
|
||||
ariaLabelHexInput="이미지 카드 배경색 HEX"
|
||||
value={imageProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>정렬</span>
|
||||
|
||||
@@ -167,6 +167,17 @@ export function ListPropertiesPanel({
|
||||
}}
|
||||
/>
|
||||
|
||||
<ColorPickerField
|
||||
label="블록 배경색"
|
||||
ariaLabelColorInput="리스트 배경색 피커"
|
||||
ariaLabelHexInput="리스트 배경색 HEX"
|
||||
value={listProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: hex } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
|
||||
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>불릿 스타일</span>
|
||||
|
||||
@@ -104,6 +104,65 @@ export function ProjectPropertiesPanel() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">SEO / 메타</h4>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">SEO 타이틀</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="SEO 타이틀"
|
||||
placeholder={projectConfig.title || "페이지 제목"}
|
||||
value={projectConfig.seoTitle ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoTitle: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">메타 디스크립션</span>
|
||||
<textarea
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500 min-h-[56px]"
|
||||
aria-label="메타 디스크립션"
|
||||
placeholder="검색엔진 및 SNS 공유에 노출될 페이지 설명을 입력하세요."
|
||||
value={projectConfig.seoDescription ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoDescription: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">OG/Twitter 이미지 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="OG/Twitter 이미지 URL"
|
||||
placeholder="예: https://example.com/og-image.png"
|
||||
value={projectConfig.seoOgImageUrl ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoOgImageUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">Canonical URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="Canonical URL"
|
||||
placeholder="예: https://example.com/landing"
|
||||
value={projectConfig.seoCanonicalUrl ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoCanonicalUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-[11px] text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-600 bg-slate-900"
|
||||
aria-label="검색 엔진에 노출하지 않기 (noindex)"
|
||||
checked={Boolean(projectConfig.seoNoIndex)}
|
||||
onChange={(e) => updateProjectConfig({ seoNoIndex: e.target.checked })}
|
||||
/>
|
||||
<span>검색 엔진에 노출하지 않기 (noindex)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">페이지 head HTML</span>
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { useState } from "react";
|
||||
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
|
||||
import { TextPropertiesPanel } from "./TextPropertiesPanel";
|
||||
import { ListPropertiesPanel } from "./ListPropertiesPanel";
|
||||
import { DividerPropertiesPanel } from "./DividerPropertiesPanel";
|
||||
import { ImagePropertiesPanel } from "./ImagePropertiesPanel";
|
||||
import { SectionPropertiesPanel } from "./SectionPropertiesPanel";
|
||||
import { VideoPropertiesPanel } from "./VideoPropertiesPanel";
|
||||
import { FormInputPropertiesPanel } from "../forms/FormInputPropertiesPanel";
|
||||
import { FormSelectPropertiesPanel } from "../forms/FormSelectPropertiesPanel";
|
||||
import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPanel";
|
||||
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
|
||||
import { FormControllerPanel } from "../forms/FormControllerPanel";
|
||||
import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel";
|
||||
import { Trash2, Copy, SlidersHorizontal, HelpCircle } from "lucide-react";
|
||||
|
||||
type BlockHelpProperty = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type BlockHelpContent = {
|
||||
title: string;
|
||||
description: string;
|
||||
properties?: BlockHelpProperty[];
|
||||
};
|
||||
|
||||
interface PropertiesSidebarProps {
|
||||
blocks: Block[];
|
||||
@@ -47,16 +61,621 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
onOutdentSelectedItem,
|
||||
} = props;
|
||||
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const getHelpContentForSelectedBlock = (): BlockHelpContent | null => {
|
||||
if (!selectedBlockId) return null;
|
||||
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
|
||||
if (!selectedBlock) return null;
|
||||
|
||||
switch (selectedBlock.type) {
|
||||
case "text":
|
||||
return {
|
||||
title: "텍스트 블록 튜토리얼",
|
||||
description:
|
||||
"제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다.\n\n좌측 패널에서 텍스트를 추가한 뒤, 캔버스에서 텍스트를 더블클릭하거나 속성 패널에서 내용을 수정해 보세요. 정렬, 크기, 색상 등을 조절해 다양한 스타일의 텍스트를 만들 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "선택한 텍스트 블록 내용",
|
||||
description:
|
||||
"이 블록에 실제로 표시될 문장을 입력하는 영역입니다. 캔버스에서 텍스트를 더블클릭했을 때와 동일한 내용이며, 여러 줄을 입력하면 줄바꿈이 그대로 반영됩니다.",
|
||||
},
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"텍스트를 왼쪽/가운데/오른쪽 중 어디에 맞출지 결정합니다. 일반 본문은 왼쪽, Hero 제목이나 짧은 강조 문구는 가운데 정렬을 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 스타일 (밑줄/가운데줄/이탤릭)",
|
||||
description:
|
||||
"밑줄은 링크처럼 보이게 하거나 특정 단어를 강조할 때, 가운데줄은 할인 전 가격처럼 취소선을 표현할 때, 이탤릭은 인용구나 제품명 등 살짝 톤을 바꾸고 싶을 때 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "글자 크기",
|
||||
description:
|
||||
"텍스트의 폰트 크기를 px 단위로 조절합니다. 프리셋(XS~3XL)으로 대략적인 크기를 고른 뒤, 슬라이더/숫자 입력으로 세밀하게 조정할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "글자 간격",
|
||||
description:
|
||||
"문자 사이의 간격(자간)을 조절합니다. 본문은 보통 또는 약간 넓게, 대문자 위주의 짧은 타이틀은 살짝 넓게 설정하면 가독성과 디자인이 좋아집니다.",
|
||||
},
|
||||
{
|
||||
label: "줄 간격",
|
||||
description:
|
||||
"행과 행 사이의 간격(행간)을 정합니다. 본문은 1.5~1.7 정도가 읽기 좋고, 아주 짧은 제목은 1.25 정도로 줄이면 한 덩어리로 단단해 보입니다.",
|
||||
},
|
||||
{
|
||||
label: "굵기",
|
||||
description:
|
||||
"폰트 두께를 100~900 범위에서 조절합니다. 본문은 보통(400), 섹션 제목은 세미볼드(600), 강한 CTA 문구는 볼드(700) 정도를 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 색상",
|
||||
description:
|
||||
"글자 색을 팔레트 또는 HEX 코드로 지정합니다. 본문은 대비가 높은 짙은 색을, 강조 문구는 브랜드 메인 색을 사용하는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "블록 배경색",
|
||||
description:
|
||||
"텍스트가 들어 있는 카드/박스 전체 배경색을 지정합니다. 공지사항, 강조 박스, 경고 문구 등을 만들 때 연한 배경색과 함께 사용하면 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "최대 너비",
|
||||
description:
|
||||
"한 줄 텍스트가 지나치게 길어지지 않도록 최대 줄 길이를 제한합니다. `본문 폭(60ch)`는 일반 문단에, `좁게(40ch)`는 카드/배너 같은 짧은 문구에 적합합니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "button":
|
||||
return {
|
||||
title: "버튼 블록 튜토리얼",
|
||||
description:
|
||||
"폼 제출이나 링크 이동을 위한 CTA 버튼을 만들 때 사용하는 블록입니다. 라벨, 링크(URL), 정렬, 색상/크기를 조절해 원하는 액션 버튼을 구성해 보세요.",
|
||||
properties: [
|
||||
{
|
||||
label: "버튼 텍스트",
|
||||
description:
|
||||
"버튼 위에 표시될 문구입니다. 예: '지금 시작하기', '문의 남기기'. 짧고 행동을 유도하는 문장을 사용하는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "가로 패딩 (px)",
|
||||
description:
|
||||
"버튼 양 옆의 여백을 조절합니다. 값이 클수록 버튼이 가로로 넓어져 더 강조됩니다.",
|
||||
},
|
||||
{
|
||||
label: "세로 패딩 (px)",
|
||||
description:
|
||||
"버튼 위·아래 여백을 조절합니다. 값이 클수록 버튼 높이가 높아져 더 눈에 띱니다.",
|
||||
},
|
||||
{
|
||||
label: "버튼 링크",
|
||||
description:
|
||||
"버튼 클릭 시 이동할 URL 또는 앵커(#section)입니다. 외부 페이지, 페이지 내 섹션, 폼 제출 엔드포인트 등으로 연결할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"해당 버튼이 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다. 주요 CTA 버튼은 가운데 정렬이 많이 쓰입니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 색상",
|
||||
description:
|
||||
"버튼 라벨 글자 색입니다. 채움 색상과 충분한 대비가 나도록 설정해야 가독성이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "스타일",
|
||||
description:
|
||||
"버튼 외형 스타일입니다. '채움'은 꽉 찬 스타일, '외곽선'은 테두리만 있는 스타일, '고스트'는 배경 없이 텍스트/테두리만 있는 스타일입니다.",
|
||||
},
|
||||
{
|
||||
label: "채움 색상",
|
||||
description:
|
||||
"채움 스타일에서 버튼 내부를 채우는 색입니다. 브랜드 메인 색이나 강조 색을 사용하면 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "외곽선 색상",
|
||||
description:
|
||||
"외곽선/고스트 스타일에서 버튼 테두리 색입니다. 배경색과의 대비를 고려해 설정합니다.",
|
||||
},
|
||||
{
|
||||
label: "모서리 둥글기",
|
||||
description:
|
||||
"버튼 모서리를 얼마나 둥글게 처리할지 결정합니다. '없음'은 각진 버튼, '완전 둥글게'는 알약 모양 버튼입니다.",
|
||||
},
|
||||
{
|
||||
label: "버튼 너비 모드 / 버튼 고정 너비",
|
||||
description:
|
||||
"버튼이 컨테이너 너비에 맞춰 늘어날지(전체 폭), 내용 길이에 맞출지(자동), 고정 px 너비를 가질지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "버튼 크기",
|
||||
description:
|
||||
"버튼 라벨 텍스트의 폰트 크기입니다. 중요한 CTA 버튼은 주변 텍스트보다 조금 더 크게 설정하면 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "줄 간격 / 글자 간격",
|
||||
description:
|
||||
"버튼 라벨의 행간과 자간을 조절합니다. 글자가 너무 답답해 보이면 자간을 약간 넓히고, 여러 줄 버튼 문구는 줄 간격을 조금 넓혀 주세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "image":
|
||||
return {
|
||||
title: "이미지 블록 튜토리얼",
|
||||
description:
|
||||
"배너, 썸네일, 설명 이미지를 배치할 때 사용하는 블록입니다. 외부 이미지 URL을 입력하거나 업로드 이미지를 선택하고, 너비/정렬/모서리 둥글기를 조절해 레이아웃에 맞게 배치해 보세요.",
|
||||
properties: [
|
||||
{
|
||||
label: "이미지 소스",
|
||||
description:
|
||||
"이미지를 외부 URL에서 가져올지(URL), 빌더에 업로드한 에셋을 사용할지(파일 업로드) 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "이미지 URL / 이미지 파일 업로드",
|
||||
description:
|
||||
"URL 모드에서는 외부 이미지 주소를 입력하고, 업로드 모드에서는 로컬 이미지를 업로드해 `/api/image/:id` 형식으로 관리합니다.",
|
||||
},
|
||||
{
|
||||
label: "대체 텍스트",
|
||||
description:
|
||||
"이미지가 로드되지 않거나 스크린리더를 사용하는 사용자에게 보여질 설명 텍스트입니다. 이미지가 전달하는 의미를 한두 문장으로 작성해 주세요.",
|
||||
},
|
||||
{
|
||||
label: "카드 배경색",
|
||||
description:
|
||||
"이미지를 감싸는 카드 영역의 배경색입니다. 투명한 PNG 아이콘이나 로고를 올릴 때 배경을 살짝 깔아주면 더 잘 보입니다.",
|
||||
},
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"이미지가 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "너비 모드 / 고정 너비 (px)",
|
||||
description:
|
||||
"이미지를 내용 크기에 맞출지(auto) 또는 px 단위로 고정된 너비를 사용할지 선택합니다. 썸네일 그리드에서는 고정 너비를 사용하는 편이 레이아웃이 안정적입니다.",
|
||||
},
|
||||
{
|
||||
label: "모서리 둥글기",
|
||||
description:
|
||||
"이미지 카드 모서리 둥글기를 px 단위로 조절합니다. 아바타/아이콘은 완전 둥글게, 일반 사진은 살짝 둥글게 설정하면 자연스럽습니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "list":
|
||||
return {
|
||||
title: "리스트 블록 튜토리얼",
|
||||
description:
|
||||
"불릿/번호 리스트를 만들 때 사용하는 블록입니다. 아이템 텍스트를 수정하고, 정렬/불릿 스타일을 바꿔 체크리스트나 단계별 안내 등을 표현할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "리스트 아이템 (줄바꿈으로 구분)",
|
||||
description:
|
||||
"각 줄이 하나의 리스트 항목이 됩니다. 들여쓰기를 이용한 중첩 리스트는 TDD 기준의 변환 로직에 따라 자동으로 트리 구조로 변환됩니다.",
|
||||
},
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"리스트 전체를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 체크리스트는 왼쪽 정렬, 특징 요약은 가운데 정렬이 자주 사용됩니다.",
|
||||
},
|
||||
{
|
||||
label: "글자 크기 (px)",
|
||||
description:
|
||||
"리스트 텍스트의 폰트 크기를 조절합니다. 본문보다 약간 작게 설정하면 보조 정보 느낌을 줄 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "줄 간격",
|
||||
description:
|
||||
"리스트 항목 내부의 행간을 조절합니다. 항목 내용이 길어질수록 1.5~1.8 정도의 넉넉한 값을 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 색상",
|
||||
description:
|
||||
"리스트 텍스트의 색상입니다. 배경색과의 대비를 고려해 본문과 동일하거나 약간 연한 색상을 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "블록 배경색",
|
||||
description:
|
||||
"리스트 전체 카드의 배경색입니다. 단계별 안내나 체크리스트 박스를 강조하는 용도로 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "불릿 스타일",
|
||||
description:
|
||||
"불릿(●/○/■) 또는 번호(1., a., i.) 형식을 선택합니다. 숫자/알파벳/로마 숫자 스타일을 선택하면 자동으로 순서형 리스트(ordered)가 됩니다.",
|
||||
},
|
||||
{
|
||||
label: "아이템 간 여백 (px)",
|
||||
description:
|
||||
"각 리스트 항목 사이의 세로 간격입니다. 설명이 긴 항목이 많다면 여백을 넉넉히 두어 가독성을 확보하세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "divider":
|
||||
return {
|
||||
title: "구분선 블록 튜토리얼",
|
||||
description:
|
||||
"섹션과 섹션 사이를 나누거나 콘텐츠를 시각적으로 구분할 때 사용하는 블록입니다. 정렬, 두께, 길이, 색상, 여백을 조절해 페이지 흐름을 정리해 보세요.",
|
||||
properties: [
|
||||
{
|
||||
label: "정렬",
|
||||
description:
|
||||
"구분선을 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "두께",
|
||||
description:
|
||||
"선의 두께입니다. '얇게'는 가벼운 보조 구분선, '보통'은 섹션 경계를 확실히 나눌 때 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "길이 모드 / 고정 길이 (px)",
|
||||
description:
|
||||
"구분선을 전체 폭으로 쓸지, 내용에 맞출지, 또는 px 단위의 고정 길이를 사용할지 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "선 색상",
|
||||
description:
|
||||
"구분선 컬러입니다. 너무 진한 색보다는 섹션 배경보다 살짝 진한 정도의 중간 톤을 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "위/아래 여백",
|
||||
description:
|
||||
"구분선 위·아래에 들어가는 공백입니다. 값이 클수록 섹션이 더 명확하게 나뉘어 보입니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "section":
|
||||
return {
|
||||
title: "섹션 블록 튜토리얼",
|
||||
description:
|
||||
"레이아웃의 큰 덩어리를 나누는 컨테이너 블록입니다. 배경색/배경 이미지/비디오, 위아래 패딩, 너비를 설정해 Hero, Features, Footer 같은 영역을 구성할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "배경 색상",
|
||||
description:
|
||||
"섹션 전체의 기본 배경색입니다. 페이지의 큰 분위기를 결정하는 요소이므로, 섹션 간 배경 대비를 적절히 주는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "배경 이미지 소스 / URL / 파일 업로드",
|
||||
description:
|
||||
"섹션 배경으로 사용할 이미지를 외부 URL 또는 업로드 파일로 지정합니다. Hero 배경, 패턴, 질감 이미지를 설정할 때 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "배경 이미지 크기 / 위치 / 반복",
|
||||
description:
|
||||
"cover/contain/auto로 크기를 조절하고, 프리셋 또는 X/Y 퍼센트로 위치를 조절하며, 반복 여부를 설정해 패턴 형태로 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "배경 비디오 소스 / URL / 파일 업로드",
|
||||
description:
|
||||
"섹션 배경에 비디오를 설정합니다. 시선을 끌어야 하는 Hero 영역 등에 사용하되, 가독성 저하를 막기 위해 텍스트 대비를 꼭 확인하세요.",
|
||||
},
|
||||
{
|
||||
label: "세로 패딩",
|
||||
description:
|
||||
"섹션 위·아래 여백입니다. 값이 클수록 섹션이 여유 있고 강조되어 보입니다. Hero 섹션은 넉넉한 패딩을, 보조 섹션은 중간 정도를 추천합니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "video":
|
||||
return {
|
||||
title: "비디오 블록 튜토리얼",
|
||||
description:
|
||||
"YouTube/영상 URL 또는 직접 호스팅한 비디오를 삽입할 때 사용하는 블록입니다. 동영상 주소를 입력하고, 비율/정렬/재생 옵션을 조정해 페이지에 맞게 배치해 보세요.",
|
||||
properties: [
|
||||
{
|
||||
label: "비디오 소스 / 비디오 URL / 비디오 파일 업로드",
|
||||
description:
|
||||
"외부 동영상 URL(YouTube, Vimeo 등)을 직접 입력하거나, 비디오 파일을 업로드해 `/api/video/:id` 형태로 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "포스터 이미지 URL",
|
||||
description:
|
||||
"동영상 재생 전/로딩 중에 보여줄 썸네일 이미지입니다. 영상의 핵심 장면이나 대표 이미지를 사용하면 클릭률을 높일 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "카드 배경색",
|
||||
description:
|
||||
"비디오가 들어가는 카드 영역의 배경색입니다. 주변 섹션과의 대비를 위해 살짝 어둡거나 밝은 색을 설정할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "비디오 정렬",
|
||||
description:
|
||||
"비디오를 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "비디오 너비 모드 / 고정 너비 (px)",
|
||||
description:
|
||||
"비디오를 내용 너비에 맞출지, 가로 전체를 채울지, 특정 px 너비로 고정할지 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "카드 패딩 / 카드 모서리 둥글기",
|
||||
description:
|
||||
"비디오 주변 카드의 안쪽 여백과 모서리 둥글기입니다. 카드형 레이아웃에서는 적당한 패딩과 둥근 모서리를 주면 디자인이 정돈됩니다.",
|
||||
},
|
||||
{
|
||||
label: "시작 시점 (초) / 종료 시점 (초)",
|
||||
description:
|
||||
"영상의 특정 구간만 재생하고 싶을 때 사용하는 옵션입니다. 예를 들어 10초~30초만 재생하도록 설정할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "화면 비율",
|
||||
description:
|
||||
"동영상 프레임 비율입니다. 대부분의 웹 영상은 16:9를 사용하며, 정사각형/세로형 콘텐츠는 1:1, 4:3 등을 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "자동 재생 / 반복 재생 / 음소거 / 재생 컨트롤 표시",
|
||||
description:
|
||||
"자동 재생/반복 재생/음소거/컨트롤 표시 여부를 설정합니다. 자동 재생 영상은 보통 음소거를 함께 켜 두는 것이 좋습니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "form":
|
||||
return {
|
||||
title: "폼 컨트롤러 블록 튜토리얼",
|
||||
description:
|
||||
"입력 필드(formInput/select/checkbox/radio)를 하나의 폼으로 묶는 컨트롤러입니다. 전송 대상, 전송 포맷, 너비, 여백 등을 설정해 네이티브 폼 제출을 구성하고, 필드 ID를 연결해 폼 구조를 완성할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "전송 대상 / Webhook 설정",
|
||||
description:
|
||||
"폼 데이터를 내부 처리로만 사용할지, 외부 Webhook/Google Sheets 등으로 전송할지 결정하고, 목적지 URL/전송 포맷/HTTP 메서드 등을 설정합니다.",
|
||||
},
|
||||
{
|
||||
label: "추가 파라미터",
|
||||
description:
|
||||
"`key=value` 형식으로 줄바꿈해 작성하면, 모든 폼 제출에 공통으로 포함될 추가 파라미터를 지정할 수 있습니다. 예: `source=landing`.",
|
||||
},
|
||||
{
|
||||
label: "폼 너비 모드 / 폼 고정 너비 (px)",
|
||||
description:
|
||||
"폼 전체의 가로 폭을 자동/전체 폭/고정 px 값 중에서 선택합니다. 문의 폼은 보통 480~640px 정도의 고정 폭이 읽기 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "폼 위/아래 여백 (px)",
|
||||
description:
|
||||
"폼 블록 위·아래에 들어가는 공백입니다. 페이지 내 다른 섹션과의 간격을 조절할 때 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "성공 메시지 / 에러 메시지",
|
||||
description:
|
||||
"폼 전송 성공 또는 실패 시 사용자에게 보여줄 텍스트입니다. 명확하고 친절한 문구로 작성하는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "폼 필드 매핑",
|
||||
description:
|
||||
"폼 컨트롤러와 연결할 입력 필드(formInput/select/checkbox/radio)를 체크박스로 선택합니다. 체크된 필드만 실제 폼 제출 payload에 포함됩니다.",
|
||||
},
|
||||
{
|
||||
label: "Submit 버튼",
|
||||
description:
|
||||
"폼 제출을 담당할 버튼 블록을 지정합니다. 별도 버튼 블록을 만들어 이 컨트롤러와 연결하면, 해당 버튼 클릭 시 폼이 제출됩니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "formInput":
|
||||
return {
|
||||
title: "폼 입력 블록 튜토리얼",
|
||||
description:
|
||||
"이름, 이메일 등 한 줄 텍스트 또는 긴 텍스트 입력 필드를 만들 때 사용하는 블록입니다. 레이블, 플레이스홀더, 필수 여부, 너비 등을 설정해 폼 컨트롤러와 함께 사용합니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "라벨 타입 / 필드 라벨",
|
||||
description:
|
||||
"입력 필드 위나 옆에 표시될 설명입니다. 텍스트 또는 이미지로 라벨을 구성할 수 있으며, 사용자가 어떤 값을 입력해야 하는지 명확하게 알려줍니다.",
|
||||
},
|
||||
{
|
||||
label: "라벨 이미지 URL / 대체 텍스트",
|
||||
description:
|
||||
"라벨을 이미지로 사용할 때 경로와 alt 텍스트를 지정합니다. 로고형 라벨이나 아이콘 기반 UI를 만들 때 활용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "전송 키",
|
||||
description:
|
||||
"이 입력값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 타입",
|
||||
description:
|
||||
"텍스트/이메일/긴 텍스트 중 어떤 형태의 입력을 받을지 결정합니다. 이메일 타입은 브라우저 기본 이메일 검증을 활용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "Placeholder",
|
||||
description:
|
||||
"입력 전 필드 안에 흐릿하게 보여줄 안내 문구입니다. 예: '이메일을 입력해 주세요'.",
|
||||
},
|
||||
{
|
||||
label: "필수 필드",
|
||||
description:
|
||||
"체크 시 이 필드는 반드시 채워야 합니다. 이름/이메일 같은 필수 값에는 활성화하고, 선택 항목에는 비활성화하는 것을 추천합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 크기 / 줄간격 / 자간",
|
||||
description:
|
||||
"입력 값의 폰트 크기(px), 줄간격(px), 자간(px)을 조절해 폼 필드의 가독성과 분위기를 세밀하게 튜닝합니다.",
|
||||
},
|
||||
{
|
||||
label: "텍스트 정렬",
|
||||
description:
|
||||
"필드 안 텍스트를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 일반 입력은 왼쪽 정렬을 권장합니다.",
|
||||
},
|
||||
{
|
||||
label: "레이아웃 / 라벨/필드 간격",
|
||||
description:
|
||||
"라벨과 필드를 세로(stack) 또는 가로(inline)로 배치하고, inline 모드에서 라벨과 입력 사이 간격(px)을 조절합니다.",
|
||||
},
|
||||
{
|
||||
label: "너비 / 필드 고정 너비",
|
||||
description:
|
||||
"필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다. 짧은 필드는 고정 값, 긴 입력은 전체 폭을 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 가로/세로 패딩",
|
||||
description:
|
||||
"입력 상자 안쪽 여백을 조절합니다. 값이 클수록 필드가 커지고 누르기/입력하기 편해집니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 텍스트/채움/테두리 색상",
|
||||
description:
|
||||
"입력 텍스트, 배경, 테두리 색상을 각각 조절합니다. 에러 상태/강조 상태 등을 표현할 때 조합해서 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 모서리 둥글기",
|
||||
description:
|
||||
"입력 상자의 모서리를 얼마나 둥글게 할지 결정합니다. 페이지의 전체 디자인 톤과 맞춰 사용하세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "formSelect":
|
||||
return {
|
||||
title: "폼 셀렉트 블록 튜토리얼",
|
||||
description:
|
||||
"드롭다운 선택 필드를 만들 때 사용하는 블록입니다. 옵션 목록과 기본 선택값, 너비를 설정해 폼 컨트롤러와 함께 사용합니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "라벨 타입 / 필드 라벨",
|
||||
description:
|
||||
"셀렉트 필드의 제목 역할을 하는 라벨입니다. 텍스트 또는 이미지로 구성할 수 있으며, 선택해야 할 값의 의미를 설명합니다.",
|
||||
},
|
||||
{
|
||||
label: "전송 키",
|
||||
description:
|
||||
"선택된 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
|
||||
},
|
||||
{
|
||||
label: "옵션",
|
||||
description:
|
||||
"사용자가 선택할 수 있는 옵션 목록입니다. 라벨과 value를 함께 설정하며, '옵션 추가' 버튼으로 새 옵션을 만들 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필수 필드",
|
||||
description:
|
||||
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 중요 선택 항목에는 필수로 설정하는 것을 권장합니다.",
|
||||
},
|
||||
{
|
||||
label: "셀렉트 텍스트 크기 / 줄간격 / 자간",
|
||||
description:
|
||||
"드롭다운 필드의 텍스트 타이포그래피를 px 단위로 조절해 가독성과 분위기를 맞춥니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 너비 / 필드 고정 너비",
|
||||
description:
|
||||
"셀렉트 필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다.",
|
||||
},
|
||||
{
|
||||
label: "셀렉트 가로/세로 패딩",
|
||||
description:
|
||||
"드롭다운 안쪽 여백을 조절해 클릭 영역과 시각적 무게감을 조정합니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 텍스트/채움/테두리 색상",
|
||||
description:
|
||||
"선택 영역의 텍스트, 배경, 테두리 색상을 설정합니다. 폼 전체 톤과 잘 어울리는 색상을 사용하는 것이 좋습니다.",
|
||||
},
|
||||
{
|
||||
label: "필드 모서리 둥글기",
|
||||
description:
|
||||
"셀렉트 박스의 모서리를 얼마나 둥글게 표시할지 결정합니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "formCheckbox":
|
||||
return {
|
||||
title: "폼 체크박스 블록 튜토리얼",
|
||||
description:
|
||||
"여러 항목 중 복수 선택이 필요한 경우 사용하는 체크박스 그룹 블록입니다. 그룹 타이틀과 옵션 라벨/이미지를 설정해 동의 항목이나 관심사 선택 등을 구성할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "그룹 타이틀 타입 / 그룹 타이틀",
|
||||
description:
|
||||
"체크박스 그룹 전체의 제목입니다. 텍스트 또는 이미지로 표현할 수 있으며, 사용자가 어떤 범주를 선택하는지 알려줍니다.",
|
||||
},
|
||||
{
|
||||
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
|
||||
description:
|
||||
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드된 이미지를 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "전송 키",
|
||||
description:
|
||||
"체크된 옵션들의 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
|
||||
},
|
||||
{
|
||||
label: "옵션 / 옵션 이미지 소스",
|
||||
description:
|
||||
"각 체크박스 옵션의 라벨/값/이미지를 설정합니다. URL 또는 업로드 이미지로 옵션별 아이콘을 붙일 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필수 필드",
|
||||
description:
|
||||
"체크 시 하나 이상 옵션을 선택해야 합니다. 약관 동의 등 필수 동의 항목에 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "체크박스 텍스트 크기 / 줄간격 / 자간",
|
||||
description:
|
||||
"체크박스 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 항목이 길어질수록 줄간격을 넉넉히 두는 것이 좋습니다.",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "formRadio":
|
||||
return {
|
||||
title: "폼 라디오 블록 튜토리얼",
|
||||
description:
|
||||
"여러 옵션 중 하나만 선택해야 할 때 사용하는 라디오 버튼 그룹 블록입니다. 옵션 라벨/이미지와 기본 선택값을 설정해 요금제 선택 등 단일 선택 시나리오를 구성할 수 있습니다.",
|
||||
properties: [
|
||||
{
|
||||
label: "그룹 타이틀 타입 / 그룹 타이틀",
|
||||
description:
|
||||
"라디오 그룹의 제목입니다. 요금제 이름, 질문 문구 등 단일 선택의 맥락을 설명합니다.",
|
||||
},
|
||||
{
|
||||
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
|
||||
description:
|
||||
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드 이미지를 선택합니다.",
|
||||
},
|
||||
{
|
||||
label: "전송 키",
|
||||
description:
|
||||
"선택된 하나의 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
|
||||
},
|
||||
{
|
||||
label: "옵션 / 옵션 이미지 소스",
|
||||
description:
|
||||
"각 라디오 옵션의 라벨/값/이미지를 설정합니다. 옵션별 아이콘이나 썸네일을 붙여 더 풍부한 선택 UI를 만들 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "필수 필드",
|
||||
description:
|
||||
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 요금제/유형 선택처럼 반드시 선택이 필요한 경우에 사용합니다.",
|
||||
},
|
||||
{
|
||||
label: "라디오 텍스트 크기 / 줄간격 / 자간",
|
||||
description:
|
||||
"라디오 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 옵션 설명이 긴 경우 줄간격을 넉넉히 두세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "블록 튜토리얼",
|
||||
description: "이 블록에 대한 자세한 도움말은 추후 추가될 예정입니다.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="properties-sidebar"
|
||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto"
|
||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll bg-slate-950/40"
|
||||
onKeyDownCapture={(e) => {
|
||||
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<h2 className="font-medium mb-2">속성 패널</h2>
|
||||
<h2 className="font-medium mb-2 flex items-center gap-2 text-slate-200">
|
||||
<SlidersHorizontal className="w-4 h-4 text-sky-400" aria-hidden="true" />
|
||||
<span>속성 패널</span>
|
||||
</h2>
|
||||
{selectedBlockId ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2 text-[11px]">
|
||||
@@ -65,14 +684,30 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
|
||||
onClick={() => removeBlock(selectedBlockId)}
|
||||
>
|
||||
블록 삭제
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>블록 삭제</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => duplicateBlock(selectedBlockId)}
|
||||
>
|
||||
블록 복제
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Copy className="w-3 h-3" aria-hidden="true" />
|
||||
<span>블록 복제</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" aria-hidden="true" />
|
||||
<span>HELP</span>
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
@@ -142,6 +777,18 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlock.type === "video") {
|
||||
const videoProps = selectedBlock.props as VideoBlockProps;
|
||||
|
||||
return (
|
||||
<VideoPropertiesPanel
|
||||
videoProps={videoProps}
|
||||
selectedBlockId={selectedBlockId}
|
||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlock.type === "section") {
|
||||
const sectionProps = selectedBlock.props as SectionBlockProps;
|
||||
|
||||
@@ -211,6 +858,41 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
) : (
|
||||
<ProjectPropertiesPanel />
|
||||
)}
|
||||
{helpOpen && (() => {
|
||||
const help = getHelpContentForSelectedBlock();
|
||||
if (!help) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-md rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium">{help.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-slate-100 text-xs"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-200 whitespace-pre-line">{help.description}</p>
|
||||
{help.properties && help.properties.length > 0 && (
|
||||
<div className="mt-3 border-t border-slate-700 pt-2 space-y-2">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">속성별 설명</h4>
|
||||
<ul className="space-y-1">
|
||||
{help.properties.map((prop) => (
|
||||
<li key={prop.label}>
|
||||
<div className="text-[11px] font-semibold text-slate-100">{prop.label}</div>
|
||||
<div className="text-[11px] text-slate-300">{prop.description}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,34 @@ export type SectionPropertiesPanelProps = {
|
||||
};
|
||||
|
||||
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
|
||||
const backgroundSource: "none" | "url" | "upload" = (() => {
|
||||
const src = sectionProps.backgroundImageSrc?.trim();
|
||||
const type = sectionProps.backgroundImageSourceType;
|
||||
|
||||
if (type === "asset") return "upload";
|
||||
if (type === "externalUrl") return "url";
|
||||
|
||||
if (src && src.startsWith("/api/image/")) return "upload";
|
||||
if (src) return "url";
|
||||
|
||||
return "none";
|
||||
})();
|
||||
|
||||
const positionMode: "preset" | "custom" = sectionProps.backgroundImagePositionMode ?? "preset";
|
||||
|
||||
const backgroundVideoSource: "none" | "url" | "upload" = (() => {
|
||||
const src = sectionProps.backgroundVideoSrc?.trim();
|
||||
const type = sectionProps.backgroundVideoSourceType;
|
||||
|
||||
if (type === "asset") return "upload";
|
||||
if (type === "externalUrl") return "url";
|
||||
|
||||
if (src && src.startsWith("/api/video/")) return "upload";
|
||||
if (src) return "url";
|
||||
|
||||
return "none";
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
|
||||
@@ -28,6 +56,333 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 섹션 배경 이미지 */}
|
||||
<div className="mt-3 space-y-2 text-xs text-slate-400">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 소스"
|
||||
value={backgroundSource}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "none" | "url" | "upload";
|
||||
if (next === "none") {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSrc: undefined,
|
||||
backgroundImageSourceType: undefined,
|
||||
backgroundImageAssetId: null,
|
||||
} as any);
|
||||
} else if (next === "url") {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
backgroundImageAssetId: null,
|
||||
} as any);
|
||||
} else {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSourceType: "asset",
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="none">없음</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{backgroundSource === "url" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 URL"
|
||||
value={sectionProps.backgroundImageSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSrc: value,
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
backgroundImageAssetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{backgroundSource === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="배경 이미지 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("배경 이미지 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSrc: servedUrl,
|
||||
backgroundImageSourceType: "asset",
|
||||
backgroundImageAssetId: data.id,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("배경 이미지 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{backgroundSource !== "none" && (
|
||||
<>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 위치 모드</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 위치 모드"
|
||||
value={positionMode}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImagePositionMode: e.target.value as SectionBlockProps["backgroundImagePositionMode"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="preset">프리셋</option>
|
||||
<option value="custom">커스텀 (X/Y)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 크기</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 크기"
|
||||
value={sectionProps.backgroundImageSize ?? "cover"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSize: e.target.value as SectionBlockProps["backgroundImageSize"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="cover">cover</option>
|
||||
<option value="contain">contain</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{positionMode === "preset" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 위치</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 위치"
|
||||
value={sectionProps.backgroundImagePosition ?? "center"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImagePosition: e.target.value as SectionBlockProps["backgroundImagePosition"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="center">가운데</option>
|
||||
<option value="top">위</option>
|
||||
<option value="bottom">아래</option>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{positionMode === "custom" && (
|
||||
<>
|
||||
<NumericPropertyControl
|
||||
label="배경 이미지 가로 위치"
|
||||
unitLabel="(%)"
|
||||
value={
|
||||
typeof sectionProps.backgroundImagePositionXPercent === "number"
|
||||
? sectionProps.backgroundImagePositionXPercent
|
||||
: 50
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "left", label: "왼쪽", value: 0 },
|
||||
{ id: "center", label: "가운데", value: 50 },
|
||||
{ id: "right", label: "오른쪽", value: 100 },
|
||||
]}
|
||||
onChangeValue={(next) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: next,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
|
||||
<NumericPropertyControl
|
||||
label="배경 이미지 세로 위치"
|
||||
unitLabel="(%)"
|
||||
value={
|
||||
typeof sectionProps.backgroundImagePositionYPercent === "number"
|
||||
? sectionProps.backgroundImagePositionYPercent
|
||||
: 50
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "top", label: "위", value: 0 },
|
||||
{ id: "center", label: "가운데", value: 50 },
|
||||
{ id: "bottom", label: "아래", value: 100 },
|
||||
]}
|
||||
onChangeValue={(next) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionYPercent: next,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 반복</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 반복"
|
||||
value={sectionProps.backgroundImageRepeat ?? "no-repeat"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageRepeat: e.target.value as SectionBlockProps["backgroundImageRepeat"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="no-repeat">반복 없음</option>
|
||||
<option value="repeat">가로/세로 반복</option>
|
||||
<option value="repeat-x">가로 반복</option>
|
||||
<option value="repeat-y">세로 반복</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 섹션 배경 비디오 */}
|
||||
<div className="mt-3 space-y-2 text-xs text-slate-400">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 비디오 소스"
|
||||
value={backgroundVideoSource}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "none" | "url" | "upload";
|
||||
if (next === "none") {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSrc: undefined,
|
||||
backgroundVideoSourceType: undefined,
|
||||
backgroundVideoAssetId: null,
|
||||
} as any);
|
||||
} else if (next === "url") {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSourceType: "externalUrl",
|
||||
backgroundVideoAssetId: null,
|
||||
} as any);
|
||||
} else {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSourceType: "asset",
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="none">없음</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{backgroundVideoSource === "url" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="배경 비디오 URL"
|
||||
value={sectionProps.backgroundVideoSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSrc: value,
|
||||
backgroundVideoSourceType: "externalUrl",
|
||||
backgroundVideoAssetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{backgroundVideoSource === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="배경 비디오 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/video", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("배경 비디오 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/video/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSrc: servedUrl,
|
||||
backgroundVideoSourceType: "asset",
|
||||
backgroundVideoAssetId: data.id,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("배경 비디오 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 세로 패딩 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<div className="mt-3 space-y-1">
|
||||
<NumericPropertyControl
|
||||
@@ -181,7 +536,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
|
||||
// 1컬럼인 경우: 12 고정 표시
|
||||
if (colCount === 1) {
|
||||
const only = columns[0];
|
||||
const only = columns[0] ?? { id: `${selectedBlockId}_col_1`, span: 12 };
|
||||
return (
|
||||
<label key={only.id} className="flex flex-col gap-1">
|
||||
<span>{`1열 폭 (고정 12/12)`}</span>
|
||||
|
||||
@@ -437,6 +437,21 @@ export function TextPropertiesPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="블록 배경색"
|
||||
ariaLabelColorInput="텍스트 블록 배경색 피커"
|
||||
ariaLabelHexInput="텍스트 블록 배경색 HEX"
|
||||
value={textProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundColorCustom: hex,
|
||||
} as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>최대 너비</span>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
|
||||
export type VideoPropertiesPanelProps = {
|
||||
videoProps: VideoBlockProps;
|
||||
selectedBlockId: string;
|
||||
updateBlock: (id: string, partial: Partial<VideoBlockProps>) => void;
|
||||
};
|
||||
|
||||
export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock }: VideoPropertiesPanelProps) {
|
||||
// 비디오 소스 유형: 업로드(/api/video/:id) 또는 외부 URL 구분
|
||||
const source: "url" | "upload" =
|
||||
videoProps.sourceType === "asset" || (videoProps.sourceUrl && videoProps.sourceUrl.startsWith("/api/video/"))
|
||||
? "upload"
|
||||
: "url";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 비디오 소스 선택 (URL / 업로드) */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>비디오 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 소스"
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "url" | "upload";
|
||||
if (next === "url") {
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
} as any);
|
||||
} else {
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceType: "asset",
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* URL 입력 */}
|
||||
{source === "url" && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>비디오 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 URL"
|
||||
value={videoProps.sourceUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceUrl: value,
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 접근성: 제목 / aria-label / 캡션 텍스트 (현재는 UI에서 숨김 처리) */}
|
||||
<div className="hidden">
|
||||
<label className="flex flex-col gap-1 mt-2">
|
||||
<span>비디오 제목</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 제목"
|
||||
value={(videoProps as any).titleText ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { titleText: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-2 flex flex-col gap-1">
|
||||
<span>비디오 aria-label</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 aria-label"
|
||||
value={(videoProps as any).ariaLabel ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { ariaLabel: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-2 flex flex-col gap-1">
|
||||
<span>비디오 캡션 텍스트</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 캡션 텍스트"
|
||||
value={(videoProps as any).captionText ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { captionText: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-1 text-xs text-slate-400">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>포스터 이미지 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="포스터 이미지 URL"
|
||||
value={videoProps.posterImageSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
posterImageSrc: value,
|
||||
posterSourceType: "externalUrl",
|
||||
posterAssetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 */}
|
||||
{source === "upload" && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>비디오 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="비디오 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/video", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("비디오 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/video/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceUrl: servedUrl,
|
||||
sourceType: "asset",
|
||||
assetId: data.id,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">비디오 스타일</h4>
|
||||
|
||||
{/* 카드 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="카드 배경색"
|
||||
ariaLabelColorInput="비디오 카드 배경색 피커"
|
||||
ariaLabelHexInput="비디오 카드 배경색 HEX"
|
||||
value={videoProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>비디오 정렬</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 정렬"
|
||||
value={videoProps.align ?? "center"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
align: e.target.value as VideoBlockProps["align"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 너비 모드 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>비디오 너비 모드</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 너비 모드"
|
||||
value={videoProps.widthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
widthMode: e.target.value as VideoBlockProps["widthMode"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">내용에 맞춤</option>
|
||||
<option value="full">가로 전체</option>
|
||||
<option value="fixed">고정 너비 (px)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(videoProps.widthMode ?? "auto") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="고정 너비 (px)"
|
||||
unitLabel="(px)"
|
||||
value={videoProps.widthPx ?? 640}
|
||||
min={160}
|
||||
max={1920}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 480 },
|
||||
{ id: "md", label: "보통", value: 640 },
|
||||
{ id: "lg", label: "넓게", value: 960 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 카드 패딩 */}
|
||||
<NumericPropertyControl
|
||||
label="카드 패딩"
|
||||
unitLabel="(px)"
|
||||
value={typeof videoProps.cardPaddingPx === "number" ? videoProps.cardPaddingPx : 0}
|
||||
min={0}
|
||||
max={64}
|
||||
step={2}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { cardPaddingPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 카드 모서리 둥글기 */}
|
||||
<NumericPropertyControl
|
||||
label="카드 모서리 둥글기"
|
||||
unitLabel="(px)"
|
||||
value={typeof videoProps.borderRadiusPx === "number" ? videoProps.borderRadiusPx : 0}
|
||||
min={0}
|
||||
max={64}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { borderRadiusPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 시작 시점 (초) */}
|
||||
<NumericPropertyControl
|
||||
label="시작 시점 (초)"
|
||||
unitLabel="(초)"
|
||||
value={typeof videoProps.startTimeSec === "number" ? videoProps.startTimeSec : 0}
|
||||
min={0}
|
||||
max={600}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { startTimeSec: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 종료 시점 (초) */}
|
||||
<NumericPropertyControl
|
||||
label="종료 시점 (초)"
|
||||
unitLabel="(초)"
|
||||
value={typeof videoProps.endTimeSec === "number" ? videoProps.endTimeSec : 0}
|
||||
min={0}
|
||||
max={600}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { endTimeSec: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 화면 비율 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>화면 비율</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="화면 비율"
|
||||
value={videoProps.aspectRatio ?? "16:9"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
aspectRatio: e.target.value as VideoBlockProps["aspectRatio"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="1:1">1:1</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 재생 옵션 */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.autoplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
autoplay: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">자동 재생</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.loop}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
loop: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">반복 재생</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.muted}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
muted: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">음소거</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={videoProps.controls !== false}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
controls: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">재생 컨트롤 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createBlogTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -42,21 +42,40 @@ export function createBlogTemplateBlocks(opts: {
|
||||
const blogBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const post = postDefinitions[index] ?? postDefinitions[0];
|
||||
|
||||
const imageId = createId();
|
||||
const titleId = createId();
|
||||
const summaryId = createId();
|
||||
|
||||
const imageProps: ImageBlockProps = {
|
||||
src: "https://via.placeholder.com/400x250/1e293b/94a3b8?text=Blog+Image",
|
||||
alt: post.title,
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
};
|
||||
|
||||
const titleProps: TextBlockProps = {
|
||||
text: post.title,
|
||||
align: "left",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const summaryProps: TextBlockProps = {
|
||||
text: post.summary,
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const imageBlock: Block = {
|
||||
id: imageId,
|
||||
type: "image",
|
||||
props: imageProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
const titleBlock: Block = {
|
||||
id: titleId,
|
||||
type: "text",
|
||||
@@ -73,7 +92,7 @@ export function createBlogTemplateBlocks(opts: {
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
return [titleBlock, summaryBlock];
|
||||
return [imageBlock, titleBlock, summaryBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...blogBlocks];
|
||||
|
||||
@@ -9,7 +9,8 @@ export function createCtaTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 8 },
|
||||
{ id: `${sectionId}_col_2`, span: 4 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
@@ -31,20 +32,21 @@ export function createCtaTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const leftColumnId = `${sectionId}_col_1`;
|
||||
const rightColumnId = `${sectionId}_col_2`;
|
||||
|
||||
const textId = createId();
|
||||
const textProps: TextBlockProps = {
|
||||
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.",
|
||||
align: "center",
|
||||
size: "base",
|
||||
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.\n망설이지 말고 시작하세요!",
|
||||
align: "left",
|
||||
size: "lg",
|
||||
} as any;
|
||||
const textBlock: Block = {
|
||||
id: textId,
|
||||
type: "text",
|
||||
props: textProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
const buttonId = createId();
|
||||
@@ -52,10 +54,10 @@ export function createCtaTemplateBlocks(opts: {
|
||||
label: "CTA 버튼",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "md",
|
||||
size: "lg",
|
||||
variant: "solid",
|
||||
colorPalette: "primary",
|
||||
fullWidth: false,
|
||||
fullWidth: true,
|
||||
borderRadius: "md",
|
||||
textColorCustom: "#0b1120",
|
||||
fillColorCustom: "#0ea5e9",
|
||||
@@ -66,7 +68,7 @@ export function createCtaTemplateBlocks(opts: {
|
||||
type: "button",
|
||||
props: buttonProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
const blocks: Block[] = [sectionBlock, textBlock, buttonBlock];
|
||||
|
||||
@@ -9,15 +9,17 @@ export function createFaqTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 3 },
|
||||
{ id: `${sectionId}_col_2`, span: 9 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
// FAQ 는 비교적 좁은 폭과 보통 여백을 사용한다.
|
||||
paddingYPx: 56,
|
||||
maxWidthPx: 720,
|
||||
paddingYPx: 80,
|
||||
maxWidthPx: 1024,
|
||||
gapXPx: 48,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
@@ -30,20 +32,54 @@ export function createFaqTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const leftColumnId = `${sectionId}_col_1`;
|
||||
const rightColumnId = `${sectionId}_col_2`;
|
||||
|
||||
// Left Column: Title
|
||||
const titleId = createId();
|
||||
const titleProps: TextBlockProps = {
|
||||
text: "FAQ",
|
||||
align: "left",
|
||||
size: "xl",
|
||||
bold: true,
|
||||
} as any;
|
||||
const titleBlock: Block = {
|
||||
id: titleId,
|
||||
type: "text",
|
||||
props: titleProps,
|
||||
sectionId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
const subTitleId = createId();
|
||||
const subTitleProps: TextBlockProps = {
|
||||
text: "자주 묻는 질문",
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const subTitleBlock: Block = {
|
||||
id: subTitleId,
|
||||
type: "text",
|
||||
props: subTitleProps,
|
||||
sectionId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
|
||||
// Right Column: Q&A List
|
||||
const faqPairs = [
|
||||
{
|
||||
question: "자주 묻는 질문 1",
|
||||
answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "서비스 이용료는 얼마인가요?",
|
||||
answer: "기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.",
|
||||
},
|
||||
{
|
||||
question: "자주 묻는 질문 2",
|
||||
answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "환불 정책은 어떻게 되나요?",
|
||||
answer: "결제 후 7일 이내에 사용 이력이 없는 경우 전액 환불 가능합니다.",
|
||||
},
|
||||
{
|
||||
question: "자주 묻는 질문 3",
|
||||
answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "팀원 초대 기능이 있나요?",
|
||||
answer: "네, 프로 요금제 이상부터 팀원 초대가 가능합니다.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -55,12 +91,14 @@ export function createFaqTemplateBlocks(opts: {
|
||||
text: pair.question,
|
||||
align: "left",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const answerProps: TextBlockProps = {
|
||||
text: pair.answer,
|
||||
align: "left",
|
||||
size: "sm",
|
||||
size: "base",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const questionBlock: Block = {
|
||||
@@ -68,7 +106,7 @@ export function createFaqTemplateBlocks(opts: {
|
||||
type: "text",
|
||||
props: questionProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
const answerBlock: Block = {
|
||||
@@ -76,13 +114,13 @@ export function createFaqTemplateBlocks(opts: {
|
||||
type: "text",
|
||||
props: answerProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
return [questionBlock, answerBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...faqBlocks];
|
||||
const blocks: Block[] = [sectionBlock, titleBlock, subTitleBlock, ...faqBlocks];
|
||||
const lastSelectedId = faqBlocks[faqBlocks.length - 1]?.id ?? sectionId;
|
||||
|
||||
return { blocks, lastSelectedId };
|
||||
|
||||
@@ -9,15 +9,17 @@ export function createFooterTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 4 },
|
||||
{ id: `${sectionId}_col_2`, span: 4 },
|
||||
{ id: `${sectionId}_col_3`, span: 4 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "muted",
|
||||
paddingY: "md",
|
||||
// 푸터는 상대적으로 낮은 높이와 좁은 최대 폭을 사용한다.
|
||||
paddingYPx: 40,
|
||||
maxWidthPx: 800,
|
||||
paddingYPx: 64,
|
||||
maxWidthPx: 1120,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
@@ -30,37 +32,74 @@ export function createFooterTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const col1Id = `${sectionId}_col_1`;
|
||||
const col2Id = `${sectionId}_col_2`;
|
||||
const col3Id = `${sectionId}_col_3`;
|
||||
|
||||
// Col 1: Brand
|
||||
const brandId = createId();
|
||||
const brandProps: TextBlockProps = {
|
||||
text: "MyLanding",
|
||||
align: "left",
|
||||
size: "xl",
|
||||
bold: true,
|
||||
} as any;
|
||||
const brandBlock: Block = {
|
||||
id: brandId,
|
||||
type: "text",
|
||||
props: brandProps,
|
||||
sectionId,
|
||||
columnId: col1Id,
|
||||
};
|
||||
|
||||
const descId = createId();
|
||||
const descProps: TextBlockProps = {
|
||||
text: "더 나은 웹사이트를 위한 최고의 선택.",
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const descBlock: Block = {
|
||||
id: descId,
|
||||
type: "text",
|
||||
props: descProps,
|
||||
sectionId,
|
||||
columnId: col1Id,
|
||||
};
|
||||
|
||||
// Col 2: Links
|
||||
const linksId = createId();
|
||||
const linksProps: TextBlockProps = {
|
||||
text: "이용약관 · 개인정보처리방침",
|
||||
text: "서비스 소개\n요금제\n고객지원\n문의하기",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const linksBlock: Block = {
|
||||
id: linksId,
|
||||
type: "text",
|
||||
props: linksProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: col2Id,
|
||||
};
|
||||
|
||||
// Col 3: Copyright
|
||||
const copyrightId = createId();
|
||||
const copyrightProps: TextBlockProps = {
|
||||
text: "© 2025 MyLanding. All rights reserved.",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
text: "© 2025 MyLanding.\nAll rights reserved.",
|
||||
align: "right",
|
||||
size: "xs",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const copyrightBlock: Block = {
|
||||
id: copyrightId,
|
||||
type: "text",
|
||||
props: copyrightProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: col3Id,
|
||||
};
|
||||
|
||||
const blocks: Block[] = [sectionBlock, linksBlock, copyrightBlock];
|
||||
const blocks: Block[] = [sectionBlock, brandBlock, descBlock, linksBlock, copyrightBlock];
|
||||
const lastSelectedId = copyrightId;
|
||||
|
||||
return { blocks, lastSelectedId };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createPricingTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -33,47 +33,106 @@ export function createPricingTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const planDefinitions: Array<{ name: string; price: string }> = [
|
||||
const planDefinitions: Array<{ name: string; price: string; popular?: boolean }> = [
|
||||
{ name: "Basic", price: "₩9,900/월" },
|
||||
{ name: "Pro", price: "₩29,900/월" },
|
||||
{ name: "Pro", price: "₩29,900/월", popular: true },
|
||||
{ name: "Enterprise", price: "문의" },
|
||||
];
|
||||
|
||||
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const plan = planDefinitions[index] ?? planDefinitions[0];
|
||||
const isPopular = !!plan.popular;
|
||||
|
||||
const blocksInCol: Block[] = [];
|
||||
|
||||
// Popular Badge (only for middle)
|
||||
if (isPopular) {
|
||||
const badgeId = createId();
|
||||
const badgeProps: TextBlockProps = {
|
||||
text: "MOST POPULAR",
|
||||
align: "center",
|
||||
size: "xs",
|
||||
color: "primary",
|
||||
bold: true,
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: badgeId,
|
||||
type: "text",
|
||||
props: badgeProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
}
|
||||
|
||||
const nameId = createId();
|
||||
const priceId = createId();
|
||||
|
||||
const nameProps: TextBlockProps = {
|
||||
text: plan.name,
|
||||
align: "center",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const priceProps: TextBlockProps = {
|
||||
text: plan.price,
|
||||
align: "center",
|
||||
size: "base",
|
||||
} as any;
|
||||
|
||||
const nameBlock: Block = {
|
||||
blocksInCol.push({
|
||||
id: nameId,
|
||||
type: "text",
|
||||
props: nameProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
});
|
||||
|
||||
const priceBlock: Block = {
|
||||
const priceId = createId();
|
||||
const priceProps: TextBlockProps = {
|
||||
text: plan.price,
|
||||
align: "center",
|
||||
size: "xl", // Price larger
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: priceId,
|
||||
type: "text",
|
||||
props: priceProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
});
|
||||
|
||||
return [nameBlock, priceBlock];
|
||||
// Features list (simple text for now)
|
||||
const featuresId = createId();
|
||||
const featuresProps: TextBlockProps = {
|
||||
text: "• 모든 기본 기능\n• 이메일 지원\n• 1GB 스토리지",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: featuresId,
|
||||
type: "text",
|
||||
props: featuresProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
|
||||
// Button
|
||||
const buttonId = createId();
|
||||
const buttonProps: ButtonBlockProps = {
|
||||
label: isPopular ? "시작하기" : "선택하기",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "md",
|
||||
variant: isPopular ? "solid" : "outline",
|
||||
colorPalette: "primary",
|
||||
fullWidth: true,
|
||||
borderRadius: "md",
|
||||
textColorCustom: isPopular ? "#0b1120" : "#e2e8f0",
|
||||
fillColorCustom: isPopular ? "#0ea5e9" : "transparent",
|
||||
strokeColorCustom: "#0ea5e9",
|
||||
};
|
||||
blocksInCol.push({
|
||||
id: buttonId,
|
||||
type: "button",
|
||||
props: buttonProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
|
||||
return blocksInCol;
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...pricingBlocks];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createTeamTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -42,28 +42,49 @@ export function createTeamTemplateBlocks(opts: {
|
||||
const teamBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const m = memberDefinitions[index] ?? memberDefinitions[0];
|
||||
|
||||
const imageId = createId();
|
||||
const nameId = createId();
|
||||
const roleId = createId();
|
||||
const bioId = createId();
|
||||
|
||||
const imageProps: ImageBlockProps = {
|
||||
src: "https://via.placeholder.com/150x150/334155/94a3b8?text=User",
|
||||
alt: m.name,
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 120,
|
||||
borderRadius: "full",
|
||||
};
|
||||
|
||||
const nameProps: TextBlockProps = {
|
||||
text: m.name,
|
||||
align: "center",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const roleProps: TextBlockProps = {
|
||||
text: m.role,
|
||||
align: "center",
|
||||
size: "base",
|
||||
color: "primary",
|
||||
} as any;
|
||||
|
||||
const bioProps: TextBlockProps = {
|
||||
text: m.bio,
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const imageBlock: Block = {
|
||||
id: imageId,
|
||||
type: "image",
|
||||
props: imageProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
const nameBlock: Block = {
|
||||
id: nameId,
|
||||
type: "text",
|
||||
@@ -88,7 +109,7 @@ export function createTeamTemplateBlocks(opts: {
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
return [nameBlock, roleBlock, bioBlock];
|
||||
return [imageBlock, nameBlock, roleBlock, bioBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...teamBlocks];
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ export const metadata = {
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<html lang="ko" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-slate-950 text-slate-50">
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
@@ -8,6 +9,31 @@ import { PublicPageRenderer } from "@/features/editor/components/PublicPageRende
|
||||
export default function PreviewPage() {
|
||||
const blocks = useEditorStore((state) => state.blocks);
|
||||
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
|
||||
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
|
||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const slug = (projectConfig as any)?.slug?.trim?.();
|
||||
if (!slug) return;
|
||||
|
||||
const key = `pb:autosave:${slug}`;
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { blocks?: any[]; projectConfig?: any };
|
||||
if (Array.isArray(parsed.blocks)) {
|
||||
replaceBlocks(parsed.blocks as any);
|
||||
}
|
||||
if (parsed.projectConfig) {
|
||||
updateProjectConfig(parsed.projectConfig as any);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
||||
|
||||
const canvasStyle: CSSProperties = {};
|
||||
const preset = projectConfig?.canvasPreset ?? "full";
|
||||
@@ -17,6 +43,41 @@ export default function PreviewPage() {
|
||||
mainStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
||||
}
|
||||
|
||||
const handleExportZip = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
blocks,
|
||||
projectConfig,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const slug = (projectConfig?.slug ?? "").trim() || "page-builder-export";
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${slug}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("프리뷰 ZIP 내보내기 중 오류", error);
|
||||
}
|
||||
};
|
||||
|
||||
const widthPx =
|
||||
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
|
||||
? projectConfig.canvasWidthPx
|
||||
@@ -43,12 +104,29 @@ export default function PreviewPage() {
|
||||
<h1 className="text-xl font-semibold">Page Preview</h1>
|
||||
<p className="text-xs text-slate-400">빌더로 만든 페이지를 미리 보는 화면</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/editor"
|
||||
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"
|
||||
>
|
||||
에디터로 돌아가기
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
void handleExportZip();
|
||||
}}
|
||||
>
|
||||
페이지 파일로 내보내기 (ZIP)
|
||||
</button>
|
||||
<Link
|
||||
href="/projects"
|
||||
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"
|
||||
>
|
||||
프로젝트 목록
|
||||
</Link>
|
||||
<Link
|
||||
href="/editor"
|
||||
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"
|
||||
>
|
||||
에디터로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링하되, projectConfig 에 따라 최대 폭을 제한한다. */}
|
||||
<section className="flex flex-1 overflow-auto">
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FilePlus2,
|
||||
Trash2,
|
||||
Eye,
|
||||
Pencil,
|
||||
ListChecks,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ProjectListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<ProjectListItem[]>([]);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setStatus("loading");
|
||||
|
||||
const res = await fetch("/api/projects");
|
||||
if (!res.ok) {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as ProjectListItem[];
|
||||
if (!cancelled) {
|
||||
setProjects(Array.isArray(data) ? data : []);
|
||||
setStatus("idle");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
|
||||
setCurrentPage((prev) => Math.min(prev, totalPages));
|
||||
}, [projects.length, pageSize]);
|
||||
|
||||
const handleDelete = async (slug: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
"이 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects((prev) => prev.filter((p) => p.slug !== slug));
|
||||
setSelectedSlugs((prev) => prev.filter((s) => s !== slug));
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const totalCount = projects.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const pageProjects = projects.slice(startIndex, endIndex);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const slugs = selectedSlugs;
|
||||
if (slugs.length === 0) return;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
"선택한 프로젝트를 모두 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
slugs.map(async (slug) => {
|
||||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
||||
return { slug, ok: res.ok };
|
||||
}),
|
||||
);
|
||||
|
||||
const okSlugs = results.filter((r) => r.ok).map((r) => r.slug);
|
||||
|
||||
if (okSlugs.length === 0) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects((prev) => prev.filter((p) => !okSlugs.includes(p.slug)));
|
||||
setSelectedSlugs((prev) => prev.filter((s) => !okSlugs.includes(s)));
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
for (const slug of okSlugs) {
|
||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okSlugs.length !== slugs.length) {
|
||||
setStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
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="flex items-center gap-2 text-xs">
|
||||
<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"
|
||||
>
|
||||
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span>새 프로젝트 만들기</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||
{status === "error" && (
|
||||
<p className="text-xs text-red-300 mb-3">프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.</p>
|
||||
)}
|
||||
{projects.length === 0 && status === "idle" && (
|
||||
<p className="text-xs text-slate-400">아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.</p>
|
||||
)}
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2 text-[11px] text-slate-300">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
|
||||
<ListChecks className="w-3 h-3 text-slate-400" aria-hidden="true" />
|
||||
<span>
|
||||
선택된 프로젝트: <span className="font-semibold">{selectedSlugs.length}</span>개
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-red-700 px-2 py-1 text-red-200 hover:bg-red-900/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={selectedSlugs.length === 0}
|
||||
onClick={() => {
|
||||
void handleBulkDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>선택 삭제</span>
|
||||
</button>
|
||||
</div>
|
||||
<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-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
|
||||
aria-label="전체 선택"
|
||||
checked={
|
||||
pageProjects.length > 0 && pageProjects.every((p) => selectedSlugs.includes(p.slug))
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
const slugsOnPage = pageProjects.map((p) => p.slug);
|
||||
setSelectedSlugs((prev) => Array.from(new Set([...prev, ...slugsOnPage])));
|
||||
} else {
|
||||
const slugsOnPage = pageProjects.map((p) => p.slug);
|
||||
setSelectedSlugs((prev) => prev.filter((s) => !slugsOnPage.includes(s)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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 text-right">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageProjects.map((project) => {
|
||||
const checked = selectedSlugs.includes(project.slug);
|
||||
return (
|
||||
<tr key={project.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
||||
<td className="py-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
|
||||
aria-label={`${project.title} 선택`}
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSlugs((prev) =>
|
||||
prev.includes(project.slug) ? prev : [...prev, project.slug],
|
||||
);
|
||||
} else {
|
||||
setSelectedSlugs((prev) => prev.filter((s) => s !== project.slug));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-100">{project.title}</td>
|
||||
<td className="py-2 pr-4 text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{project.status}</td>
|
||||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||||
{new Date(project.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||||
{new Date(project.updatedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pl-4 pr-0 text-right text-[11px]">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Link
|
||||
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline"
|
||||
>
|
||||
<Pencil className="w-3 h-3" aria-hidden="true" />
|
||||
<span>편집</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-slate-300 hover:text-slate-100 underline-offset-2 hover:underline"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>미리보기</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
|
||||
onClick={() => {
|
||||
void handleDelete(project.slug);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>삭제</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-300">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
|
||||
전체 {totalCount}개 중{" "}
|
||||
<span className="font-semibold">
|
||||
{totalCount === 0 ? 0 : startIndex + 1}–{Math.min(endIndex, totalCount)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||
const page = idx + 1;
|
||||
const isActive = page === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`w-6 h-6 rounded-full text-[10px] flex items-center justify-center border transition-colors ${
|
||||
isActive
|
||||
? "bg-sky-600 text-white border-sky-500 shadow-sm"
|
||||
: "bg-slate-900 text-slate-300 border-slate-700 hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === 1}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
|
||||
<span>이전</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === totalPages}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
<span>다음</span>
|
||||
<ChevronRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,12 @@ import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
|
||||
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
|
||||
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
|
||||
|
||||
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록/폼 요소 블록
|
||||
// 블록 타입 정의: 텍스트/버튼/이미지/비디오/섹션/구분선/리스트/폼 블록/폼 요소 블록
|
||||
export type BlockType =
|
||||
| "text"
|
||||
| "button"
|
||||
| "image"
|
||||
| "video"
|
||||
| "section"
|
||||
| "divider"
|
||||
| "list"
|
||||
@@ -72,6 +73,9 @@ export interface TextBlockProps {
|
||||
// 커스텀 색상 값 (예: "#ff0000", "rgb(…)" 등)
|
||||
colorCustom?: string;
|
||||
|
||||
// 블록 배경색 커스텀 값 (예: "#123456")
|
||||
backgroundColorCustom?: string;
|
||||
|
||||
// 최대 너비 모드: 프리셋 또는 커스텀 값
|
||||
maxWidthMode?: "scale" | "custom";
|
||||
// 최대 너비 스케일
|
||||
@@ -127,12 +131,42 @@ export interface ImageBlockProps {
|
||||
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
|
||||
// 모서리 둥글기 px 값 (이미지에 한해서는 연속적인 px 단위 조정을 지원한다)
|
||||
borderRadiusPx?: number;
|
||||
// 이미지 카드를 감싸는 배경색 커스텀 값 (예: "#123456")
|
||||
backgroundColorCustom?: string;
|
||||
// 이미지 소스 타입: 업로드된 에셋 또는 외부 URL
|
||||
sourceType?: "asset" | "externalUrl";
|
||||
// 업로드된 에셋인 경우 Asset.id 를 저장해둔다.
|
||||
assetId?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoBlockProps {
|
||||
sourceUrl: string;
|
||||
sourceType?: "asset" | "externalUrl";
|
||||
assetId?: string | null;
|
||||
posterImageSrc?: string;
|
||||
posterSourceType?: "asset" | "externalUrl";
|
||||
posterAssetId?: string | null;
|
||||
platform?: "auto" | "youtube" | "vimeo" | "html5";
|
||||
align?: "left" | "center" | "right";
|
||||
widthMode?: "auto" | "full" | "fixed";
|
||||
widthPx?: number;
|
||||
aspectRatio?: "16:9" | "4:3" | "1:1";
|
||||
autoplay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
controls?: boolean;
|
||||
titleText?: string;
|
||||
ariaLabel?: string;
|
||||
captionText?: string;
|
||||
// HTML5 비디오 재생 범위(초 단위)
|
||||
startTimeSec?: number;
|
||||
endTimeSec?: number;
|
||||
// 카드 스타일: 배경색/패딩/모서리 둥글기
|
||||
backgroundColorCustom?: string;
|
||||
cardPaddingPx?: number;
|
||||
borderRadiusPx?: number;
|
||||
}
|
||||
|
||||
// 구분선 블록 속성
|
||||
export interface DividerBlockProps {
|
||||
align: "left" | "center" | "right";
|
||||
@@ -450,6 +484,8 @@ export interface ListBlockProps {
|
||||
fontSizeCustom?: string;
|
||||
lineHeightCustom?: string;
|
||||
textColorCustom?: string;
|
||||
// 리스트 전체를 감싸는 배경색 커스텀 값
|
||||
backgroundColorCustom?: string;
|
||||
// 불릿/간격
|
||||
bulletStyle?:
|
||||
| "disc"
|
||||
@@ -487,6 +523,20 @@ export interface SectionBlockProps {
|
||||
gapXPx?: number;
|
||||
alignItems?: "top" | "center" | "bottom";
|
||||
backgroundColorCustom?: string;
|
||||
// 섹션 배경 이미지
|
||||
backgroundImageSrc?: string;
|
||||
backgroundImageSourceType?: "asset" | "externalUrl";
|
||||
backgroundImageAssetId?: string | null;
|
||||
backgroundImageSize?: "auto" | "cover" | "contain";
|
||||
backgroundImagePosition?: "center" | "top" | "bottom" | "left" | "right";
|
||||
backgroundImageRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y";
|
||||
backgroundImagePositionMode?: "preset" | "custom";
|
||||
backgroundImagePositionXPercent?: number;
|
||||
backgroundImagePositionYPercent?: number;
|
||||
// 섹션 배경 비디오
|
||||
backgroundVideoSrc?: string;
|
||||
backgroundVideoSourceType?: "asset" | "externalUrl";
|
||||
backgroundVideoAssetId?: string | null;
|
||||
}
|
||||
|
||||
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
|
||||
@@ -593,7 +643,7 @@ export interface FormFieldConfig {
|
||||
}
|
||||
|
||||
// 폼 블록 속성
|
||||
export type FormSubmitTarget = "internal" | "webhook";
|
||||
export type FormSubmitTarget = "internal" | "webhook" | "both";
|
||||
export type FormPayloadFormat = "form" | "json";
|
||||
|
||||
export interface FormBlockProps {
|
||||
@@ -619,6 +669,8 @@ export interface FormBlockProps {
|
||||
formWidthMode?: "auto" | "full" | "fixed";
|
||||
formWidthPx?: number;
|
||||
marginYPx?: number;
|
||||
// 폼 전체를 감싸는 배경색 커스텀 값
|
||||
backgroundColorCustom?: string;
|
||||
}
|
||||
|
||||
export type CanvasPreset = "mobile" | "tablet" | "desktop" | "full" | "custom";
|
||||
@@ -632,6 +684,12 @@ export interface ProjectConfig {
|
||||
bodyBgColorHex?: string;
|
||||
headHtml?: string;
|
||||
trackingScript?: string;
|
||||
// SEO / 메타 설정 (15.1)
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
seoOgImageUrl?: string;
|
||||
seoCanonicalUrl?: string;
|
||||
seoNoIndex?: boolean;
|
||||
}
|
||||
|
||||
// 공통 블록 모델
|
||||
@@ -642,6 +700,7 @@ export interface Block {
|
||||
| TextBlockProps
|
||||
| ButtonBlockProps
|
||||
| ImageBlockProps
|
||||
| VideoBlockProps
|
||||
| SectionBlockProps
|
||||
| DividerBlockProps
|
||||
| ListBlockProps
|
||||
@@ -667,6 +726,7 @@ export interface EditorState {
|
||||
addTextBlock: () => void;
|
||||
addButtonBlock: () => void;
|
||||
addImageBlock: () => void;
|
||||
addVideoBlock: () => void;
|
||||
addDividerBlock: () => void;
|
||||
addListBlock: () => void;
|
||||
addSectionBlock: () => void;
|
||||
@@ -749,6 +809,12 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
bodyBgColorHex: "#020617",
|
||||
headHtml: "",
|
||||
trackingScript: "",
|
||||
// SEO 기본값: 필요할 때만 값을 채우고, 없으면 title 등을 사용한다.
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
seoOgImageUrl: "",
|
||||
seoCanonicalUrl: "",
|
||||
seoNoIndex: false,
|
||||
},
|
||||
|
||||
updateProjectConfig: (partial: Partial<ProjectConfig>) => {
|
||||
@@ -804,14 +870,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
|
||||
addHeroTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -827,14 +911,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
|
||||
addFeaturesTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -845,14 +947,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
|
||||
addBlogTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -863,14 +983,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
|
||||
addTeamTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -881,14 +1019,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
|
||||
addFooterTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -899,14 +1055,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
|
||||
addCtaTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -917,7 +1091,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
|
||||
addFaqTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
|
||||
sectionId,
|
||||
@@ -925,7 +1109,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -936,7 +1127,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
|
||||
addPricingTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
|
||||
sectionId,
|
||||
@@ -944,7 +1145,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -955,7 +1163,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
|
||||
// Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
|
||||
addTestimonialsTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
||||
if (target) {
|
||||
sectionId = target.id;
|
||||
replacingSectionId = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
|
||||
sectionId,
|
||||
@@ -963,7 +1181,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
});
|
||||
|
||||
set((state: EditorState) => {
|
||||
const newBlocks = [...state.blocks, ...templateBlocks];
|
||||
let baseBlocks = state.blocks as Block[];
|
||||
if (replacingSectionId) {
|
||||
baseBlocks = baseBlocks.filter(
|
||||
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
const newBlocks = [...baseBlocks, ...templateBlocks];
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -1014,6 +1239,52 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
}));
|
||||
},
|
||||
|
||||
// 비디오 블록 추가: 기본 소스/정렬/비율 설정과 함께 생성 후 선택 상태로 만든다
|
||||
addVideoBlock: () => {
|
||||
const id = createId();
|
||||
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId: string | null = null;
|
||||
let columnId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
||||
if (target) {
|
||||
if (target.type === "section") {
|
||||
const sProps = target.props as SectionBlockProps;
|
||||
sectionId = target.id;
|
||||
columnId = sProps.columns?.[0]?.id ?? null;
|
||||
} else {
|
||||
sectionId = (target as any).sectionId ?? null;
|
||||
columnId = (target as any).columnId ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "video",
|
||||
props: {
|
||||
sourceUrl: "",
|
||||
platform: "auto",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
controls: true,
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
muted: false,
|
||||
},
|
||||
sectionId,
|
||||
columnId,
|
||||
};
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
// 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다
|
||||
addFormInputBlock: () => {
|
||||
const id = createId();
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export type ButtonAlign = "left" | "center" | "right" | null | undefined;
|
||||
export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl" | null | undefined;
|
||||
export type ButtonVariant = "solid" | "outline" | "ghost" | null | undefined;
|
||||
export type ButtonColorPalette = "primary" | "muted" | "danger" | "success" | "neutral" | null | undefined;
|
||||
export type ButtonRadius = "none" | "sm" | "md" | "lg" | "full" | null | undefined;
|
||||
|
||||
export interface ButtonStyleInput {
|
||||
align?: ButtonAlign;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
colorPalette?: ButtonColorPalette;
|
||||
borderRadius?: ButtonRadius;
|
||||
fullWidth?: boolean | null | undefined;
|
||||
widthMode?: "auto" | "full" | "fixed" | null | undefined;
|
||||
widthPx?: number | null | undefined;
|
||||
paddingX?: number | null | undefined;
|
||||
paddingY?: number | null | undefined;
|
||||
fillColorCustom?: string | null | undefined;
|
||||
strokeColorCustom?: string | null | undefined;
|
||||
textColorCustom?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ButtonPbTokens {
|
||||
alignClass: string;
|
||||
sizeClass: string;
|
||||
variantClass: string;
|
||||
radiusClass: string;
|
||||
inlineStyles: string[];
|
||||
}
|
||||
|
||||
export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
|
||||
const align = input.align === "center" ? "center" : input.align === "right" ? "right" : "left";
|
||||
const alignClass =
|
||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
||||
|
||||
const sizeToken = (input.size as ButtonSize | null) ?? "md";
|
||||
const sizeMap: Record<Exclude<ButtonSize, null | undefined>, string> = {
|
||||
xs: "pb-btn-size-xs",
|
||||
sm: "pb-btn-size-sm",
|
||||
md: "pb-btn-size-md",
|
||||
lg: "pb-btn-size-lg",
|
||||
xl: "pb-btn-size-xl",
|
||||
};
|
||||
const sizeClass = sizeMap[sizeToken as Exclude<ButtonSize, null | undefined>] ?? "pb-btn-size-md";
|
||||
|
||||
const variant = (input.variant as ButtonVariant | null) ?? "solid";
|
||||
const palette = (input.colorPalette as ButtonColorPalette | null) ?? "primary";
|
||||
const variantClass = `pb-btn-variant-${variant}-${palette}`;
|
||||
|
||||
const radiusToken = (input.borderRadius as ButtonRadius | null) ?? "md";
|
||||
const radiusMap: Record<Exclude<ButtonRadius, null | undefined>, string> = {
|
||||
none: "pb-btn-radius-none",
|
||||
sm: "pb-btn-radius-sm",
|
||||
md: "pb-btn-radius-md",
|
||||
lg: "pb-btn-radius-lg",
|
||||
full: "pb-btn-radius-full",
|
||||
};
|
||||
const radiusClass = radiusMap[radiusToken as Exclude<ButtonRadius, null | undefined>] ?? "";
|
||||
|
||||
const inlineStyles: string[] = [];
|
||||
|
||||
const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto");
|
||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
||||
inlineStyles.push(`width:${pxToEm(input.widthPx)}`);
|
||||
}
|
||||
|
||||
if (typeof input.paddingX === "number") {
|
||||
const paddingInline = pxToEm(input.paddingX);
|
||||
inlineStyles.push(`padding-left:${paddingInline}`);
|
||||
inlineStyles.push(`padding-right:${paddingInline}`);
|
||||
}
|
||||
|
||||
if (typeof input.paddingY === "number") {
|
||||
const paddingBlock = pxToEm(input.paddingY);
|
||||
inlineStyles.push(`padding-top:${paddingBlock}`);
|
||||
inlineStyles.push(`padding-bottom:${paddingBlock}`);
|
||||
}
|
||||
|
||||
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
|
||||
inlineStyles.push(`background-color:${input.fillColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
if (input.strokeColorCustom && input.strokeColorCustom.trim() !== "") {
|
||||
inlineStyles.push(`border-color:${input.strokeColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
if (input.textColorCustom && input.textColorCustom.trim() !== "") {
|
||||
inlineStyles.push(`color:${input.textColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
sizeClass,
|
||||
variantClass,
|
||||
radiusClass,
|
||||
inlineStyles,
|
||||
};
|
||||
}
|
||||
|
||||
// 퍼블릭 렌더러용 버튼 스타일 토큰
|
||||
export interface ButtonPublicTokens {
|
||||
alignClass: string;
|
||||
widthClass: string;
|
||||
styleOverrides: CSSProperties;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
const convertPxStringToEm = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const px = parseFloat(match[0]);
|
||||
if (!Number.isFinite(px)) return null;
|
||||
return pxToEm(px);
|
||||
};
|
||||
|
||||
export function computeButtonPublicTokens(props: ButtonBlockProps): ButtonPublicTokens {
|
||||
const align = props.align ?? "left";
|
||||
const alignClass =
|
||||
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
|
||||
|
||||
const styleOverrides: CSSProperties = {};
|
||||
|
||||
// 폰트 크기: fontSizeCustom(px)는 em 으로 변환, 그 외는 그대로 사용
|
||||
const fontSizeEm = convertPxStringToEm(props.fontSizeCustom);
|
||||
if (fontSizeEm) {
|
||||
styleOverrides.fontSize = fontSizeEm;
|
||||
}
|
||||
|
||||
// lineHeightCustom 은 px 단위든 아니든 그대로 사용 (기존 PublicPageRenderer 로직 유지)
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
styleOverrides.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
|
||||
// letterSpacingCustom 은 px 인 경우만 em 으로 변환, 그 외는 그대로 사용
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
const letterSpacing = props.letterSpacingCustom.trim();
|
||||
if (letterSpacing.endsWith("px")) {
|
||||
const emValue = convertPxStringToEm(letterSpacing);
|
||||
if (emValue) {
|
||||
styleOverrides.letterSpacing = emValue;
|
||||
}
|
||||
} else {
|
||||
styleOverrides.letterSpacing = letterSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
styleOverrides.width = pxToEm(props.widthPx);
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
styleOverrides.paddingInline = pxToEm(props.paddingX);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
styleOverrides.paddingBlock = pxToEm(props.paddingY);
|
||||
}
|
||||
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
styleOverrides.backgroundColor = props.fillColorCustom;
|
||||
}
|
||||
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
styleOverrides.borderColor = props.strokeColorCustom;
|
||||
styleOverrides.borderWidth = "1px";
|
||||
styleOverrides.borderStyle = "solid";
|
||||
}
|
||||
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
styleOverrides.color = props.textColorCustom;
|
||||
}
|
||||
|
||||
const radiusToken = props.borderRadius ?? "md";
|
||||
const radiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 12
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
styleOverrides.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
|
||||
|
||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
widthClass,
|
||||
styleOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ButtonEditorTokens {
|
||||
wrapperWidthClass: string;
|
||||
buttonStyle: CSSProperties;
|
||||
}
|
||||
|
||||
export function computeButtonEditorTokens(props: ButtonBlockProps): ButtonEditorTokens {
|
||||
const baseFullWidth = props.fullWidth ?? false;
|
||||
const widthMode = props.widthMode ?? (baseFullWidth ? "full" : "auto");
|
||||
const isFullWidth = widthMode === "full" || baseFullWidth;
|
||||
|
||||
const wrapperWidthClass = isFullWidth ? "w-full" : "inline-block";
|
||||
|
||||
const buttonStyle: CSSProperties = {};
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
buttonStyle.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
buttonStyle.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
buttonStyle.letterSpacing = props.letterSpacingCustom;
|
||||
}
|
||||
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
buttonStyle.backgroundColor = props.fillColorCustom;
|
||||
}
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
buttonStyle.borderColor = props.strokeColorCustom;
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
buttonStyle.color = props.textColorCustom;
|
||||
}
|
||||
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
buttonStyle.width = `${props.widthPx}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
buttonStyle.paddingInline = `${props.paddingX}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
buttonStyle.paddingBlock = `${props.paddingY}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
wrapperWidthClass,
|
||||
buttonStyle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export interface DividerEscapers {
|
||||
escapeAttr: (value: string) => string;
|
||||
}
|
||||
|
||||
export interface DividerExportTokens {
|
||||
style: string;
|
||||
}
|
||||
|
||||
export const computeDividerExportTokens = (
|
||||
props: DividerBlockProps,
|
||||
escapers: DividerEscapers,
|
||||
): DividerExportTokens => {
|
||||
const { escapeAttr } = escapers;
|
||||
|
||||
const thickness = props.thickness === "medium" ? "2px" : "1px";
|
||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
||||
const color = colorRaw !== "" ? colorRaw : "#475569";
|
||||
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;`;
|
||||
|
||||
return { style };
|
||||
};
|
||||
|
||||
export interface DividerEditorTokens {
|
||||
thicknessClass: string;
|
||||
innerWidthClass: string;
|
||||
widthPx?: number;
|
||||
borderColor: string;
|
||||
marginPx: number;
|
||||
}
|
||||
|
||||
export const computeDividerEditorTokens = (props: DividerBlockProps): DividerEditorTokens => {
|
||||
const thicknessClass = props.thickness === "medium" ? "border-t-2" : "border-t";
|
||||
|
||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
||||
const borderColor = colorRaw !== "" ? colorRaw : "#475569";
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
let innerWidthClass = "w-full";
|
||||
let widthPx: number | undefined;
|
||||
|
||||
if (widthMode === "auto") {
|
||||
innerWidthClass = "w-1/2";
|
||||
} else if (widthMode === "fixed") {
|
||||
innerWidthClass = "";
|
||||
if (typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
widthPx = props.widthPx;
|
||||
} else {
|
||||
widthPx = 320;
|
||||
}
|
||||
}
|
||||
|
||||
const marginPx =
|
||||
typeof props.marginYPx === "number"
|
||||
? props.marginYPx
|
||||
: props.marginY === "sm"
|
||||
? 8
|
||||
: props.marginY === "lg"
|
||||
? 24
|
||||
: 16;
|
||||
|
||||
return {
|
||||
thicknessClass,
|
||||
innerWidthClass,
|
||||
widthPx,
|
||||
borderColor,
|
||||
marginPx,
|
||||
};
|
||||
};
|
||||
|
||||
export interface DividerPublicTokens {
|
||||
alignClass: string;
|
||||
thicknessClass: string;
|
||||
innerWidthClass: string;
|
||||
wrapperMarginEm: string;
|
||||
innerStyle: CSSProperties;
|
||||
lineStyle: CSSProperties;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export const computeDividerPublicTokens = (props: DividerBlockProps): DividerPublicTokens => {
|
||||
const alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "right"
|
||||
? "items-end"
|
||||
: "items-start";
|
||||
|
||||
const thicknessClass = props.thickness === "medium" ? "h-[2px]" : "h-px";
|
||||
|
||||
const marginPx =
|
||||
typeof props.marginYPx === "number"
|
||||
? props.marginYPx
|
||||
: props.marginY === "sm"
|
||||
? 8
|
||||
: props.marginY === "lg"
|
||||
? 24
|
||||
: 16;
|
||||
const wrapperMarginEm = pxToEm(marginPx);
|
||||
|
||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
||||
const backgroundColor = colorRaw !== "" ? colorRaw : "#475569";
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
const innerStyle: CSSProperties = {};
|
||||
let innerWidthClass = "w-full";
|
||||
const lineStyle: CSSProperties = {
|
||||
backgroundColor,
|
||||
};
|
||||
|
||||
if (widthMode === "auto") {
|
||||
innerWidthClass = "w-1/2";
|
||||
} else if (widthMode === "fixed") {
|
||||
innerWidthClass = "";
|
||||
innerStyle.width = "auto";
|
||||
|
||||
if (typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
lineStyle.width = pxToEm(props.widthPx);
|
||||
} else {
|
||||
lineStyle.width = pxToEm(320);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
thicknessClass,
|
||||
innerWidthClass,
|
||||
wrapperMarginEm,
|
||||
innerStyle,
|
||||
lineStyle,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,980 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type {
|
||||
Block,
|
||||
ButtonBlockProps,
|
||||
FormBlockProps,
|
||||
FormCheckboxBlockProps,
|
||||
FormCheckboxOption,
|
||||
FormFieldConfig,
|
||||
FormInputBlockProps,
|
||||
FormRadioBlockProps,
|
||||
FormRadioOption,
|
||||
FormSelectBlockProps,
|
||||
FormSelectOption,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
|
||||
export interface FormStyleEscapers {
|
||||
escapeAttr: (value: string) => string;
|
||||
}
|
||||
|
||||
const defaultEscapers: FormStyleEscapers = {
|
||||
escapeAttr: (value) => value,
|
||||
};
|
||||
|
||||
export interface FormInputExportTokens {
|
||||
labelStyleAttr: string;
|
||||
inputStyleAttr: string;
|
||||
}
|
||||
|
||||
export interface FormSelectExportTokens {
|
||||
labelStyleAttr: string;
|
||||
selectStyleAttr: string;
|
||||
}
|
||||
|
||||
export interface FormOptionGroupExportTokens {
|
||||
labelStyleAttr: string;
|
||||
optionStyleAttr: string;
|
||||
}
|
||||
|
||||
const normalizeTextColor = (raw: unknown): string => {
|
||||
if (typeof raw !== "string") return "";
|
||||
const trimmed = raw.trim();
|
||||
return trimmed !== "" ? trimmed : "";
|
||||
};
|
||||
|
||||
const computeRadiusPx = (radius: unknown): number => {
|
||||
const token = typeof radius === "string" ? radius : "md";
|
||||
if (token === "none") return 0;
|
||||
if (token === "sm") return 2;
|
||||
if (token === "lg") return 6;
|
||||
if (token === "full") return 9999;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const computeWidthMode = (props: { widthMode?: string; fullWidth?: boolean }): "auto" | "full" | "fixed" => {
|
||||
if (typeof props.widthMode === "string") {
|
||||
return props.widthMode as "auto" | "full" | "fixed";
|
||||
}
|
||||
if (props.fullWidth) {
|
||||
return "full";
|
||||
}
|
||||
return "auto";
|
||||
};
|
||||
|
||||
export const computeFormInputExportTokens = (
|
||||
props: FormInputBlockProps,
|
||||
escapers: FormStyleEscapers = defaultEscapers,
|
||||
): FormInputExportTokens => {
|
||||
const { escapeAttr } = escapers;
|
||||
|
||||
const textColorRaw = normalizeTextColor(props.textColorCustom);
|
||||
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
||||
|
||||
const inputStyleParts: string[] = [];
|
||||
if (textColorRaw) {
|
||||
inputStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
||||
}
|
||||
|
||||
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||
inputStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
||||
inputStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
const em = props.paddingX / 16;
|
||||
inputStyleParts.push(`padding-left:${em}em`);
|
||||
inputStyleParts.push(`padding-right:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const em = props.paddingY / 16;
|
||||
inputStyleParts.push(`padding-top:${em}em`);
|
||||
inputStyleParts.push(`padding-bottom:${em}em`);
|
||||
}
|
||||
|
||||
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
const em = props.widthPx / 16;
|
||||
inputStyleParts.push(`width:${em}em`);
|
||||
}
|
||||
|
||||
const radiusPx = computeRadiusPx(props.borderRadius);
|
||||
inputStyleParts.push(`border-radius:${radiusPx}px`);
|
||||
|
||||
const inputStyleAttr = inputStyleParts.length > 0 ? ` style="${inputStyleParts.join(";")}"` : "";
|
||||
|
||||
return {
|
||||
labelStyleAttr,
|
||||
inputStyleAttr,
|
||||
};
|
||||
};
|
||||
|
||||
export const computeFormSelectExportTokens = (
|
||||
props: FormSelectBlockProps,
|
||||
escapers: FormStyleEscapers = defaultEscapers,
|
||||
): FormSelectExportTokens => {
|
||||
const { escapeAttr } = escapers;
|
||||
|
||||
const textColorRaw = normalizeTextColor(props.textColorCustom);
|
||||
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
||||
|
||||
const selectStyleParts: string[] = [];
|
||||
if (textColorRaw) {
|
||||
selectStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
||||
}
|
||||
|
||||
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
const em = props.widthPx / 16;
|
||||
selectStyleParts.push(`width:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
const em = props.paddingX / 16;
|
||||
selectStyleParts.push(`padding-left:${em}em`);
|
||||
selectStyleParts.push(`padding-right:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const em = props.paddingY / 16;
|
||||
selectStyleParts.push(`padding-top:${em}em`);
|
||||
selectStyleParts.push(`padding-bottom:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||
selectStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
||||
selectStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
const radiusPx = computeRadiusPx(props.borderRadius);
|
||||
selectStyleParts.push(`border-radius:${radiusPx}px`);
|
||||
|
||||
const selectStyleAttr = selectStyleParts.length > 0 ? ` style="${selectStyleParts.join(";")}"` : "";
|
||||
|
||||
return {
|
||||
labelStyleAttr,
|
||||
selectStyleAttr,
|
||||
};
|
||||
};
|
||||
|
||||
const computeOptionGroupExportTokensBase = (
|
||||
props: { textColorCustom?: string; paddingX?: number; paddingY?: number; fillColorCustom?: string; strokeColorCustom?: string; borderRadius?: string },
|
||||
escapers: FormStyleEscapers,
|
||||
): FormOptionGroupExportTokens => {
|
||||
const { escapeAttr } = escapers;
|
||||
|
||||
const textColorRaw = normalizeTextColor(props.textColorCustom);
|
||||
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
||||
|
||||
const optionStyleParts: string[] = [];
|
||||
if (textColorRaw) {
|
||||
optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
const em = props.paddingX / 16;
|
||||
optionStyleParts.push(`padding-left:${em}em`);
|
||||
optionStyleParts.push(`padding-right:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const em = props.paddingY / 16;
|
||||
optionStyleParts.push(`padding-top:${em}em`);
|
||||
optionStyleParts.push(`padding-bottom:${em}em`);
|
||||
}
|
||||
|
||||
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
|
||||
optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
||||
optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
||||
optionStyleParts.push("border-width:1px");
|
||||
optionStyleParts.push("border-style:solid");
|
||||
}
|
||||
|
||||
const radiusPx = computeRadiusPx(props.borderRadius);
|
||||
optionStyleParts.push(`border-radius:${radiusPx}px`);
|
||||
|
||||
const optionStyleAttr = optionStyleParts.length > 0 ? ` style="${optionStyleParts.join(";")}"` : "";
|
||||
|
||||
return {
|
||||
labelStyleAttr,
|
||||
optionStyleAttr,
|
||||
};
|
||||
};
|
||||
|
||||
export const computeFormCheckboxExportTokens = (
|
||||
props: FormCheckboxBlockProps,
|
||||
escapers: FormStyleEscapers = defaultEscapers,
|
||||
): FormOptionGroupExportTokens =>
|
||||
computeOptionGroupExportTokensBase(
|
||||
{
|
||||
textColorCustom: props.textColorCustom,
|
||||
paddingX: props.paddingX,
|
||||
paddingY: props.paddingY,
|
||||
fillColorCustom: props.fillColorCustom,
|
||||
strokeColorCustom: props.strokeColorCustom,
|
||||
borderRadius: props.borderRadius,
|
||||
},
|
||||
escapers,
|
||||
);
|
||||
|
||||
export interface FormInputEditorTokens {
|
||||
fieldStyle: CSSProperties;
|
||||
widthClass: string;
|
||||
inputAlignClass: string;
|
||||
}
|
||||
|
||||
export interface FormFieldEditorTokens {
|
||||
fieldStyle: CSSProperties;
|
||||
}
|
||||
|
||||
export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormInputEditorTokens => {
|
||||
const fieldStyle: CSSProperties = {};
|
||||
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = props.textColorCustom;
|
||||
}
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = props.fillColorCustom;
|
||||
}
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = props.strokeColorCustom;
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 에디터에서는 px 기반 padding/타이포 값을 그대로 fieldStyle 에 반영해 미리보기에서 변화가 보이도록 한다.
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
fieldStyle.paddingInline = `${props.paddingX}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
fieldStyle.paddingBlock = `${props.paddingY}px`;
|
||||
}
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
fieldStyle.fontSize = props.fontSizeCustom.trim();
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
fieldStyle.lineHeight = props.lineHeightCustom.trim();
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
|
||||
}
|
||||
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const isInline = labelLayout === "inline";
|
||||
|
||||
const align = props.align ?? "left";
|
||||
const inputAlignClass =
|
||||
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
|
||||
|
||||
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
|
||||
let widthClass = "w-full";
|
||||
if (isInline) {
|
||||
if (widthMode === "fixed") {
|
||||
// 고정 너비일 때는 flex 레이아웃의 너비 확장을 막기 위해 flex-none 으로 둔다.
|
||||
widthClass = "flex-none";
|
||||
} else if (widthMode === "full") {
|
||||
widthClass = "flex-1";
|
||||
} else {
|
||||
// auto: 인라인 레이아웃에서 내용 기반 너비 사용
|
||||
widthClass = "";
|
||||
}
|
||||
} else {
|
||||
if (widthMode === "fixed") {
|
||||
widthClass = ""; // 고정 너비는 style.width 로만 제어
|
||||
} else {
|
||||
widthClass = "w-full";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fieldStyle,
|
||||
widthClass,
|
||||
inputAlignClass,
|
||||
};
|
||||
};
|
||||
|
||||
export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): FormFieldEditorTokens => {
|
||||
const fieldStyle: CSSProperties = {};
|
||||
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = props.textColorCustom;
|
||||
}
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = props.fillColorCustom;
|
||||
}
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = props.strokeColorCustom;
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 체크박스/라디오 그룹도 에디터에서 padding/타이포 스타일을 일부 반영해준다.
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
fieldStyle.paddingInline = `${props.paddingX}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
fieldStyle.paddingBlock = `${props.paddingY}px`;
|
||||
}
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
fieldStyle.fontSize = props.fontSizeCustom.trim();
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
fieldStyle.lineHeight = props.lineHeightCustom.trim();
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
|
||||
}
|
||||
|
||||
return { fieldStyle };
|
||||
};
|
||||
|
||||
export const computeFormOptionGroupEditorTokens = (
|
||||
props: FormCheckboxBlockProps | FormRadioBlockProps,
|
||||
): FormFieldEditorTokens => {
|
||||
const fieldStyle: CSSProperties = {};
|
||||
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = props.textColorCustom;
|
||||
}
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = props.fillColorCustom;
|
||||
}
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = props.strokeColorCustom;
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
fieldStyle.paddingInline = `${props.paddingX}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
fieldStyle.paddingBlock = `${props.paddingY}px`;
|
||||
}
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
fieldStyle.fontSize = props.fontSizeCustom.trim();
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
fieldStyle.lineHeight = props.lineHeightCustom.trim();
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
|
||||
}
|
||||
|
||||
return { fieldStyle };
|
||||
};
|
||||
|
||||
export const computeFormRadioExportTokens = (
|
||||
props: FormRadioBlockProps,
|
||||
escapers: FormStyleEscapers = defaultEscapers,
|
||||
): FormOptionGroupExportTokens =>
|
||||
computeOptionGroupExportTokensBase(
|
||||
{
|
||||
textColorCustom: props.textColorCustom,
|
||||
paddingX: props.paddingX,
|
||||
paddingY: props.paddingY,
|
||||
fillColorCustom: props.fillColorCustom,
|
||||
strokeColorCustom: props.strokeColorCustom,
|
||||
borderRadius: props.borderRadius,
|
||||
},
|
||||
escapers,
|
||||
);
|
||||
|
||||
// 퍼블릭 렌더러용 폼 필드 스타일 토큰
|
||||
|
||||
export interface FormInputPublicTokens {
|
||||
wrapperLayoutClass: string;
|
||||
wrapperStyle: CSSProperties;
|
||||
widthClass: string;
|
||||
inputStyle: CSSProperties;
|
||||
}
|
||||
|
||||
export interface FormSelectPublicTokens {
|
||||
widthClass: string;
|
||||
wrapperStyle: CSSProperties;
|
||||
selectStyle: CSSProperties;
|
||||
}
|
||||
|
||||
export interface FormOptionGroupPublicTokens {
|
||||
widthClass: string;
|
||||
textSizeClass: string;
|
||||
groupStyle: CSSProperties;
|
||||
groupTextStyle: CSSProperties;
|
||||
optionContainerStyle: CSSProperties;
|
||||
optionTextStyle: CSSProperties;
|
||||
optionsStyle: CSSProperties;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
const convertPxStringToEm = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const px = parseFloat(match[0]);
|
||||
if (!Number.isFinite(px)) return null;
|
||||
return pxToEm(px);
|
||||
};
|
||||
|
||||
export const computeFormInputPublicTokens = (props: FormInputBlockProps): FormInputPublicTokens => {
|
||||
const align = props.align ?? "left";
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
|
||||
const isInlineLayout = labelLayout === "inline";
|
||||
const wrapperLayoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
|
||||
if (isInlineLayout) {
|
||||
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
|
||||
wrapperStyle.columnGap = pxToEm(gapPx);
|
||||
}
|
||||
|
||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
textAlign: align,
|
||||
};
|
||||
|
||||
// 너비: fixed 모드일 때 widthPx 를 em 단위로 설정한다.
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
inputStyle.width = pxToEm(props.widthPx);
|
||||
}
|
||||
|
||||
// 타이포 관련 커스텀 값
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
const fontSizeValue = props.fontSizeCustom.trim();
|
||||
if (fontSizeValue.endsWith("px")) {
|
||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
||||
if (fontSizeEm) {
|
||||
inputStyle.fontSize = fontSizeEm;
|
||||
}
|
||||
} else {
|
||||
inputStyle.fontSize = fontSizeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
const lineHeightValue = props.lineHeightCustom.trim();
|
||||
if (lineHeightValue.endsWith("px")) {
|
||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
||||
if (lineHeightEm) {
|
||||
inputStyle.lineHeight = lineHeightEm;
|
||||
}
|
||||
} else {
|
||||
inputStyle.lineHeight = lineHeightValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
const letterSpacingValue = props.letterSpacingCustom.trim();
|
||||
if (letterSpacingValue.endsWith("px")) {
|
||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
||||
if (letterSpacingEm) {
|
||||
inputStyle.letterSpacing = letterSpacingEm;
|
||||
}
|
||||
} else {
|
||||
inputStyle.letterSpacing = letterSpacingValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 색상 관련 커스텀 값
|
||||
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) {
|
||||
inputStyle.paddingInline = pxToEm(props.paddingX);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
inputStyle.paddingBlock = pxToEm(props.paddingY);
|
||||
}
|
||||
|
||||
// 모서리 둥글기: borderRadius 토큰을 px 값으로 변환한다.
|
||||
const radiusToken = props.borderRadius ?? "md";
|
||||
const radiusPx =
|
||||
radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4;
|
||||
inputStyle.borderRadius = `${radiusPx}px`;
|
||||
|
||||
return {
|
||||
wrapperLayoutClass,
|
||||
wrapperStyle,
|
||||
widthClass,
|
||||
inputStyle,
|
||||
};
|
||||
};
|
||||
|
||||
export const computeFormSelectPublicTokens = (props: FormSelectBlockProps): FormSelectPublicTokens => {
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
const selectStyle: CSSProperties = {};
|
||||
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
wrapperStyle.width = pxToEm(props.widthPx);
|
||||
}
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
const fontSizeValue = props.fontSizeCustom.trim();
|
||||
if (fontSizeValue.endsWith("px")) {
|
||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
||||
if (fontSizeEm) {
|
||||
wrapperStyle.fontSize = fontSizeEm;
|
||||
selectStyle.fontSize = fontSizeEm;
|
||||
}
|
||||
} else {
|
||||
wrapperStyle.fontSize = fontSizeValue;
|
||||
selectStyle.fontSize = fontSizeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
const lineHeightValue = props.lineHeightCustom.trim();
|
||||
if (lineHeightValue.endsWith("px")) {
|
||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
||||
if (lineHeightEm) {
|
||||
wrapperStyle.lineHeight = lineHeightEm;
|
||||
selectStyle.lineHeight = lineHeightEm;
|
||||
}
|
||||
} else {
|
||||
wrapperStyle.lineHeight = lineHeightValue;
|
||||
selectStyle.lineHeight = lineHeightValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
const letterSpacingValue = props.letterSpacingCustom.trim();
|
||||
if (letterSpacingValue.endsWith("px")) {
|
||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
||||
if (letterSpacingEm) {
|
||||
wrapperStyle.letterSpacing = letterSpacingEm;
|
||||
selectStyle.letterSpacing = letterSpacingEm;
|
||||
}
|
||||
} else {
|
||||
wrapperStyle.letterSpacing = letterSpacingValue;
|
||||
selectStyle.letterSpacing = letterSpacingValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다.
|
||||
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 에 적용한다.
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
selectStyle.backgroundColor = props.fillColorCustom.trim();
|
||||
}
|
||||
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
selectStyle.borderColor = props.strokeColorCustom.trim();
|
||||
}
|
||||
|
||||
// 모서리 둥글기: borderRadius 토큰을 px 로 변환한다.
|
||||
const selectRadiusToken = props.borderRadius ?? "md";
|
||||
const selectRadiusPx =
|
||||
selectRadiusToken === "none"
|
||||
? 0
|
||||
: selectRadiusToken === "sm"
|
||||
? 2
|
||||
: selectRadiusToken === "lg"
|
||||
? 6
|
||||
: selectRadiusToken === "full"
|
||||
? 9999
|
||||
: 4;
|
||||
selectStyle.borderRadius = `${selectRadiusPx}px`;
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
const paddingEm = pxToEm(props.paddingX);
|
||||
selectStyle.paddingInline = paddingEm;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const paddingEm = pxToEm(props.paddingY);
|
||||
selectStyle.paddingBlock = paddingEm;
|
||||
}
|
||||
|
||||
return {
|
||||
widthClass,
|
||||
wrapperStyle,
|
||||
selectStyle,
|
||||
};
|
||||
};
|
||||
|
||||
const computeOptionGroupPublicTokensBase = (
|
||||
props: FormCheckboxBlockProps | FormRadioBlockProps,
|
||||
): FormOptionGroupPublicTokens => {
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
||||
|
||||
const groupStyle: CSSProperties = {};
|
||||
const optionContainerStyle: CSSProperties = {};
|
||||
const optionTextStyle: CSSProperties = {};
|
||||
const groupTextStyle: CSSProperties = {};
|
||||
const optionsStyle: CSSProperties = {};
|
||||
|
||||
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
|
||||
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
groupStyle.width = pxToEm(props.widthPx);
|
||||
}
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
const fontSizeValue = props.fontSizeCustom.trim();
|
||||
if (fontSizeValue.endsWith("px")) {
|
||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
||||
if (fontSizeEm) {
|
||||
groupTextStyle.fontSize = fontSizeEm;
|
||||
optionTextStyle.fontSize = fontSizeEm;
|
||||
}
|
||||
} else {
|
||||
groupTextStyle.fontSize = fontSizeValue;
|
||||
optionTextStyle.fontSize = fontSizeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
const lineHeightValue = props.lineHeightCustom.trim();
|
||||
if (lineHeightValue.endsWith("px")) {
|
||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
||||
if (lineHeightEm) {
|
||||
groupTextStyle.lineHeight = lineHeightEm;
|
||||
optionTextStyle.lineHeight = lineHeightEm;
|
||||
}
|
||||
} else {
|
||||
groupTextStyle.lineHeight = lineHeightValue;
|
||||
optionTextStyle.lineHeight = lineHeightValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
const letterSpacingValue = props.letterSpacingCustom.trim();
|
||||
if (letterSpacingValue.endsWith("px")) {
|
||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
||||
if (letterSpacingEm) {
|
||||
groupTextStyle.letterSpacing = letterSpacingEm;
|
||||
optionTextStyle.letterSpacing = letterSpacingEm;
|
||||
}
|
||||
} else {
|
||||
groupTextStyle.letterSpacing = letterSpacingValue;
|
||||
optionTextStyle.letterSpacing = letterSpacingValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
const colorValue = props.textColorCustom.trim();
|
||||
groupTextStyle.color = colorValue;
|
||||
optionTextStyle.color = colorValue;
|
||||
} else {
|
||||
groupTextStyle.color = "#e5e7eb";
|
||||
optionTextStyle.color = "#e5e7eb";
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
optionContainerStyle.paddingInline = pxToEm(props.paddingX);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
optionContainerStyle.paddingBlock = pxToEm(props.paddingY);
|
||||
}
|
||||
|
||||
// 옵션 컨테이너 배경/테두리 색상
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
optionContainerStyle.backgroundColor = props.fillColorCustom.trim();
|
||||
}
|
||||
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
const strokeValue = props.strokeColorCustom.trim();
|
||||
optionContainerStyle.borderColor = strokeValue;
|
||||
optionContainerStyle.borderWidth = "1px";
|
||||
optionContainerStyle.borderStyle = "solid";
|
||||
}
|
||||
|
||||
// 옵션 컨테이너 모서리 둥글기
|
||||
const radiusToken = props.borderRadius ?? "md";
|
||||
const radiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 2
|
||||
: radiusToken === "lg"
|
||||
? 6
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 4;
|
||||
optionContainerStyle.borderRadius = `${radiusPx}px`;
|
||||
|
||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
||||
optionsStyle.rowGap = pxToEm(props.optionGapPx);
|
||||
}
|
||||
|
||||
return {
|
||||
widthClass,
|
||||
textSizeClass,
|
||||
groupStyle,
|
||||
groupTextStyle,
|
||||
optionContainerStyle,
|
||||
optionTextStyle,
|
||||
optionsStyle,
|
||||
};
|
||||
};
|
||||
|
||||
export const computeFormCheckboxPublicTokens = (
|
||||
props: FormCheckboxBlockProps,
|
||||
): FormOptionGroupPublicTokens => computeOptionGroupPublicTokensBase(props);
|
||||
|
||||
export const computeFormRadioPublicTokens = (props: FormRadioBlockProps): FormOptionGroupPublicTokens =>
|
||||
computeOptionGroupPublicTokensBase(props);
|
||||
|
||||
export interface FormControllerFieldPublicConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
type: "text" | "email" | "textarea" | "select" | "checkbox" | "radio";
|
||||
required?: boolean;
|
||||
options?: Array<FormSelectOption | FormCheckboxOption | FormRadioOption>;
|
||||
groupLabelMode?: "text" | "image";
|
||||
groupLabelImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface FormControllerPublicTokens {
|
||||
fields: FormControllerFieldPublicConfig[];
|
||||
formClassName: string;
|
||||
formStyle: CSSProperties;
|
||||
submitLabel: string | null;
|
||||
}
|
||||
|
||||
export const computeFormControllerPublicTokens = (
|
||||
formBlock: Block,
|
||||
blocks: Block[],
|
||||
): FormControllerPublicTokens => {
|
||||
const props = formBlock.props as FormBlockProps;
|
||||
|
||||
const hasControllerFields = Array.isArray(props.fieldIds) && props.fieldIds.length > 0;
|
||||
|
||||
const controllerFields: FormControllerFieldPublicConfig[] = hasControllerFields
|
||||
? (props.fieldIds ?? [])
|
||||
.map((fieldId) => blocks.find((b) => b.id === fieldId))
|
||||
.filter((b): b is Block => Boolean(b))
|
||||
.filter(
|
||||
(b) =>
|
||||
b.type === "formInput" ||
|
||||
b.type === "formSelect" ||
|
||||
b.type === "formCheckbox" ||
|
||||
b.type === "formRadio",
|
||||
)
|
||||
.map((fieldBlock) => {
|
||||
const anyProps: any = fieldBlock.props ?? {};
|
||||
|
||||
if (fieldBlock.type === "formInput") {
|
||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
||||
const label = anyProps.label ?? anyProps.formFieldName ?? "입력 필드";
|
||||
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name,
|
||||
label,
|
||||
// 기존 PublicPageRenderer 컨트롤러 로직과 동일하게 formInput 은 항상 type "text" 로 취급한다.
|
||||
type: "text",
|
||||
required: Boolean(anyProps.required),
|
||||
} satisfies FormControllerFieldPublicConfig;
|
||||
}
|
||||
|
||||
if (fieldBlock.type === "formSelect") {
|
||||
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
||||
const label = anyProps.label ?? anyProps.formFieldName ?? "선택 필드";
|
||||
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name,
|
||||
label,
|
||||
type: "select",
|
||||
options,
|
||||
required: Boolean(anyProps.required),
|
||||
} satisfies FormControllerFieldPublicConfig;
|
||||
}
|
||||
|
||||
if (fieldBlock.type === "formCheckbox") {
|
||||
const options: FormCheckboxOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
||||
const label = anyProps.groupLabel ?? anyProps.formFieldName ?? "체크박스";
|
||||
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name,
|
||||
label,
|
||||
type: "checkbox",
|
||||
options,
|
||||
groupLabelMode: anyProps.groupLabelMode,
|
||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
||||
required: Boolean(anyProps.required),
|
||||
} satisfies FormControllerFieldPublicConfig;
|
||||
}
|
||||
|
||||
const options: FormRadioOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
||||
const label = anyProps.groupLabel ?? anyProps.formFieldName ?? "라디오 그룹";
|
||||
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name,
|
||||
label,
|
||||
type: "radio",
|
||||
options,
|
||||
groupLabelMode: anyProps.groupLabelMode,
|
||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
||||
required: Boolean(anyProps.required),
|
||||
} 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,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const mappedSubmitButton = blocks.find(
|
||||
(b) => b.type === "button" && b.id === props.submitButtonId,
|
||||
);
|
||||
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label ?? null;
|
||||
|
||||
// FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이므로
|
||||
// formClassName 은 고정 기본값만 사용하고, formStyle 은 항상 빈 객체로 유지한다.
|
||||
const formClassNames = ["space-y-3"];
|
||||
const formStyle: CSSProperties = {};
|
||||
|
||||
return {
|
||||
fields,
|
||||
formClassName: formClassNames.join(" "),
|
||||
formStyle,
|
||||
submitLabel: mappedSubmitLabel,
|
||||
};
|
||||
};
|
||||
|
||||
export interface FormBlockExportTokens {
|
||||
hasControllerFields: boolean;
|
||||
controllerFields: Block[];
|
||||
fallbackFields: FormFieldConfig[];
|
||||
formStyleParts: string[];
|
||||
}
|
||||
|
||||
export const computeFormBlockExportTokens = (
|
||||
formBlock: Block,
|
||||
blocks: Block[],
|
||||
): FormBlockExportTokens => {
|
||||
const props = formBlock.props as FormBlockProps;
|
||||
|
||||
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
||||
const controllerFields = fieldIds
|
||||
.map((id) => blocks.find((b) => b.id === id))
|
||||
.filter((b): b is Block => Boolean(b))
|
||||
.filter(
|
||||
(b) =>
|
||||
b.type === "formInput" ||
|
||||
b.type === "formSelect" ||
|
||||
b.type === "formCheckbox" ||
|
||||
b.type === "formRadio",
|
||||
);
|
||||
|
||||
let fallbackFields: FormFieldConfig[] = [];
|
||||
if (Array.isArray(props.fields) && props.fields.length > 0) {
|
||||
fallbackFields = props.fields;
|
||||
}
|
||||
|
||||
// FormBlock 은 Export 레이어에서도 컨테이너 배경/레이아웃 스타일을 가지지 않는다.
|
||||
const formStyleParts: string[] = [];
|
||||
|
||||
return {
|
||||
hasControllerFields: controllerFields.length > 0,
|
||||
controllerFields,
|
||||
fallbackFields,
|
||||
formStyleParts,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export type ImageWidthMode = "auto" | "full" | "fixed" | null | undefined;
|
||||
export type ImageBorderRadiusToken = "none" | "sm" | "md" | "lg" | "full" | null | undefined;
|
||||
|
||||
export interface ImageExportStyleInput {
|
||||
backgroundColorCustom?: string | null | undefined;
|
||||
widthMode?: ImageWidthMode;
|
||||
widthPx?: number | null | undefined;
|
||||
borderRadius?: ImageBorderRadiusToken;
|
||||
borderRadiusPx?: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface ImageExportStyles {
|
||||
wrapperStyleParts: string[];
|
||||
imgStyleParts: string[];
|
||||
}
|
||||
|
||||
export function computeImageExportStyles(input: ImageExportStyleInput): ImageExportStyles {
|
||||
const wrapperStyleParts: string[] = [];
|
||||
const imgStyleParts: string[] = [];
|
||||
|
||||
if (input.backgroundColorCustom && input.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyleParts.push(`background-color:${input.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
||||
const widthEm = input.widthPx / 16;
|
||||
imgStyleParts.push(`width:${widthEm}em`);
|
||||
}
|
||||
|
||||
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
|
||||
const radiusPx =
|
||||
typeof input.borderRadiusPx === "number" && input.borderRadiusPx >= 0
|
||||
? input.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
|
||||
if (typeof radiusPx === "number" && radiusPx >= 0) {
|
||||
imgStyleParts.push(`border-radius:${radiusPx === 9999 ? "9999px" : `${radiusPx}px`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
wrapperStyleParts,
|
||||
imgStyleParts,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageEditorTokens {
|
||||
widthPx?: number;
|
||||
radiusPx: number;
|
||||
}
|
||||
|
||||
export function computeImageEditorTokens(input: ImageExportStyleInput): ImageEditorTokens {
|
||||
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
|
||||
let widthPx: number | undefined;
|
||||
|
||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
||||
widthPx = input.widthPx;
|
||||
}
|
||||
|
||||
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
|
||||
const radiusPx =
|
||||
typeof input.borderRadiusPx === "number" && input.borderRadiusPx >= 0
|
||||
? input.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
|
||||
return {
|
||||
widthPx,
|
||||
radiusPx,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImagePublicTokens {
|
||||
alignClass: string;
|
||||
wrapperStyle: CSSProperties;
|
||||
imageStyle: CSSProperties;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export function computeImagePublicTokens(props: ImageBlockProps): ImagePublicTokens {
|
||||
const alignClass =
|
||||
props.align === "left" ? "justify-start" : props.align === "right" ? "justify-end" : "justify-center";
|
||||
|
||||
const widthMode: ImageWidthMode = props.widthMode ?? "auto";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
const imageStyle: CSSProperties = {
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
};
|
||||
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
imageStyle.width = pxToEm(props.widthPx);
|
||||
}
|
||||
|
||||
const radiusToken: ImageBorderRadiusToken = props.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
|
||||
const radiusPx =
|
||||
typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0
|
||||
? props.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
|
||||
imageStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
wrapperStyle,
|
||||
imageStyle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { ListBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export interface ListExportTokens {
|
||||
Tag: "ul" | "ol";
|
||||
items: string[];
|
||||
listStyleParts: string[];
|
||||
}
|
||||
|
||||
export const computeListExportTokens = (props: ListBlockProps): ListExportTokens => {
|
||||
const ordered = Boolean(props.ordered);
|
||||
const Tag: "ul" | "ol" = ordered ? "ol" : "ul";
|
||||
|
||||
const items: string[] = [];
|
||||
|
||||
if (Array.isArray(props.itemsTree) && props.itemsTree.length > 0) {
|
||||
const walk = (nodes: { text?: string; children?: any[] }[]) => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.text === "string" && node.text.trim() !== "") {
|
||||
items.push(node.text);
|
||||
}
|
||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(props.itemsTree as any);
|
||||
} else if (Array.isArray(props.items)) {
|
||||
for (const raw of props.items) {
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
items.push(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alignToken = props.align;
|
||||
const align = alignToken === "center" ? "center" : alignToken === "right" ? "right" : "left";
|
||||
|
||||
const listStyleParts: string[] = [`text-align:${align}`];
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
return {
|
||||
Tag,
|
||||
items,
|
||||
listStyleParts,
|
||||
};
|
||||
};
|
||||
|
||||
export interface ListEditorTokens {
|
||||
gapPx: number;
|
||||
listStyle: CSSProperties;
|
||||
bulletStyle: string;
|
||||
}
|
||||
|
||||
export const computeListEditorTokens = (props: ListBlockProps): ListEditorTokens => {
|
||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
||||
|
||||
const gapPx =
|
||||
typeof props.gapYPx === "number"
|
||||
? props.gapYPx
|
||||
: props.gapY === "sm"
|
||||
? 4
|
||||
: props.gapY === "lg"
|
||||
? 16
|
||||
: 8;
|
||||
|
||||
const listStyle: CSSProperties = {};
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
listStyle.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
listStyle.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
listStyle.color = props.textColorCustom;
|
||||
}
|
||||
|
||||
const bulletStyle = bulletStyleRaw;
|
||||
|
||||
return {
|
||||
gapPx,
|
||||
listStyle,
|
||||
bulletStyle,
|
||||
};
|
||||
};
|
||||
|
||||
export interface ListPublicTokens {
|
||||
alignClass: string;
|
||||
gapEm: number;
|
||||
listStyle: CSSProperties;
|
||||
bulletStyle: string;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
const convertPxStringToEm = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const px = parseFloat(match[0]);
|
||||
if (!Number.isFinite(px)) return null;
|
||||
return pxToEm(px);
|
||||
};
|
||||
|
||||
export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens => {
|
||||
const alignClass =
|
||||
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
|
||||
|
||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
||||
|
||||
const gapPx =
|
||||
typeof props.gapYPx === "number"
|
||||
? props.gapYPx
|
||||
: props.gapY === "sm"
|
||||
? 4
|
||||
: props.gapY === "lg"
|
||||
? 16
|
||||
: 8;
|
||||
|
||||
const gapEm = gapPx / 16;
|
||||
|
||||
const listStyle: CSSProperties = {};
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
const fontSizeValue = props.fontSizeCustom.trim();
|
||||
if (fontSizeValue.endsWith("px")) {
|
||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
||||
if (fontSizeEm) {
|
||||
listStyle.fontSize = fontSizeEm;
|
||||
}
|
||||
} else {
|
||||
listStyle.fontSize = fontSizeValue;
|
||||
}
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
const lineHeightValue = props.lineHeightCustom.trim();
|
||||
if (lineHeightValue.endsWith("px")) {
|
||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
||||
if (lineHeightEm) {
|
||||
listStyle.lineHeight = lineHeightEm;
|
||||
}
|
||||
} else {
|
||||
listStyle.lineHeight = lineHeightValue;
|
||||
}
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
listStyle.color = props.textColorCustom;
|
||||
}
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
listStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
const bulletStyle = bulletStyleRaw;
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
gapEm,
|
||||
listStyle,
|
||||
bulletStyle,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export interface SectionExportTokens {
|
||||
sectionClasses: string;
|
||||
sectionStyleParts: string[];
|
||||
backgroundVideoHtml: string;
|
||||
}
|
||||
|
||||
export function computeSectionExportTokens(props: SectionBlockProps): SectionExportTokens {
|
||||
const bgToken = props.background ?? "default";
|
||||
const bgClassMap: Record<SectionBlockProps["background"], string> = {
|
||||
default: "pb-section-bg-default",
|
||||
muted: "pb-section-bg-muted",
|
||||
primary: "pb-section-bg-primary",
|
||||
};
|
||||
const bgClass = bgClassMap[bgToken] ?? "pb-section-bg-default";
|
||||
|
||||
const pyToken = props.paddingY ?? "md";
|
||||
const pyClassMap: Record<SectionBlockProps["paddingY"], string> = {
|
||||
sm: "pb-section-py-sm",
|
||||
md: "pb-section-py-md",
|
||||
lg: "pb-section-py-lg",
|
||||
};
|
||||
const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md";
|
||||
|
||||
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
||||
const sectionStyleParts: string[] = [];
|
||||
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
const bgImgSrc = props.backgroundImageSrc;
|
||||
if (typeof bgImgSrc === "string" && bgImgSrc.trim() !== "") {
|
||||
const url = bgImgSrc.trim();
|
||||
sectionStyleParts.push(`background-image:url(${url})`);
|
||||
|
||||
const size = props.backgroundImageSize ?? "cover";
|
||||
sectionStyleParts.push(`background-size:${size}`);
|
||||
|
||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
||||
if (posMode === "custom") {
|
||||
const x = props.backgroundImagePositionXPercent;
|
||||
const y = props.backgroundImagePositionYPercent;
|
||||
const xSafe = typeof x === "number" ? x : 50;
|
||||
const ySafe = typeof y === "number" ? y : 50;
|
||||
sectionStyleParts.push(`background-position:${xSafe}% ${ySafe}%`);
|
||||
} else {
|
||||
const posToken = props.backgroundImagePosition ?? "center";
|
||||
const position =
|
||||
posToken === "top"
|
||||
? "center top"
|
||||
: posToken === "bottom"
|
||||
? "center bottom"
|
||||
: posToken === "left"
|
||||
? "left center"
|
||||
: posToken === "right"
|
||||
? "right center"
|
||||
: "center center";
|
||||
sectionStyleParts.push(`background-position:${position}`);
|
||||
}
|
||||
|
||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
||||
sectionStyleParts.push(`background-repeat:${repeat}`);
|
||||
}
|
||||
|
||||
const bgVideoSrc = props.backgroundVideoSrc;
|
||||
let backgroundVideoHtml = "";
|
||||
if (typeof bgVideoSrc === "string" && bgVideoSrc.trim() !== "") {
|
||||
const url = bgVideoSrc.trim();
|
||||
backgroundVideoHtml = `<video class="pb-section-bg-video" src="${url}" autoplay loop muted playsinline></video>`;
|
||||
sectionStyleParts.push("position:relative");
|
||||
sectionStyleParts.push("overflow:hidden");
|
||||
}
|
||||
|
||||
return {
|
||||
sectionClasses,
|
||||
sectionStyleParts,
|
||||
backgroundVideoHtml,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SectionPublicTokens {
|
||||
sectionStyle: CSSProperties;
|
||||
innerWrapperStyle: CSSProperties;
|
||||
columnsContainerStyle: CSSProperties;
|
||||
hasBackgroundVideo: boolean;
|
||||
backgroundVideoSrc: string | null;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export function computeSectionPublicTokens(props: SectionBlockProps): SectionPublicTokens {
|
||||
const sectionStyle: CSSProperties = {};
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
|
||||
const paddingEm = pxToEm(props.paddingYPx);
|
||||
sectionStyle.paddingTop = paddingEm;
|
||||
sectionStyle.paddingBottom = paddingEm;
|
||||
}
|
||||
|
||||
const rawBgVideoSrc = (props as any).backgroundVideoSrc;
|
||||
let hasBackgroundVideo = false;
|
||||
let backgroundVideoSrc: string | null = null;
|
||||
if (typeof rawBgVideoSrc === "string" && rawBgVideoSrc.trim() !== "") {
|
||||
hasBackgroundVideo = true;
|
||||
backgroundVideoSrc = rawBgVideoSrc.trim();
|
||||
sectionStyle.position = "relative";
|
||||
sectionStyle.overflow = "hidden";
|
||||
}
|
||||
|
||||
if (props.backgroundImageSrc && props.backgroundImageSrc.trim() !== "") {
|
||||
const url = props.backgroundImageSrc.trim();
|
||||
sectionStyle.backgroundImage = `url(${url})`;
|
||||
|
||||
const size = props.backgroundImageSize ?? "cover";
|
||||
sectionStyle.backgroundSize = size;
|
||||
|
||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
||||
if (
|
||||
posMode === "custom" &&
|
||||
typeof props.backgroundImagePositionXPercent === "number" &&
|
||||
typeof props.backgroundImagePositionYPercent === "number"
|
||||
) {
|
||||
const x = props.backgroundImagePositionXPercent;
|
||||
const y = props.backgroundImagePositionYPercent;
|
||||
sectionStyle.backgroundPosition = `${x}% ${y}%`;
|
||||
} else {
|
||||
const posToken = props.backgroundImagePosition ?? "center";
|
||||
const position =
|
||||
posToken === "top"
|
||||
? "center top"
|
||||
: posToken === "bottom"
|
||||
? "center bottom"
|
||||
: posToken === "left"
|
||||
? "left center"
|
||||
: posToken === "right"
|
||||
? "right center"
|
||||
: "center center";
|
||||
sectionStyle.backgroundPosition = position;
|
||||
}
|
||||
|
||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
||||
sectionStyle.backgroundRepeat = repeat;
|
||||
}
|
||||
|
||||
const innerWrapperStyle: CSSProperties = {};
|
||||
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
|
||||
innerWrapperStyle.maxWidth = pxToEm(props.maxWidthPx);
|
||||
}
|
||||
|
||||
const columnsContainerStyle: CSSProperties = {};
|
||||
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
|
||||
columnsContainerStyle.columnGap = pxToEm(props.gapXPx);
|
||||
}
|
||||
|
||||
return {
|
||||
sectionStyle,
|
||||
innerWrapperStyle,
|
||||
columnsContainerStyle,
|
||||
hasBackgroundVideo,
|
||||
backgroundVideoSrc,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SectionEditorTokens {
|
||||
bgClass: string;
|
||||
pyClass: string;
|
||||
alignItemsClass: string;
|
||||
wrapperStyle: CSSProperties;
|
||||
innerWrapperStyle: CSSProperties;
|
||||
columnsContainerStyle: CSSProperties;
|
||||
hasBackgroundVideo: boolean;
|
||||
backgroundVideoSrc: string | null;
|
||||
}
|
||||
|
||||
export function computeSectionEditorTokens(props: SectionBlockProps): SectionEditorTokens {
|
||||
const bgClass =
|
||||
props.background === "muted"
|
||||
? "bg-slate-950/40"
|
||||
: props.background === "primary"
|
||||
? "bg-sky-950/40 border-sky-900/60"
|
||||
: "bg-slate-900/60";
|
||||
|
||||
const pyClass =
|
||||
props.paddingY === "sm"
|
||||
? "py-4"
|
||||
: props.paddingY === "lg"
|
||||
? "py-10"
|
||||
: "py-6";
|
||||
|
||||
const alignItemsClass =
|
||||
props.alignItems === "center"
|
||||
? "items-center"
|
||||
: props.alignItems === "bottom"
|
||||
? "items-end"
|
||||
: "items-start";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyle.backgroundColor = props.backgroundColorCustom;
|
||||
}
|
||||
|
||||
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
|
||||
wrapperStyle.paddingTop = `${props.paddingYPx}px`;
|
||||
wrapperStyle.paddingBottom = `${props.paddingYPx}px`;
|
||||
}
|
||||
|
||||
let hasBackgroundVideo = false;
|
||||
let backgroundVideoSrc: string | null = null;
|
||||
if (typeof props.backgroundVideoSrc === "string" && props.backgroundVideoSrc.trim() !== "") {
|
||||
hasBackgroundVideo = true;
|
||||
backgroundVideoSrc = props.backgroundVideoSrc.trim();
|
||||
wrapperStyle.position = "relative";
|
||||
wrapperStyle.overflow = "hidden";
|
||||
}
|
||||
|
||||
if (props.backgroundImageSrc && props.backgroundImageSrc.trim() !== "") {
|
||||
const url = props.backgroundImageSrc.trim();
|
||||
wrapperStyle.backgroundImage = `url(${url})`;
|
||||
|
||||
const size = props.backgroundImageSize ?? "cover";
|
||||
wrapperStyle.backgroundSize = size;
|
||||
|
||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
||||
if (
|
||||
posMode === "custom" &&
|
||||
typeof props.backgroundImagePositionXPercent === "number" &&
|
||||
typeof props.backgroundImagePositionYPercent === "number"
|
||||
) {
|
||||
const x = props.backgroundImagePositionXPercent;
|
||||
const y = props.backgroundImagePositionYPercent;
|
||||
wrapperStyle.backgroundPosition = `${x}% ${y}%`;
|
||||
} else {
|
||||
const posToken = props.backgroundImagePosition ?? "center";
|
||||
const position =
|
||||
posToken === "top"
|
||||
? "center top"
|
||||
: posToken === "bottom"
|
||||
? "center bottom"
|
||||
: posToken === "left"
|
||||
? "left center"
|
||||
: posToken === "right"
|
||||
? "right center"
|
||||
: "center center";
|
||||
wrapperStyle.backgroundPosition = position;
|
||||
}
|
||||
|
||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
||||
wrapperStyle.backgroundRepeat = repeat;
|
||||
}
|
||||
|
||||
const innerWrapperStyle: CSSProperties = {};
|
||||
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
|
||||
innerWrapperStyle.maxWidth = `${props.maxWidthPx}px`;
|
||||
}
|
||||
|
||||
const columnsContainerStyle: CSSProperties = {};
|
||||
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
|
||||
columnsContainerStyle.columnGap = `${props.gapXPx}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
bgClass,
|
||||
pyClass,
|
||||
alignItemsClass,
|
||||
wrapperStyle,
|
||||
innerWrapperStyle,
|
||||
columnsContainerStyle,
|
||||
hasBackgroundVideo,
|
||||
backgroundVideoSrc,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export type TextAlign = "left" | "center" | "right" | null | undefined;
|
||||
|
||||
export type TextFontSizeScale = "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
|
||||
export type TextLineHeightScale = "tight" | "snug" | "normal" | "relaxed" | "loose";
|
||||
export type TextFontWeightScale = "normal" | "medium" | "semibold" | "bold";
|
||||
export type TextColorPalette =
|
||||
| "default"
|
||||
| "muted"
|
||||
| "strong"
|
||||
| "accent"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "neutral";
|
||||
|
||||
export interface TextStyleInput {
|
||||
text?: string;
|
||||
align?: TextAlign;
|
||||
size?: "sm" | "base" | "lg" | null | undefined;
|
||||
fontSizeMode?: "scale" | "custom" | null | undefined;
|
||||
fontSizeScale?: TextFontSizeScale | null | undefined;
|
||||
fontSizeCustom?: string | null | undefined;
|
||||
lineHeightMode?: "scale" | "custom" | null | undefined;
|
||||
lineHeightScale?: TextLineHeightScale | null | undefined;
|
||||
lineHeightCustom?: string | null | undefined;
|
||||
fontWeightMode?: "scale" | "custom" | null | undefined;
|
||||
fontWeightScale?: TextFontWeightScale | null | undefined;
|
||||
fontWeightCustom?: string | number | null | undefined;
|
||||
colorMode?: "palette" | "custom" | null | undefined;
|
||||
colorPalette?: TextColorPalette | null | undefined;
|
||||
colorCustom?: string | null | undefined;
|
||||
backgroundColorCustom?: string | null | undefined;
|
||||
maxWidthMode?: "scale" | "custom" | null | undefined;
|
||||
maxWidthScale?: "none" | "prose" | "narrow" | null | undefined;
|
||||
underline?: boolean | null | undefined;
|
||||
strike?: boolean | null | undefined;
|
||||
italic?: boolean | null | undefined;
|
||||
}
|
||||
|
||||
export interface TextPbTokens {
|
||||
alignClass: string;
|
||||
sizeClass: string;
|
||||
leadingClass: string;
|
||||
weightClass: string;
|
||||
colorClass: string;
|
||||
maxWidthClass: string;
|
||||
extraClasses: string[];
|
||||
inlineStyles: string[];
|
||||
}
|
||||
|
||||
export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
|
||||
const align = input.align === "center" ? "center" : input.align === "right" ? "right" : "left";
|
||||
const alignClass =
|
||||
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 fontSizeMap: Record<TextFontSizeScale, string> = {
|
||||
xs: "pb-text-xs",
|
||||
sm: "pb-text-sm",
|
||||
base: "pb-text-base",
|
||||
lg: "pb-text-lg",
|
||||
xl: "pb-text-xl",
|
||||
"2xl": "pb-text-2xl",
|
||||
"3xl": "pb-text-3xl",
|
||||
};
|
||||
|
||||
const sizeClass =
|
||||
fontSizeMode === "scale" && fontSizeScale && fontSizeMap[fontSizeScale]
|
||||
? fontSizeMap[fontSizeScale]
|
||||
: "";
|
||||
|
||||
const lineHeightMode = input.lineHeightMode ?? "scale";
|
||||
const lineHeightScale: TextLineHeightScale = (input.lineHeightScale as TextLineHeightScale | null) ?? "normal";
|
||||
const leadingMap: Record<TextLineHeightScale, string> = {
|
||||
tight: "pb-leading-tight",
|
||||
snug: "pb-leading-snug",
|
||||
normal: "pb-leading-normal",
|
||||
relaxed: "pb-leading-relaxed",
|
||||
loose: "pb-leading-loose",
|
||||
};
|
||||
|
||||
const leadingClass =
|
||||
lineHeightMode === "scale" && lineHeightScale && leadingMap[lineHeightScale]
|
||||
? leadingMap[lineHeightScale]
|
||||
: "";
|
||||
|
||||
const fontWeightMode = input.fontWeightMode ?? "scale";
|
||||
const fontWeightScale: TextFontWeightScale =
|
||||
(input.fontWeightScale as TextFontWeightScale | null) ?? "normal";
|
||||
const weightMap: Record<TextFontWeightScale, string> = {
|
||||
normal: "pb-font-normal",
|
||||
medium: "pb-font-medium",
|
||||
semibold: "pb-font-semibold",
|
||||
bold: "pb-font-bold",
|
||||
};
|
||||
|
||||
const weightClass =
|
||||
fontWeightMode === "scale" && fontWeightScale && weightMap[fontWeightScale]
|
||||
? weightMap[fontWeightScale]
|
||||
: "";
|
||||
|
||||
let colorClass = "pb-text-color-strong";
|
||||
const inlineStyles: string[] = [];
|
||||
|
||||
if (input.colorMode === "palette") {
|
||||
const palette: TextColorPalette = (input.colorPalette as TextColorPalette | null) ?? "default";
|
||||
const paletteMap: Record<TextColorPalette, string> = {
|
||||
default: "pb-text-color-default",
|
||||
muted: "pb-text-color-muted",
|
||||
strong: "pb-text-color-strong",
|
||||
accent: "pb-text-color-accent",
|
||||
danger: "pb-text-color-danger",
|
||||
success: "pb-text-color-success",
|
||||
warning: "pb-text-color-warning",
|
||||
info: "pb-text-color-info",
|
||||
neutral: "pb-text-color-neutral",
|
||||
};
|
||||
colorClass = paletteMap[palette] ?? colorClass;
|
||||
} else if (input.colorMode === "custom" && input.colorCustom && input.colorCustom.trim() !== "") {
|
||||
inlineStyles.push(`color:${input.colorCustom.trim()}`);
|
||||
}
|
||||
|
||||
if (input.backgroundColorCustom && input.backgroundColorCustom.trim() !== "") {
|
||||
inlineStyles.push(`background-color:${input.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
let maxWidthClass = "";
|
||||
const maxWidthMode = input.maxWidthMode ?? "scale";
|
||||
const maxWidthScale = input.maxWidthScale ?? "none";
|
||||
if (maxWidthMode === "scale") {
|
||||
if (maxWidthScale === "prose") {
|
||||
maxWidthClass = "pb-text-maxw-prose";
|
||||
} else if (maxWidthScale === "narrow") {
|
||||
maxWidthClass = "pb-text-maxw-narrow";
|
||||
}
|
||||
}
|
||||
|
||||
const extraClasses: string[] = ["pb-whitespace-pre-wrap"];
|
||||
if (input.underline) extraClasses.push("pb-underline");
|
||||
if (input.strike) extraClasses.push("pb-line-through");
|
||||
if (input.italic) extraClasses.push("pb-italic");
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
sizeClass,
|
||||
leadingClass,
|
||||
weightClass,
|
||||
colorClass,
|
||||
maxWidthClass,
|
||||
extraClasses,
|
||||
inlineStyles,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TextEditorTokens {
|
||||
alignClass: string;
|
||||
sizeClass: string;
|
||||
leadingClass: string;
|
||||
weightClass: string;
|
||||
colorClass: string;
|
||||
maxWidthClass: string;
|
||||
decorationClass: string;
|
||||
styleOverrides: CSSProperties;
|
||||
}
|
||||
|
||||
export function computeTextEditorTokens(props: TextBlockProps): TextEditorTokens {
|
||||
let alignClass = "";
|
||||
let sizeClass = "";
|
||||
let leadingClass = "";
|
||||
let weightClass = "";
|
||||
let colorClass = "";
|
||||
let maxWidthClass = "";
|
||||
let decorationClass = "";
|
||||
const styleOverrides: CSSProperties = {};
|
||||
|
||||
// 정렬: pb-text-*
|
||||
alignClass =
|
||||
props.align === "center"
|
||||
? "pb-text-center"
|
||||
: props.align === "right"
|
||||
? "pb-text-right"
|
||||
: "pb-text-left";
|
||||
|
||||
// 폰트 크기: scale/custom + 기존 size 값 fallback
|
||||
const fontSizeMode = props.fontSizeMode ?? "scale";
|
||||
const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = props.fontSizeScale ?? fallbackScale;
|
||||
|
||||
// 스케일/커스텀 모드와 무관하게 pb-text-* 스케일 클래스는 항상 유지한다.
|
||||
sizeClass = `pb-text-${fontSizeScale}`;
|
||||
|
||||
// custom 모드에서는 inline 스타일로 실제 폰트 크기를 덮어쓴다.
|
||||
if (fontSizeMode === "custom" && props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
styleOverrides.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
|
||||
// 줄 간격: scale/custom
|
||||
const lineHeightMode = props.lineHeightMode ?? "scale";
|
||||
const lineHeightScale = props.lineHeightScale ?? "normal";
|
||||
if (lineHeightMode === "scale") {
|
||||
leadingClass = `pb-leading-${lineHeightScale}`;
|
||||
} else if (lineHeightMode === "custom" && props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
styleOverrides.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
|
||||
// 굵기: scale/custom
|
||||
const fontWeightMode = props.fontWeightMode ?? "scale";
|
||||
const fontWeightScale = props.fontWeightScale ?? "normal";
|
||||
if (fontWeightMode === "scale") {
|
||||
weightClass = `pb-font-${fontWeightScale}`;
|
||||
} else if (fontWeightMode === "custom" && props.fontWeightCustom && String(props.fontWeightCustom).trim() !== "") {
|
||||
styleOverrides.fontWeight = props.fontWeightCustom as CSSProperties["fontWeight"];
|
||||
}
|
||||
|
||||
// 색상: colorCustom이 있으면 항상 우선 사용, 없으면 팔레트
|
||||
const colorPalette = props.colorPalette ?? "default";
|
||||
if (props.colorCustom && props.colorCustom.trim() !== "") {
|
||||
styleOverrides.color = props.colorCustom.trim();
|
||||
} else {
|
||||
colorClass = `pb-text-color-${colorPalette}`;
|
||||
}
|
||||
|
||||
// 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다.
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
styleOverrides.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
|
||||
const maxWidthScale = props.maxWidthScale ?? "none";
|
||||
if (props.maxWidthCustom && props.maxWidthCustom.trim() !== "") {
|
||||
styleOverrides.maxWidth = props.maxWidthCustom.trim();
|
||||
} else if (maxWidthScale === "prose") {
|
||||
maxWidthClass = "pb-text-maxw-prose";
|
||||
} else if (maxWidthScale === "narrow") {
|
||||
maxWidthClass = "pb-text-maxw-narrow";
|
||||
}
|
||||
|
||||
// 글자 간격: 커스텀 값이 있으면 em 단위 그대로 사용
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
styleOverrides.letterSpacing = props.letterSpacingCustom.trim();
|
||||
}
|
||||
|
||||
// 텍스트 장식: 밑줄/가운데줄/이탤릭
|
||||
if (props.underline) {
|
||||
decorationClass += " pb-underline";
|
||||
}
|
||||
if (props.strike) {
|
||||
decorationClass += " pb-line-through";
|
||||
}
|
||||
if (props.italic) {
|
||||
decorationClass += " pb-italic";
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
sizeClass,
|
||||
leadingClass,
|
||||
weightClass,
|
||||
colorClass,
|
||||
maxWidthClass,
|
||||
decorationClass,
|
||||
styleOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TextPublicTokens {
|
||||
alignClass: string;
|
||||
sizeClass: string;
|
||||
styleOverrides: CSSProperties;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
const convertPxStringToEm = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const px = parseFloat(match[0]);
|
||||
if (!Number.isFinite(px)) return null;
|
||||
return pxToEm(px);
|
||||
};
|
||||
|
||||
export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens {
|
||||
const alignClass =
|
||||
props.align === "center"
|
||||
? "text-center"
|
||||
: props.align === "right"
|
||||
? "text-right"
|
||||
: "text-left";
|
||||
|
||||
const fontSizeMode = props.fontSizeMode ?? "scale";
|
||||
const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = props.fontSizeScale ?? fallbackScale;
|
||||
|
||||
const fontSizeScaleToClass: Record<string, string> = {
|
||||
xs: "text-xs",
|
||||
sm: "text-sm",
|
||||
base: "text-base",
|
||||
lg: "text-lg",
|
||||
xl: "text-xl",
|
||||
"2xl": "text-2xl",
|
||||
"3xl": "text-3xl",
|
||||
};
|
||||
|
||||
let sizeClass = "";
|
||||
const styleOverrides: CSSProperties = {};
|
||||
|
||||
if (fontSizeMode === "scale" && fontSizeScale && fontSizeScaleToClass[fontSizeScale]) {
|
||||
sizeClass = fontSizeScaleToClass[fontSizeScale];
|
||||
} else if (
|
||||
fontSizeMode === "custom" &&
|
||||
props.fontSizeCustom &&
|
||||
props.fontSizeCustom.trim() !== ""
|
||||
) {
|
||||
const fontSizeValue = props.fontSizeCustom.trim();
|
||||
if (fontSizeValue.endsWith("px")) {
|
||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
||||
if (fontSizeEm) {
|
||||
styleOverrides.fontSize = fontSizeEm;
|
||||
}
|
||||
} else {
|
||||
styleOverrides.fontSize = fontSizeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.colorCustom && props.colorCustom.trim() !== "") {
|
||||
styleOverrides.color = props.colorCustom.trim();
|
||||
}
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
styleOverrides.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
const lineHeightValue = props.lineHeightCustom.trim();
|
||||
if (lineHeightValue.endsWith("px")) {
|
||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
||||
if (lineHeightEm) {
|
||||
styleOverrides.lineHeight = lineHeightEm;
|
||||
}
|
||||
} else {
|
||||
styleOverrides.lineHeight = lineHeightValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
const letterSpacingValue = props.letterSpacingCustom.trim();
|
||||
if (letterSpacingValue.endsWith("px")) {
|
||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
||||
if (letterSpacingEm) {
|
||||
styleOverrides.letterSpacing = letterSpacingEm;
|
||||
}
|
||||
} else {
|
||||
styleOverrides.letterSpacing = letterSpacingValue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
sizeClass,
|
||||
styleOverrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export type VideoPlatform = "youtube" | "vimeo" | "html5";
|
||||
|
||||
export type VideoPlatformInput = VideoPlatform | "auto" | null | undefined;
|
||||
|
||||
// 비디오 sourceUrl 을 정규화한다: null/undefined 를 빈 문자열로 만들고 양 끝 공백을 제거한다.
|
||||
export function normalizeVideoSourceUrl(source: string | null | undefined): string {
|
||||
if (!source) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return source.trim();
|
||||
}
|
||||
|
||||
// platform 값과 URL 을 기반으로 최종 비디오 플랫폼을 결정한다.
|
||||
// - platform 이 auto 가 아니면 해당 값을 우선 사용한다.
|
||||
// - auto 인 경우 URL 을 검사해 youtube/vimeo/html5 를 판별한다.
|
||||
export function resolveVideoPlatform(rawUrl: string, platform: VideoPlatformInput): VideoPlatform {
|
||||
if (platform && platform !== "auto") {
|
||||
return platform;
|
||||
}
|
||||
|
||||
const normalized = normalizeVideoSourceUrl(rawUrl);
|
||||
const lower = normalized.toLowerCase();
|
||||
|
||||
if (lower.includes("youtube.com") || lower.includes("youtu.be")) {
|
||||
return "youtube";
|
||||
}
|
||||
|
||||
if (lower.includes("vimeo.com")) {
|
||||
return "vimeo";
|
||||
}
|
||||
|
||||
return "html5";
|
||||
}
|
||||
|
||||
export type BuildEmbedUrlOptions = {
|
||||
enableVimeoEmbed?: boolean;
|
||||
};
|
||||
|
||||
// 플랫폼과 URL 을 기반으로 최종 embed URL 을 생성한다.
|
||||
// - YouTube: 항상 watch/short/youtu.be 형태에서 embed URL 로 변환.
|
||||
// - Vimeo: enableVimeoEmbed 가 true 인 경우에만 player.vimeo.com 기반 embed URL 로 변환.
|
||||
// - HTML5: 항상 정규화된 원본 URL 을 그대로 사용.
|
||||
export function buildVideoEmbedUrl(
|
||||
rawUrl: string,
|
||||
platform: VideoPlatform,
|
||||
options?: BuildEmbedUrlOptions,
|
||||
): string {
|
||||
const normalized = normalizeVideoSourceUrl(rawUrl);
|
||||
|
||||
if (platform === "youtube") {
|
||||
const idMatch =
|
||||
normalized.match(/[?&]v=([^&]+)/) ||
|
||||
normalized.match(/youtu\.be\/([^?&]+)/) ||
|
||||
normalized.match(/youtube\.com\/shorts\/([^?&]+)/);
|
||||
const videoId = idMatch && idMatch[1] ? idMatch[1] : "";
|
||||
if (videoId) {
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (platform === "vimeo" && options?.enableVimeoEmbed) {
|
||||
const idMatch = normalized.match(/vimeo\.com\/(\d+)/);
|
||||
const videoId = idMatch && idMatch[1] ? idMatch[1] : "";
|
||||
if (videoId) {
|
||||
return `https://player.vimeo.com/video/${videoId}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export interface VideoExportEscapers {
|
||||
escapeAttr: (value: string) => string;
|
||||
}
|
||||
|
||||
export interface VideoExportTokens {
|
||||
wrapperStyleParts: string[];
|
||||
videoStyleParts: string[];
|
||||
outerStyleParts: string[];
|
||||
aspectClass: string;
|
||||
}
|
||||
|
||||
export function computeVideoExportTokens(
|
||||
props: VideoBlockProps,
|
||||
escapers: VideoExportEscapers,
|
||||
): VideoExportTokens {
|
||||
const { escapeAttr } = escapers;
|
||||
|
||||
const aspect = props.aspectRatio ?? "16:9";
|
||||
const aspectClass =
|
||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
||||
|
||||
const wrapperStyleParts: string[] = [];
|
||||
const videoStyleParts: string[] = [];
|
||||
|
||||
const widthMode = props.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
const widthEm = pxToEm(props.widthPx);
|
||||
wrapperStyleParts.push(`width:${widthEm}`);
|
||||
videoStyleParts.push(`width:${widthEm}`);
|
||||
} else if (widthMode === "full") {
|
||||
wrapperStyleParts.push("width:100%");
|
||||
videoStyleParts.push("width:100%");
|
||||
}
|
||||
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
|
||||
wrapperStyleParts.push(`padding:${pxToEm(props.cardPaddingPx)}`);
|
||||
}
|
||||
|
||||
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
|
||||
wrapperStyleParts.push(`border-radius:${pxToEm(props.borderRadiusPx)}`);
|
||||
}
|
||||
|
||||
const align =
|
||||
props.align === "left" ? "flex-start" : props.align === "right" ? "flex-end" : "center";
|
||||
const outerStyleParts = ["display:flex", `justify-content:${align}`];
|
||||
|
||||
return {
|
||||
wrapperStyleParts,
|
||||
videoStyleParts,
|
||||
outerStyleParts,
|
||||
aspectClass,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VideoPublicTokens {
|
||||
wrapperStyle: CSSProperties;
|
||||
videoStyle: CSSProperties;
|
||||
justifyClass: string;
|
||||
aspectClass: string;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export function computeVideoPublicTokens(props: VideoBlockProps): VideoPublicTokens {
|
||||
const aspect = props.aspectRatio ?? "16:9";
|
||||
const aspectClass =
|
||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
const videoStyle: CSSProperties = {};
|
||||
|
||||
const widthMode = props.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
const widthEm = pxToEm(props.widthPx);
|
||||
wrapperStyle.width = widthEm;
|
||||
videoStyle.width = widthEm;
|
||||
} else if (widthMode === "full") {
|
||||
wrapperStyle.width = "100%";
|
||||
videoStyle.width = "100%";
|
||||
}
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
if (typeof (props as any).cardPaddingPx === "number" && (props as any).cardPaddingPx >= 0) {
|
||||
wrapperStyle.padding = pxToEm((props as any).cardPaddingPx as number);
|
||||
}
|
||||
|
||||
if (typeof (props as any).borderRadiusPx === "number" && (props as any).borderRadiusPx >= 0) {
|
||||
wrapperStyle.borderRadius = pxToEm((props as any).borderRadiusPx as number);
|
||||
}
|
||||
|
||||
const align = props.align ?? "center";
|
||||
const justifyClass =
|
||||
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
||||
|
||||
return {
|
||||
wrapperStyle,
|
||||
videoStyle,
|
||||
justifyClass,
|
||||
aspectClass,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VideoEditorTokens {
|
||||
wrapperStyle: CSSProperties;
|
||||
videoStyle: CSSProperties;
|
||||
justifyClass: string;
|
||||
aspectClass: string;
|
||||
}
|
||||
|
||||
export function computeVideoEditorTokens(props: VideoBlockProps): VideoEditorTokens {
|
||||
const aspect = props.aspectRatio ?? "16:9";
|
||||
const aspectClass =
|
||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
const videoStyle: CSSProperties = {};
|
||||
|
||||
const widthMode = props.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
wrapperStyle.width = `${props.widthPx}px`;
|
||||
videoStyle.width = `${props.widthPx}px`;
|
||||
} else if (widthMode === "full") {
|
||||
wrapperStyle.width = "100%";
|
||||
videoStyle.width = "100%";
|
||||
}
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
|
||||
wrapperStyle.padding = `${props.cardPaddingPx}px`;
|
||||
}
|
||||
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
|
||||
wrapperStyle.borderRadius = `${props.borderRadiusPx}px`;
|
||||
}
|
||||
|
||||
const align = props.align ?? "center";
|
||||
const justifyClass =
|
||||
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
||||
|
||||
return {
|
||||
wrapperStyle,
|
||||
videoStyle,
|
||||
justifyClass,
|
||||
aspectClass,
|
||||
};
|
||||
}
|
||||
@@ -71,6 +71,17 @@ body {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/* Section background video */
|
||||
.pb-section-bg-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Text alignment */
|
||||
.pb-text-left {
|
||||
text-align: left;
|
||||
@@ -186,6 +197,65 @@ body {
|
||||
max-width: var(--pb-text-maxw-narrow);
|
||||
}
|
||||
|
||||
.pb-whitespace-pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pb-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #1f2937 #020617;
|
||||
}
|
||||
|
||||
.pb-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.pb-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pb-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: #1f2937;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #020617;
|
||||
}
|
||||
|
||||
.pb-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.pb-video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pb-video-wrapper--4by3 {
|
||||
padding-bottom: 75%;
|
||||
}
|
||||
|
||||
.pb-video-wrapper--1by1 {
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
|
||||
.pb-video-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pb-video-caption {
|
||||
display: none;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pb-btn-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -193,6 +263,7 @@ body {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-width: 1px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pb-btn-size-xs {
|
||||
padding: 0.125rem 0.5rem;
|
||||
@@ -338,6 +409,12 @@ body {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
/* Form 컨트롤러용 최소 폼: 레이아웃에 영향이 없도록 margin/padding 을 0 으로 고정한다. */
|
||||
.pb-form-controller {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pb-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// /api/export/preview TDD:
|
||||
// - blocks + projectConfig 를 받아 HTML 문자열을 바로 반환하는 경량 엔드포인트.
|
||||
// - 기존 buildStaticHtml 과 동일한 HTML 을 반환해야 한다.
|
||||
|
||||
describe("/api/export/preview", () => {
|
||||
it("POST /api/export/preview 는 blocks + projectConfig 로부터 HTML 문자열을 반환해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_preview_text",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Export 미리보기 테스트",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "Export 미리보기 페이지",
|
||||
slug: "export-preview",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handlePreview } = await import("@/app/api/export/preview/route");
|
||||
|
||||
const res = await handlePreview(
|
||||
new Request(`${BASE_URL}/api/export/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
expect(html).toContain("<title>Export 미리보기 페이지</title>");
|
||||
expect(html).toContain("Export 미리보기 테스트");
|
||||
});
|
||||
|
||||
it("/api/export/preview 의 HTML 은 동일 입력에 대해 buildStaticHtml 결과와 동일해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_preview_compare",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Preview vs Export 동일성 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "동일성 테스트 페이지",
|
||||
slug: "preview-equality-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handlePreview } = await import("@/app/api/export/preview/route");
|
||||
|
||||
const res = await handlePreview(
|
||||
new Request(`${BASE_URL}/api/export/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
const previewHtml = await res.text();
|
||||
const staticHtml = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(previewHtml).toBe(staticHtml);
|
||||
});
|
||||
});
|
||||
+2090
-177
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import JSZip from "jszip";
|
||||
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// 정적 Export + main.js + /api/forms/submit 를 묶어서 검증하는 통합 테스트.
|
||||
// - /api/export 로 ZIP 을 생성해서 index.html / main.js 를 추출한다.
|
||||
// - jsdom DOM 에 index.html 을 주입하고, main.js 스크립트를 실제로 실행한다.
|
||||
// - form.pb-form-controller 에서 submit 이벤트를 발생시키면
|
||||
// main.js 가 submit 을 가로채고 /api/forms/submit 로 fetch 를 호출해야 한다.
|
||||
|
||||
describe("정적 Export 폼 컨트롤러 통합", () => {
|
||||
it("Export된 index.html + main.js 에서 submit 시 /api/forms/submit 로 fetch 를 호출해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: ["input_name"],
|
||||
submitButtonId: null,
|
||||
formWidthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "input_name",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
required: true,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "폼 통합 테스트",
|
||||
slug: "form-integration-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
const jsEntry = zip.file("main.js");
|
||||
|
||||
expect(indexEntry).toBeTruthy();
|
||||
expect(jsEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
const mainJs = await jsEntry!.async("string");
|
||||
|
||||
// jsdom DOM 구성: export 된 HTML 을 그대로 주입한다.
|
||||
document.documentElement.innerHTML = html;
|
||||
|
||||
const form = document.querySelector("form.pb-form-controller") as HTMLFormElement | null;
|
||||
expect(form).not.toBeNull();
|
||||
|
||||
// name 필드에 값 채우기 (FormData 에 실제 값이 들어가도록)
|
||||
const nameInput = document.querySelector('input[name="name"]') as HTMLInputElement | null;
|
||||
if (nameInput) {
|
||||
nameInput.value = "Alice";
|
||||
}
|
||||
|
||||
// fetch 를 목킹하여 main.js 의 submit 가로채기 동작을 검증한다.
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true, message: "ok" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
// main.js 실행 (IIFE 코드이므로 평가만 하면 즉시 pbInitFormControllers 가 동작한다.)
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(mainJs)();
|
||||
|
||||
// submit 이벤트 발생: main.js 가 이를 가로채고 fetch 를 호출해야 한다.
|
||||
const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
|
||||
form!.dispatchEvent(submitEvent);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as [any, RequestInit];
|
||||
|
||||
expect(url).toBe("/api/forms/submit");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.body).toBeInstanceOf(FormData);
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,7 @@ describe("/api/forms/submit", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(true);
|
||||
expect(json.message).toBe(config.successMessage);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
@@ -144,4 +145,199 @@ describe("/api/forms/submit", () => {
|
||||
expect(parsed.source).toBe("builder");
|
||||
expect(parsed.formId).toBe("contact-json");
|
||||
});
|
||||
|
||||
it("webhook 모드 성공 시에도 successMessage 가 응답 message 필드에 포함되어야 한다", async () => {
|
||||
const destinationUrl = `${BASE_URL}/webhook-success-message`;
|
||||
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "webhook",
|
||||
destinationUrl,
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer success-token" },
|
||||
extraParams: { source: "builder" },
|
||||
successMessage: "웹훅으로 전송되었습니다.",
|
||||
errorMessage: "웹훅 전송 실패",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
// @ts-expect-error - 글로벌 fetch 재정의
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "success-webhook@example.com");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(true);
|
||||
expect(json.message).toBe(config.successMessage);
|
||||
});
|
||||
|
||||
it("both 모드에서는 internal 처리 후 webhook 으로도 전송되어야 한다", async () => {
|
||||
const destinationUrl = `${BASE_URL}/webhook-both`;
|
||||
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "both",
|
||||
destinationUrl,
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer both-token" },
|
||||
extraParams: { source: "builder-both" },
|
||||
successMessage: "양쪽 모두 전송되었습니다.",
|
||||
errorMessage: "both 모드 전송 실패",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "both@example.com");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(true);
|
||||
expect(json.message).toBe(config.successMessage);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(destinationUrl);
|
||||
expect(options.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("webhook 모드에서 destinationUrl 이 없으면 400 과 destinationUrl_missing 및 errorMessage 가 포함되어야 한다", async () => {
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "webhook",
|
||||
// destinationUrl 누락
|
||||
successMessage: "성공 메시지",
|
||||
errorMessage: "Webhook URL 이 설정되지 않았습니다.",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
} as any;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "missing-destination@example.com");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(false);
|
||||
expect(json.error).toBe("destinationUrl_missing");
|
||||
expect(json.message).toBe(config.errorMessage);
|
||||
});
|
||||
|
||||
it("webhook 모드에서 원격 응답이 실패하면 502 와 webhook_failed, errorMessage 가 포함되어야 한다", async () => {
|
||||
const destinationUrl = `${BASE_URL}/webhook-fail`;
|
||||
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "webhook",
|
||||
destinationUrl,
|
||||
method: "POST",
|
||||
headers: {},
|
||||
extraParams: {},
|
||||
successMessage: "성공 메시지",
|
||||
errorMessage: "웹훅 호출이 실패했습니다.",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("remote error", { status: 503 }));
|
||||
// @ts-expect-error - 글로벌 fetch 재정의
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "fail-remote@example.com");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(false);
|
||||
expect(json.error).toBe("webhook_failed");
|
||||
expect(json.message).toBe(config.errorMessage);
|
||||
});
|
||||
|
||||
it("webhook 모드에서 fetch 예외가 발생하면 500 과 webhook_exception, errorMessage 가 포함되어야 한다", async () => {
|
||||
const destinationUrl = `${BASE_URL}/webhook-exception`;
|
||||
|
||||
const config: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "webhook",
|
||||
destinationUrl,
|
||||
method: "POST",
|
||||
headers: {},
|
||||
extraParams: {},
|
||||
successMessage: "성공 메시지",
|
||||
errorMessage: "웹훅 호출 중 예외가 발생했습니다.",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("network error");
|
||||
});
|
||||
// @ts-expect-error - 글로벌 fetch 재정의
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "exception@example.com");
|
||||
formData.append("__config", JSON.stringify(config));
|
||||
|
||||
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||
|
||||
const res = await handleSubmit(
|
||||
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.ok).toBe(false);
|
||||
expect(json.error).toBe("webhook_exception");
|
||||
expect(json.message).toBe(config.errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
+203
-1
@@ -9,13 +9,80 @@ vi.mock("@/generated/prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
project = {
|
||||
create: async ({ data }: any) => {
|
||||
const project = { id: inMemoryProjects.length + 1, ...data };
|
||||
const now = new Date();
|
||||
const project = {
|
||||
id: String(inMemoryProjects.length + 1),
|
||||
status: "DRAFT",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
};
|
||||
inMemoryProjects.push(project);
|
||||
return project;
|
||||
},
|
||||
findUnique: async ({ where: { slug } }: any) => {
|
||||
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
||||
},
|
||||
upsert: async ({ where: { slug }, update, create }: any) => {
|
||||
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
|
||||
if (index >= 0) {
|
||||
const existing = inMemoryProjects[index];
|
||||
const updated = {
|
||||
...existing,
|
||||
...update,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
inMemoryProjects[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const project = {
|
||||
id: String(inMemoryProjects.length + 1),
|
||||
status: "DRAFT",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...create,
|
||||
};
|
||||
inMemoryProjects.push(project);
|
||||
return project;
|
||||
},
|
||||
delete: async ({ where: { slug } }: any) => {
|
||||
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
|
||||
if (index === -1) {
|
||||
const error: any = new Error("Project not found");
|
||||
error.code = "P2025";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [removed] = inMemoryProjects.splice(index, 1);
|
||||
return removed;
|
||||
},
|
||||
findMany: async ({ orderBy, take, select }: any = {}) => {
|
||||
let items = [...inMemoryProjects];
|
||||
|
||||
if (orderBy?.createdAt === "desc") {
|
||||
items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
if (typeof take === "number") {
|
||||
items = items.slice(0, take);
|
||||
}
|
||||
|
||||
if (select) {
|
||||
return items.map((p) => {
|
||||
const picked: any = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) {
|
||||
picked[key] = p[key];
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,4 +137,139 @@ describe("/api/projects", () => {
|
||||
expect(fetched.slug).toBe(payload.slug);
|
||||
expect(fetched.contentJson).toEqual(payload.contentJson);
|
||||
});
|
||||
|
||||
it("GET /api/projects 는 최근 생성 순으로 프로젝트 목록을 반환해야 한다", async () => {
|
||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const firstPayload = {
|
||||
title: "첫 번째 프로젝트",
|
||||
slug: "first-project",
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const secondPayload = {
|
||||
title: "두 번째 프로젝트",
|
||||
slug: "second-project",
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(firstPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(secondPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
const { GET: listProjects } = await import("@/app/api/projects/route");
|
||||
|
||||
const res = await listProjects(new Request(`${BASE_URL}/api/projects`));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const list = (await res.json()) as any[];
|
||||
|
||||
expect(list.length).toBeGreaterThanOrEqual(2);
|
||||
expect(list[0].slug).toBe("second-project");
|
||||
expect(list[1].slug).toBe("first-project");
|
||||
});
|
||||
|
||||
it("DELETE /api/projects/[slug] 로 프로젝트를 삭제할 수 있어야 한다", async () => {
|
||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const slug = `delete-slug-${Date.now()}`;
|
||||
const payload = {
|
||||
title: "삭제 테스트 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const deleteResponse = await deleteProject(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE" }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
|
||||
const getAfterDelete = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
|
||||
expect(getAfterDelete.status).toBe(404);
|
||||
});
|
||||
|
||||
it("동일 slug 로 두 번 POST 하면 두 번째 요청은 기존 프로젝트를 업데이트해야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const slug = "duplicate-slug";
|
||||
|
||||
const firstPayload = {
|
||||
title: "첫 번째 제목",
|
||||
slug,
|
||||
contentJson: [
|
||||
{
|
||||
id: "blk_first",
|
||||
type: "text",
|
||||
props: { text: "첫 번째 내용", align: "left", size: "base" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const secondPayload = {
|
||||
title: "두 번째 제목",
|
||||
slug,
|
||||
contentJson: [
|
||||
{
|
||||
id: "blk_second",
|
||||
type: "text",
|
||||
props: { text: "두 번째 내용", align: "left", size: "base" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const firstRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(firstPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(firstRes.status).toBe(201);
|
||||
|
||||
const secondRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(secondPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect([200, 201]).toContain(secondRes.status);
|
||||
const updated = (await secondRes.json()) as any;
|
||||
|
||||
expect(updated.slug).toBe(slug);
|
||||
expect(updated.title).toBe("두 번째 제목");
|
||||
expect(updated.contentJson).toEqual(secondPayload.contentJson);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// /api/video 업로드 TDD
|
||||
// - POST /api/video 로 FormData(file) 를 전송하면 업로드 파일이 uploads 디렉터리에 저장되고
|
||||
// id / servedUrl(`/api/video/:id`) 을 반환해야 한다.
|
||||
|
||||
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 목 처리한다.
|
||||
vi.mock("@/generated/prisma/client", () => {
|
||||
class PrismaClient {
|
||||
project = {
|
||||
upsert: async () => ({ id: "project-mock" }),
|
||||
};
|
||||
|
||||
asset = {
|
||||
create: async () => ({ id: "asset-mock" }),
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient };
|
||||
});
|
||||
|
||||
describe("/api/video 업로드", () => {
|
||||
it("POST /api/video 는 업로드된 비디오 파일을 저장하고 id/servedUrl 을 반환해야 한다", async () => {
|
||||
const { POST: handleUpload } = await import("@/app/api/video/route");
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob(["dummy-video"], { type: "video/mp4" });
|
||||
// FormData 에서는 File/Blob 모두 허용되므로, Blob 을 그대로 사용한다.
|
||||
formData.append("file", blob as any);
|
||||
|
||||
const res = await handleUpload(
|
||||
new Request(`${BASE_URL}/api/video`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const json = (await res.json()) as { id: string; servedUrl?: string | null };
|
||||
expect(json.id).toBeTruthy();
|
||||
expect(json.servedUrl).toBe(`/api/video/${json.id}`);
|
||||
|
||||
const filePath = path.join(process.cwd(), "uploads", json.id);
|
||||
const stat = await fs.stat(filePath);
|
||||
expect(stat.isFile()).toBe(true);
|
||||
});
|
||||
});
|
||||
+90
-70
@@ -8,10 +8,10 @@ test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한
|
||||
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
|
||||
test("텍스트 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
await expect(canvas.getByText("새 텍스트")).toBeVisible();
|
||||
@@ -75,7 +75,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 1단계: 더블클릭 → Enter 로 커밋
|
||||
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
|
||||
@@ -109,7 +109,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
|
||||
await editor.fill("blur 로 커밋된 텍스트");
|
||||
|
||||
// 포커스를 다른 곳(예: 속성 패널의 버튼 추가 텍스트 입력)을 클릭하여 blur 를 발생시킨다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// blur 이후에는 마지막 입력 값이 캔버스에 반영되어야 한다.
|
||||
canvasAfterEdit = page.getByTestId("editor-canvas");
|
||||
@@ -120,7 +120,7 @@ test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 캔버스 블록을 클릭해서 선택한다.
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
@@ -140,8 +140,8 @@ test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록 두 개를 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
@@ -172,7 +172,7 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 하나 추가하고 선택한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const block = canvas.getByTestId("editor-block").nth(0);
|
||||
await block.click({ force: true });
|
||||
@@ -196,7 +196,7 @@ test("텍스트 블록 인라인 툴바에서 정렬과 글자 크기를 변경
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const block = canvas.getByTestId("editor-block").nth(0);
|
||||
@@ -226,8 +226,8 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가하고 각각 다른 내용/스타일을 설정한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
@@ -290,8 +290,8 @@ test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
@@ -318,8 +318,8 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 블록 두 개를 추가하고 서로 다른 텍스트로 구분한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const firstBlock = blocks.nth(0);
|
||||
@@ -352,8 +352,8 @@ test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록과 리스트 블록을 하나씩 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(2);
|
||||
@@ -387,7 +387,7 @@ test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 버튼 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
// 캔버스에 기본 버튼이 렌더되어야 한다.
|
||||
const button = canvas.getByRole("button", { name: "버튼" });
|
||||
@@ -410,7 +410,7 @@ test("버튼 블록을 추가하면 속성 패널 기본 색상/패딩 값이
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
const button = canvas.getByRole("button", { name: "버튼" });
|
||||
await expect(button).toBeVisible();
|
||||
@@ -437,7 +437,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
const button = canvas.getByRole("button", { name: "버튼" }).first();
|
||||
|
||||
@@ -470,7 +470,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
|
||||
test("이미지 블록 고정 너비와 모서리 둥글기 스타일이 에디터에서 적용되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "이미지" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -504,7 +504,7 @@ test("에디터 메뉴에서 페이지 파일로 내보내기 (ZIP)를 클릭하
|
||||
await page.goto("/editor");
|
||||
|
||||
// 최소 한 개의 블록을 추가해 내보낼 데이터가 있도록 한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 헤더 메뉴를 연다.
|
||||
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||
@@ -528,7 +528,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 섹션을 하나 추가하고 레이아웃을 2컬럼으로 변경한다.
|
||||
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "섹션" }).click();
|
||||
|
||||
// 섹션을 선택한다 (캔버스 첫 블록).
|
||||
const sectionBlock = canvas.getByTestId("editor-block").nth(0);
|
||||
@@ -538,7 +538,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
|
||||
await layoutSelect.selectOption("2col");
|
||||
|
||||
// 왼쪽 컬럼에 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const leftColumn = canvas.locator('[data-section-id][data-column-id]').nth(0);
|
||||
const rightColumn = canvas.locator('[data-section-id][data-column-id]').nth(1);
|
||||
@@ -563,7 +563,7 @@ test("Hero 템플릿 버튼을 클릭하면 섹션과 기본 텍스트/버튼
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// Hero 템플릿을 추가한다.
|
||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Hero 템플릿" }).click();
|
||||
|
||||
// 섹션/블록이 하나 이상 존재해야 한다.
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
@@ -582,7 +582,7 @@ test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "기능 템플릿" }).click();
|
||||
|
||||
// Feature 제목들이 렌더되어야 한다.
|
||||
await expect(canvas.getByText("Feature 1 제목")).toBeVisible();
|
||||
@@ -595,7 +595,7 @@ test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "CTA 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
|
||||
const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" });
|
||||
@@ -607,13 +607,13 @@ test("FAQ 템플릿 버튼을 클릭하면 질문/답변 텍스트가 세 쌍
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "FAQ 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "FAQ 템플릿" }).click();
|
||||
|
||||
// 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다.
|
||||
await expect(canvas.getByText("자주 묻는 질문 1")).toBeVisible();
|
||||
await expect(canvas.getByText("첫 번째 질문에 대한 답변을 여기에 입력하세요.")).toBeVisible();
|
||||
await expect(canvas.getByText("자주 묻는 질문 2")).toBeVisible();
|
||||
await expect(canvas.getByText("자주 묻는 질문 3")).toBeVisible();
|
||||
await expect(canvas.getByText("서비스 이용료는 얼마인가요?")).toBeVisible();
|
||||
await expect(canvas.getByText("기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.")).toBeVisible();
|
||||
await expect(canvas.getByText("환불 정책은 어떻게 되나요?")).toBeVisible();
|
||||
await expect(canvas.getByText("팀원 초대 기능이 있나요?")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생성되어야 한다", async ({ page }) => {
|
||||
@@ -621,7 +621,7 @@ test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "상품 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("Basic")).toBeVisible();
|
||||
await expect(canvas.getByText("₩9,900/월")).toBeVisible();
|
||||
@@ -636,7 +636,7 @@ test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "후기 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
|
||||
await expect(canvas.getByText("디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.")).toBeVisible();
|
||||
@@ -648,7 +648,7 @@ test("Blog 템플릿 버튼을 클릭하면 3개의 포스트 카드(제목/요
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Blog 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "블로그 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("블로그 포스트 1")).toBeVisible();
|
||||
await expect(canvas.getByText("첫 번째 포스트 요약을 여기에 입력하세요.")).toBeVisible();
|
||||
@@ -661,7 +661,7 @@ test("Team 템플릿 버튼을 클릭하면 3명의 팀 카드가 생성되어
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Team 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Team 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("홍길동")).toBeVisible();
|
||||
await expect(canvas.getByText("Product Designer")).toBeVisible();
|
||||
@@ -675,7 +675,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// Hero 템플릿 섹션을 추가한다.
|
||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Hero 템플릿" }).click();
|
||||
|
||||
// 섹션 블록을 선택한다 (캔버스의 첫 번째 블록이 섹션이다).
|
||||
const sectionBlock = canvas.getByTestId("editor-block").first();
|
||||
@@ -685,7 +685,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
|
||||
await page.getByRole("button", { name: "블록 삭제" }).click();
|
||||
|
||||
// 텍스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 새 텍스트 블록이 캔버스에 보여야 한다.
|
||||
await expect(canvas.getByText("새 텍스트")).toBeVisible();
|
||||
@@ -696,10 +696,12 @@ test("Footer 템플릿 버튼을 클릭하면 링크/카피라이트 텍스트
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "Footer 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Footer 템플릿" }).click();
|
||||
|
||||
await expect(canvas.getByText("이용약관 · 개인정보처리방침")).toBeVisible();
|
||||
await expect(canvas.getByText("© 2025 MyLanding. All rights reserved.")).toBeVisible();
|
||||
await expect(canvas.getByText("서비스 소개")).toBeVisible();
|
||||
await expect(canvas.getByText("고객지원")).toBeVisible();
|
||||
await expect(canvas.getByText("© 2025 MyLanding.")).toBeVisible();
|
||||
await expect(canvas.getByText("All rights reserved.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할 수 있어야 한다", async ({ page }) => {
|
||||
@@ -708,7 +710,7 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
||||
|
||||
// Cmd/Ctrl + Z 로 Undo 한다.
|
||||
@@ -726,6 +728,24 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("헤더의 실행 취소/다시 실행 버튼으로 블록 추가를 Undo/Redo 할 수 있어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
||||
|
||||
const undoButton = page.getByRole("button", { name: "실행 취소" });
|
||||
const redoButton = page.getByRole("button", { name: "다시 실행" });
|
||||
|
||||
await undoButton.click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
|
||||
|
||||
await redoButton.click();
|
||||
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며 순차적으로 선택할 수 있어야 한다", async ({ page }) => {
|
||||
test.skip(true, "ArrowUp/Down 기반 선택 이동 E2E는 불안정하여 임시 스킵");
|
||||
await page.goto("/editor");
|
||||
@@ -733,9 +753,9 @@ test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록을 세 개 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(3);
|
||||
@@ -769,8 +789,8 @@ test("속성 패널의 블록 삭제 버튼으로 선택된 블록을 삭제할
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록 두 개를 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(2);
|
||||
@@ -791,7 +811,7 @@ test("Cmd/Ctrl+D 단축키로 선택된 블록을 복제할 수 있어야 한다
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
let blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(1);
|
||||
@@ -819,7 +839,7 @@ test("구분선 블록을 추가하면 캔버스에 구분선이 렌더되고
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 구분선 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const dividerBlock = blocks.nth(0);
|
||||
@@ -843,7 +863,7 @@ test("리스트 블록을 추가하고 첫 번째 아이템과 번호 매기기/
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 리스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const listBlock = blocks.nth(0);
|
||||
@@ -871,7 +891,7 @@ test("리스트 아이템을 선택하면 패널에서 아이템 위/아래/들
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 리스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const listBlock = blocks.nth(0);
|
||||
@@ -910,23 +930,23 @@ test("폼 요소 사이드바에서 폼 입력/셀렉트/라디오/체크박스
|
||||
await expect(page.getByRole("heading", { name: "폼 요소" })).toBeVisible();
|
||||
|
||||
// 폼 입력 블록 추가 버튼을 클릭하면 formInput 블록이 캔버스에 생성되어야 한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
let blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(1);
|
||||
|
||||
// 폼 셀렉트 블록 추가 버튼을 클릭하면 formSelect 블록이 추가되어야 한다.
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(2);
|
||||
|
||||
// 폼 라디오 블록 추가 버튼을 클릭하면 formRadio 블록이 추가되어야 한다.
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(3);
|
||||
|
||||
// 폼 체크박스 블록 추가 버튼을 클릭하면 formCheckbox 블록이 추가되어야 한다.
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(4);
|
||||
});
|
||||
@@ -937,12 +957,12 @@ test("폼 블록을 선택하면 우측 속성 패널에서 폼 필드/버튼
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 요소와 버튼 블록을 몇 개 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
// 폼 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const formBlock = blocks.nth((await blocks.count()) - 1);
|
||||
@@ -982,7 +1002,7 @@ test("폼 입력 블록의 라벨/전송 키/필수 여부를 우측 패널에
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 입력 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const inputBlock = blocks.nth(0);
|
||||
@@ -1011,7 +1031,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 셀렉트 블록
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
let blocks = canvas.getByTestId("editor-block");
|
||||
const selectBlock = blocks.nth((await blocks.count()) - 1);
|
||||
await selectBlock.click({ force: true });
|
||||
@@ -1029,7 +1049,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
|
||||
await requiredCheckbox.check();
|
||||
|
||||
// 폼 라디오 블록
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
const radioBlock = blocks.nth((await blocks.count()) - 1);
|
||||
await radioBlock.click({ force: true });
|
||||
@@ -1045,7 +1065,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
|
||||
// 라디오 그룹 타이틀 텍스트 필드는 그대로 편집 가능해야 한다.
|
||||
|
||||
// 폼 체크박스 블록
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
blocks = canvas.getByTestId("editor-block");
|
||||
const checkboxBlock = blocks.nth((await blocks.count()) - 1);
|
||||
await checkboxBlock.click({ force: true });
|
||||
@@ -1067,7 +1087,7 @@ test("폼 입력 블록을 추가하면 에디터 캔버스에 라벨과 입력
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 입력 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const inputBlock = blocks.nth(0);
|
||||
@@ -1086,7 +1106,7 @@ test("폼 셀렉트 블록을 추가하면 에디터 캔버스에 라벨과 셀
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 셀렉트 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const selectBlock = blocks.nth(0);
|
||||
@@ -1105,7 +1125,7 @@ test("폼 라디오 블록을 추가하면 에디터 캔버스에 그룹 라벨
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 라디오 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const radioBlock = blocks.nth(0);
|
||||
@@ -1124,7 +1144,7 @@ test("폼 체크박스 블록을 추가하면 에디터 캔버스에 체크박
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 체크박스 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const checkboxBlock = blocks.nth(0);
|
||||
@@ -1142,7 +1162,7 @@ test("폼 입력 필드의 타입과 Placeholder 를 우측 패널에서 변경
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const inputBlock = blocks.nth(0);
|
||||
@@ -1165,7 +1185,7 @@ test("폼 셀렉트 블록의 옵션 목록을 우측 패널에서 수정하면
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const selectBlock = blocks.nth(0);
|
||||
@@ -1206,7 +1226,7 @@ test("폼 입력 블록의 스타일(텍스트/배경/모서리)을 우측 패
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 입력 블록을 하나 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const inputBlock = blocks.nth(0);
|
||||
|
||||
+93
-117
@@ -10,15 +10,15 @@ test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
// 에디터 좌측 패널용 버튼들은 나타나지 않아야 한다.
|
||||
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Hero 템플릿 추가" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Hero 템플릿" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 Hero 콘텐츠가 보여야 한다", async ({ page }) => {
|
||||
// 에디터에서 Hero 템플릿을 추가한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Hero 템플릿" }).click();
|
||||
|
||||
// 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
@@ -36,8 +36,8 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
|
||||
// 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다.
|
||||
await expect(page.getByText("지금 시작하기")).toBeVisible();
|
||||
|
||||
// 프리뷰 화면에는 에디터용 "텍스트 블록 추가" 버튼이 없어야 한다.
|
||||
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
|
||||
// 프리뷰 화면에는 에디터용 "텍스트" 버튼이 없어야 한다.
|
||||
await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => {
|
||||
@@ -58,7 +58,7 @@ test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야
|
||||
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "기능 템플릿" }).click();
|
||||
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible();
|
||||
@@ -77,7 +77,7 @@ test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의
|
||||
test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "CTA 템플릿" }).click();
|
||||
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
|
||||
@@ -95,10 +95,10 @@ test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템
|
||||
// 에디터에서 여러 템플릿을 추가한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
|
||||
await page.getByRole("button", { name: "Hero 템플릿" }).click();
|
||||
await page.getByRole("button", { name: "기능 템플릿" }).click();
|
||||
await page.getByRole("button", { name: "상품 템플릿" }).click();
|
||||
await page.getByRole("button", { name: "후기 템플릿" }).click();
|
||||
|
||||
// 모바일 뷰포트로 전환한다.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
@@ -127,7 +127,7 @@ test("텍스트 블록의 정렬/폰트 크기/색상이 프리뷰에도 반영
|
||||
await page.goto("/editor");
|
||||
|
||||
// 텍스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "텍스트" }).first().click();
|
||||
|
||||
// 기본 텍스트 대신 프리뷰에서 쉽게 찾을 수 있도록 내용도 함께 수정한다.
|
||||
const textTextarea = page.getByLabel("선택한 텍스트 블록 내용");
|
||||
@@ -175,8 +175,8 @@ test("구분선/리스트 블록도 프리뷰에서 렌더링되어야 한다",
|
||||
// 에디터에서 구분선과 리스트 블록을 추가한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
// 에디터 캔버스에서 리스트 항목 일부가 보이는지 확인해 sanity check 를 한다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
@@ -195,7 +195,7 @@ test("리스트 블록 스타일 속성이 프리뷰에도 반영되어야 한
|
||||
await page.goto("/editor");
|
||||
|
||||
// 리스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
// 테스트를 위해 리스트 아이템을 2개 이상으로 만든다 (첫 번째 li 에 margin-bottom 이 적용되도록).
|
||||
const itemsTextarea = page.getByLabel("리스트 아이템들");
|
||||
@@ -271,8 +271,8 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
|
||||
await page.goto("/editor");
|
||||
|
||||
// 폼 입력/셀렉트 블록을 각각 하나씩 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
|
||||
@@ -293,7 +293,7 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
|
||||
test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -306,8 +306,6 @@ test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰
|
||||
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
|
||||
|
||||
const editorImage = canvas.getByRole("img", { name: "체크박스 라벨 이미지" }).first();
|
||||
await expect(editorImage).toBeVisible();
|
||||
|
||||
const editorSrc = await editorImage.getAttribute("src");
|
||||
expect(editorSrc).not.toBeNull();
|
||||
expect(editorSrc).toMatch(/^\/api\/image\//);
|
||||
@@ -325,7 +323,7 @@ test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰
|
||||
test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -358,7 +356,7 @@ test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰
|
||||
test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -392,7 +390,7 @@ test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰
|
||||
test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -425,7 +423,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
|
||||
await page.goto("/editor");
|
||||
|
||||
// 폼 입력 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
// 우측 속성 패널에서 폼 입력 스타일을 설정한다.
|
||||
// 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다.
|
||||
@@ -485,7 +483,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
|
||||
test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -514,7 +512,7 @@ test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되
|
||||
test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -556,7 +554,7 @@ test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링
|
||||
test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -605,7 +603,7 @@ test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "이미지" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -636,7 +634,7 @@ test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어
|
||||
test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "이미지" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
@@ -673,7 +671,7 @@ test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모
|
||||
test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -720,7 +718,7 @@ test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스
|
||||
test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "섹션" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -782,7 +780,7 @@ test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일
|
||||
test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -811,7 +809,7 @@ test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -839,7 +837,7 @@ test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -868,7 +866,7 @@ test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케
|
||||
test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -896,7 +894,7 @@ test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -924,7 +922,7 @@ test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반
|
||||
test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -952,7 +950,7 @@ test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영
|
||||
test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const paddingSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -980,7 +978,7 @@ test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const paddingSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1008,7 +1006,7 @@ test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1037,7 +1035,7 @@ test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스
|
||||
test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1064,10 +1062,15 @@ test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케
|
||||
expect(gapStyles.inlineRowGap.endsWith("em")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
// 폼 블록이 실제로 선택되어 우측 속성 패널에 FormControllerPanel 이 노출되도록 캔버스에서 한 번 더 명시적으로 선택한다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
const editorBlocks = editorCanvas.getByTestId("editor-block");
|
||||
await editorBlocks.last().click({ force: true });
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1077,26 +1080,20 @@ test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const form = page.getByTestId("preview-form-controller").first();
|
||||
const inlineStyles = await form.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
return {
|
||||
computedWidth: s.width,
|
||||
inlineWidth: element.style.width,
|
||||
};
|
||||
});
|
||||
|
||||
const widthPx = parseFloat(inlineStyles.computedWidth);
|
||||
expect(widthPx).toBeGreaterThan(430);
|
||||
expect(widthPx).toBeLessThan(520);
|
||||
expect(inlineStyles.inlineWidth.endsWith("em")).toBeTruthy();
|
||||
const formLocator = page.getByTestId("preview-form-controller");
|
||||
await expect(formLocator).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
// 앞선 테스트들과의 상호 영향으로 selectedBlockId 가 어긋나는 경우를 방지하기 위해,
|
||||
// 방금 추가한 폼 블록을 캔버스에서 다시 선택해 속성 패널을 확실히 FormBlock 기준으로 맞춘다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
const editorBlocks = editorCanvas.getByTestId("editor-block");
|
||||
await editorBlocks.last().click({ force: true });
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1105,26 +1102,14 @@ test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const form = page.getByTestId("preview-form-controller").first();
|
||||
const inlineStyles = await form.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
return {
|
||||
marginTop: s.marginTop,
|
||||
inlineMarginTop: element.style.marginTop,
|
||||
};
|
||||
});
|
||||
|
||||
const marginPx = parseFloat(inlineStyles.marginTop);
|
||||
expect(marginPx).toBeGreaterThan(24);
|
||||
expect(marginPx).toBeLessThan(32);
|
||||
expect(inlineStyles.inlineMarginTop.endsWith("em")).toBeTruthy();
|
||||
const formLocator = page.getByTestId("preview-form-controller");
|
||||
await expect(formLocator).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1152,7 +1137,7 @@ test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1180,7 +1165,7 @@ test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반
|
||||
test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1189,7 +1174,7 @@ test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const checkboxOption = page.getByTestId("preview-form-checkbox-option").first();
|
||||
const checkboxOption = page.getByTestId("preview-form-checkbox-option-container").first();
|
||||
const paddingStyles = await checkboxOption.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
@@ -1208,7 +1193,7 @@ test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1217,7 +1202,7 @@ test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const checkboxOption = page.getByTestId("preview-form-checkbox-option").first();
|
||||
const checkboxOption = page.getByTestId("preview-form-checkbox-option-container").first();
|
||||
const paddingStyles = await checkboxOption.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
@@ -1236,7 +1221,7 @@ test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케
|
||||
test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1264,7 +1249,7 @@ test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일
|
||||
test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1292,7 +1277,7 @@ test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일
|
||||
test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1320,7 +1305,7 @@ test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일
|
||||
test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1348,7 +1333,7 @@ test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1376,7 +1361,7 @@ test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반
|
||||
test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1404,7 +1389,7 @@ test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케
|
||||
test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1432,7 +1417,7 @@ test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일
|
||||
test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1460,7 +1445,7 @@ test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로
|
||||
test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1489,7 +1474,7 @@ test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영
|
||||
test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1518,7 +1503,7 @@ test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로
|
||||
test("폼 체크박스 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
|
||||
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
@@ -1551,9 +1536,9 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 폼 입력 요소와 버튼, 폼 블록을 순서대로 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
// 방금 추가된 폼 블록을 선택한다.
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
@@ -1587,12 +1572,9 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
|
||||
const input = page.getByRole("textbox").first();
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
// 값을 입력한 뒤, 매핑된 버튼(기본 라벨 "버튼")을 클릭하면 폼이 전송되고 성공 메시지가 보여야 한다.
|
||||
await input.fill("테스트 값");
|
||||
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
|
||||
await expect(page.getByText("성공적으로 전송되었습니다.")).toBeVisible();
|
||||
// 프리뷰 정책: FormBlock 컨트롤러는 프리뷰에서 실제 `<form>` DOM 을 렌더하지 않는다.
|
||||
await expect(page.getByTestId("preview-form-controller")).toHaveCount(0);
|
||||
await expect(page.getByText("성공적으로 전송되었습니다.")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으로 제출되어야 한다", async ({ page }) => {
|
||||
@@ -1603,9 +1585,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
|
||||
// Form A: 입력 필드 + 버튼 + 폼 블록을 추가한 뒤, 즉시 컨트롤러를 매핑한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
let count = await blocks.count();
|
||||
const formBlockA = blocks.nth(count - 1);
|
||||
@@ -1621,9 +1603,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
|
||||
await submitSelect.selectOption({ index: 1 });
|
||||
|
||||
// Form B: 입력 필드 + 버튼 + 폼 블록을 다시 추가한 뒤, 즉시 컨트롤러를 매핑한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "입력(Input)" }).click();
|
||||
await page.getByRole("button", { name: "버튼" }).first().click();
|
||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||
|
||||
count = await blocks.count();
|
||||
const formBlockB = blocks.nth(count - 1);
|
||||
@@ -1653,25 +1635,19 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
|
||||
const firstInput = textboxes.nth(0);
|
||||
const secondInput = textboxes.nth(1);
|
||||
|
||||
// Form A 입력값을 채우고 첫 번째 버튼 클릭 → 성공 메시지 기대
|
||||
await firstInput.fill("A 폼 값");
|
||||
await page.getByRole("button", { name: "버튼" }).first().click();
|
||||
// 첫 번째 폼에 대한 성공 메시지가 최소 하나는 보여야 한다.
|
||||
await expect(page.getByText("성공적으로 전송되었습니다.").first()).toBeVisible();
|
||||
// 프리뷰 정책: 여러 FormBlock 이 있어도 preview-form-controller DOM 은 생성되지 않아야 한다.
|
||||
const controllers = page.getByTestId("preview-form-controller");
|
||||
await expect(controllers).toHaveCount(0);
|
||||
|
||||
// Form B 입력값을 채우고 두 번째 버튼 클릭 → 다시 성공 메시지 기대
|
||||
await secondInput.fill("B 폼 값");
|
||||
await page.getByRole("button", { name: "버튼" }).nth(1).click();
|
||||
// 두 번째 폼에 대한 성공 메시지도 두 번째 요소로 표시되었는지 확인한다.
|
||||
const successMessages = page.getByText("성공적으로 전송되었습니다.");
|
||||
await expect(successMessages.nth(1)).toBeVisible();
|
||||
await expect(successMessages).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에 맞게 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 섹션 블록을 직접 추가한다.
|
||||
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "섹션" }).click();
|
||||
|
||||
// 캔버스에서 첫 번째 섹션 블록을 선택한다.
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
@@ -1729,7 +1705,7 @@ test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에
|
||||
test("구분선 여백/길이 수치가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "구분선" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const dividerBlock = canvas.getByTestId("editor-block").first();
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { BlocksSidebar } from "@/app/editor/panels/BlocksSidebar";
|
||||
|
||||
// BlocksSidebar 템플릿 버튼 TDD
|
||||
// - 각 템플릿 추가 버튼이 대응하는 editorStore 액션(add*TemplateSection)을 호출하는지 검증한다.
|
||||
|
||||
const templateActions = {
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
};
|
||||
|
||||
// BlocksSidebar 가 사용하는 나머지 액션들도 더미 함수로 채워둔다.
|
||||
const otherActions = {
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addVideoBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
...templateActions,
|
||||
...otherActions,
|
||||
};
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
// useEditorStore(selector) 형태로 사용되므로, selector 에 mockState 를 넘겨준다.
|
||||
useEditorStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
|
||||
};
|
||||
});
|
||||
|
||||
describe("BlocksSidebar - 템플릿 버튼", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.values(templateActions).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
it("Hero 템플릿 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
const button = screen.getByRole("button", { name: "Hero 템플릿" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Features 템플릿 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
const button = screen.getByRole("button", { name: "기능 템플릿" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("CTA 템플릿 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
const button = screen.getByRole("button", { name: "CTA 템플릿" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "상품 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "후기 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "블로그 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿" }));
|
||||
|
||||
expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1);
|
||||
expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1);
|
||||
expect(templateActions.addTestimonialsTemplateSection).toHaveBeenCalledTimes(1);
|
||||
expect(templateActions.addBlogTemplateSection).toHaveBeenCalledTimes(1);
|
||||
expect(templateActions.addTeamTemplateSection).toHaveBeenCalledTimes(1);
|
||||
expect(templateActions.addFooterTemplateSection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("템플릿 섹션에는 각 템플릿의 용도를 설명하는 텍스트가 함께 표시되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
expect(screen.getByText("페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)"));
|
||||
expect(screen.getByText("3컬럼 기능 소개 섹션"));
|
||||
expect(screen.getByText("콜투액션(CTA) 섹션"));
|
||||
expect(screen.getByText("자주 묻는 질문(FAQ) 섹션"));
|
||||
expect(screen.getByText("요금제/플랜 소개 섹션"));
|
||||
expect(screen.getByText("고객 후기(Testimonials) 섹션"));
|
||||
expect(screen.getByText("블로그 포스트 목록 섹션"));
|
||||
expect(screen.getByText("팀 소개 섹션"));
|
||||
expect(screen.getByText("페이지 푸터 섹션"));
|
||||
});
|
||||
|
||||
it("비디오 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
const button = screen.getByRole("button", { name: "비디오" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("템플릿들은 카테고리별로 그룹 헤더를 가져야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
expect(screen.getByText("히어로 · CTA")).toBeDefined();
|
||||
expect(screen.getByText("콘텐츠 섹션")).toBeDefined();
|
||||
expect(screen.getByText("신뢰/소개")).toBeDefined();
|
||||
expect(screen.getByText("푸터/기타")).toBeDefined();
|
||||
});
|
||||
|
||||
it("각 템플릿 카드에는 미니 썸네일(레이아웃 힌트)가 렌더링되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
expect(screen.getByTestId("template-preview-hero")).toBeDefined();
|
||||
expect(screen.getByTestId("template-preview-features")).toBeDefined();
|
||||
expect(screen.getByTestId("template-preview-cta")).toBeDefined();
|
||||
expect(screen.getByTestId("template-preview-faq")).toBeDefined();
|
||||
expect(screen.getByTestId("template-preview-pricing")).toBeDefined();
|
||||
expect(screen.getByTestId("template-preview-testimonials")).toBeDefined();
|
||||
expect(screen.getByTestId("template-preview-blog")).toBeDefined();
|
||||
expect(screen.getByTestId("template-preview-team")).toBeDefined();
|
||||
expect(screen.getByTestId("template-preview-footer")).toBeDefined();
|
||||
});
|
||||
|
||||
it("미니 썸네일은 각 템플릿 레이아웃을 암시하는 구조를 가져야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
const heroThumb = screen.getByTestId("template-preview-hero");
|
||||
const ctaThumb = screen.getByTestId("template-preview-cta");
|
||||
const featuresThumb = screen.getByTestId("template-preview-features");
|
||||
const pricingThumb = screen.getByTestId("template-preview-pricing");
|
||||
const blogThumb = screen.getByTestId("template-preview-blog");
|
||||
const teamThumb = screen.getByTestId("template-preview-team");
|
||||
|
||||
// Hero/CTA: 하나의 메인 영역을 가진 1열 구조
|
||||
expect(heroThumb.children.length).toBe(1);
|
||||
expect(ctaThumb.children.length).toBe(1);
|
||||
|
||||
// Features/Blog: 3컬럼 레이아웃을 암시하는 3개의 세그먼트
|
||||
expect(featuresThumb.children.length).toBe(3);
|
||||
expect(blogThumb.children.length).toBe(3);
|
||||
|
||||
// Pricing/Team: 높이가 다른 3개의 바를 가진 3컬럼 카드 레이아웃
|
||||
expect(pricingThumb.children.length).toBe(3);
|
||||
expect(teamThumb.children.length).toBe(3);
|
||||
});
|
||||
|
||||
it("블록 섹션 타이틀을 클릭하면 블록 버튼들을 접고 펼 수 있어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
// 초기에는 텍스트 버튼이 보여야 한다.
|
||||
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
|
||||
|
||||
const blockToggle = screen.getByRole("button", { name: "블록" });
|
||||
fireEvent.click(blockToggle);
|
||||
|
||||
// 접힌 상태에서는 텍스트 버튼이 보이지 않아야 한다.
|
||||
expect(screen.queryByRole("button", { name: "텍스트" })).toBeNull();
|
||||
|
||||
// 다시 클릭하면 펼쳐져야 한다.
|
||||
fireEvent.click(blockToggle);
|
||||
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("폼 요소 섹션 타이틀을 클릭하면 폼 관련 버튼들을 접고 펼 수 있어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
// 초기에는 폼 입력 버튼이 보여야 한다.
|
||||
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
|
||||
|
||||
const formToggle = screen.getByRole("button", { name: "폼 요소" });
|
||||
fireEvent.click(formToggle);
|
||||
|
||||
// 접힌 상태에서는 폼 입력 버튼이 보이지 않아야 한다.
|
||||
expect(screen.queryByRole("button", { name: "입력(Input)" })).toBeNull();
|
||||
|
||||
// 다시 클릭하면 펼쳐져야 한다.
|
||||
fireEvent.click(formToggle);
|
||||
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("템플릿 섹션 타이틀을 클릭하면 템플릿 카드들을 접고 펼 수 있어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
// 초기에는 Hero 템플릿 버튼이 보여야 한다.
|
||||
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
|
||||
|
||||
const templateToggle = screen.getByRole("button", { name: "템플릿" });
|
||||
fireEvent.click(templateToggle);
|
||||
|
||||
// 접힌 상태에서는 Hero 템플릿 버튼이 보이지 않아야 한다.
|
||||
expect(screen.queryByRole("button", { name: "Hero 템플릿" })).toBeNull();
|
||||
|
||||
// 다시 클릭하면 펼쳐져야 한다.
|
||||
fireEvent.click(templateToggle);
|
||||
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ButtonPropertiesPanel } from "@/app/editor/panels/ButtonPropertiesPanel";
|
||||
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// ButtonPropertiesPanel 컨트롤 TDD
|
||||
// - 색상 HEX 인풋 및 너비 모드 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다.
|
||||
|
||||
describe("ButtonPropertiesPanel", () => {
|
||||
const baseProps: ButtonBlockProps = {
|
||||
label: "버튼",
|
||||
href: "#",
|
||||
align: "left",
|
||||
size: "md",
|
||||
variant: "solid",
|
||||
colorPalette: "primary",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<ButtonPropertiesPanel
|
||||
buttonProps={{ ...baseProps, textColorCustom: "#ffffff" }}
|
||||
selectedBlockId="btn-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("버튼 텍스트 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"btn-1",
|
||||
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<ButtonPropertiesPanel
|
||||
buttonProps={{ ...baseProps, fillColorCustom: "#000000" }}
|
||||
selectedBlockId="btn-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("버튼 채움 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#445566" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"btn-2",
|
||||
expect.objectContaining({ fillColorCustom: "#445566" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("외곽선 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<ButtonPropertiesPanel
|
||||
buttonProps={{ ...baseProps, strokeColorCustom: "#000000" }}
|
||||
selectedBlockId="btn-3"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("버튼 외곽선 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"btn-3",
|
||||
expect.objectContaining({ strokeColorCustom: "#778899" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("버튼 너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<ButtonPropertiesPanel
|
||||
buttonProps={{ ...baseProps, widthMode: "auto", fullWidth: false }}
|
||||
selectedBlockId="btn-4"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("버튼 너비 모드");
|
||||
fireEvent.change(select, { target: { value: "fixed" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"btn-4",
|
||||
expect.objectContaining({ widthMode: "fixed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { DividerPropertiesPanel } from "@/app/editor/panels/DividerPropertiesPanel";
|
||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// DividerPropertiesPanel 컨트롤 TDD
|
||||
// - 정렬/두께 select 변경 시 updateBlock 이 올바른 필드로 호출되는지
|
||||
// - 색상 HEX 인풋 변경 시 colorHex 로 호출되는지
|
||||
// - 위/아래 여백 커스텀(px) 입력 시 marginYPx 로 호출되는지
|
||||
|
||||
describe("DividerPropertiesPanel", () => {
|
||||
const baseProps: DividerBlockProps = {
|
||||
align: "left",
|
||||
thickness: "thin",
|
||||
widthMode: "full",
|
||||
colorHex: "#475569",
|
||||
marginY: "md",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("정렬 select 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<DividerPropertiesPanel
|
||||
dividerProps={baseProps}
|
||||
selectedBlockId="divider-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("구분선 정렬");
|
||||
fireEvent.change(select, { target: { value: "center" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"divider-1",
|
||||
expect.objectContaining({ align: "center" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("두께 select 변경 시 updateBlock 이 thickness 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<DividerPropertiesPanel
|
||||
dividerProps={baseProps}
|
||||
selectedBlockId="divider-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("구분선 두께");
|
||||
fireEvent.change(select, { target: { value: "medium" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"divider-2",
|
||||
expect.objectContaining({ thickness: "medium" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("색상 HEX 인풋 변경 시 updateBlock 이 colorHex 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<DividerPropertiesPanel
|
||||
dividerProps={{ ...baseProps, colorHex: "#111111" }}
|
||||
selectedBlockId="divider-3"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInputs = screen.getAllByLabelText("구분선 색상 HEX");
|
||||
const hexInput = hexInputs[0] as HTMLInputElement;
|
||||
fireEvent.change(hexInput, { target: { value: "#123456" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"divider-3",
|
||||
expect.objectContaining({ colorHex: "#123456" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("위/아래 여백 커스텀 (px) 인풋 변경 시 updateBlock 이 marginYPx 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<DividerPropertiesPanel
|
||||
dividerProps={baseProps}
|
||||
selectedBlockId="divider-4"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const marginInputs = screen.getAllByLabelText("위/아래 여백 커스텀 (px)");
|
||||
const marginInput = marginInputs[0] as HTMLInputElement;
|
||||
|
||||
fireEvent.change(marginInput, { target: { value: "24" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"divider-4",
|
||||
expect.objectContaining({ marginYPx: 24 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
// EditorPage 자동저장 TDD
|
||||
// - blocks + projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다.
|
||||
// - localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks + updateProjectConfig 로 복원해야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "자동저장 테스트 페이지",
|
||||
slug: "autosave-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "자동저장 테스트 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 자동저장", () => {
|
||||
it("blocks 와 projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다", () => {
|
||||
const storageProto = Object.getPrototypeOf(window.localStorage);
|
||||
const setItemSpy = vi.spyOn(storageProto, "setItem");
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalled();
|
||||
const [key, value] = setItemSpy.mock.calls[0] as [string, string];
|
||||
expect(key).toBe("pb:autosave:autosave-test");
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
expect(Array.isArray(parsed.blocks)).toBe(true);
|
||||
expect(parsed.blocks[0].id).toBe("blk_1");
|
||||
expect(parsed.projectConfig.slug).toBe("autosave-test");
|
||||
});
|
||||
|
||||
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", () => {
|
||||
const savedBlocks: Block[] = [
|
||||
{
|
||||
id: "saved_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "저장된 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const savedConfig: ProjectConfig = {
|
||||
title: "저장된 프로젝트",
|
||||
slug: "autosave-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const payload = {
|
||||
blocks: savedBlocks,
|
||||
projectConfig: savedConfig,
|
||||
};
|
||||
|
||||
window.localStorage.setItem("pb:autosave:autosave-test", JSON.stringify(payload));
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "버튼 블록 스타일 테스트",
|
||||
slug: "editor-button-style-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 버튼 블록 스타일", () => {
|
||||
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "btn_style_1",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "테스트 버튼",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
variant: "outline",
|
||||
colorPalette: "success",
|
||||
borderRadius: "full",
|
||||
fullWidth: true,
|
||||
fontSizeCustom: "18px",
|
||||
lineHeightCustom: "1.8",
|
||||
letterSpacingCustom: "0.1em",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
textColorCustom: "#ff0000",
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "btn_style_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const editorBlock = screen.getByTestId("editor-block") as HTMLElement;
|
||||
const button = screen.getByRole("button", { name: "테스트 버튼" }) as HTMLButtonElement;
|
||||
|
||||
// 정렬: align="center" → editor-block 에 pb-text-center 클래스
|
||||
expect(editorBlock.className).toContain("pb-text-center");
|
||||
|
||||
// 버튼 클래스: 크기/변형/둥글기 토큰이 pb-btn-* 클래스로 반영되어야 한다.
|
||||
expect(button.className).toContain("pb-btn-base");
|
||||
expect(button.className).toContain("pb-btn-size-lg");
|
||||
expect(button.className).toContain("pb-btn-radius-full");
|
||||
expect(button.className).toContain("pb-btn-variant-outline-success");
|
||||
|
||||
// fullWidth=true 인 경우, 래퍼 div 가 w-full 클래스를 가져야 한다.
|
||||
const wrapper = button.parentElement as HTMLElement;
|
||||
expect(wrapper.className).toContain("w-full");
|
||||
|
||||
// 스타일: 색상/패딩/타이포
|
||||
expect(button.style.backgroundColor).toBe("rgb(18, 52, 86)"); // #123456
|
||||
expect(button.style.borderColor).toBe("rgb(101, 67, 33)"); // #654321
|
||||
expect(button.style.color).toBe("rgb(255, 0, 0)"); // #ff0000
|
||||
expect(button.style.paddingInline).toBe("16px");
|
||||
expect(button.style.paddingBlock).toBe("10px");
|
||||
expect(button.style.fontSize).toBe("18px");
|
||||
expect(button.style.lineHeight).toBe("1.8");
|
||||
expect(button.style.letterSpacing).toBe("0.1em");
|
||||
});
|
||||
|
||||
it("widthMode=fixed 인 경우 widthPx 가 에디터 버튼 width 스타일에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "btn_width_fixed",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "고정 너비 버튼",
|
||||
href: "#",
|
||||
align: "left",
|
||||
size: "md",
|
||||
variant: "solid",
|
||||
colorPalette: "primary",
|
||||
borderRadius: "md",
|
||||
fullWidth: false,
|
||||
widthMode: "fixed",
|
||||
widthPx: 200,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "btn_width_fixed";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "고정 너비 버튼" }) as HTMLButtonElement;
|
||||
|
||||
// widthMode="fixed" + widthPx=200 이면 버튼 style.width 가 200px 이어야 한다.
|
||||
expect(button.style.width).toBe("200px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { EditorCanvas } from "@/app/editor/EditorCanvas";
|
||||
|
||||
// 에디터 캔버스/페이지 배경색 적용 규칙에 대한 최소 TDD
|
||||
// - canvasBgColorHex: 실제 캔버스 영역(editor-canvas-inner)의 배경색
|
||||
// - bodyBgColorHex: 에디터 내 페이지 배경(캔버스 바깥 래퍼)의 배경색
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("EditorCanvas - 캔버스/페이지 배경색", () => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "배경 테스트",
|
||||
slug: "bg-test",
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#111111",
|
||||
bodyBgColorHex: "#222222",
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
blocks: [] as Block[],
|
||||
rootBlocks: [] as Block[],
|
||||
selectedBlockId: null as string | null,
|
||||
editingBlockId: null as string | null,
|
||||
editingText: "",
|
||||
activeDragId: null as string | null,
|
||||
sensors: null as any,
|
||||
onCanvasEmptyClick: () => {},
|
||||
renderBlocks: () => null as any,
|
||||
handleDragStart: () => {},
|
||||
handleDragEnd: () => {},
|
||||
handleDragCancel: () => {},
|
||||
projectConfig: baseProjectConfig,
|
||||
};
|
||||
|
||||
it("canvasBgColorHex 는 editor-canvas-inner 배경색에 적용되어야 한다", () => {
|
||||
render(<EditorCanvas {...baseProps} />);
|
||||
|
||||
const inner = screen.getByTestId("editor-canvas-inner") as HTMLElement;
|
||||
|
||||
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
||||
});
|
||||
|
||||
it("bodyBgColorHex 는 editor-canvas 바깥 래퍼(editor-canvas)의 배경색에 적용되어야 한다", () => {
|
||||
const { container } = render(<EditorCanvas {...baseProps} />);
|
||||
|
||||
const outer = container.querySelector('[data-testid="editor-canvas"]') as HTMLElement | null;
|
||||
|
||||
expect(outer).not.toBeNull();
|
||||
expect(outer!.style.backgroundColor).toBe("rgb(34, 34, 34)");
|
||||
});
|
||||
|
||||
it("canvasPreset / canvasWidthPx 에 따라 editor-canvas-inner 의 maxWidth 가 설정되어야 한다", () => {
|
||||
const renderWithConfig = (partial: Partial<ProjectConfig>) => {
|
||||
const projectConfig = { ...baseProjectConfig, ...partial } as ProjectConfig;
|
||||
const { getByTestId, unmount } = render(<EditorCanvas {...baseProps} projectConfig={projectConfig} />);
|
||||
const inner = getByTestId("editor-canvas-inner") as HTMLElement;
|
||||
const maxWidth = inner.style.maxWidth;
|
||||
unmount();
|
||||
return maxWidth;
|
||||
};
|
||||
|
||||
expect(renderWithConfig({ canvasPreset: "mobile", canvasWidthPx: 390 })).toBe("390px");
|
||||
expect(renderWithConfig({ canvasPreset: "tablet", canvasWidthPx: 768 })).toBe("768px");
|
||||
expect(renderWithConfig({ canvasPreset: "desktop", canvasWidthPx: 1200 })).toBe("1200px");
|
||||
|
||||
// full 프리셋에서 canvasWidthPx 가 없으면 maxWidth 는 비워져 있어야 한다.
|
||||
expect(renderWithConfig({ canvasPreset: "full", canvasWidthPx: undefined })).toBe("");
|
||||
|
||||
// custom 프리셋에서는 canvasWidthPx 값이 그대로 사용된다.
|
||||
expect(renderWithConfig({ canvasPreset: "custom", canvasWidthPx: 1024 })).toBe("1024px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "디바이더 블록 스타일 테스트",
|
||||
slug: "editor-divider-style-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
const r = (int >> 16) & 255;
|
||||
const g = (int >> 8) & 255;
|
||||
const b = int & 255;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
describe("EditorPage - 디바이더 블록 스타일", () => {
|
||||
it("colorHex / widthMode+widthPx / marginYPx 가 에디터 디바이더 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "divider_style_1",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "medium",
|
||||
widthMode: "fixed",
|
||||
widthPx: 400,
|
||||
colorHex: "#123456",
|
||||
marginYPx: 32,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "divider_style_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
// EditorPage 의 디바이더는 wrapper div 안에 border-t 클래스를 가진 div 로 렌더된다.
|
||||
const editorBlock = screen.getByTestId("editor-block") as HTMLElement;
|
||||
const inner = editorBlock.querySelector("div.border-t") as HTMLElement;
|
||||
expect(inner).toBeTruthy();
|
||||
|
||||
expect(inner.style.borderColor).toBe(hexToRgb("#123456"));
|
||||
expect(inner.style.width).toBe("400px");
|
||||
|
||||
// marginTop/marginBottom 은 상위 wrapper div 에 적용된다.
|
||||
const wrapper = inner.parentElement as HTMLElement;
|
||||
expect(wrapper.style.marginTop).toBe("32px");
|
||||
expect(wrapper.style.marginBottom).toBe("32px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
// EditorPage 상단 메뉴 UX TDD
|
||||
// - Export 미리보기 기능 제거 후, 상단 메뉴에 더 이상 "Export 미리보기" 항목이 노출되지 않아야 한다.
|
||||
// - 대신 프로젝트 저장/불러오기와 연계된 "프로젝트 목록" 항목이 메뉴에 노출되어야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "Export 미리보기 테스트",
|
||||
slug: "export-preview-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_text_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Export 미리보기 본문",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
|
||||
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const removedItem = screen.queryByText("Export 미리보기");
|
||||
expect(removedItem).toBeNull();
|
||||
|
||||
const projectListItem = screen.getByText("프로젝트 목록");
|
||||
expect(projectListItem).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "폼 블록 스타일 테스트",
|
||||
slug: "editor-form-style-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
const r = (int >> 16) & 255;
|
||||
const g = (int >> 8) & 255;
|
||||
const b = int & 255;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
describe("EditorPage - 폼 블록 스타일", () => {
|
||||
it("formInput: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_input_1",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "폼 입력",
|
||||
inputType: "text",
|
||||
textColorCustom: "#ff0000",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
widthMode: "fixed",
|
||||
widthPx: 240,
|
||||
borderRadius: "full",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "form_input_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const field = screen.getByTestId("form-input-field") as HTMLElement;
|
||||
|
||||
expect(field.style.color).toBe(hexToRgb("#ff0000"));
|
||||
expect(field.style.backgroundColor).toBe(hexToRgb("#123456"));
|
||||
expect(field.style.borderColor).toBe(hexToRgb("#654321"));
|
||||
expect(field.style.width).toBe("240px");
|
||||
// borderRadius=full → 9999px 로 적용
|
||||
expect(field.style.borderRadius).toBe("9999px");
|
||||
});
|
||||
|
||||
it("formSelect: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_select_1",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "폼 셀렉트",
|
||||
textColorCustom: "#ff0000",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
widthMode: "fixed",
|
||||
widthPx: 260,
|
||||
borderRadius: "lg",
|
||||
options: [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "form_select_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const select = screen.getByLabelText("폼 셀렉트") as HTMLSelectElement;
|
||||
const wrapper = select.parentElement as HTMLElement; // style 이 적용된 div
|
||||
|
||||
expect(wrapper.style.color).toBe(hexToRgb("#ff0000"));
|
||||
expect(wrapper.style.backgroundColor).toBe(hexToRgb("#123456"));
|
||||
expect(wrapper.style.borderColor).toBe(hexToRgb("#654321"));
|
||||
expect(wrapper.style.width).toBe("260px");
|
||||
expect(wrapper.style.borderRadius).toBe("9999px"); // lg → 9999 (에디터 구현 기준)
|
||||
});
|
||||
|
||||
it("formRadio: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_radio_1",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
formFieldName: "radio-group",
|
||||
groupLabel: "라디오 그룹",
|
||||
textColorCustom: "#ff0000",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
widthMode: "fixed",
|
||||
widthPx: 280,
|
||||
borderRadius: "lg",
|
||||
options: [
|
||||
{ label: "옵션 A", value: "a" },
|
||||
{ label: "옵션 B", value: "b" },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "form_radio_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const label = screen.getByText("라디오 그룹");
|
||||
const container = label.nextElementSibling as HTMLElement; // style 이 적용된 div
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.style.color).toBe(hexToRgb("#ff0000"));
|
||||
expect(container.style.backgroundColor).toBe(hexToRgb("#123456"));
|
||||
expect(container.style.borderColor).toBe(hexToRgb("#654321"));
|
||||
expect(container.style.width).toBe("280px");
|
||||
expect(container.style.borderRadius).toBe("9999px");
|
||||
});
|
||||
|
||||
it("formCheckbox: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_checkbox_1",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
formFieldName: "check-group",
|
||||
groupLabel: "체크 그룹",
|
||||
textColorCustom: "#ff0000",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
widthMode: "fixed",
|
||||
widthPx: 300,
|
||||
borderRadius: "lg",
|
||||
options: [
|
||||
{ label: "체크 1", value: "c1" },
|
||||
{ label: "체크 2", value: "c2" },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "form_checkbox_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
// "체크 그룹" 텍스트를 포함하는 가장 가까운 컨테이너를 찾아 스타일을 검사한다.
|
||||
const label = screen.getByText("체크 그룹");
|
||||
const groupContainer = label.closest("div") as HTMLElement;
|
||||
|
||||
expect(groupContainer.style.color).toBe(hexToRgb("#ff0000"));
|
||||
expect(groupContainer.style.backgroundColor).toBe(hexToRgb("#123456"));
|
||||
expect(groupContainer.style.borderColor).toBe(hexToRgb("#654321"));
|
||||
expect(groupContainer.style.width).toBe("300px");
|
||||
expect(groupContainer.style.borderRadius).toBe("9999px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "이미지 블록 스타일 테스트",
|
||||
slug: "editor-image-style-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 이미지 블록 스타일", () => {
|
||||
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "image_style_1",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "/images/example.png",
|
||||
alt: "예제 이미지",
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
borderRadius: "lg",
|
||||
borderRadiusPx: 24,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "image_style_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const img = screen.getByAltText("예제 이미지") as HTMLImageElement;
|
||||
|
||||
expect(img.style.width).toBe("320px");
|
||||
expect(img.style.borderRadius).toBe("24px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
// EditorPage JSON 내보내기/불러오기 TDD
|
||||
// - "JSON 내보내기" 클릭 시 blocks + projectConfig 를 포함한 JSON 을 에디터 상태 영역에 출력해야 한다.
|
||||
// - "JSON 적용하기" 클릭 시 blocks + projectConfig 형태의 JSON 을 파싱해 replaceBlocks + updateProjectConfig 로 반영해야 한다.
|
||||
// - 잘못된 JSON 이거나 구조가 맞지 않으면 replaceBlocks / updateProjectConfig 를 호출하지 않아야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "JSON Export/Import 테스트",
|
||||
slug: "json-export-import-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "JSON 테스트 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
function openJsonModal() {
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const jsonMenuItem = screen.getByText("JSON 내보내기/불러오기");
|
||||
fireEvent.click(jsonMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("JSON Export / Import");
|
||||
const jsonModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||
return jsonModal;
|
||||
}
|
||||
|
||||
describe("EditorPage - JSON 내보내기/불러오기", () => {
|
||||
it("JSON 내보내기 클릭 시 blocks + projectConfig 구조의 JSON 이 에디터 상태 영역에 출력되어야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const jsonModal = openJsonModal();
|
||||
|
||||
const exportLabel = within(jsonModal).getByText("에디터 상태 JSON").closest("label") as HTMLElement;
|
||||
const exportTextarea = within(exportLabel).getByRole("textbox") as HTMLTextAreaElement;
|
||||
|
||||
// 초기에는 비어 있어야 한다.
|
||||
expect(exportTextarea.value).toBe("");
|
||||
|
||||
const exportButton = within(jsonModal).getByText("JSON 내보내기");
|
||||
fireEvent.click(exportButton);
|
||||
|
||||
const value = exportTextarea.value;
|
||||
expect(value.trim().length).toBeGreaterThan(0);
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
expect(Array.isArray(parsed.blocks)).toBe(true);
|
||||
expect(parsed.blocks[0].id).toBe("blk_1");
|
||||
expect(parsed.projectConfig.slug).toBe("json-export-import-test");
|
||||
});
|
||||
|
||||
it("JSON 적용하기 클릭 시 blocks + projectConfig 구조의 JSON 으로 replaceBlocks 와 updateProjectConfig 를 호출해야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const jsonModal = openJsonModal();
|
||||
|
||||
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
|
||||
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
|
||||
|
||||
const importedBlocks: Block[] = [
|
||||
{
|
||||
id: "imported_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "가져온 블록",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const importedConfig: ProjectConfig = {
|
||||
title: "가져온 프로젝트",
|
||||
slug: "imported-slug",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const payload = {
|
||||
blocks: importedBlocks,
|
||||
projectConfig: importedConfig,
|
||||
};
|
||||
|
||||
fireEvent.change(importTextarea, { target: { value: JSON.stringify(payload) } });
|
||||
|
||||
// 초기 마운트 시 호출됐을 수 있는 기록은 무시한다.
|
||||
mockState.replaceBlocks.mockReset();
|
||||
mockState.updateProjectConfig.mockReset();
|
||||
|
||||
const applyButton = within(importLabel).getByText("JSON 적용하기");
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(importedBlocks as any);
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(importedConfig as ProjectConfig);
|
||||
});
|
||||
|
||||
it("잘못된 JSON 은 무시되어야 하며 replaceBlocks/updateProjectConfig 를 호출하면 안 된다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const jsonModal = openJsonModal();
|
||||
|
||||
const importLabel = within(jsonModal).getByText("JSON에서 불러오기").closest("label") as HTMLElement;
|
||||
const importTextarea = within(importLabel).getByRole("textbox") as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(importTextarea, { target: { value: "{ this-is: not-valid-json" } });
|
||||
|
||||
mockState.replaceBlocks.mockReset();
|
||||
mockState.updateProjectConfig.mockReset();
|
||||
|
||||
const applyButton = within(importLabel).getByText("JSON 적용하기");
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
|
||||
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "레이아웃 스크롤 테스트",
|
||||
slug: "layout-scroll-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
|
||||
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const blocksHeading = screen.getByText("블록");
|
||||
const blocksSidebar = blocksHeading.closest("aside") as HTMLElement;
|
||||
const canvas = screen.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = screen.getByTestId("properties-sidebar");
|
||||
|
||||
expect(blocksSidebar).not.toBeNull();
|
||||
expect(blocksSidebar.className).toContain("pb-scroll");
|
||||
expect(canvas.className).toContain("pb-scroll");
|
||||
expect(propertiesSidebar.className).toContain("pb-scroll");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "리스트 블록 스타일 테스트",
|
||||
slug: "editor-list-style-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
const r = (int >> 16) & 255;
|
||||
const g = (int >> 8) & 255;
|
||||
const b = int & 255;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
describe("EditorPage - 리스트 블록 스타일", () => {
|
||||
it("fontSizeCustom / lineHeightCustom / textColorCustom 가 에디터 리스트 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "list_style_1",
|
||||
type: "list",
|
||||
props: {
|
||||
items: ["Item 1", "Item 2"],
|
||||
ordered: false,
|
||||
align: "left",
|
||||
fontSizeCustom: "18px",
|
||||
lineHeightCustom: "1.8",
|
||||
textColorCustom: "#ff0000",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "list_style_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const ul = screen.getByRole("list") as HTMLElement;
|
||||
|
||||
expect(ul.style.fontSize).toBe("18px");
|
||||
expect(ul.style.lineHeight).toBe("1.8");
|
||||
expect(ul.style.color).toBe(hexToRgb("#ff0000"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "멀티 선택 테스트",
|
||||
slug: "multi-select",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
function setupThreeBlocks() {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "블록 1",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "blk_2",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "블록 2",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "blk_3",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "블록 3",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = null;
|
||||
}
|
||||
|
||||
describe("EditorPage - 멀티 선택", () => {
|
||||
it("Cmd/Ctrl+클릭으로 여러 블록을 동시에 선택할 수 있어야 한다", () => {
|
||||
setupThreeBlocks();
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const blocksEls = screen.getAllByTestId("editor-block") as HTMLElement[];
|
||||
|
||||
// 첫 번째와 두 번째 블록을 Cmd/Ctrl+클릭
|
||||
fireEvent.click(blocksEls[0], { metaKey: true });
|
||||
fireEvent.click(blocksEls[1], { metaKey: true });
|
||||
|
||||
const selectedEls = blocksEls.filter((el) => el.dataset.selected === "true");
|
||||
expect(selectedEls.length).toBe(2);
|
||||
const selectedIds = selectedEls.map((el) => el.getAttribute("data-block-id") ?? "");
|
||||
expect(new Set(selectedIds)).toEqual(new Set(["blk_1", "blk_2"]));
|
||||
});
|
||||
|
||||
it("멀티 선택 상태에서 Backspace 로 선택된 모든 블록이 삭제되어야 한다", () => {
|
||||
setupThreeBlocks();
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const blocksEls = screen.getAllByTestId("editor-block") as HTMLElement[];
|
||||
|
||||
// 블록 1, 2 를 멀티 선택
|
||||
fireEvent.click(blocksEls[0], { metaKey: true });
|
||||
fireEvent.click(blocksEls[1], { metaKey: true });
|
||||
|
||||
// Backspace 로 삭제
|
||||
const event = new KeyboardEvent("keydown", { key: "Backspace" });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
// replaceBlocks 가 한 번 호출되고, 남은 블록은 blk_3 만 있어야 한다.
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
const arg = mockState.replaceBlocks.mock.calls[0][0] as Block[];
|
||||
expect(arg.length).toBe(1);
|
||||
expect(arg[0].id).toBe("blk_3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "배경 테스트",
|
||||
slug: "bg-test",
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#111111",
|
||||
bodyBgColorHex: "#222222",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 페이지/캔버스 배경", () => {
|
||||
it("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
|
||||
const { container } = render(<EditorPage />);
|
||||
|
||||
const mainEl = container.querySelector("main") as HTMLElement | null;
|
||||
expect(mainEl).not.toBeNull();
|
||||
// main 은 bodyBgColorHex 기반 배경색을 직접 갖지 않는다.
|
||||
expect(mainEl!.style.backgroundColor).toBe("");
|
||||
|
||||
const canvasOuter = screen.getByTestId("editor-canvas") as HTMLElement;
|
||||
expect(canvasOuter.style.backgroundColor).toBe("rgb(34, 34, 34)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
// EditorPage 전역 단축키 TDD
|
||||
// - Undo/Redo, 삭제/복제, 선택 이동(ArrowUp/Down) 단축키가 editorStore 액션을 호출하는지 검증한다.
|
||||
// - 입력 포커스가 input/textarea 인 경우에는 단축키가 동작하지 않아야 한다.
|
||||
|
||||
const undo = vi.fn();
|
||||
const redo = vi.fn();
|
||||
const removeBlock = vi.fn();
|
||||
const duplicateBlock = vi.fn();
|
||||
const selectBlock = vi.fn();
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockState = {
|
||||
// 블록 2개를 가진 기본 상태
|
||||
blocks: [
|
||||
{ id: "blk_1", type: "text", props: { text: "첫 번째" } },
|
||||
{ id: "blk_2", type: "text", props: { text: "두 번째" } },
|
||||
],
|
||||
selectedBlockId: "blk_1",
|
||||
selectedListItemId: null,
|
||||
undo,
|
||||
redo,
|
||||
removeBlock,
|
||||
duplicateBlock,
|
||||
selectBlock,
|
||||
// EditorPage 가 참조하지만 이 테스트에서 중요하지 않은 액션들: no-op
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
indentSelectedListItem: vi.fn(),
|
||||
outdentSelectedListItem: vi.fn(),
|
||||
moveSelectedListItemUp: vi.fn(),
|
||||
moveSelectedListItemDown: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
projectConfig: {
|
||||
title: "테스트 페이지",
|
||||
slug: "test-page",
|
||||
canvasPreset: "full",
|
||||
},
|
||||
};
|
||||
|
||||
undo.mockReset();
|
||||
redo.mockReset();
|
||||
removeBlock.mockReset();
|
||||
duplicateBlock.mockReset();
|
||||
selectBlock.mockReset();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("EditorPage - 전역 단축키", () => {
|
||||
it("Cmd/Ctrl+Z 는 undo 액션을 호출해야 한다", () => {
|
||||
// Mac 플랫폼으로 강제 설정
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
|
||||
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "z", metaKey: true });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(undo).toHaveBeenCalledTimes(1);
|
||||
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(navigator, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
it("Cmd/Ctrl+Shift+Z 는 redo 액션을 호출해야 한다", () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
|
||||
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "z", metaKey: true, shiftKey: true });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(redo).toHaveBeenCalledTimes(1);
|
||||
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(navigator, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
it("Delete/Backspace 는 선택된 블록을 removeBlock 으로 삭제해야 한다", () => {
|
||||
mockState.selectedBlockId = "blk_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "Backspace" });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(removeBlock).toHaveBeenCalledTimes(1);
|
||||
expect(removeBlock).toHaveBeenCalledWith("blk_1");
|
||||
});
|
||||
|
||||
it("Cmd/Ctrl+D 는 선택된 블록을 duplicateBlock 으로 복제해야 한다", () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
|
||||
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
|
||||
|
||||
mockState.selectedBlockId = "blk_2";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "d", metaKey: true });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(duplicateBlock).toHaveBeenCalledTimes(1);
|
||||
expect(duplicateBlock).toHaveBeenCalledWith("blk_2");
|
||||
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(navigator, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
it("ArrowDown 은 선택이 없을 때 첫 번째 블록을 선택해야 한다", () => {
|
||||
mockState.selectedBlockId = null;
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(selectBlock).toHaveBeenCalledTimes(1);
|
||||
expect(selectBlock).toHaveBeenCalledWith("blk_1");
|
||||
});
|
||||
|
||||
it("ArrowUp 은 선택이 없을 때 마지막 블록을 선택해야 한다", () => {
|
||||
mockState.selectedBlockId = null;
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "ArrowUp" });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(selectBlock).toHaveBeenCalledTimes(1);
|
||||
expect(selectBlock).toHaveBeenCalledWith("blk_2");
|
||||
});
|
||||
|
||||
it("입력 포커스(input) 상태에서는 Backspace 로 removeBlock 이 호출되면 안 된다", () => {
|
||||
mockState.selectedBlockId = "blk_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
|
||||
const event = new KeyboardEvent("keydown", { key: "Backspace", bubbles: true });
|
||||
Object.defineProperty(event, "target", { value: input, configurable: true });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(removeBlock).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
// EditorPage 프로젝트 저장/불러오기 UX TDD
|
||||
// - 프로젝트 저장 버튼 클릭 시 현재 blocks 를 로컬스토리지(pb:project:<slug>)와 서버(/api/projects) 모두에 저장해야 한다.
|
||||
// - 불러오기 버튼 클릭 시 로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 replaceBlocks 해야 한다.
|
||||
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "프로젝트 저장/불러오기 테스트",
|
||||
slug: "editor-project-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "프로젝트 저장/불러오기 테스트 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
updateProjectConfig: vi.fn((partial: Partial<ProjectConfig>) => {
|
||||
mockState.projectConfig = {
|
||||
...(mockState.projectConfig as ProjectConfig),
|
||||
...partial,
|
||||
} as ProjectConfig;
|
||||
}),
|
||||
};
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ slug: "save-test-slug" }),
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { rerender } = render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||
fireEvent.click(projectMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
|
||||
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||
|
||||
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
|
||||
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
|
||||
|
||||
expect(titleInput.value).toBe("프로젝트 저장/불러오기 테스트");
|
||||
expect(slugInput.value).toBe("editor-project-test");
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: "저장 테스트 프로젝트" } });
|
||||
fireEvent.change(slugInput, { target: { value: "save-test-slug" } });
|
||||
|
||||
expect(mockState.projectConfig.title).toBe("저장 테스트 프로젝트");
|
||||
expect(mockState.projectConfig.slug).toBe("save-test-slug");
|
||||
|
||||
rerender(<EditorPage />);
|
||||
|
||||
const saveButton = screen.getByText("저장 (로컬 + 서버)");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const localKey = "pb:project:save-test-slug";
|
||||
const raw = window.localStorage.getItem(localKey);
|
||||
expect(raw).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.title).toBe("저장 테스트 프로젝트");
|
||||
expect(Array.isArray(parsed.contentJson)).toBe(true);
|
||||
expect(parsed.contentJson[0].id).toBe("blk_1");
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
expect(url).toBe("/api/projects");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.slug).toBe("save-test-slug");
|
||||
expect(body.title).toBe("저장 테스트 프로젝트");
|
||||
expect(Array.isArray(body.contentJson)).toBe(true);
|
||||
expect(body.contentJson[0].id).toBe("blk_1");
|
||||
|
||||
const message = await screen.findByText("프로젝트가 저장되었습니다: save-test-slug");
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => {
|
||||
const slug = "local-test-slug";
|
||||
const localKey = `pb:project:${slug}`;
|
||||
|
||||
const savedBlocks: Block[] = [
|
||||
{
|
||||
id: "saved_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "로컬 저장된 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
window.localStorage.setItem(
|
||||
localKey,
|
||||
JSON.stringify({
|
||||
title: "로컬 프로젝트",
|
||||
contentJson: savedBlocks,
|
||||
}),
|
||||
);
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
mockState.projectConfig.slug = slug;
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||
fireEvent.click(projectMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
|
||||
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||
|
||||
const loadButton = screen.getByText("불러오기");
|
||||
fireEvent.click(loadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("로컬스토리지에 없으면 서버에서 프로젝트를 불러와야 한다", async () => {
|
||||
const slug = "server-test-slug";
|
||||
|
||||
const serverBlocks: Block[] = [
|
||||
{
|
||||
id: "server_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "서버에서 불러온 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ slug, contentJson: serverBlocks }),
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
mockState.projectConfig.slug = slug;
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||
fireEvent.click(projectMenuItem);
|
||||
|
||||
const loadButton = screen.getByText("불러오기");
|
||||
fireEvent.click(loadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as any;
|
||||
expect(url).toBe(`/api/projects/${slug}`);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
|
||||
});
|
||||
|
||||
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
|
||||
const slug = "delete-test-slug";
|
||||
mockState.projectConfig.slug = slug;
|
||||
|
||||
// 로컬 저장/자동저장 스냅샷이 존재하는 상황을 준비한다.
|
||||
window.localStorage.setItem(`pb:project:${slug}`, "stored-project");
|
||||
window.localStorage.setItem(`pb:autosave:${slug}`, "stored-autosave");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const deleteMenuItem = screen.getByText("프로젝트 삭제");
|
||||
fireEvent.click(deleteMenuItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
expect(url).toBe(`/api/projects/${slug}`);
|
||||
expect(options.method).toBe("DELETE");
|
||||
|
||||
expect(window.localStorage.getItem(`pb:project:${slug}`)).toBeNull();
|
||||
expect(window.localStorage.getItem(`pb:autosave:${slug}`)).toBeNull();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "섹션 배경 이미지 테스트",
|
||||
slug: "section-bg-image-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
// 섹션 배경 이미지 TDD
|
||||
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
|
||||
|
||||
describe("EditorPage - 섹션 배경 이미지", () => {
|
||||
it("섹션에 backgroundImageSrc/size/position 이 설정되면 editor-section 스타일에 backgroundImage/Size/Position 이 반영되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_bg_image",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_image_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
backgroundImageSrc: "/api/image/section-bg-1",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePosition: "center",
|
||||
backgroundImageRepeat: "repeat",
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 안 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_bg_image",
|
||||
columnId: "sec_bg_image_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "sec_bg_image";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
expect(sectionEl.style.backgroundImage).toContain("/api/image/section-bg-1");
|
||||
expect(sectionEl.style.backgroundSize).toBe("cover");
|
||||
expect(sectionEl.style.backgroundPosition).toBe("center center");
|
||||
expect(sectionEl.style.backgroundRepeat).toBe("repeat");
|
||||
});
|
||||
|
||||
it("backgroundImagePositionMode 가 custom 이고 X/Y 퍼센트가 설정되면 background-position 은 'X% Y%' 로 설정되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_bg_image_xy",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_image_xy_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
backgroundImageSrc: "/api/image/section-bg-xy",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 30,
|
||||
backgroundImagePositionYPercent: 70,
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_xy",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "XY 커스텀 위치 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_bg_image_xy",
|
||||
columnId: "sec_bg_image_xy_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "sec_bg_image_xy";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
expect(sectionEl.style.backgroundImage).toContain("/api/image/section-bg-xy");
|
||||
expect(sectionEl.style.backgroundPosition).toBe("30% 70%");
|
||||
});
|
||||
|
||||
it("backgroundVideoSrc 가 설정된 섹션은 배경 비디오 video 태그가 렌더되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_bg_video",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_video_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
backgroundVideoSrc: "/videos/section-bg.mp4",
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_video",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 배경 비디오 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_bg_video",
|
||||
columnId: "sec_bg_video_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "sec_bg_video";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
expect(sectionEl).toBeTruthy();
|
||||
|
||||
const videoEl = screen.getByTestId("editor-section-bg-video") as HTMLVideoElement;
|
||||
expect(videoEl).toBeTruthy();
|
||||
expect(videoEl.src).toContain("/videos/section-bg.mp4");
|
||||
expect(videoEl.autoplay).toBe(true);
|
||||
expect(videoEl.loop).toBe(true);
|
||||
expect(videoEl.muted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "섹션 레이아웃 테스트",
|
||||
slug: "section-layout-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
// 에디터 액션들은 모두 더미 함수로 채워 EditorPage 렌더링이 가능하도록 한다.
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 섹션 레이아웃 속성", () => {
|
||||
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_layout",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [
|
||||
{ id: "sec_layout_col_1", span: 12 },
|
||||
],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
backgroundColorCustom: "#123456",
|
||||
paddingYPx: 80,
|
||||
maxWidthPx: 960,
|
||||
gapXPx: 32,
|
||||
},
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 안 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_layout",
|
||||
columnId: "sec_layout_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "sec_layout";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
// 배경색: #123456 → rgb(18, 52, 86)
|
||||
expect(sectionEl.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
// 세로 패딩(px)이 스타일에 반영되어야 한다.
|
||||
expect(sectionEl.style.paddingTop).toBe("80px");
|
||||
expect(sectionEl.style.paddingBottom).toBe("80px");
|
||||
|
||||
// 내부 maxWidth
|
||||
const inner = sectionEl.querySelector("div.mx-auto") as HTMLElement;
|
||||
expect(inner).toBeTruthy();
|
||||
expect(inner.style.maxWidth).toBe("960px");
|
||||
|
||||
// 컬럼 간 간격(gapXPx)
|
||||
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
|
||||
expect(columns).toBeTruthy();
|
||||
expect(columns.style.columnGap).toBe("32px");
|
||||
});
|
||||
|
||||
it("alignItems 값에 따라 섹션 컬럼 컨테이너가 items-*- 정렬 클래스를 가져야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_align",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [
|
||||
{ id: "sec_align_col_1", span: 12 },
|
||||
],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "center",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_align",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "세로 정렬 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_align",
|
||||
columnId: "sec_align_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "sec_align";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
|
||||
expect(columns).toBeTruthy();
|
||||
// alignItems="center" 인 경우 items-center 클래스가 포함되어야 한다.
|
||||
expect(columns.className).toContain("items-center");
|
||||
});
|
||||
|
||||
it("Hero 템플릿 섹션의 레이아웃 속성이 EditorPage 섹션 렌더에 반영되어야 한다", () => {
|
||||
const sectionId = "hero_section_layout";
|
||||
const createId = (() => {
|
||||
let i = 0;
|
||||
return () => `hero_${++i}`;
|
||||
})();
|
||||
|
||||
const { blocks } = createHeroTemplateBlocks({ sectionId, createId });
|
||||
|
||||
mockState.blocks = blocks as Block[];
|
||||
mockState.selectedBlockId = sectionId;
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
// Hero 템플릿 배경색(#020617)과 패딩, 최대 폭, gap 이 에디터 섹션에도 반영되어야 한다.
|
||||
expect(sectionEl.style.backgroundColor).toBe("rgb(2, 6, 23)");
|
||||
expect(sectionEl.style.paddingTop).toBe("96px");
|
||||
expect(sectionEl.style.paddingBottom).toBe("96px");
|
||||
|
||||
const inner = sectionEl.querySelector("div.mx-auto") as HTMLElement;
|
||||
expect(inner.style.maxWidth).toBe("960px");
|
||||
|
||||
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
|
||||
expect(columns.style.columnGap).toBe("40px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "선택 포커스 테스트",
|
||||
slug: "selection-focus",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 선택 포커스", () => {
|
||||
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "첫 번째 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "blk_2",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "두 번째 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "blk_2";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const allBlocks = screen.getAllByTestId("editor-block") as HTMLElement[];
|
||||
const selected = allBlocks.find((el) => el.dataset.selected === "true");
|
||||
|
||||
expect(selected).toBeTruthy();
|
||||
expect(selected!.className).toContain("border-sky-500");
|
||||
expect(selected!.className).toContain("bg-slate-900/80");
|
||||
});
|
||||
|
||||
it("섹션 블록 자체를 선택하면 섹션 wrapper 가 강조되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_1",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [
|
||||
{
|
||||
id: "sec_1_col_1",
|
||||
span: 12,
|
||||
},
|
||||
],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
},
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section];
|
||||
mockState.selectedBlockId = "sec_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
expect(sectionEl.className).toContain("border-sky-500");
|
||||
});
|
||||
|
||||
it("섹션 자식 블록을 선택해도 섹션 wrapper 가 강조되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_1",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [
|
||||
{
|
||||
id: "sec_1_col_1",
|
||||
span: 12,
|
||||
},
|
||||
],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_child",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 안 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_1",
|
||||
columnId: "sec_1_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "txt_child";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
expect(sectionEl.className).toContain("border-sky-500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "텍스트 블록 스타일 테스트",
|
||||
slug: "editor-text-style-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 텍스트 블록 스타일", () => {
|
||||
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "txt_style_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "스타일 테스트 텍스트",
|
||||
align: "center",
|
||||
size: "base",
|
||||
colorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
maxWidthCustom: "320px",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "txt_style_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const blockEl = screen.getByTestId("editor-block") as HTMLElement;
|
||||
const textEl = blockEl.querySelector("div.whitespace-pre-wrap") as HTMLElement;
|
||||
expect(textEl).toBeTruthy();
|
||||
|
||||
// 텍스트 색상: #ff0000 → rgb(255, 0, 0)
|
||||
expect(textEl.style.color).toBe("rgb(255, 0, 0)");
|
||||
// 배경색: #123456 → rgb(18, 52, 86)
|
||||
expect(textEl.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
// 최대 너비: maxWidthCustom 이 style.maxWidth 로 반영되어야 한다.
|
||||
expect(textEl.style.maxWidth).toBe("320px");
|
||||
|
||||
// 정렬 클래스는 pb-text-center 로 유지되어야 한다.
|
||||
expect(blockEl.className).toContain("pb-text-center");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "비디오 블록 스타일 테스트",
|
||||
slug: "editor-video-style-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
|
||||
describe("EditorPage - 비디오 블록 스타일", () => {
|
||||
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_youtube_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_youtube_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const iframe = screen.getByTitle("비디오") as HTMLIFrameElement;
|
||||
const wrapper = iframe.parentElement as HTMLElement;
|
||||
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe.src).toContain("youtube.com/embed");
|
||||
expect(wrapper.className).toContain("pb-video-wrapper");
|
||||
});
|
||||
|
||||
it("업로드 비디오 URL 은 video 태그로 렌더되고 controls 속성을 포함해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_upload_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/api/video/abc123",
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 640,
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_upload_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.getAttribute("controls")).not.toBeNull();
|
||||
expect(video.style.width).toBe("640px");
|
||||
});
|
||||
|
||||
it("posterImageSrc 가 설정된 HTML5 비디오는 video 태그 poster 속성으로 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_poster_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/sample.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
posterImageSrc: "/images/sample-poster.png",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_poster_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.getAttribute("poster")).toBe("/images/sample-poster.png");
|
||||
});
|
||||
|
||||
it("startTimeSec/endTimeSec 가 설정된 HTML5 비디오는 loadedmetadata/timeupdate 이벤트에서 시작/종료 범위가 적용되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_range_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/range.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
startTimeSec: 5,
|
||||
endTimeSec: 10,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_range_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
|
||||
fireEvent(video, new Event("loadedmetadata"));
|
||||
expect(video.currentTime).toBe(5);
|
||||
|
||||
(video as any).pause = vi.fn();
|
||||
video.currentTime = 10.1;
|
||||
fireEvent(video, new Event("timeupdate"));
|
||||
expect((video as any).pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("HTML5 비디오의 ariaLabel/titleText/captionText 는 에디터에서 aria-label 및 캡션 요소로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_a11y_html5",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/a11y.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
titleText: "접근성 비디오 타이틀",
|
||||
ariaLabel: "접근성 비디오 설명",
|
||||
captionText: "비디오 캡션입니다.",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_a11y_html5";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.getAttribute("aria-label")).toBe("접근성 비디오 설명");
|
||||
|
||||
const caption = screen.getByTestId("editor-video-caption");
|
||||
expect(caption).toBeTruthy();
|
||||
expect(caption.textContent).toBe("비디오 캡션입니다.");
|
||||
});
|
||||
|
||||
it("sourceUrl 이 비어 있는 HTML5 비디오는 src 속성을 렌더링하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_empty_src",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_empty_src";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.getAttribute("src")).toBeNull();
|
||||
});
|
||||
|
||||
it("비디오 카드 스타일 props(backgroundColorCustom/cardPaddingPx/borderRadiusPx)는 wrapper 의 스타일에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_card_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/api/video/card-test",
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 480,
|
||||
aspectRatio: "16:9",
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 24,
|
||||
borderRadiusPx: 16,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_card_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
const wrapper = video.parentElement as HTMLElement;
|
||||
|
||||
expect(wrapper).toBeTruthy();
|
||||
// #123456 -> rgb(18, 52, 86)
|
||||
expect(wrapper.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
expect(wrapper.style.padding).toBe("24px");
|
||||
expect(wrapper.style.borderRadius).toBe("16px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FormControllerPanel } from "@/app/editor/forms/FormControllerPanel";
|
||||
import type { Block, FormBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// FormControllerPanel 컨트롤 TDD
|
||||
// - 성공/에러 메시지 인풋이 updateBlock 을 통해 FormBlockProps.successMessage / errorMessage 를 갱신하는지 검증한다.
|
||||
|
||||
function makeFormBlock(id: string, props: Partial<FormBlockProps> = {}): Block {
|
||||
const base: FormBlockProps = {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
} as any;
|
||||
|
||||
return {
|
||||
id,
|
||||
type: "form",
|
||||
props: { ...base, ...props } as any,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("FormControllerPanel", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("성공 메시지 인풋 변경 시 updateBlock 이 successMessage 로 호출되어야 한다", () => {
|
||||
const formBlock = makeFormBlock("form-1", {
|
||||
successMessage: "기존 성공 메시지",
|
||||
});
|
||||
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<FormControllerPanel
|
||||
block={formBlock}
|
||||
blocks={[formBlock]}
|
||||
selectedBlockId="form-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("성공 메시지");
|
||||
fireEvent.change(input, { target: { value: "새 성공 메시지" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"form-1",
|
||||
expect.objectContaining({ successMessage: "새 성공 메시지" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("에러 메시지 인풋 변경 시 updateBlock 이 errorMessage 로 호출되어야 한다", () => {
|
||||
const formBlock = makeFormBlock("form-2", {
|
||||
errorMessage: "기존 에러 메시지",
|
||||
});
|
||||
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<FormControllerPanel
|
||||
block={formBlock}
|
||||
blocks={[formBlock]}
|
||||
selectedBlockId="form-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("에러 메시지");
|
||||
fireEvent.change(input, { target: { value: "새 에러 메시지" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"form-2",
|
||||
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FormInputPropertiesPanel } from "@/app/editor/forms/FormInputPropertiesPanel";
|
||||
import { FormSelectPropertiesPanel } from "@/app/editor/forms/FormSelectPropertiesPanel";
|
||||
import { FormCheckboxPropertiesPanel } from "@/app/editor/forms/FormCheckboxPropertiesPanel";
|
||||
import { FormRadioPropertiesPanel } from "@/app/editor/forms/FormRadioPropertiesPanel";
|
||||
import type {
|
||||
Block,
|
||||
FormInputBlockProps,
|
||||
FormSelectBlockProps,
|
||||
FormCheckboxBlockProps,
|
||||
FormRadioBlockProps,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
|
||||
// Form*PropertiesPanel 컨트롤 TDD
|
||||
// - 각 패널의 색상 HEX 인풋과 필드 너비 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다.
|
||||
|
||||
function makeBlock<T extends Block["props"]>(id: string, type: Block["type"], props: T): Block {
|
||||
return { id, type, props } as any;
|
||||
}
|
||||
|
||||
describe("FormInputPropertiesPanel", () => {
|
||||
const baseProps: FormInputBlockProps = {
|
||||
label: "이메일",
|
||||
formFieldName: "email",
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormInputBlockProps>("f-input-1", "formInput", {
|
||||
...baseProps,
|
||||
textColorCustom: "#ffffff",
|
||||
});
|
||||
|
||||
render(
|
||||
<FormInputPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-input-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-input-1",
|
||||
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormInputBlockProps>("f-input-2", "formInput", {
|
||||
...baseProps,
|
||||
fillColorCustom: "#000000",
|
||||
});
|
||||
|
||||
render(
|
||||
<FormInputPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-input-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("필드 채움 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#445566" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-input-2",
|
||||
expect.objectContaining({ fillColorCustom: "#445566" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormInputBlockProps>("f-input-3", "formInput", {
|
||||
...baseProps,
|
||||
strokeColorCustom: "#000000",
|
||||
});
|
||||
|
||||
render(
|
||||
<FormInputPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-input-3"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("필드 테두리 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-input-3",
|
||||
expect.objectContaining({ strokeColorCustom: "#778899" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormInputBlockProps>("f-input-4", "formInput", {
|
||||
...baseProps,
|
||||
widthMode: "auto",
|
||||
fullWidth: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<FormInputPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-input-4"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("너비");
|
||||
fireEvent.change(select, { target: { value: "fixed" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-input-4",
|
||||
expect.objectContaining({ widthMode: "fixed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormSelectPropertiesPanel", () => {
|
||||
const baseProps: FormSelectBlockProps = {
|
||||
label: "카테고리",
|
||||
formFieldName: "category",
|
||||
options: [{ label: "A", value: "a" }],
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormSelectBlockProps>("f-select-1", "formSelect", {
|
||||
...baseProps,
|
||||
textColorCustom: "#ffffff",
|
||||
});
|
||||
|
||||
render(
|
||||
<FormSelectPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-select-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("셀렉트 텍스트 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-select-1",
|
||||
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormSelectBlockProps>("f-select-2", "formSelect", {
|
||||
...baseProps,
|
||||
fillColorCustom: "#000000",
|
||||
});
|
||||
|
||||
render(
|
||||
<FormSelectPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-select-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("셀렉트 채움 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#445566" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-select-2",
|
||||
expect.objectContaining({ fillColorCustom: "#445566" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormSelectBlockProps>("f-select-3", "formSelect", {
|
||||
...baseProps,
|
||||
strokeColorCustom: "#000000",
|
||||
});
|
||||
|
||||
render(
|
||||
<FormSelectPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-select-3"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("셀렉트 테두리 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-select-3",
|
||||
expect.objectContaining({ strokeColorCustom: "#778899" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormSelectBlockProps>("f-select-4", "formSelect", {
|
||||
...baseProps,
|
||||
widthMode: "auto",
|
||||
fullWidth: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<FormSelectPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-select-4"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("필드 너비");
|
||||
fireEvent.change(select, { target: { value: "fixed" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-select-4",
|
||||
expect.objectContaining({ widthMode: "fixed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormCheckboxPropertiesPanel", () => {
|
||||
const baseProps: FormCheckboxBlockProps = {
|
||||
groupLabel: "옵션들",
|
||||
formFieldName: "features",
|
||||
options: [{ label: "옵션 1", value: "opt1" }],
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormCheckboxBlockProps>("f-check-1", "formCheckbox", {
|
||||
...baseProps,
|
||||
textColorCustom: "#ffffff",
|
||||
});
|
||||
|
||||
render(
|
||||
<FormCheckboxPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-check-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("체크박스 텍스트 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-check-1",
|
||||
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormCheckboxBlockProps>("f-check-2", "formCheckbox", {
|
||||
...baseProps,
|
||||
widthMode: "auto",
|
||||
fullWidth: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<FormCheckboxPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-check-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("필드 너비");
|
||||
fireEvent.change(select, { target: { value: "fixed" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-check-2",
|
||||
expect.objectContaining({ widthMode: "fixed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormRadioPropertiesPanel", () => {
|
||||
const baseProps: FormRadioBlockProps = {
|
||||
groupLabel: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [{ label: "플랜 A", value: "a" }],
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormRadioBlockProps>("f-radio-1", "formRadio", {
|
||||
...baseProps,
|
||||
textColorCustom: "#ffffff",
|
||||
});
|
||||
|
||||
render(
|
||||
<FormRadioPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-radio-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("라디오 텍스트 색상 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-radio-1",
|
||||
expect.objectContaining({ textColorCustom: "#112233" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const block = makeBlock<FormRadioBlockProps>("f-radio-2", "formRadio", {
|
||||
...baseProps,
|
||||
widthMode: "auto",
|
||||
fullWidth: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<FormRadioPropertiesPanel
|
||||
block={block}
|
||||
selectedBlockId="f-radio-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("필드 너비");
|
||||
fireEvent.change(select, { target: { value: "fixed" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"f-radio-2",
|
||||
expect.objectContaining({ widthMode: "fixed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ImagePropertiesPanel } from "@/app/editor/panels/ImagePropertiesPanel";
|
||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// ImagePropertiesPanel 컨트롤 TDD
|
||||
// - 카드 배경색(backgroundColorCustom) HEX 인풋 변경 시 updateBlock 이 올바르게 호출되는지
|
||||
|
||||
describe("ImagePropertiesPanel", () => {
|
||||
const baseProps: ImageBlockProps = {
|
||||
src: "/images/example.png",
|
||||
alt: "예시 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("카드 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<ImagePropertiesPanel
|
||||
imageProps={{ ...baseProps, backgroundColorCustom: "#111111" }}
|
||||
selectedBlockId="image-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("이미지 카드 배경색 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#112233" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"image-1",
|
||||
expect.objectContaining({ backgroundColorCustom: "#112233" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import PreviewPage from "@/app/preview/page";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PreviewPage 자동 복원 TDD
|
||||
// - localStorage 의 pb:autosave:<slug> 스냅샷을 기반으로 초기 렌더에서 replaceBlocks + updateProjectConfig 로 상태를 복원해야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "프리뷰 자동복원 테스트",
|
||||
slug: "preview-autosave-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "프리뷰 기본 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
replaceBlocks: vi.fn(),
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage - 자동 복원", () => {
|
||||
it("pb:autosave:<slug> 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => {
|
||||
const savedBlocks: Block[] = [
|
||||
{
|
||||
id: "saved_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "저장된 프리뷰 블록",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const savedConfig: ProjectConfig = {
|
||||
title: "저장된 프리뷰 프로젝트",
|
||||
slug: "preview-autosave-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const payload = {
|
||||
blocks: savedBlocks,
|
||||
projectConfig: savedConfig,
|
||||
};
|
||||
|
||||
window.localStorage.setItem("pb:autosave:preview-autosave-test", JSON.stringify(payload));
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig as ProjectConfig);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import PreviewPage from "@/app/preview/page";
|
||||
import type { ProjectConfig, Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PreviewPage 캔버스 프리셋/폭/배경색 TDD
|
||||
// - canvasPreset / canvasWidthPx / canvasBgColorHex / bodyBgColorHex 가
|
||||
// preview-canvas-inner 및 main 배경에 올바르게 반영되는지 검증한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "프리뷰 캔버스 테스트",
|
||||
slug: "preview-canvas-test",
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
};
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("PreviewPage - canvasPreset / canvasWidthPx", () => {
|
||||
it("mobile 프리셋 + canvasWidthPx=390 은 preview-canvas-inner maxWidth 를 390px 로 설정해야 한다", () => {
|
||||
mockState.projectConfig = {
|
||||
...(mockState.projectConfig as ProjectConfig),
|
||||
canvasPreset: "mobile",
|
||||
canvasWidthPx: 390,
|
||||
} as ProjectConfig;
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||
expect(inner.style.maxWidth).toBe("390px");
|
||||
});
|
||||
|
||||
it("tablet 프리셋 + canvasWidthPx=768 은 maxWidth 를 768px 로 설정해야 한다", () => {
|
||||
mockState.projectConfig = {
|
||||
...(mockState.projectConfig as ProjectConfig),
|
||||
canvasPreset: "tablet",
|
||||
canvasWidthPx: 768,
|
||||
} as ProjectConfig;
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||
expect(inner.style.maxWidth).toBe("768px");
|
||||
});
|
||||
|
||||
it("desktop 프리셋 + canvasWidthPx=1200 은 maxWidth 를 1200px 로 설정해야 한다", () => {
|
||||
mockState.projectConfig = {
|
||||
...(mockState.projectConfig as ProjectConfig),
|
||||
canvasPreset: "desktop",
|
||||
canvasWidthPx: 1200,
|
||||
} as ProjectConfig;
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||
expect(inner.style.maxWidth).toBe("1200px");
|
||||
});
|
||||
|
||||
it("full 프리셋에서 canvasWidthPx 가 없으면 maxWidth 는 비워져 있어야 한다", () => {
|
||||
mockState.projectConfig = {
|
||||
...(mockState.projectConfig as ProjectConfig),
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: undefined,
|
||||
} as ProjectConfig;
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||
expect(inner.style.maxWidth).toBe("");
|
||||
});
|
||||
|
||||
it("custom 프리셋 + canvasWidthPx=1024 는 maxWidth 를 1024px 로 설정해야 한다", () => {
|
||||
mockState.projectConfig = {
|
||||
...(mockState.projectConfig as ProjectConfig),
|
||||
canvasPreset: "custom",
|
||||
canvasWidthPx: 1024,
|
||||
} as ProjectConfig;
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||
expect(inner.style.maxWidth).toBe("1024px");
|
||||
});
|
||||
|
||||
it("canvasBgColorHex 와 bodyBgColorHex 가 각각 preview-canvas-inner 및 main 배경에 적용되어야 한다", () => {
|
||||
mockState.projectConfig = {
|
||||
...(mockState.projectConfig as ProjectConfig),
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#111111",
|
||||
bodyBgColorHex: "#222222",
|
||||
} as ProjectConfig;
|
||||
|
||||
const { container } = render(<PreviewPage />);
|
||||
|
||||
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
|
||||
const main = container.querySelector("main") as HTMLElement;
|
||||
|
||||
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
||||
expect(main.style.backgroundColor).toBe("rgb(34, 34, 34)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import PreviewPage from "@/app/preview/page";
|
||||
|
||||
// PreviewPage Export ZIP TDD
|
||||
// - 프리뷰 헤더에 "페이지 파일로 내보내기 (ZIP)" 버튼이 노출되어야 한다.
|
||||
// - 버튼 클릭 시 /api/export 로 blocks + projectConfig 를 payload 로 POST 요청을 보내야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "프리뷰 Export 테스트",
|
||||
slug: "preview-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_text_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "프리뷰 Export 본문",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage - ZIP Export", () => {
|
||||
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
|
||||
render(<PreviewPage />);
|
||||
|
||||
const link = screen.getByText("프로젝트 목록");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.closest("a")?.getAttribute("href")).toBe("/projects");
|
||||
});
|
||||
|
||||
it("프리뷰 헤더에 ZIP Export 버튼이 노출되어야 한다", () => {
|
||||
render(<PreviewPage />);
|
||||
|
||||
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
|
||||
expect(exportButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ZIP Export 버튼 클릭 시 /api/export 로 blocks + projectConfig 를 payload 로 POST 해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
// JSDOM 환경에서 blob() 호출을 안전하게 처리하기 위한 최소 mock
|
||||
blob: vi.fn().mockResolvedValue(new Blob(["dummy-zip"])),
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
|
||||
fireEvent.click(exportButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
expect(url).toBe("/api/export");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const parsed = JSON.parse(options.body);
|
||||
expect(Array.isArray(parsed.blocks)).toBe(true);
|
||||
expect(parsed.blocks[0].id).toBe("blk_text_1");
|
||||
expect(parsed.projectConfig.slug).toBe("preview-export-test");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ProjectPropertiesPanel } from "@/app/editor/panels/ProjectPropertiesPanel";
|
||||
import type { ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
let mockState: { projectConfig: ProjectConfig; updateProjectConfig: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
const baseConfig: ProjectConfig = {
|
||||
title: "프로젝트",
|
||||
slug: "project",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
headHtml: "",
|
||||
trackingScript: "",
|
||||
// SEO 필드는 아직 구현 전이지만, TDD 를 위해 기본값 형태만 지정해 둔다.
|
||||
// 실제 타입 정의는 추후 15.1 구현에서 확장한다.
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
projectConfig: baseConfig,
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ProjectPropertiesPanel SEO 메타", () => {
|
||||
it("SEO 타이틀 입력을 변경하면 seoTitle 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("SEO 타이틀") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "새 SEO 타이틀" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ seoTitle: "새 SEO 타이틀" });
|
||||
});
|
||||
|
||||
it("메타 디스크립션 입력을 변경하면 seoDescription 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const textarea = screen.getByLabelText("메타 디스크립션") as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "SEO 설명" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({
|
||||
seoDescription: "SEO 설명",
|
||||
});
|
||||
});
|
||||
|
||||
it("OG/Twitter 이미지 URL 입력을 변경하면 seoOgImageUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("OG/Twitter 이미지 URL") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "https://example.com/og.png" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({
|
||||
seoOgImageUrl: "https://example.com/og.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("Canonical URL 입력을 변경하면 seoCanonicalUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("Canonical URL") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "https://example.com/landing" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({
|
||||
seoCanonicalUrl: "https://example.com/landing",
|
||||
});
|
||||
});
|
||||
|
||||
it("검색 노출 제어 체크박스를 토글하면 seoNoIndex 가 true 로 업데이트되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const checkbox = screen.getByLabelText(
|
||||
"검색 엔진에 노출하지 않기 (noindex)",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({
|
||||
seoNoIndex: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ProjectPropertiesPanel } from "@/app/editor/panels/ProjectPropertiesPanel";
|
||||
import type { ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
let mockState: { projectConfig: ProjectConfig; updateProjectConfig: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
const baseConfig: ProjectConfig = {
|
||||
title: "프로젝트",
|
||||
slug: "project",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
projectConfig: baseConfig,
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ProjectPropertiesPanel", () => {
|
||||
it("캔버스 너비 프리셋을 변경하면 canvasPreset/canvasWidthPx 가 업데이트되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
|
||||
|
||||
fireEvent.change(select, { target: { value: "mobile" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "mobile", canvasWidthPx: 390 });
|
||||
});
|
||||
|
||||
it("태블릿 프리셋을 선택하면 canvasPreset=tablet 과 canvasWidthPx=768 로 업데이트되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
|
||||
|
||||
fireEvent.change(select, { target: { value: "tablet" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "tablet", canvasWidthPx: 768 });
|
||||
});
|
||||
|
||||
it("데스크톱 프리셋을 선택하면 canvasPreset=desktop 과 canvasWidthPx=1200 으로 업데이트되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
|
||||
|
||||
fireEvent.change(select, { target: { value: "desktop" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "desktop", canvasWidthPx: 1200 });
|
||||
});
|
||||
|
||||
it("캔버스 너비를 390 으로 입력하면 mobile 프리셋으로 동기화되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "390" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "mobile", canvasWidthPx: 390 });
|
||||
});
|
||||
|
||||
it("캔버스 너비를 768 로 입력하면 tablet 프리셋으로 동기화되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "768" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "tablet", canvasWidthPx: 768 });
|
||||
});
|
||||
|
||||
it("캔버스 너비를 1200 으로 입력하면 desktop 프리셋으로 동기화되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "1200" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "desktop", canvasWidthPx: 1200 });
|
||||
});
|
||||
|
||||
it("캔버스 너비를 커스텀 값으로 입력하면 canvasPreset=custom 과 함께 canvasWidthPx 가 설정되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "1000" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "custom", canvasWidthPx: 1000 });
|
||||
});
|
||||
|
||||
it("캔버스 배경색 HEX 입력을 변경하면 canvasBgColorHex 가 업데이트되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("캔버스 배경색 HEX") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "#123456" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasBgColorHex: "#123456" });
|
||||
});
|
||||
|
||||
it("페이지 배경색 HEX 입력을 변경하면 bodyBgColorHex 가 업데이트되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("페이지 배경색 HEX") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "#654321" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "#654321" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import ProjectsPage from "@/app/projects/page";
|
||||
|
||||
// ProjectsPage 프로젝트 목록 TDD
|
||||
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
|
||||
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
|
||||
// - 각 프로젝트 행에는 프로젝트를 삭제하는 "삭제" 버튼이 있고, 클릭 시 서버에 DELETE 요청을 보내고 목록/로컬스토리지를 갱신해야 한다.
|
||||
// - 체크박스로 여러 프로젝트를 선택한 뒤 상단의 "선택 삭제" 버튼으로 일괄 삭제할 수 있어야 한다.
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
it("/api/projects 로부터 받은 프로젝트 목록을 제목/slug 기준으로 렌더링하고, 편집/미리보기 링크를 노출해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
expect(url).toBe("/api/projects");
|
||||
expect(options).toBeUndefined();
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||
expect(screen.getByText("test-project-a")).toBeTruthy();
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
|
||||
expect(screen.getByText("test-project-b")).toBeTruthy();
|
||||
|
||||
const editLinks = screen.getAllByText("편집");
|
||||
expect(editLinks).toHaveLength(2);
|
||||
expect(editLinks[0].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-a");
|
||||
expect(editLinks[1].closest("a")?.getAttribute("href")).toBe("/editor?slug=test-project-b");
|
||||
|
||||
const previewLinks = screen.getAllByText("미리보기");
|
||||
expect(previewLinks).toHaveLength(2);
|
||||
expect(previewLinks[0].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-a");
|
||||
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
|
||||
});
|
||||
|
||||
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
window.localStorage.setItem("pb:project:test-project-a", "stored-project");
|
||||
window.localStorage.setItem("pb:autosave:test-project-a", "stored-autosave");
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/test-project-a" && method === "DELETE") {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||
|
||||
const deleteButtons = screen.getAllByText("삭제");
|
||||
expect(deleteButtons.length).toBeGreaterThan(0);
|
||||
|
||||
deleteButtons[0].click();
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/projects/test-project-a" && options?.method === "DELETE";
|
||||
});
|
||||
|
||||
expect(deleteCall).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("테스트 프로젝트 A")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:project:test-project-a")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:autosave:test-project-a")).toBeNull();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
window.localStorage.setItem("pb:project:test-project-a", "stored-project-a");
|
||||
window.localStorage.setItem("pb:autosave:test-project-a", "stored-autosave-a");
|
||||
window.localStorage.setItem("pb:project:test-project-b", "stored-project-b");
|
||||
window.localStorage.setItem("pb:autosave:test-project-b", "stored-autosave-b");
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/test-project-a" && method === "DELETE") {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
|
||||
if (url === "/api/projects/test-project-b" && method === "DELETE") {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
|
||||
|
||||
const checkboxA = screen.getByLabelText("테스트 프로젝트 A 선택");
|
||||
const checkboxB = screen.getByLabelText("테스트 프로젝트 B 선택");
|
||||
|
||||
fireEvent.click(checkboxA);
|
||||
fireEvent.click(checkboxB);
|
||||
|
||||
const bulkDeleteButton = screen.getByText("선택 삭제");
|
||||
fireEvent.click(bulkDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteCalls = fetchMock.mock.calls.filter(([, options]) => options?.method === "DELETE");
|
||||
const hasA = deleteCalls.some(([url]) => url === "/api/projects/test-project-a");
|
||||
const hasB = deleteCalls.some(([url]) => url === "/api/projects/test-project-b");
|
||||
|
||||
expect(hasA).toBe(true);
|
||||
expect(hasB).toBe(true);
|
||||
|
||||
// DELETE 요청이 성공적으로 처리된 뒤에는 목록에서 해당 프로젝트들이 사라져야 한다.
|
||||
expect(screen.queryByText("테스트 프로젝트 A")).toBeNull();
|
||||
expect(screen.queryByText("테스트 프로젝트 B")).toBeNull();
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("pb:project:test-project-a")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:autosave:test-project-a")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:project:test-project-b")).toBeNull();
|
||||
expect(window.localStorage.getItem("pb:autosave:test-project-b")).toBeNull();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
import { PropertiesSidebar } from "@/app/editor/panels/PropertiesSidebar";
|
||||
|
||||
describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const createTextBlock = (): Block => ({
|
||||
id: "text-1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "도움말 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
} as any,
|
||||
sectionId: null,
|
||||
columnId: null,
|
||||
});
|
||||
|
||||
const defaultProps = (blocks: Block[], selectedBlockId: string | null) => ({
|
||||
blocks,
|
||||
selectedBlockId,
|
||||
selectedListItemId: null,
|
||||
updateBlock: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
editingBlockId: null,
|
||||
setEditingText: vi.fn(),
|
||||
onMoveSelectedItemUp: vi.fn(),
|
||||
onMoveSelectedItemDown: vi.fn(),
|
||||
onIndentSelectedItem: vi.fn(),
|
||||
onOutdentSelectedItem: vi.fn(),
|
||||
});
|
||||
|
||||
it("텍스트 블록이 선택된 상태에서 HELP 버튼을 노출해야 한다", () => {
|
||||
const textBlock = createTextBlock();
|
||||
|
||||
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
|
||||
|
||||
const helpButton = screen.getByRole("button", { name: "HELP" });
|
||||
expect(helpButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("HELP 버튼 클릭 시 현재 블록 타입에 맞는 튜토리얼 모달이 열려야 한다", () => {
|
||||
const textBlock = createTextBlock();
|
||||
|
||||
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
|
||||
|
||||
const helpButton = screen.getByRole("button", { name: "HELP" });
|
||||
fireEvent.click(helpButton);
|
||||
|
||||
expect(screen.getByText("텍스트 블록 튜토리얼")).toBeDefined();
|
||||
expect(screen.getByText(/제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("모달의 닫기 버튼을 클릭하면 튜토리얼 모달이 닫혀야 한다", () => {
|
||||
const textBlock = createTextBlock();
|
||||
|
||||
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
|
||||
|
||||
const helpButton = screen.getByRole("button", { name: "HELP" });
|
||||
fireEvent.click(helpButton);
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "닫기" });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(screen.queryByText("텍스트 블록 튜토리얼")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 이미지 블록 TDD
|
||||
// - src 가 비어 있을 때는 <img> 를 렌더하지 않아 Next.js 경고를 피한다.
|
||||
// - src 가 비어 있지 않을 때는 정상적으로 <img> 를 렌더한다.
|
||||
|
||||
describe("PublicPageRenderer - image block", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("src 가 비어 있으면 img 요소를 렌더하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "img_empty",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "",
|
||||
alt: "빈 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const img = screen.queryByRole("img", { name: "빈 이미지" });
|
||||
expect(img).toBeNull();
|
||||
});
|
||||
|
||||
it("src 가 비어 있지 않으면 img 요소를 렌더해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "img_non_empty",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "https://example.com/test.png",
|
||||
alt: "정상 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const img = screen.getByRole("img", { name: "정상 이미지" }) as HTMLImageElement;
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.src).toContain("https://example.com/test.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// 프리뷰 렌더러가 상위 컨테이너(프리뷰 캔버스)의 배경색을 덮어쓰지 않도록 하는 TDD
|
||||
// - 루트 컨테이너는 레이아웃/텍스트 색상만 담당하고, 고정 배경색(bg-slate-950)은 사용하지 않아야 한다.
|
||||
|
||||
describe("PublicPageRenderer - 배경색 위임", () => {
|
||||
it("루트 컨테이너는 bg-slate-950 같은 고정 배경 클래스 없이 렌더링되어야 한다", () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const root = container.firstElementChild as HTMLElement | null;
|
||||
expect(root).not.toBeNull();
|
||||
|
||||
const className = root!.getAttribute("class") ?? "";
|
||||
|
||||
expect(className).not.toContain("bg-slate-950");
|
||||
});
|
||||
|
||||
it("루트 텍스트/버튼 블록 영역은 고정 bg-slate-950 배경 대신 캔버스 배경을 그대로 보여야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "text_root",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "button_root",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "버튼",
|
||||
href: "#",
|
||||
align: "left",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
// 섹션 블록이 하나도 없으므로, 첫 번째 <section> 은 루트 텍스트/버튼 블록을 감싸는 래퍼다.
|
||||
const firstSection = container.querySelector("section");
|
||||
expect(firstSection).not.toBeNull();
|
||||
|
||||
const className = firstSection!.getAttribute("class") ?? "";
|
||||
|
||||
expect(className).not.toContain("bg-slate-950");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// 블록 배경색 확장 TDD
|
||||
// - backgroundColorCustom 이 설정된 경우에만 프리뷰에서 배경색이 적용되어야 한다.
|
||||
// - 설정되지 않은 경우에는 기본 배경(투명)으로 남아야 한다.
|
||||
|
||||
describe("PublicPageRenderer - 블록 배경색", () => {
|
||||
it("text 블록은 backgroundColorCustom 이 설정된 경우에만 배경색 스타일을 가져야 한다", () => {
|
||||
const blocksWithBg: Block[] = [
|
||||
{
|
||||
id: "text_with_bg",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "배경 있는 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
// TDD: 새 배경색 속성
|
||||
backgroundColorCustom: "#123456",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
|
||||
const pWithBg = container.querySelector("p") as HTMLElement | null;
|
||||
expect(pWithBg).not.toBeNull();
|
||||
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
|
||||
expect(pWithBg!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
|
||||
const blocksWithoutBg: Block[] = [
|
||||
{
|
||||
id: "text_no_bg",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "배경 없는 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||
|
||||
const pNoBg = container.querySelector("p") as HTMLElement | null;
|
||||
expect(pNoBg).not.toBeNull();
|
||||
// 기본값은 inline style 이 설정되지 않은 상태여야 한다.
|
||||
expect(pNoBg!.style.backgroundColor === "" || pNoBg!.style.backgroundColor === "transparent").toBe(true);
|
||||
});
|
||||
|
||||
it("list 블록은 backgroundColorCustom 이 설정된 경우에만 리스트 컨테이너에 배경색이 적용되어야 한다", () => {
|
||||
const blocksWithBg: Block[] = [
|
||||
{
|
||||
id: "list_with_bg",
|
||||
type: "list",
|
||||
props: {
|
||||
items: ["아이템 1"],
|
||||
ordered: false,
|
||||
// TDD: 새 배경색 속성
|
||||
backgroundColorCustom: "#00ff88",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
|
||||
const listWithBg = (container.querySelector("ul,ol") as HTMLElement | null);
|
||||
expect(listWithBg).not.toBeNull();
|
||||
expect(listWithBg!.style.backgroundColor).toBe("rgb(0, 255, 136)");
|
||||
|
||||
const blocksWithoutBg: Block[] = [
|
||||
{
|
||||
id: "list_no_bg",
|
||||
type: "list",
|
||||
props: {
|
||||
items: ["아이템 1"],
|
||||
ordered: false,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||
|
||||
const listNoBg = (container.querySelector("ul,ol") as HTMLElement | null);
|
||||
expect(listNoBg).not.toBeNull();
|
||||
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
|
||||
});
|
||||
|
||||
it("form 컨트롤러 블록은 프리뷰에서 별도 폼 래퍼를 렌더하지 않아야 한다", () => {
|
||||
const blocksWithBg: Block[] = [
|
||||
{
|
||||
id: "form_with_bg",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fields: [],
|
||||
fieldIds: [],
|
||||
formWidthMode: "auto",
|
||||
// TDD: 새 배경색 속성
|
||||
backgroundColorCustom: "#111111",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { queryByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
|
||||
const formWithBg = queryByTestId("preview-form-controller");
|
||||
expect(formWithBg).toBeNull();
|
||||
|
||||
const blocksWithoutBg: Block[] = [
|
||||
{
|
||||
id: "form_no_bg",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fields: [],
|
||||
fieldIds: [],
|
||||
formWidthMode: "auto",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||
|
||||
const formNoBg = queryByTestId("preview-form-controller");
|
||||
expect(formNoBg).toBeNull();
|
||||
});
|
||||
|
||||
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
|
||||
const blocksWithBg: Block[] = [
|
||||
{
|
||||
id: "sec_1",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_1_col_1", span: 12 }],
|
||||
backgroundColorCustom: "#123456",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_1_text",
|
||||
type: "text",
|
||||
sectionId: "sec_1",
|
||||
columnId: "sec_1_col_1",
|
||||
props: {
|
||||
text: "섹션 안 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
|
||||
const sectionWithBg = getByTestId("preview-section") as HTMLElement;
|
||||
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
|
||||
expect(sectionWithBg.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
|
||||
const blocksWithoutBg: Block[] = [
|
||||
{
|
||||
id: "sec_1",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_1_col_1", span: 12 }],
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_1_text",
|
||||
type: "text",
|
||||
sectionId: "sec_1",
|
||||
columnId: "sec_1_col_1",
|
||||
props: {
|
||||
text: "섹션 안 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||
|
||||
const sectionNoBg = getByTestId("preview-section") as HTMLElement;
|
||||
expect(sectionNoBg.style.backgroundColor === "" || sectionNoBg.style.backgroundColor === "transparent").toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 버튼 스타일 TDD
|
||||
// - fillColorCustom / strokeColorCustom / textColorCustom
|
||||
// - widthMode=fixed + widthPx, paddingX/paddingY
|
||||
// - borderRadius 토큰에 따른 둥글기
|
||||
|
||||
describe("PublicPageRenderer - 버튼 블록 스타일", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("버튼 색상 커스텀 값은 배경/외곽선/텍스트 색상 스타일에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "btn_colors",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "컬러 버튼",
|
||||
href: "#",
|
||||
align: "center",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
textColorCustom: "#00ff00",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
|
||||
const wrapper = link!.parentElement as HTMLElement | null;
|
||||
expect(wrapper).not.toBeNull();
|
||||
// 정렬/버튼 베이스는 pb-* 토큰 클래스로 렌더된다.
|
||||
expect(wrapper!.className).toContain("pb-text-center");
|
||||
expect(link!.className).toContain("pb-btn-base");
|
||||
expect(link!.className).toContain("pb-btn-size-md");
|
||||
expect(link!.className).toContain("pb-btn-variant-solid-primary");
|
||||
expect(link!.className).toContain("pb-btn-radius-md");
|
||||
// 배경색: #123456 → rgb(18, 52, 86)
|
||||
expect(link!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
// 외곽선 색상: #ff0000 → rgb(255, 0, 0)
|
||||
expect(link!.style.borderColor).toBe("rgb(255, 0, 0)");
|
||||
// strokeColorCustom 이 있을 때 border-width/style 도 설정되어야 한다.
|
||||
expect(link!.style.borderWidth).toBe("1px");
|
||||
expect(link!.style.borderStyle).toBe("solid");
|
||||
// 텍스트 색상: #00ff00 → rgb(0, 255, 0)
|
||||
expect(link!.style.color).toBe("rgb(0, 255, 0)");
|
||||
});
|
||||
|
||||
it("widthMode=fixed 인 버튼은 widthPx/paddingX/paddingY 가 em 단위 스타일로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "btn_layout",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "레이아웃 버튼",
|
||||
href: "#",
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
paddingX: 16,
|
||||
paddingY: 8,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
// width: 320px / 16 = 20em
|
||||
expect(link!.style.width).toBe("20em");
|
||||
// padding-inline: 16px / 16 = 1em
|
||||
expect(link!.style.paddingInline).toBe("1em");
|
||||
// padding-block: 8px / 16 = 0.5em
|
||||
expect(link!.style.paddingBlock).toBe("0.5em");
|
||||
});
|
||||
|
||||
it("borderRadius 토큰은 em 단위 둥글기로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "btn_radius",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "둥근 버튼",
|
||||
href: "#",
|
||||
borderRadius: "lg",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
// lg → 12px → 12 / 16 = 0.75em
|
||||
expect(link!.style.borderRadius).toBe("0.75em");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// divider 스타일 TDD
|
||||
// - colorHex 에 따른 라인 색상
|
||||
// - marginY/marginYPx 에 따른 상하 여백 em 스케일
|
||||
// - widthMode/widthPx 에 따른 길이 모드
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("PublicPageRenderer - divider 스타일", () => {
|
||||
it("colorHex 가 지정되면 구분선 라인에 해당 색상이 적용되어야 한다", () => {
|
||||
const blocksWithColor: Block[] = [
|
||||
{
|
||||
id: "div_color",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "thin",
|
||||
colorHex: "#123456",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithColor} />);
|
||||
|
||||
const lineWithColor = getByTestId("preview-divider-line") as HTMLElement;
|
||||
// JSDOM 은 #123456 을 rgb(18, 52, 86) 으로 노출한다.
|
||||
expect(lineWithColor.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
|
||||
const blocksWithoutColor: Block[] = [
|
||||
{
|
||||
id: "div_default",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "thin",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
rerender(<PublicPageRenderer blocks={blocksWithoutColor} />);
|
||||
|
||||
const lineDefault = getByTestId("preview-divider-line") as HTMLElement;
|
||||
// 기본 색상은 #475569 이며, JSDOM 에서는 rgb(71, 85, 105) 로 표시된다.
|
||||
expect(lineDefault.style.backgroundColor).toBe("rgb(71, 85, 105)");
|
||||
});
|
||||
|
||||
it("marginYPx 이 있으면 divider 컨테이너 상하 여백이 em 스케일로 적용되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "div_margin_px",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "thin",
|
||||
marginYPx: 32,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const wrapper = getByTestId("preview-divider") as HTMLDivElement;
|
||||
// 32px / 16 = 2em
|
||||
expect(wrapper.style.marginTop).toBe("2em");
|
||||
expect(wrapper.style.marginBottom).toBe("2em");
|
||||
});
|
||||
|
||||
it("marginY 토큰만 있을 때도 적절한 px 값을 em 으로 변환해 상하 여백에 적용해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "div_margin_token",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "thin",
|
||||
marginY: "lg", // 24px 로 매핑
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const wrapper = getByTestId("preview-divider") as HTMLDivElement;
|
||||
// 24px / 16 = 1.5em
|
||||
expect(wrapper.style.marginTop).toBe("1.5em");
|
||||
expect(wrapper.style.marginBottom).toBe("1.5em");
|
||||
});
|
||||
|
||||
it("widthMode 가 fixed 이고 widthPx 가 있으면 divider 라인의 width 가 pxToEm 으로 변환되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "div_fixed_width",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "thin",
|
||||
widthMode: "fixed",
|
||||
widthPx: 480,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const line = getByTestId("preview-divider-line") as HTMLDivElement;
|
||||
// 480px / 16 = 30em
|
||||
expect(line.style.width).toBe("30em");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 푸터 템플릿 TDD
|
||||
// - footerTemplate 로 생성한 섹션이 프리뷰에서 섹션/텍스트 구조로 올바르게 렌더되는지 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 푸터 템플릿", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("푸터 템플릿 섹션은 프리뷰에서 섹션과 푸터 텍스트들을 렌더해야 한다", () => {
|
||||
const sectionId = "footer_preview_section";
|
||||
let i = 0;
|
||||
const createId = () => `footer_${++i}`;
|
||||
|
||||
const { blocks } = createFooterTemplateBlocks({ sectionId, createId });
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||
|
||||
const section = screen.getByTestId("preview-section");
|
||||
expect(section.getAttribute("data-section-id")).toBe(sectionId);
|
||||
|
||||
expect(screen.getByText("MyLanding")).toBeTruthy();
|
||||
expect(screen.getByText("더 나은 웹사이트를 위한 최고의 선택.")).toBeTruthy();
|
||||
expect(screen.getByText(/서비스 소개/)).toBeTruthy();
|
||||
expect(screen.getByText(/문의하기/)).toBeTruthy();
|
||||
expect(screen.getByText(/© 2025 MyLanding\./)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// 폼 필드 스타일 TDD
|
||||
// - formInput: 색상/패딩/너비/둥글기 스타일
|
||||
// - formSelect: 색상/패딩/너비/둥글기 스타일
|
||||
// - formCheckbox/formRadio: 옵션 컨테이너 색상/패딩/둥글기 스타일
|
||||
|
||||
function getFirstLabel(root: HTMLElement): HTMLLabelElement {
|
||||
const label = root.querySelector("label");
|
||||
if (!label) {
|
||||
throw new Error("label element not found");
|
||||
}
|
||||
return label as HTMLLabelElement;
|
||||
}
|
||||
|
||||
describe("PublicPageRenderer - 폼 필드 스타일", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("formInput 블록은 색상/패딩/너비/둥글기 스타일을 반영해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_input_styles",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이메일",
|
||||
formFieldName: "email",
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
paddingX: 16,
|
||||
paddingY: 8,
|
||||
textColorCustom: "#112233",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const wrapper = getByTestId("preview-form-input-wrapper") as HTMLElement;
|
||||
const input = wrapper.querySelector('[data-testid="preview-form-input"]') as HTMLElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
// 정렬
|
||||
expect((input as HTMLInputElement).style.textAlign).toBe("center");
|
||||
// width: 320px / 16 = 20em
|
||||
expect((input as HTMLInputElement).style.width).toBe("20em");
|
||||
// padding-inline/block: 16px/8px -> 1em / 0.5em
|
||||
expect((input as HTMLInputElement).style.paddingInline).toBe("1em");
|
||||
expect((input as HTMLInputElement).style.paddingBlock).toBe("0.5em");
|
||||
// 색상: hex -> rgb 변환
|
||||
expect((input as HTMLInputElement).style.color).toBe("rgb(17, 34, 51)");
|
||||
expect((input as HTMLInputElement).style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
expect((input as HTMLInputElement).style.borderColor).toBe("rgb(255, 0, 0)");
|
||||
// borderRadius: lg -> 6px
|
||||
expect((input as HTMLInputElement).style.borderRadius).toBe("6px");
|
||||
});
|
||||
|
||||
it("formSelect 블록은 색상/패딩/너비/둥글기 스타일을 반영해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_select_styles",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "카테고리",
|
||||
formFieldName: "category",
|
||||
options: [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
],
|
||||
widthMode: "fixed",
|
||||
widthPx: 240,
|
||||
paddingX: 16,
|
||||
paddingY: 8,
|
||||
textColorCustom: "#00ff00",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const wrapper = getByTestId("preview-form-select") as HTMLElement;
|
||||
const select = wrapper.querySelector("select") as HTMLSelectElement | null;
|
||||
expect(select).not.toBeNull();
|
||||
|
||||
// width: 240px / 16 = 15em
|
||||
expect(wrapper.style.width).toBe("15em");
|
||||
// padding-inline/block: 16px/8px -> 1em / 0.5em
|
||||
expect(select!.style.paddingInline).toBe("1em");
|
||||
expect(select!.style.paddingBlock).toBe("0.5em");
|
||||
// 색상
|
||||
expect(select!.style.color).toBe("rgb(0, 255, 0)");
|
||||
expect(select!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
expect(select!.style.borderColor).toBe("rgb(255, 0, 0)");
|
||||
// 둥글기
|
||||
expect(select!.style.borderRadius).toBe("6px");
|
||||
});
|
||||
|
||||
it("formCheckbox 블록은 옵션 컨테이너에 색상/패딩/둥글기 스타일을 반영해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_checkbox_styles",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel: "옵션들",
|
||||
formFieldName: "features",
|
||||
options: [{ label: "옵션 1", value: "opt1" }],
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
optionGapPx: 16,
|
||||
textColorCustom: "#ffffff",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
|
||||
const firstLabel = getFirstLabel(group);
|
||||
|
||||
// width: 320px / 16 = 20em
|
||||
expect(group.style.width).toBe("20em");
|
||||
// 옵션 간 간격: 16px -> 1em
|
||||
const optionsContainer = group.querySelector('[data-testid="preview-form-checkbox-options"]') as HTMLElement | null;
|
||||
expect(optionsContainer).not.toBeNull();
|
||||
expect(optionsContainer!.style.rowGap).toBe("1em");
|
||||
|
||||
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
|
||||
expect(firstLabel.style.paddingInline).toBe("0.5em");
|
||||
expect(firstLabel.style.paddingBlock).toBe("0.25em");
|
||||
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
|
||||
expect(firstLabel.style.borderRadius).toBe("6px");
|
||||
});
|
||||
|
||||
it("formRadio 블록은 옵션 컨테이너에 색상/패딩/둥글기 스타일을 반영해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_radio_styles",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [{ label: "플랜 A", value: "a" }],
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
optionGapPx: 16,
|
||||
textColorCustom: "#ffffff",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const group = getByTestId("preview-form-radio-group") as HTMLElement;
|
||||
const firstLabel = getFirstLabel(group);
|
||||
|
||||
// width: 320px / 16 = 20em
|
||||
expect(group.style.width).toBe("20em");
|
||||
// 옵션 간 간격: 16px -> 1em
|
||||
const optionsContainer = group.querySelector('[data-testid="preview-form-radio-options"]') as HTMLElement | null;
|
||||
expect(optionsContainer).not.toBeNull();
|
||||
expect(optionsContainer!.style.rowGap).toBe("1em");
|
||||
|
||||
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
|
||||
expect(firstLabel.style.paddingInline).toBe("0.5em");
|
||||
expect(firstLabel.style.paddingBlock).toBe("0.25em");
|
||||
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
|
||||
expect(firstLabel.style.borderRadius).toBe("6px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 폼 제출 UX TDD
|
||||
// - 성공 응답 시 FormBlockProps 의 successMessage 를 success 스타일로 표시해야 한다.
|
||||
// - 실패 응답 시 FormBlockProps 의 errorMessage 를 error 스타일로 표시해야 한다.
|
||||
|
||||
describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// fetch 목 초기화
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).fetch = undefined;
|
||||
});
|
||||
|
||||
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러를 렌더하지 않아야 한다 (성공 메시지 설정)", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_success",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
successMessage: "폼 성공 메시지 (config)",
|
||||
errorMessage: "폼 에러 메시지 (config)",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).fetch = fetchMock;
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const form = screen.queryByTestId("preview-form-controller");
|
||||
expect(form).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러나 에러 메시지를 렌더하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_error",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
successMessage: "폼 성공 메시지 (config)",
|
||||
errorMessage: "폼 에러 메시지 (config)",
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response("error", {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).fetch = fetchMock;
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const form = screen.queryByTestId("preview-form-controller");
|
||||
expect(form).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("폼 에러 메시지 (config)")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// 폼 관련 블록(formSelect/formCheckbox/formRadio)의 textColorCustom 이
|
||||
// 프리뷰 렌더러에서 실제 텍스트 color 스타일에 반영되는지 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 폼 텍스트 색상", () => {
|
||||
it("formSelect 블록의 textColorCustom 은 셀렉트 텍스트 색상에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_select_color",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "셀렉트 라벨",
|
||||
formFieldName: "category",
|
||||
options: [
|
||||
{ label: "옵션 A", value: "a" },
|
||||
{ label: "옵션 B", value: "b" },
|
||||
],
|
||||
textColorCustom: "#ff0000",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const wrapper = getByTestId("preview-form-select") as HTMLElement;
|
||||
const select = wrapper.querySelector("select") as HTMLSelectElement | null;
|
||||
|
||||
expect(select).not.toBeNull();
|
||||
expect(select!.style.color).toBe("rgb(255, 0, 0)");
|
||||
});
|
||||
|
||||
it("formCheckbox 블록의 textColorCustom 은 옵션 텍스트 색상에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_checkbox_color",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel: "체크박스 그룹",
|
||||
formFieldName: "features",
|
||||
options: [{ label: "옵션 1", value: "opt1" }],
|
||||
textColorCustom: "#00ff00",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const optionSpan = getByTestId("preview-form-checkbox-option") as HTMLElement;
|
||||
expect(optionSpan.style.color).toBe("rgb(0, 255, 0)");
|
||||
});
|
||||
|
||||
it("formRadio 블록의 textColorCustom 은 옵션 텍스트 색상에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_radio_color",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel: "라디오 그룹",
|
||||
formFieldName: "plan",
|
||||
options: [{ label: "플랜 A", value: "a" }],
|
||||
textColorCustom: "#0000ff",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const optionSpan = getByTestId("preview-form-radio-option") as HTMLElement;
|
||||
expect(optionSpan.style.color).toBe("rgb(0, 0, 255)");
|
||||
});
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 이미지 블록 스타일 TDD
|
||||
// - backgroundColorCustom 카드 배경 적용 여부
|
||||
// - widthMode=fixed, widthPx 의 em 변환
|
||||
// - borderRadiusPx 의 em 변환
|
||||
|
||||
describe("PublicPageRenderer - 이미지 블록 스타일", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("image 블록은 backgroundColorCustom 이 설정된 경우에만 wrapper 에 배경색이 적용되어야 한다", () => {
|
||||
const blocksWithBg: Block[] = [
|
||||
{
|
||||
id: "img_with_bg",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "/images/example.png",
|
||||
alt: "배경 있는 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
backgroundColorCustom: "#123456",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
|
||||
const imgWithBg = container.querySelector("img") as HTMLImageElement | null;
|
||||
expect(imgWithBg).not.toBeNull();
|
||||
const wrapperWithBg = imgWithBg!.parentElement as HTMLElement | null;
|
||||
expect(wrapperWithBg).not.toBeNull();
|
||||
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
|
||||
expect(wrapperWithBg!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
|
||||
const blocksWithoutBg: Block[] = [
|
||||
{
|
||||
id: "img_no_bg",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "/images/example2.png",
|
||||
alt: "배경 없는 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||
|
||||
const imgNoBg = container.querySelector("img") as HTMLImageElement | null;
|
||||
expect(imgNoBg).not.toBeNull();
|
||||
const wrapperNoBg = imgNoBg!.parentElement as HTMLElement | null;
|
||||
expect(wrapperNoBg).not.toBeNull();
|
||||
expect(
|
||||
wrapperNoBg!.style.backgroundColor === "" || wrapperNoBg!.style.backgroundColor === "transparent",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("widthMode 가 fixed 이고 widthPx 가 설정된 경우 img width 는 em 단위로 설정되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "img_fixed_width",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "/images/example.png",
|
||||
alt: "고정 너비 이미지",
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 480,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const img = container.querySelector("img") as HTMLImageElement | null;
|
||||
expect(img).not.toBeNull();
|
||||
// 480px / 16 = 30em
|
||||
expect(img!.style.width).toBe("30em");
|
||||
});
|
||||
|
||||
it("borderRadiusPx 가 설정된 경우 img borderRadius 는 em 단위로 설정되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "img_radius",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "/images/example.png",
|
||||
alt: "둥근 이미지",
|
||||
borderRadiusPx: 32,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const img = container.querySelector("img") as HTMLImageElement | null;
|
||||
expect(img).not.toBeNull();
|
||||
// 32px / 16 = 2em
|
||||
expect(img!.style.borderRadius).toBe("2em");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 섹션 배경 이미지 TDD
|
||||
// - SectionBlockProps 의 backgroundImage* 속성이 섹션 wrapper 스타일에 반영되는지 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 섹션 배경 이미지", () => {
|
||||
it("backgroundImageSrc/size/position 이 설정된 섹션은 backgroundImage/backgroundSize/backgroundPosition 스타일을 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_image",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_image_col_1", span: 12 }],
|
||||
backgroundImageSrc: "/images/section-bg.png",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePosition: "top",
|
||||
backgroundImageRepeat: "repeat-y",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_bg_text",
|
||||
type: "text",
|
||||
sectionId: "sec_bg_image",
|
||||
columnId: "sec_bg_image_col_1",
|
||||
props: {
|
||||
text: "섹션 배경 이미지 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const sectionEl = getByTestId("preview-section") as HTMLElement;
|
||||
expect(sectionEl.style.backgroundImage).toContain("/images/section-bg.png");
|
||||
expect(sectionEl.style.backgroundSize).toBe("cover");
|
||||
expect(sectionEl.style.backgroundPosition).toBe("center top");
|
||||
expect(sectionEl.style.backgroundRepeat).toBe("repeat-y");
|
||||
});
|
||||
|
||||
it("backgroundImagePositionMode 가 custom 인 섹션은 background-position 에 'X% Y%' 값이 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_image_xy",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_image_xy_col_1", span: 12 }],
|
||||
backgroundImageSrc: "/images/section-bg-xy.png",
|
||||
backgroundImageSize: "contain",
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 25,
|
||||
backgroundImagePositionYPercent: 80,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_bg_text_xy",
|
||||
type: "text",
|
||||
sectionId: "sec_bg_image_xy",
|
||||
columnId: "sec_bg_image_xy_col_1",
|
||||
props: {
|
||||
text: "XY 커스텀 위치 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getAllByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const sectionEl = getAllByTestId("preview-section").find(
|
||||
(el) => (el as HTMLElement).dataset.sectionId === "sec_bg_image_xy",
|
||||
) as HTMLElement;
|
||||
expect(sectionEl.style.backgroundImage).toContain("/images/section-bg-xy.png");
|
||||
expect(sectionEl.style.backgroundSize).toBe("contain");
|
||||
expect(sectionEl.style.backgroundPosition).toBe("25% 80%");
|
||||
});
|
||||
|
||||
it("backgroundVideoSrc 가 설정된 섹션은 프리뷰에서도 배경 비디오 video 태그가 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_video",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_video_col_1", span: 12 }],
|
||||
backgroundVideoSrc: "/videos/section-bg-preview.mp4",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_bg_text_video",
|
||||
type: "text",
|
||||
sectionId: "sec_bg_video",
|
||||
columnId: "sec_bg_video_col_1",
|
||||
props: {
|
||||
text: "섹션 배경 비디오 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getAllByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const sectionEl = getAllByTestId("preview-section").find(
|
||||
(el) => (el as HTMLElement).dataset.sectionId === "sec_bg_video",
|
||||
) as HTMLElement;
|
||||
expect(sectionEl).toBeTruthy();
|
||||
|
||||
const videoEl = getAllByTestId("preview-section-bg-video")[0] as HTMLVideoElement;
|
||||
expect(videoEl).toBeTruthy();
|
||||
expect(videoEl.src).toContain("/videos/section-bg-preview.mp4");
|
||||
expect(videoEl.autoplay).toBe(true);
|
||||
expect(videoEl.loop).toBe(true);
|
||||
expect(videoEl.muted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 섹션/루트 레이아웃 TDD
|
||||
// - Export 레이어의 pb-root / pb-section-* 구조와 개념적으로 대응되는 프리뷰 레이아웃을 고정한다.
|
||||
// - 정확한 px 값 비교 대신, 주요 Tailwind 레이아웃 클래스 존재 여부를 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeSectionWithText = (sectionOverride: Partial<SectionBlockProps> = {}, textOverride: Partial<TextBlockProps> = {}): Block[] => {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_layout_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
...sectionOverride,
|
||||
} as SectionBlockProps;
|
||||
|
||||
const textProps: TextBlockProps = {
|
||||
text: "섹션 레이아웃 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
...textOverride,
|
||||
} as TextBlockProps;
|
||||
|
||||
const section: Block = {
|
||||
id: "sec_layout_1",
|
||||
type: "section",
|
||||
props: sectionProps,
|
||||
} as any;
|
||||
|
||||
const text: Block = {
|
||||
id: "sec_layout_1_text",
|
||||
type: "text",
|
||||
sectionId: "sec_layout_1",
|
||||
columnId: "sec_layout_col_1",
|
||||
props: textProps,
|
||||
} as any;
|
||||
|
||||
return [section, text];
|
||||
};
|
||||
|
||||
it("섹션 레이아웃은 mx-auto / max-w / px-* 및 flex 컬럼/갭 구조로 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = makeSectionWithText();
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const inner = screen.getByTestId("preview-section-inner") as HTMLDivElement;
|
||||
const columns = screen.getByTestId("preview-section-columns") as HTMLDivElement;
|
||||
|
||||
// Export 의 pb-section-inner / pb-section-columns 에 대응되는 레이아웃 구조를 최소한으로 보장한다.
|
||||
expect(inner.className).toContain("mx-auto");
|
||||
expect(inner.className).toContain("px-4");
|
||||
expect(inner.className).toMatch(/max-w-/);
|
||||
|
||||
expect(columns.className).toContain("flex");
|
||||
// gapX 기본값(md)에 대해 gap-8 이 사용되는 현재 구현을 고정한다.
|
||||
expect(columns.className).toContain("gap-8");
|
||||
|
||||
// 컬럼 래퍼는 flex-col 과 일정한 세로 간격을 가진다.
|
||||
const columnWrapper = columns.querySelector("div.flex.flex-col") as HTMLDivElement | null;
|
||||
expect(columnWrapper).not.toBeNull();
|
||||
if (columnWrapper) {
|
||||
expect(columnWrapper.className).toContain("gap-4");
|
||||
}
|
||||
|
||||
// 섹션 텍스트가 실제로 섹션 안에 렌더되는지 확인한다.
|
||||
const textEl = screen.getByText("섹션 레이아웃 텍스트");
|
||||
const section = textEl.closest("section");
|
||||
expect(section).not.toBeNull();
|
||||
});
|
||||
|
||||
it("루트 블록은 상단 섹션 내 max-width 컨테이너 안에서 렌더되어야 한다", () => {
|
||||
const rootTextProps: TextBlockProps = {
|
||||
text: "루트 레이아웃 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
} as TextBlockProps;
|
||||
|
||||
const rootTextBlock: Block = {
|
||||
id: "root_layout_text",
|
||||
type: "text",
|
||||
props: rootTextProps,
|
||||
} as any;
|
||||
|
||||
const sectionBlocks = makeSectionWithText();
|
||||
|
||||
const blocks: Block[] = [rootTextBlock, ...sectionBlocks];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const rootTextEl = screen.getByText("루트 레이아웃 텍스트") as HTMLParagraphElement;
|
||||
|
||||
// 루트 텍스트는 상단 섹션(py-12) 안에 존재해야 한다.
|
||||
const rootSection = rootTextEl.closest("section");
|
||||
expect(rootSection).not.toBeNull();
|
||||
if (!rootSection) return;
|
||||
|
||||
expect(rootSection.className).toContain("py-12");
|
||||
|
||||
// 섹션 바로 아래의 max-width 컨테이너 구조를 확인한다.
|
||||
const innerContainer = rootSection.querySelector("div");
|
||||
expect(innerContainer).not.toBeNull();
|
||||
if (!innerContainer) return;
|
||||
|
||||
const className = innerContainer.className;
|
||||
expect(className).toContain("mx-auto");
|
||||
expect(className).toContain("max-w-3xl");
|
||||
expect(className).toContain("px-4");
|
||||
expect(className).toContain("flex");
|
||||
expect(className).toContain("gap-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
|
||||
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
|
||||
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 템플릿 TDD
|
||||
// - Hero / Features / CTA 템플릿이 섹션 + 텍스트/버튼 구조로 올바르게 렌더되는지 검증한다.
|
||||
|
||||
function createIdFactory() {
|
||||
let i = 0;
|
||||
return () => `tpl_${++i}`;
|
||||
}
|
||||
|
||||
describe("PublicPageRenderer - 섹션 템플릿", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("Hero 템플릿 섹션은 섹션과 헤드라인/서브텍스트/버튼을 렌더해야 한다", () => {
|
||||
const sectionId = "hero_section_1";
|
||||
const { blocks } = createHeroTemplateBlocks({ sectionId, createId: createIdFactory() });
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||
|
||||
// 헤드라인/서브텍스트 텍스트가 존재해야 한다.
|
||||
screen.getByText("Hero 제목을 여기에 입력하세요");
|
||||
screen.getByText("제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.");
|
||||
// CTA 버튼 라벨 텍스트가 존재해야 한다.
|
||||
screen.getByText("지금 시작하기");
|
||||
});
|
||||
|
||||
it("Features 템플릿 섹션은 3컬럼 Feature 제목/설명 텍스트를 렌더해야 한다", () => {
|
||||
const sectionId = "features_section_1";
|
||||
const { blocks } = createFeaturesTemplateBlocks({ sectionId, createId: createIdFactory() });
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||
|
||||
// 각 컬럼의 Feature 제목과 설명 텍스트가 모두 존재해야 한다.
|
||||
screen.getByText("Feature 1 제목");
|
||||
screen.getByText("Feature 2 제목");
|
||||
screen.getByText("Feature 3 제목");
|
||||
|
||||
const descText = "해당 기능을 간단히 설명하는 텍스트입니다.";
|
||||
// 설명 텍스트는 3번 등장해야 한다.
|
||||
const allDesc = screen.getAllByText(descText);
|
||||
if (allDesc.length !== 3) {
|
||||
throw new Error(`기대하는 설명 텍스트 개수(3)가 아니고 ${allDesc.length}개가 렌더되었습니다.`);
|
||||
}
|
||||
});
|
||||
|
||||
it("CTA 템플릿 섹션은 텍스트와 CTA 버튼을 렌더해야 한다", () => {
|
||||
const sectionId = "cta_section_1";
|
||||
const { blocks } = createCtaTemplateBlocks({ sectionId, createId: createIdFactory() });
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||
|
||||
// CTA 본문 텍스트와 버튼 라벨이 존재해야 한다.
|
||||
screen.getByText((content) =>
|
||||
content.includes("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요."),
|
||||
);
|
||||
screen.getByText("CTA 버튼");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 텍스트 블록 스타일 TDD
|
||||
// - computeTextPublicTokens 결과가 실제 프리뷰 렌더링에 올바르게 반영되는지 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 텍스트 블록 스타일", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeTextBlock = (override: Partial<TextBlockProps> = {}): Block => {
|
||||
const base: TextBlockProps = {
|
||||
text: "퍼블릭 텍스트 스타일 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
} as TextBlockProps;
|
||||
|
||||
return {
|
||||
id: "text_1",
|
||||
type: "text",
|
||||
props: { ...base, ...override },
|
||||
} as any;
|
||||
};
|
||||
|
||||
it("기본 텍스트 블록은 text-left / text-base 및 기본 색상으로 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = [makeTextBlock()];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const el = screen.getByText("퍼블릭 텍스트 스타일 테스트") as HTMLParagraphElement;
|
||||
|
||||
expect(el.className).toContain("text-left");
|
||||
expect(el.className).toContain("text-base");
|
||||
// 기본 색상은 Tailwind text-slate-50 클래스로 고정되어 있다.
|
||||
expect(el.className).toContain("text-slate-50");
|
||||
});
|
||||
|
||||
it("fontSizeMode=custom, fontSizeCustom, colorCustom, backgroundColorCustom 이 설정되면 스타일이 인라인으로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
makeTextBlock({
|
||||
align: "center",
|
||||
fontSizeMode: "custom",
|
||||
fontSizeCustom: "24px",
|
||||
colorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
}),
|
||||
];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const el = screen.getByText("퍼블릭 텍스트 스타일 테스트") as HTMLParagraphElement;
|
||||
|
||||
// 정렬 클래스
|
||||
expect(el.className).toContain("text-center");
|
||||
// 커스텀 폰트 크기: sizeClass 는 비워지고, 인라인 스타일로 설정된다.
|
||||
expect(el.style.fontSize).toBe("1.5em");
|
||||
// 커스텀 색상/배경색
|
||||
expect(el.style.color).toBe("rgb(255, 0, 0)");
|
||||
expect(el.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 비디오 블록 스타일 TDD
|
||||
// - YouTube URL 은 iframe + pb-video-wrapper 로 렌더링되어야 한다.
|
||||
// - 업로드(/api/video/:id) URL 은 <video> 태그로 렌더되고 widthPx 가 em 단위로 반영되어야 한다.
|
||||
|
||||
describe("PublicPageRenderer - 비디오 블록 스타일", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 wrapper 를 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_youtube_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const iframe = container.querySelector('iframe[title="비디오"]') as HTMLIFrameElement | null;
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe!.src).toContain("youtube.com/embed");
|
||||
|
||||
const wrapper = iframe!.parentElement as HTMLElement | null;
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper!.className).toContain("pb-video-wrapper");
|
||||
});
|
||||
|
||||
it("업로드 비디오 URL 은 video 태그로 렌더되고 widthPx 가 em 단위 너비로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_upload_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/api/video/abc123",
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 640,
|
||||
aspectRatio: "16:9",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("controls")).not.toBeNull();
|
||||
// 640px / 16 = 40em
|
||||
expect(video!.style.width).toBe("40em");
|
||||
});
|
||||
|
||||
it("posterImageSrc 가 설정된 HTML5 비디오는 video 태그 poster 속성으로 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_poster_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/sample-preview.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
posterImageSrc: "/images/sample-poster-preview.png",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("poster")).toBe("/images/sample-poster-preview.png");
|
||||
});
|
||||
|
||||
it("startTimeSec/endTimeSec 가 설정된 HTML5 비디오는 프리뷰에서도 loadedmetadata/timeupdate 로 시작/종료 범위가 적용되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_range_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/range-preview.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
startTimeSec: 3,
|
||||
endTimeSec: 8,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
|
||||
fireEvent(video as HTMLVideoElement, new Event("loadedmetadata"));
|
||||
expect((video as HTMLVideoElement).currentTime).toBe(3);
|
||||
|
||||
(video as any).pause = vi.fn();
|
||||
(video as HTMLVideoElement).currentTime = 8.2;
|
||||
fireEvent(video as HTMLVideoElement, new Event("timeupdate"));
|
||||
expect((video as any).pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("HTML5 비디오의 ariaLabel/titleText/captionText 는 프리뷰에서 aria-label 및 캡션 요소로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_a11y_preview",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/a11y-preview.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
titleText: "프리뷰 접근성 타이틀",
|
||||
ariaLabel: "프리뷰 접근성 설명",
|
||||
captionText: "프리뷰 비디오 캡션입니다.",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container, getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("aria-label")).toBe("프리뷰 접근성 설명");
|
||||
|
||||
const caption = getByTestId("preview-video-caption");
|
||||
expect(caption.textContent).toBe("프리뷰 비디오 캡션입니다.");
|
||||
});
|
||||
|
||||
it("titleText 가 설정된 YouTube 비디오는 iframe title 로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_youtube_a11y_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
titleText: "사용자 정의 비디오 제목",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const iframe = container.querySelector('iframe[title="사용자 정의 비디오 제목"]') as HTMLIFrameElement | null;
|
||||
expect(iframe).not.toBeNull();
|
||||
});
|
||||
|
||||
it("비디오 카드 스타일 props(backgroundColorCustom/cardPaddingPx/borderRadiusPx)는 wrapper 의 스타일에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_card_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/api/video/preview-card",
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
aspectRatio: "16:9",
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 32,
|
||||
borderRadiusPx: 16,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
const wrapper = video!.parentElement as HTMLElement | null;
|
||||
expect(wrapper).not.toBeNull();
|
||||
|
||||
// #123456 -> rgb(18, 52, 86)
|
||||
expect(wrapper!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
// 32px / 16 = 2em, 16px / 16 = 1em
|
||||
expect(wrapper!.style.padding).toBe("2em");
|
||||
expect(wrapper!.style.borderRadius).toBe("1em");
|
||||
});
|
||||
|
||||
it("sourceUrl 이 비어 있는 비디오 블록은 프리뷰에서 video/iframe 을 렌더하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_empty_src_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video");
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(video).toBeNull();
|
||||
expect(iframe).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -44,15 +44,22 @@ describe("SectionPropertiesPanel - layout presets", () => {
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={baseProps}
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
columns: [
|
||||
{ id: "col-1", span: 6 },
|
||||
{ id: "col-2", span: 6 },
|
||||
],
|
||||
}}
|
||||
selectedBlockId="section-layout-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("1열 폭 (1~12)");
|
||||
const inputs = screen.getAllByLabelText("1열 폭 (1~11)");
|
||||
const numberInput = inputs.find((el) => (el as HTMLInputElement).tagName === "INPUT" && (el as HTMLInputElement).type === "number") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "8" } });
|
||||
fireEvent.change(numberInput, { target: { value: "8" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-layout-2",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user