Compare commits

...

4 Commits

Author SHA1 Message Date
jaybe 7a8ad7c057 이미지 파일 업로드 기능
CI / test (push) Failing after 10m34s
CI / pr_and_merge (push) Has been cancelled
2025-11-23 19:07:41 +09:00
jaybe 8ea8a186a0 Merge pull request 'CI: feature/builder-13-forms-style-sync-layout-em' (#8) from feature/builder-13-forms-style-sync-layout-em into main
CI / test (push) Successful in 46m25s
CI / pr_and_merge (push) Has been skipped
2025-11-22 06:30:51 +00:00
jaybe ece5658276 Merge pull request 'CI: feature/builder-13-forms-style-sync' (#7) from feature/builder-13-forms-style-sync into main
CI / test (push) Failing after 47m32s
CI / pr_and_merge (push) Has been skipped
2025-11-21 08:26:14 +00:00
jaybe 7e77ea55da Merge pull request 'CI: feature/builder-13-forms' (#6) from feature/builder-13-forms into main
CI / test (push) Failing after 40m28s
CI / pr_and_merge (push) Has been skipped
2025-11-20 14:49:26 +00:00
77 changed files with 3206 additions and 124 deletions
+111
View File
@@ -13,6 +13,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@prisma/client": "^6.19.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -24,6 +25,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
@@ -2825,6 +2827,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jszip": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
"integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jszip": "*"
}
},
"node_modules/@types/node": {
"version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
@@ -4261,6 +4273,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5982,6 +6000,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -6019,6 +6043,12 @@
"node": ">=8"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -6624,6 +6654,18 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6668,6 +6710,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -7555,6 +7606,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7826,6 +7883,12 @@
}
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7933,6 +7996,27 @@
"license": "MIT",
"peer": true
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -8177,6 +8261,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -8300,6 +8390,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -8538,6 +8634,15 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -9227,6 +9332,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
+2
View File
@@ -19,6 +19,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@prisma/client": "^6.19.0",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -30,6 +31,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
+613
View File
@@ -0,0 +1,613 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import JSZip from "jszip";
import type {
Block,
ProjectConfig,
FormBlockProps,
FormSelectOption,
TextBlockProps,
ButtonBlockProps,
SectionBlockProps,
} from "@/features/editor/state/editorStore";
type ExportRequestBody = {
blocks: Block[];
projectConfig?: ProjectConfig;
};
const BUILDER_CSS_PATH = path.join(process.cwd(), "src", "styles", "builder.css");
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
const escapeHtml = (value: string): string =>
value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const escapeAttr = (value: string): string => escapeHtml(value);
export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): string => {
const pageTitleRaw = (projectConfig?.title ?? "").trim() || "Page Builder Export";
const pageTitle = escapeHtml(pageTitleRaw);
const headExtraRaw = (projectConfig?.headHtml ?? "").trim();
const headExtra = headExtraRaw ? `\n${headExtraRaw}\n` : "";
const preset = projectConfig?.canvasPreset ?? "full";
let maxWidth: string | null = null;
const widthPx =
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
? projectConfig.canvasWidthPx
: null;
if (widthPx != null) {
maxWidth = `${widthPx}px`;
} else if (preset === "mobile") {
maxWidth = "390px";
} else if (preset === "tablet") {
maxWidth = "768px";
} else if (preset === "desktop") {
maxWidth = "1200px";
}
const bgColor = (projectConfig?.canvasBgColorHex ?? "").trim();
const widthPart = maxWidth != null ? `max-width:${maxWidth};` : "";
const bgPart = bgColor ? `background-color:${bgColor};` : "";
const basePart = "margin:0 auto;padding:24px;box-sizing:border-box;";
const canvasStyle = `${widthPart}${bgPart}${basePart}`;
const bodyBgRaw = (projectConfig?.bodyBgColorHex ?? "#020617").trim() || "#020617";
const bodyStyle = `background-color:${bodyBgRaw};margin:0;padding:0;`;
const trackingRaw = (projectConfig?.trackingScript ?? "").trim();
const trackingHtml = trackingRaw ? `\n${trackingRaw}\n` : "";
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
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 classes = [
alignClass,
sizeClass,
leadingClass,
weightClass,
colorClass,
maxWidthClass,
...decoClasses,
]
.filter(Boolean)
.join(" ");
return `<p class="${classes}">${escapeHtml(text)}</p>`;
}
if (block.type === "button") {
const props = (block.props ?? {}) as ButtonBlockProps;
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 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 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]
.filter(Boolean)
.join(" ");
return `<div class="${alignClass}"><a href="${escapeAttr(
href,
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></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>`;
}
if (block.type === "form") {
const props = (block.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",
);
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>`);
}
}
formParts.push(`<button type="submit" class="pb-btn-base pb-btn-size-md pb-btn-variant-solid-primary">제출</button>`);
formParts.push(`</form>`);
return formParts.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;" />`;
}
if (block.type === "list") {
const props: any = block.props ?? {};
const ordered = Boolean(props.ordered);
const Tag = ordered ? "ol" : "ul";
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);
}
}
}
if (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}>`;
}
if (block.type === "formInput") {
const props: any = block.props ?? {};
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
const label =
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
const type = props.inputType === "email" ? "email" : "text";
const required = props.required ? " required" : "";
return [
'<div class="pb-form-field">',
`<label class="pb-form-label">${escapeHtml(label)}</label>`,
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
label,
)}"${required} />`,
"</div>",
].join("");
}
if (block.type === "formSelect") {
const props: any = block.props ?? {};
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
const label =
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
const required = props.required ? " required" : "";
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
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}>`);
for (const opt of options) {
parts.push(
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
);
}
parts.push("</select>");
parts.push("</div>");
return parts.join("");
}
if (block.type === "formCheckbox") {
const props: any = block.props ?? {};
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
const label =
typeof props.groupLabel === "string" && props.groupLabel.trim() !== ""
? props.groupLabel
: name;
const options = Array.isArray(props.options) ? props.options : [];
const parts: string[] = [];
parts.push('<div class="pb-form-field">');
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
for (const opt of options) {
parts.push(
`<label class="pb-form-option"><input type="checkbox" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
}
parts.push("</div>");
return parts.join("");
}
if (block.type === "formRadio") {
const props: any = block.props ?? {};
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
const label =
typeof props.groupLabel === "string" && props.groupLabel.trim() !== ""
? props.groupLabel
: name;
const options = Array.isArray(props.options) ? props.options : [];
const parts: string[] = [];
parts.push('<div class="pb-form-field">');
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
for (const opt of options) {
parts.push(
`<label class="pb-form-option"><input type="radio" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
}
parts.push("</div>");
return parts.join("");
}
return "";
};
const bodyParts: string[] = [];
if (rootBlocks.length > 0) {
bodyParts.push('<section class="pb-root"><div class="pb-root-inner">');
for (const block of rootBlocks) {
bodyParts.push(renderBlock(block));
}
bodyParts.push("</div></section>");
}
for (const section of sectionBlocks) {
const props = (section.props ?? {}) as SectionBlockProps;
const columns =
Array.isArray(props.columns) && props.columns.length > 0
? 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 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(" ");
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);
bodyParts.push('<div class="pb-section-column">');
for (const block of columnBlocks) {
bodyParts.push(renderBlock(block));
}
bodyParts.push("</div>");
}
bodyParts.push("</div></div></section>");
}
const bodyContent = bodyParts.join("\n");
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${pageTitle}</title>
<link rel="stylesheet" href="./builder.css" />${headExtra}
</head>
<body style="${bodyStyle}">
<main>
<div id="app-root" style="${canvasStyle}">
${bodyContent}
</div>
</main>
<script src="./main.js"></script>${trackingHtml}
</body>
</html>`;
};
export async function POST(request: Request) {
let body: ExportRequestBody;
try {
body = (await request.json()) as ExportRequestBody;
} catch {
return NextResponse.json({ message: "잘못된 JSON 요청입니다." }, { status: 400 });
}
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
const projectConfig = body.projectConfig;
let html = buildStaticHtml(blocks, projectConfig);
const zip = new JSZip();
const imageIds = new Set<string>();
const imageRegex = /\/api\/image\/([^"'>\s]+)/g;
let match: RegExpExecArray | null;
// eslint-disable-next-line no-cond-assign
while ((match = imageRegex.exec(html)) !== null) {
if (match[1]) {
imageIds.add(match[1]);
}
}
for (const id of imageIds) {
const filePath = path.join(UPLOAD_DIR, id);
try {
const bytes = await fs.readFile(filePath);
zip.file(`images/${id}`, bytes);
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const srcRegex = new RegExp(`/api/image/${escapedId}`, "g");
html = html.replace(srcRegex, `./images/${id}`);
} catch {
// 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다.
}
}
zip.file("index.html", html);
try {
const cssContent = await fs.readFile(BUILDER_CSS_PATH, "utf8");
zip.file("builder.css", cssContent);
} catch {
// CSS 파일이 없어도 내보내기가 가능하도록 한다.
zip.file("builder.css", "/* builder.css 를 찾을 수 없습니다. */\n");
}
const mainJs = `console.log("Page Builder static export loaded");`;
zip.file("main.js", mainJs);
const zipArrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
return new NextResponse(zipArrayBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename=page-builder-export.zip`,
},
});
}
+97
View File
@@ -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", "image", ":id"] 형태를 기대한다.
const last = segments[segments.length - 1];
if (last && last !== "image") {
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 타입은 png 로 두되, 가능하면 Asset 메타데이터에서 실제 타입을 읽어온다.
let mimeType = "image/png";
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 });
}
}
+89
View File
@@ -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: "image",
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/image/${id}`,
},
{ status: 201 },
);
} catch (error) {
console.error("이미지 업로드 처리 중 오류", error);
return NextResponse.json({ message: "이미지 업로드 중 오류가 발생했습니다." }, { status: 500 });
}
}
+31 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import {
DndContext,
DragOverlay,
@@ -10,7 +10,7 @@ import {
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { rectIntersection } from "@dnd-kit/core";
import type { Block, ButtonBlockProps, ImageBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { Block, ButtonBlockProps, ImageBlockProps, ProjectConfig, TextBlockProps } from "@/features/editor/state/editorStore";
interface EditorCanvasProps {
blocks: Block[];
@@ -25,6 +25,7 @@ interface EditorCanvasProps {
handleDragStart: (event: DragStartEvent) => void;
handleDragEnd: (event: DragEndEvent) => void;
handleDragCancel: () => void;
projectConfig: ProjectConfig;
}
export function EditorCanvas(props: EditorCanvasProps) {
@@ -38,8 +39,30 @@ export function EditorCanvas(props: EditorCanvasProps) {
handleDragStart,
handleDragEnd,
handleDragCancel,
projectConfig,
} = props;
const canvasInnerStyle: CSSProperties = {};
const preset = projectConfig.canvasPreset ?? "full";
const widthPx =
typeof projectConfig.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
? projectConfig.canvasWidthPx
: null;
if (widthPx != null) {
canvasInnerStyle.maxWidth = `${widthPx}px`;
} else if (preset === "mobile") {
canvasInnerStyle.maxWidth = "390px";
} else if (preset === "tablet") {
canvasInnerStyle.maxWidth = "768px";
} else if (preset === "desktop") {
canvasInnerStyle.maxWidth = "1200px";
}
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"
@@ -49,6 +72,11 @@ export function EditorCanvas(props: EditorCanvasProps) {
onCanvasEmptyClick();
}
}}
>
<div
data-testid="editor-canvas-inner"
className="mx-auto w-full flex flex-col gap-2"
style={canvasInnerStyle}
>
{blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
@@ -76,6 +104,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
</DndContext>
)}
</div>
</div>
);
}
@@ -48,7 +48,32 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
/>
</label>
{checkboxProps.groupLabelMode === "image" && (
{checkboxProps.groupLabelMode === "image" && (() => {
const source: "url" | "upload" =
checkboxProps.groupLabelImageSource ??
(checkboxProps.groupLabelImageUrl && checkboxProps.groupLabelImageUrl.startsWith("/api/image/")
? "upload"
: "url");
return (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={source}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelImageSource: e.target.value,
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
</select>
</label>
{source === "url" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<input
@@ -63,6 +88,52 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
</label>
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </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, {
groupLabelImageUrl: servedUrl,
groupLabelImageSource: "upload",
} as any);
} catch (error) {
console.error("체크박스 그룹 라벨 이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
)}
</div>
);
})()}
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
@@ -99,6 +170,27 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
</button>
</div>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={(() => {
if (checkboxProps.optionImageSource) return checkboxProps.optionImageSource;
const first = (((checkboxProps as any).options ?? []) as any[])[0];
if (first?.labelImageUrl && first.labelImageUrl.startsWith("/api/image/")) return "upload";
return "url";
})()}
onChange={(e) =>
updateBlock(selectedBlockId, {
optionImageSource: e.target.value,
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
</select>
</label>
{(((checkboxProps as any).options ?? []) as any[]).map((opt, index) => (
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
<div className="flex items-center gap-1">
@@ -146,6 +238,14 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
</button>
</div>
<div className="flex flex-col gap-1">
{(() => {
const source: "url" | "upload" =
checkboxProps.optionImageSource ??
(opt.labelImageUrl && opt.labelImageUrl.startsWith("/api/image/") ? "upload" : "url");
return (
<>
{source === "url" && (
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="옵션 이미지 URL (선택)"
@@ -161,6 +261,58 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
} as any);
}}
/>
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </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}`;
const next = [...(((checkboxProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
labelImageUrl: servedUrl,
};
updateBlock(selectedBlockId, {
options: next,
optionImageSource: "upload",
} as any);
} catch (error) {
console.error("체크박스 옵션 라벨 이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
)}
</>
);
})()}
</div>
</div>
))}
@@ -48,7 +48,32 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
</label>
{radioProps.groupLabelMode === "image" && (
{radioProps.groupLabelMode === "image" && (() => {
const source: "url" | "upload" =
radioProps.groupLabelImageSource ??
(radioProps.groupLabelImageUrl && radioProps.groupLabelImageUrl.startsWith("/api/image/")
? "upload"
: "url");
return (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={source}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelImageSource: e.target.value,
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
</select>
</label>
{source === "url" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<input
@@ -63,6 +88,52 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
</label>
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </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, {
groupLabelImageUrl: servedUrl,
groupLabelImageSource: "upload",
} as any);
} catch (error) {
console.error("라디오 그룹 라벨 이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
)}
</div>
);
})()}
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
@@ -99,6 +170,27 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
</button>
</div>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={(() => {
if (radioProps.optionImageSource) return radioProps.optionImageSource;
const first = (((radioProps as any).options ?? []) as any[])[0];
if (first?.labelImageUrl && first.labelImageUrl.startsWith("/api/image/")) return "upload";
return "url";
})()}
onChange={(e) =>
updateBlock(selectedBlockId, {
optionImageSource: e.target.value,
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
</select>
</label>
{(((radioProps as any).options ?? []) as any[]).map((opt, index) => (
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
<div className="flex items-center gap-1">
@@ -146,6 +238,14 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
</button>
</div>
<div className="flex flex-col gap-1">
{(() => {
const source: "url" | "upload" =
radioProps.optionImageSource ??
(opt.labelImageUrl && opt.labelImageUrl.startsWith("/api/image/") ? "upload" : "url");
return (
<>
{source === "url" && (
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="옵션 이미지 URL (선택)"
@@ -161,6 +261,58 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
/>
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </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}`;
const next = [...(((radioProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
labelImageUrl: servedUrl,
};
updateBlock(selectedBlockId, {
options: next,
optionImageSource: "upload",
} as any);
} catch (error) {
console.error("라디오 옵션 라벨 이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
)}
</>
);
})()}
</div>
</div>
))}
+96 -6
View File
@@ -38,6 +38,7 @@ import type {
FormCheckboxBlockProps,
FormRadioBlockProps,
ListItemNode,
ProjectConfig,
} from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
@@ -95,6 +96,9 @@ export default function EditorPage() {
const redo = useEditorStore((state) => state.redo);
const removeBlock = useEditorStore((state) => state.removeBlock);
const duplicateBlock = useEditorStore((state) => state.duplicateBlock);
const projectConfig = useEditorStore(
(state) => (state as any).projectConfig as ProjectConfig,
);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
@@ -115,12 +119,53 @@ export default function EditorPage() {
const sensors = useSensors(useSensor(PointerSensor));
const editorMainStyle: CSSProperties = {};
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
editorMainStyle.backgroundColor = projectConfig.bodyBgColorHex;
}
const startEditing = (id: string, initialText: string) => {
selectBlock(id);
setEditingBlockId(id);
setEditingText(initialText);
};
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) {
console.error("정적 파일 내보내기 실패", await response.text().catch(() => ""));
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("정적 파일 내보내기 중 오류", error);
}
};
const commitEditing = () => {
if (!editingBlockId) return;
updateBlock(editingBlockId, { text: editingText });
@@ -545,7 +590,7 @@ export default function EditorPage() {
});
return (
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
<main className="min-h-screen flex flex-col" style={editorMainStyle}>
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
<div className="flex items-baseline gap-3">
<h1 className="text-xl font-semibold">Page Editor</h1>
@@ -589,6 +634,16 @@ export default function EditorPage() {
>
JSON /
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
onClick={() => {
setMenuOpen(false);
void handleExportZip();
}}
>
(ZIP)
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
@@ -621,6 +676,7 @@ export default function EditorPage() {
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragCancel={handleDragCancel}
projectConfig={projectConfig}
/>
<PropertiesSidebar
blocks={blocks}
@@ -1232,7 +1288,7 @@ function SortableEditorBlock({
<img
src={radioProps.groupLabelImageUrl}
alt={radioProps.groupLabel || "폼 그룹 타이틀"}
className="h-4 w-auto text-slate-200"
className="inline-block max-w-full h-auto"
/>
) : (
<span className="text-slate-200">{radioProps.groupLabel}</span>
@@ -1309,7 +1365,7 @@ function SortableEditorBlock({
<img
src={checkboxProps.groupLabelImageUrl}
alt={checkboxProps.groupLabel || "폼 그룹 타이틀"}
className="h-4 w-auto text-slate-200"
className="inline-block max-w-full h-auto"
/>
) : (
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
@@ -1416,18 +1472,52 @@ function SortableEditorBlock({
{block.type === "image" && (() => {
const imageProps = block.props as ImageBlockProps;
const hasSrc = imageProps.src.trim().length > 0;
const align = imageProps.align ?? "center";
const alignClass =
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
const containerStyle: React.CSSProperties = {};
const widthMode = imageProps.widthMode ?? "auto";
if (widthMode === "fixed" && typeof imageProps.widthPx === "number" && imageProps.widthPx > 0) {
containerStyle.width = `${imageProps.widthPx}px`;
}
const imageStyle: React.CSSProperties = {
maxWidth: "100%",
height: "auto",
};
const radiusToken = imageProps.borderRadius ?? "md";
const fallbackRadiusPx =
radiusToken === "none"
? 0
: radiusToken === "sm"
? 4
: radiusToken === "lg"
? 16
: radiusToken === "full"
? 9999
: 8;
const radiusPx =
typeof imageProps.borderRadiusPx === "number" && imageProps.borderRadiusPx >= 0
? imageProps.borderRadiusPx
: fallbackRadiusPx;
imageStyle.borderRadius = `${radiusPx}px`;
return (
<div className="w-full border border-dashed border-slate-700 bg-slate-900/40 rounded flex items-center justify-center overflow-hidden">
<div
className={`w-full border border-dashed border-slate-700 bg-slate-900/40 rounded flex items-center overflow-hidden ${alignClass}`}
>
{hasSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageProps.src}
alt={imageProps.alt}
className="max-w-full h-auto object-contain"
className="object-contain"
style={{ ...containerStyle, ...imageStyle }}
/>
) : (
<span className="text-[10px] text-slate-500 px-2 py-4">
URL을 .
URL .
</span>
)}
</div>
+101 -10
View File
@@ -10,8 +10,41 @@ export type ImagePropertiesPanelProps = {
};
export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock }: ImagePropertiesPanelProps) {
const source: "url" | "upload" =
imageProps.sourceType === "asset" || (imageProps.src && imageProps.src.startsWith("/api/image/"))
? "upload"
: "url";
return (
<>
<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>
{source === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> URL</span>
@@ -20,11 +53,63 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
aria-label="이미지 URL"
value={imageProps.src}
onChange={(e) => {
updateBlock(selectedBlockId, { src: e.target.value } as any);
const value = e.target.value;
updateBlock(selectedBlockId, {
src: value,
sourceType: "externalUrl",
assetId: 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="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, {
src: servedUrl,
sourceType: "asset",
assetId: data.id,
} as any);
} catch (error) {
console.error("이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
</div>
)}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
@@ -104,24 +189,30 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
<NumericPropertyControl
label="모서리 둥글기"
value={(() => {
if (typeof imageProps.borderRadiusPx === "number") {
return imageProps.borderRadiusPx;
}
const r = imageProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
// 토큰만 있는 기존 데이터의 경우, 토큰별 대표값으로 초기화한다.
return r === "none" ? 0 : r === "sm" ? 20 : r === "lg" ? 120 : r === "full" ? 180 : 60;
})()}
min={0}
max={8}
max={200}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
{ id: "sm", label: "작게", value: 20 },
{ id: "md", label: "보통", value: 60 },
{ id: "lg", label: "크게", value: 120 },
{ id: "full", label: "완전 둥글게", value: 180 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
// 0~50 범위를 토큰으로 매핑한다.
const nextToken =
v <= 0 ? "none" : v <= 30 ? "sm" : v <= 90 ? "md" : v <= 150 ? "lg" : "full";
updateBlock(selectedBlockId, {
borderRadius: next,
borderRadius: nextToken,
borderRadiusPx: v,
} as any);
}}
/>
@@ -0,0 +1,134 @@
"use client";
import { useEditorStore } from "@/features/editor/state/editorStore";
import type { CanvasPreset, ProjectConfig } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
export function ProjectPropertiesPanel() {
const projectConfig = useEditorStore((state) => (state as any).projectConfig as ProjectConfig);
const updateProjectConfig = useEditorStore(
(state) => (state as any).updateProjectConfig as (partial: Partial<ProjectConfig>) => void,
);
const handleChangePreset = (preset: CanvasPreset) => {
if (preset === "mobile") {
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 390 });
} else if (preset === "tablet") {
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 768 });
} else if (preset === "desktop") {
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: 1200 });
} else if (preset === "full") {
updateProjectConfig({ canvasPreset: preset, canvasWidthPx: undefined });
} else {
updateProjectConfig({ canvasPreset: preset });
}
};
const handleChangeCanvasWidth = (value: number) => {
if (value === 390) {
handleChangePreset("mobile");
} else if (value === 768) {
handleChangePreset("tablet");
} else if (value === 1200) {
handleChangePreset("desktop");
} else {
updateProjectConfig({ canvasPreset: "custom", canvasWidthPx: value });
}
};
return (
<div className="space-y-4 text-xs text-slate-200">
<h3 className="text-sm font-medium text-slate-100"> </h3>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </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={projectConfig.title}
onChange={(e) => updateProjectConfig({ title: e.target.value })}
/>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> (slug)</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="프로젝트 주소 (slug)"
value={projectConfig.slug}
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
/>
</label>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="캔버스 너비"
unitLabel="(px)"
value={projectConfig.canvasWidthPx ?? 1024}
min={320}
max={1920}
step={10}
presets={[
{ id: "mobile", label: "모바일 (390px)", value: 390 },
{ id: "tablet", label: "태블릿 (768px)", value: 768 },
{ id: "desktop", label: "데스크톱 (1200px)", value: 1200 },
]}
onChangeValue={handleChangeCanvasWidth}
/>
</div>
<div className="space-y-1">
<ColorPickerField
label="캔버스 배경색"
ariaLabelColorInput="캔버스 배경색"
ariaLabelHexInput="캔버스 배경색 HEX"
value={projectConfig.canvasBgColorHex ?? "#020617"}
onChange={(hex) => updateProjectConfig({ canvasBgColorHex: hex })}
palette={TEXT_COLOR_PALETTE}
/>
</div>
<div className="space-y-1">
<ColorPickerField
label="페이지 배경색"
ariaLabelColorInput="페이지 배경색"
ariaLabelHexInput="페이지 배경색 HEX"
value={projectConfig.bodyBgColorHex ?? "#020617"}
onChange={(hex) => updateProjectConfig({ bodyBgColorHex: hex })}
palette={TEXT_COLOR_PALETTE}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> head HTML</span>
<textarea
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500 min-h-[72px]"
aria-label="페이지 head HTML"
value={projectConfig.headHtml ?? ""}
onChange={(e) => updateProjectConfig({ headHtml: e.target.value })}
placeholder="예: &lt;meta name=&quot;description&quot; content=&quot;...&quot; /&gt;"
/>
</label>
</div>
<div className="space-y-1">
<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] font-mono outline-none focus:border-sky-500 min-h-[72px]"
aria-label="추적 스크립트"
value={projectConfig.trackingScript ?? ""}
onChange={(e) => updateProjectConfig({ trackingScript: e.target.value })}
placeholder="예: &lt;script&gt;/* GA, Pixel 코드 */&lt;/script&gt;"
/>
</label>
</div>
</div>
);
}
+2 -1
View File
@@ -12,6 +12,7 @@ import { FormSelectPropertiesPanel } from "../forms/FormSelectPropertiesPanel";
import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPanel";
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
import { FormControllerPanel } from "../forms/FormControllerPanel";
import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel";
interface PropertiesSidebarProps {
blocks: Block[];
@@ -208,7 +209,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
})()}
</div>
) : (
<p className="text-xs text-slate-500"> .</p>
<ProjectPropertiesPanel />
)}
</aside>
);
+50 -2
View File
@@ -1,19 +1,67 @@
"use client";
import type { CSSProperties } from "react";
import Link from "next/link";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
export default function PreviewPage() {
const blocks = useEditorStore((state) => state.blocks);
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
const canvasStyle: CSSProperties = {};
const preset = projectConfig?.canvasPreset ?? "full";
const mainStyle: CSSProperties = {};
if (projectConfig?.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
mainStyle.backgroundColor = projectConfig.bodyBgColorHex;
}
const widthPx =
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
? projectConfig.canvasWidthPx
: null;
if (widthPx != null) {
canvasStyle.maxWidth = `${widthPx}px`;
} else if (preset === "mobile") {
canvasStyle.maxWidth = "390px";
} else if (preset === "tablet") {
canvasStyle.maxWidth = "768px";
} else if (preset === "desktop") {
canvasStyle.maxWidth = "1200px";
}
if (projectConfig?.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
canvasStyle.backgroundColor = projectConfig.canvasBgColorHex;
}
return (
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50" style={mainStyle}>
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
<div>
<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>
</header>
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링 */}
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링하되, projectConfig 에 따라 최대 폭을 제한한다. */}
<section className="flex flex-1 overflow-auto">
<div className="flex-1 flex justify-center px-4 py-6">
<div
data-testid="preview-canvas-inner"
className="w-full"
style={canvasStyle}
>
<PublicPageRenderer blocks={blocks} />
</div>
</div>
</section>
</main>
);
}
@@ -356,6 +356,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const optionTextStyle: CSSProperties = {};
const groupTextStyle: CSSProperties = {};
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
const groupLabelMode = props.groupLabelMode ?? "text";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
groupStyle.width = pxToEm(props.widthPx);
@@ -425,7 +426,17 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
.join(" ")}
style={groupStyle}
>
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={props.groupLabelImageUrl}
alt={props.groupLabel || "폼 그룹 타이틀"}
className="inline-block max-w-full h-auto"
style={groupTextStyle}
/>
) : (
<span style={groupTextStyle}>{props.groupLabel}</span>
)}
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={optionsStyle}>
{props.options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-1">
@@ -433,9 +444,19 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
type="checkbox"
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || props.groupLabel || "체크박스 옵션"}
className="inline-block max-w-full h-auto"
style={optionTextStyle}
/>
) : (
<span data-testid="preview-form-checkbox-option" style={optionTextStyle}>
{opt.label}
</span>
)}
</label>
))}
</div>
@@ -451,6 +472,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const optionTextStyle: CSSProperties = {};
const groupTextStyle: CSSProperties = {};
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
const groupLabelMode = props.groupLabelMode ?? "text";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
groupStyle.width = pxToEm(props.widthPx);
@@ -520,7 +542,17 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
.join(" ")}
style={groupStyle}
>
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={props.groupLabelImageUrl}
alt={props.groupLabel || "폼 그룹 타이틀"}
className="inline-block max-w-full h-auto"
style={groupTextStyle}
/>
) : (
<span style={groupTextStyle}>{props.groupLabel}</span>
)}
<div className="flex flex-col" data-testid="preview-form-radio-options" style={optionsStyle}>
{props.options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-1">
@@ -529,9 +561,19 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
name={props.formFieldName}
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || props.groupLabel || "라디오 옵션"}
className="inline-block max-w-full h-auto"
style={optionTextStyle}
/>
) : (
<span data-testid="preview-form-radio-option" style={optionTextStyle}>
{opt.label}
</span>
)}
</label>
))}
</div>
@@ -952,7 +994,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
<img
src={field.groupLabelImageUrl}
alt={field.label}
className="h-4 w-auto"
className="max-h-10 w-auto max-w-[200px]"
/>
) : (
<span>{field.label}</span>
@@ -986,7 +1028,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
<img
src={field.groupLabelImageUrl}
alt={field.label}
className="h-4 w-auto"
className="max-h-10 w-auto max-w-[200px] md:max-w-[300px] lg:max-w-[400px]"
/>
) : (
<span>{field.label}</span>
@@ -1005,7 +1047,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
<img
src={opt.labelImageUrl}
alt={opt.label || field.label || "라디오 옵션"}
className="h-4 w-auto"
className="h-5 w-auto max-w-[120px]"
/>
) : (
<span>{opt.label}</span>
@@ -1066,7 +1108,20 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
}
const radiusToken = props.borderRadius ?? "md";
const radiusPx = radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4;
const fallbackRadiusPx =
radiusToken === "none"
? 0
: radiusToken === "sm"
? 4
: radiusToken === "lg"
? 16
: radiusToken === "full"
? 9999
: 8;
const radiusPx =
typeof (props as any).borderRadiusPx === "number" && (props as any).borderRadiusPx >= 0
? (props as any).borderRadiusPx
: fallbackRadiusPx;
imageStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
return (
+43
View File
@@ -125,6 +125,12 @@ export interface ImageBlockProps {
widthPx?: number;
// 모서리 둥글기
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 모서리 둥글기 px 값 (이미지에 한해서는 연속적인 px 단위 조정을 지원한다)
borderRadiusPx?: number;
// 이미지 소스 타입: 업로드된 에셋 또는 외부 URL
sourceType?: "asset" | "externalUrl";
// 업로드된 에셋인 경우 Asset.id 를 저장해둔다.
assetId?: string | null;
}
// 구분선 블록 속성
@@ -553,6 +559,8 @@ export interface FormCheckboxBlockProps extends FormFieldStyleProps {
// 그룹 타이틀 자체도 텍스트/이미지 모드를 가질 수 있도록 분리한다.
groupLabelMode?: FormLabelMode;
groupLabelImageUrl?: string;
groupLabelImageSource?: "url" | "upload";
optionImageSource?: "url" | "upload";
}
// 폼 라디오 그룹 옵션/블록 속성
@@ -571,6 +579,8 @@ export interface FormRadioBlockProps extends FormFieldStyleProps {
// 라디오 그룹 타이틀의 텍스트/이미지 모드
groupLabelMode?: FormLabelMode;
groupLabelImageUrl?: string;
groupLabelImageSource?: "url" | "upload";
optionImageSource?: "url" | "upload";
}
// 폼 필드 설정
@@ -611,6 +621,19 @@ export interface FormBlockProps {
marginYPx?: number;
}
export type CanvasPreset = "mobile" | "tablet" | "desktop" | "full" | "custom";
export interface ProjectConfig {
title: string;
slug: string;
canvasPreset: CanvasPreset;
canvasWidthPx?: number;
canvasBgColorHex?: string;
bodyBgColorHex?: string;
headHtml?: string;
trackingScript?: string;
}
// 공통 블록 모델
export interface Block {
id: string;
@@ -639,6 +662,8 @@ export interface EditorState {
selectedListItemId?: string | null;
history: Block[][];
future: Block[][];
projectConfig: ProjectConfig;
updateProjectConfig: (partial: Partial<ProjectConfig>) => void;
addTextBlock: () => void;
addButtonBlock: () => void;
addImageBlock: () => void;
@@ -716,6 +741,24 @@ const createEditorState = (set: any, get: any): EditorState => ({
selectedListItemId: null,
history: [],
future: [],
projectConfig: {
title: "새 페이지",
slug: "my-landing",
canvasPreset: "full",
canvasBgColorHex: "#020617",
bodyBgColorHex: "#020617",
headHtml: "",
trackingScript: "",
},
updateProjectConfig: (partial: Partial<ProjectConfig>) => {
set((state: EditorState) => ({
projectConfig: {
...state.projectConfig,
...partial,
},
}));
},
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
addTextBlock: () => {
+85
View File
@@ -35,11 +35,42 @@
/* 에디터/프리뷰 공통 기본 타이포 설정 (em/rem 기반) */
body {
margin: 0;
padding: 0;
font-size: 1rem; /* 기본 16px 기준 */
line-height: 1.5; /* 읽기 좋은 기본 라인하이트 */
letter-spacing: 0em; /* 기본 글자 간격은 0em */
}
/* Static export root layout */
.pb-root {
display: block;
}
.pb-root-inner {
max-width: 72rem; /* ~1152px */
margin-inline: auto;
padding-inline: 1.5rem;
padding-block: 2.5rem;
}
.pb-section {
display: block;
}
.pb-section-inner {
padding-inline: 1.5rem;
}
.pb-section-columns {
display: flex;
gap: 2rem;
}
.pb-section-column {
flex: 1 1 0;
}
/* Text alignment */
.pb-text-left {
text-align: left;
@@ -298,3 +329,57 @@ body {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
/* Simple form styling for static export */
.pb-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 40rem;
}
.pb-form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.pb-form-label {
font-size: 0.75rem;
color: #e5e7eb;
}
.pb-form-option {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #e5e7eb;
}
.pb-input,
.pb-select,
.pb-textarea {
width: 100%;
border-radius: 0.375rem;
border: 1px solid #1f2937;
background-color: #020617;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
color: #e5e7eb;
box-sizing: border-box;
}
.pb-textarea {
min-height: 6rem;
}
/* List styling for static export */
.pb-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
line-height: var(--pb-leading-normal);
color: var(--pb-color-text-default);
}
+725
View File
@@ -0,0 +1,725 @@
import "dotenv/config";
import { describe, it, expect } from "vitest";
import JSZip from "jszip";
import { promises as fs } from "fs";
import path from "path";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
const BASE_URL = "http://localhost";
// /api/export 라우트 TDD:
// - blocks + projectConfig 를 받아 HTML/CSS/JS 가 포함된 ZIP 을 반환해야 한다.
// - index.html 의 <title> 은 projectConfig.title 을 사용해야 한다.
// - ZIP 안에 builder.css 와 main.js 파일이 포함되어야 한다.
describe("/api/export", () => {
it("POST /api/export 로 블록과 프로젝트 설정을 전송하면 기본 정적 ZIP 파일을 반환해야 한다", async () => {
const blocks: Block[] = [
{
id: "blk_test",
type: "text",
props: {
text: "ZIP 내보내기 테스트",
align: "left",
size: "base",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "내보내기 테스트 페이지",
slug: "export-test",
canvasPreset: "full",
canvasBgColorHex: "#ffffff",
bodyBgColorHex: "#111111",
};
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);
expect(res.headers.get("Content-Type")).toBe("application/zip");
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
const cssEntry = zip.file("builder.css");
const jsEntry = zip.file("main.js");
expect(indexEntry).toBeTruthy();
expect(cssEntry).toBeTruthy();
expect(jsEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<title>내보내기 테스트 페이지</title>");
expect(html).toContain("ZIP 내보내기 테스트");
// 캔버스 래퍼 배경색
expect(html).toContain("background-color:#ffffff");
// body 스타일: 배경색 + margin/padding 0
expect(html).toContain('style="background-color:#111111;margin:0;padding:0;"');
});
it("projectConfig.headHtml 과 trackingScript 는 index.html 의 head/body 에 그대로 포함되어야 한다", async () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
title: "헤드/트래킹 테스트",
slug: "head-tracking-test",
canvasPreset: "full",
headHtml: '<meta name="robots" content="noindex" />',
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\/script>',
};
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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// head 커스텀 HTML
expect(html).toContain('<meta name="robots" content="noindex" />');
// body 하단 추적 스크립트
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\/script>');
});
it("폼 블록과 컨트롤러 필드 블록들은 정적 HTML에서도 폼 요소로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
fieldIds: ["input_name", "select_plan"],
submitButtonId: null,
formWidthMode: "full",
},
} as any,
{
id: "input_name",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
required: true,
},
} as any,
{
id: "select_plan",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "폼 내보내기 테스트",
slug: "form-export-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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// form 요소와 기본 input/select 가 포함되어야 한다.
expect(html).toContain("<form");
expect(html).toContain("name=\"name\"");
expect(html).toContain("name=\"plan\"");
expect(html).toContain("<select");
});
it("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
const imageId = `test-image-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
await fs.mkdir(uploadDir, { recursive: true });
await fs.writeFile(path.join(uploadDir, imageId), Buffer.from("dummy-image"));
const blocks: Block[] = [
{
id: "blk_image",
type: "image",
props: {
src: `/api/image/${imageId}`,
alt: "테스트 이미지",
align: "center",
widthMode: "auto",
borderRadius: "md",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "이미지 내보내기 테스트",
slug: "image-export-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 buffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
const imageEntry = zip.file(`images/${imageId}`);
expect(imageEntry).toBeTruthy();
const imageBytes = await imageEntry!.async("nodebuffer");
expect(imageBytes.length).toBeGreaterThan(0);
const html = await zip.file("index.html")!.async("string");
expect(html).toContain(`src="./images/${imageId}"`);
expect(html).not.toContain(`/api/image/${imageId}`);
});
it("divider 블록은 정적 HTML에서 구분선 요소로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "divider_1",
type: "divider",
props: {
align: "center",
thickness: "medium",
widthMode: "full",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "divider 테스트",
slug: "divider-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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<hr");
expect(html).toContain("pb-divider");
});
it("list 블록은 정적 HTML에서 리스트 요소로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "list_1",
type: "list",
props: {
items: ["Item 1", "Item 2"],
ordered: false,
align: "left",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "list 테스트",
slug: "list-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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<ul");
expect(html).toContain("<li>Item 1</li>");
expect(html).toContain("<li>Item 2</li>");
});
it("formInput 단독 블록은 입력 필드로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "input_standalone",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
required: true,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formInput 단독 테스트",
slug: "form-input-standalone",
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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<input");
expect(html).toContain("name=\"name\"");
expect(html).toContain("이름");
});
it("formSelect 단독 블록은 셀렉트 박스로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "select_standalone",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formSelect 단독 테스트",
slug: "form-select-standalone",
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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("<select");
expect(html).toContain("<option value=\"basic\">Basic</option>");
expect(html).toContain("<option value=\"pro\">Pro</option>");
});
it("formCheckbox 단독 블록은 체크박스 그룹으로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "checkbox_standalone",
type: "formCheckbox",
props: {
groupLabel: "관심사",
formFieldName: "interests",
options: [
{ label: "뉴스", value: "news" },
{ label: "업데이트", value: "updates" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formCheckbox 단독 테스트",
slug: "form-checkbox-standalone",
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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("type=\"checkbox\"");
expect(html).toContain("뉴스");
expect(html).toContain("업데이트");
});
it("formRadio 단독 블록은 라디오 그룹으로 렌더되어야 한다", async () => {
const blocks: Block[] = [
{
id: "radio_standalone",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formRadio 단독 테스트",
slug: "form-radio-standalone",
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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("type=\"radio\"");
expect(html).toContain("Basic");
expect(html).toContain("Pro");
});
it("text 블록은 pb 텍스트 스타일 클래스를 포함해야 한다", async () => {
const blocks: Block[] = [
{
id: "text_styled",
type: "text",
props: {
text: "스타일 테스트",
align: "center",
size: "base",
fontSizeMode: "scale",
fontSizeScale: "lg",
lineHeightMode: "scale",
lineHeightScale: "relaxed",
fontWeightMode: "scale",
fontWeightScale: "semibold",
colorMode: "palette",
colorPalette: "strong",
underline: true,
italic: true,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "텍스트 스타일 테스트",
slug: "text-style-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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("스타일 테스트");
expect(html).toContain("pb-text-center");
expect(html).toContain("pb-text-lg");
expect(html).toContain("pb-leading-relaxed");
expect(html).toContain("pb-font-semibold");
expect(html).toContain("pb-text-color-strong");
expect(html).toContain("pb-underline");
expect(html).toContain("pb-italic");
});
it("버튼 블록은 버튼 스타일 클래스를 포함해야 한다", async () => {
const blocks: Block[] = [
{
id: "button_styled",
type: "button",
props: {
label: "클릭",
href: "#",
align: "center",
size: "md",
variant: "solid",
colorPalette: "primary",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "버튼 스타일 테스트",
slug: "button-style-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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("클릭");
expect(html).toContain("pb-text-center");
expect(html).toContain("pb-btn-base");
expect(html).toContain("pb-btn-size-md");
expect(html).toContain("pb-btn-variant-solid-primary");
});
it("section 블록은 배경/패딩 토큰을 pb-section 클래스 조합으로 반영해야 한다", async () => {
const blocks: Block[] = [
{
id: "sec_1",
type: "section",
props: {
background: "primary",
paddingY: "lg",
columns: [
{
id: "sec_1_col_1",
span: 12,
},
],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
},
} as any,
{
id: "sec_1_text",
type: "text",
props: {
text: "섹션 텍스트",
align: "left",
size: "base",
},
sectionId: "sec_1",
columnId: "sec_1_col_1",
} as any,
];
const projectConfig: ProjectConfig = {
title: "섹션 스타일 테스트",
slug: "section-style-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");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("pb-section");
expect(html).toContain("pb-section-bg-primary");
expect(html).toContain("pb-section-py-lg");
});
it("builder.css 는 리스트/섹션 유틸리티 클래스를 포함해야 한다", async () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
title: "CSS 유틸리티 테스트",
slug: "css-utility-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 cssEntry = zip.file("builder.css");
expect(cssEntry).toBeTruthy();
const css = await cssEntry!.async("string");
expect(css).toContain(".pb-section-bg-default");
expect(css).toContain(".pb-section-py-md");
expect(css).toContain(".pb-list");
});
});
+107
View File
@@ -17,6 +17,59 @@ test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트'
await expect(canvas.getByText("새 텍스트")).toBeVisible();
});
test("선택된 블록이 없을 때 우측 패널에서 프로젝트 설정 패널이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
const sidebar = page.getByTestId("properties-sidebar");
await expect(sidebar.getByText("프로젝트 설정")).toBeVisible();
await expect(sidebar.getByLabel("프로젝트 제목")).toBeVisible();
await expect(sidebar.getByLabel("프로젝트 주소 (slug)")).toBeVisible();
await expect(sidebar.getByRole("combobox", { name: "캔버스 너비 프리셋" })).toBeVisible();
await expect(sidebar.getByLabel("페이지 배경색 HEX")).toBeVisible();
});
test("프로젝트 설정 패널에서 캔버스 배경색을 변경하면 에디터 캔버스 배경에 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
const sidebar = page.getByTestId("properties-sidebar");
const inner = page.getByTestId("editor-canvas-inner");
const beforeBg = await inner.evaluate((el) => {
const s = window.getComputedStyle(el as HTMLElement);
return s.backgroundColor;
});
const bgHexInput = sidebar.getByLabel("캔버스 배경색 HEX");
await bgHexInput.fill("#ff0000");
const afterBg = await inner.evaluate((el) => {
const s = window.getComputedStyle(el as HTMLElement);
return s.backgroundColor;
});
expect(afterBg).not.toBe(beforeBg);
});
test("프로젝트 설정 패널에서 캔버스 프리셋을 변경하면 에디터 캔버스 너비가 달라져야 한다", async ({ page }) => {
await page.goto("/editor");
const inner = page.getByTestId("editor-canvas-inner");
const fullWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
const presetSelect = page.getByRole("combobox", { name: "캔버스 너비 프리셋" });
await presetSelect.selectOption("mobile");
const mobileWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
await presetSelect.selectOption("desktop");
const desktopWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
expect(mobileWidth).toBeLessThan(desktopWidth);
expect(desktopWidth).toBeLessThanOrEqual(fullWidth);
});
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
test.skip(process.env.CI === "true");
await page.goto("/editor");
@@ -414,6 +467,60 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
expect(parseFloat(updatedPadding.paddingBlock)).toBeGreaterThan(parseFloat(initialPadding.paddingBlock));
});
test("이미지 블록 고정 너비와 모서리 둥글기 스타일이 에디터에서 적용되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
await propertiesSidebar.getByLabel("이미지 URL").fill("https://picsum.photos/400");
await propertiesSidebar.getByLabel("너비 모드").selectOption("fixed");
await propertiesSidebar.getByLabel("고정 너비 (px) 커스텀 (px)").fill("300");
// 모서리 둥글기를 full 에 해당하는 값(8px)으로 설정한다.
await propertiesSidebar.getByLabel("모서리 둥글기 커스텀").fill("8");
const image = canvas.getByRole("img").first();
const styles = await image.evaluate((el) => {
const s = window.getComputedStyle(el as HTMLImageElement);
return {
width: s.width,
borderRadius: s.borderRadius,
};
});
const widthPx = parseFloat(styles.width);
expect(widthPx).toBeGreaterThan(200);
expect(widthPx).toBeLessThan(360);
// borderRadius 가 0보다 커야 한다.
expect(parseFloat(styles.borderRadius)).toBeGreaterThan(0);
});
test("에디터 메뉴에서 페이지 파일로 내보내기 (ZIP)를 클릭하면 ZIP 다운로드가 시작되어야 한다", async ({ page }) => {
await page.goto("/editor");
// 최소 한 개의 블록을 추가해 내보낼 데이터가 있도록 한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
// 헤더 메뉴를 연다.
await page.getByRole("button", { name: "메뉴 ▼" }).click();
const exportButton = page.getByRole("button", { name: "페이지 파일로 내보내기 (ZIP)" });
await expect(exportButton).toBeVisible();
const [download] = await Promise.all([
page.waitForEvent("download"),
exportButton.click(),
]);
const suggested = download.suggestedFilename();
expect(suggested).toMatch(/\.zip$/);
});
test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면 컬럼이 변경되어야 한다", async ({ page }) => {
test.skip(true, "섹션 컬럼 드래그 E2E는 불안정하여 임시 스킵");
await page.goto("/editor");
+183
View File
@@ -40,6 +40,21 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
});
test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => {
// 에디터에서 간단히 진입한 뒤 프리뷰로 이동한다.
await page.goto("/editor");
await page.getByRole("link", { name: "프리뷰 열기" }).click();
// 프리뷰 헤더와 함께 "에디터로 돌아가기" 링크가 보여야 한다.
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
await expect(page.getByRole("link", { name: "에디터로 돌아가기" })).toBeVisible();
// 링크를 클릭하면 다시 에디터로 돌아가야 한다.
await page.getByRole("link", { name: "에디터로 돌아가기" }).click();
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
});
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
@@ -275,6 +290,137 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
await expect(page.getByText("선택 필드")).toBeVisible();
});
test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
await propertiesSidebar.getByLabel("그룹 타이틀 타입").selectOption("image");
await propertiesSidebar.getByLabel("그룹 타이틀 이미지 소스").selectOption("upload");
await propertiesSidebar.getByLabel("그룹 타이틀", { exact: true }).fill("체크박스 라벨 이미지");
const fileInput = propertiesSidebar.getByLabel("그룹 타이틀 이미지 파일 업로드");
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\//);
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const previewImage = page.getByRole("img", { name: "체크박스 라벨 이미지" }).first();
await expect(previewImage).toBeVisible();
const previewSrc = await previewImage.getAttribute("src");
expect(previewSrc).toBe(editorSrc);
});
test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
const firstOptionLabel = propertiesSidebar.getByPlaceholder("라벨").first();
await firstOptionLabel.fill("라디오 옵션 이미지");
await propertiesSidebar.getByLabel("옵션 이미지 소스").selectOption("upload");
const fileInput = propertiesSidebar.getByLabel("옵션 이미지 파일 업로드").first();
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\//);
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const previewImage = page.getByRole("img", { name: "라디오 옵션 이미지" }).first();
await expect(previewImage).toBeVisible();
const previewSrc = await previewImage.getAttribute("src");
expect(previewSrc).toBe(editorSrc);
});
test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
// 첫 번째 옵션 라벨을 이미지 alt 텍스트로 사용할 값으로 설정한다.
const firstOptionLabel = propertiesSidebar.getByPlaceholder("라벨").first();
await firstOptionLabel.fill("체크박스 옵션 이미지");
await propertiesSidebar.getByLabel("옵션 이미지 소스").selectOption("upload");
const fileInput = propertiesSidebar.getByLabel("옵션 이미지 파일 업로드").first();
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\//);
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const previewImage = page.getByRole("img", { name: "체크박스 옵션 이미지" }).first();
await expect(previewImage).toBeVisible();
const previewSrc = await previewImage.getAttribute("src");
expect(previewSrc).toBe(editorSrc);
});
test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
await propertiesSidebar.getByLabel("그룹 타이틀 타입").selectOption("image");
await propertiesSidebar.getByLabel("그룹 타이틀 이미지 소스").selectOption("upload");
await propertiesSidebar.getByLabel("그룹 타이틀", { exact: true }).fill("라디오 라벨 이미지");
const fileInput = propertiesSidebar.getByLabel("그룹 타이틀 이미지 파일 업로드");
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\//);
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const previewImage = page.getByRole("img", { name: "라디오 라벨 이미지" }).first();
await expect(previewImage).toBeVisible();
const previewSrc = await previewImage.getAttribute("src");
expect(previewSrc).toBe(editorSrc);
});
test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
@@ -487,6 +633,43 @@ test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어
expect(computed.inlineWidth.endsWith("em")).toBeTruthy();
});
test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
// alt 텍스트를 먼저 설정해 에디터/프리뷰에서 이미지를 안정적으로 찾을 수 있게 한다.
await propertiesSidebar.getByLabel("대체 텍스트").fill("업로드 이미지");
// 이미지 소스를 파일 업로드 모드로 전환한다.
await propertiesSidebar.getByLabel("이미지 소스").selectOption("upload");
// 아직 구현되지 않은 이미지 업로드 인풋(aria-label: "이미지 파일 업로드")을 통해 로컬 파일을 업로드한다고 가정한다.
const fileInput = propertiesSidebar.getByLabel("이미지 파일 업로드");
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
// 에디터 캔버스에서 alt 텍스트 기준으로 이미지를 찾는다.
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\//);
// 프리뷰로 이동하면 동일한 /api/image/:id URL을 사용하는 이미지가 보여야 한다.
await page.getByRole("link", { name: "프리뷰 열기" }).click();
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
const previewImage = page.getByRole("img", { name: "업로드 이미지" }).first();
await expect(previewImage).toBeVisible();
const previewSrc = await previewImage.getAttribute("src");
expect(previewSrc).toBe(editorSrc);
});
test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
+1
View File
@@ -0,0 +1 @@
PNG TEST FIXTURE
+38
View File
@@ -19,6 +19,8 @@ import type {
DividerBlockProps,
ImageBlockProps,
SectionBlockProps,
ListLine,
ButtonBlockProps,
} from "@/features/editor/state/editorStore";
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
@@ -1188,4 +1190,40 @@ describe("editorStore", () => {
expect(updatedFormProps.fieldIds).toEqual(fieldIds);
expect(updatedFormProps.submitButtonId).toBe(submitButtonId);
});
it("projectConfig 기본값과 updateProjectConfig 액션으로 프로젝트 설정을 관리할 수 있어야 한다", () => {
const store = createEditorStore();
const stateAny = store.getState() as any;
expect(stateAny.projectConfig).toBeTruthy();
expect(stateAny.projectConfig.title).toBe("새 페이지");
expect(stateAny.projectConfig.slug).toBe("my-landing");
expect(stateAny.projectConfig.canvasPreset).toBe("full");
expect(stateAny.projectConfig.canvasBgColorHex).toBe("#020617");
expect(stateAny.projectConfig.bodyBgColorHex).toBe("#020617");
expect(stateAny.projectConfig.headHtml).toBe("");
expect(stateAny.projectConfig.trackingScript).toBe("");
stateAny.updateProjectConfig({
title: "랜딩 페이지",
slug: "landing-page",
canvasPreset: "desktop",
bodyBgColorHex: "#111111",
});
const next = store.getState() as any;
expect(next.projectConfig.title).toBe("랜딩 페이지");
expect(next.projectConfig.slug).toBe("landing-page");
expect(next.projectConfig.canvasPreset).toBe("desktop");
expect(next.projectConfig.bodyBgColorHex).toBe("#111111");
stateAny.updateProjectConfig({
canvasPreset: "custom",
canvasWidthPx: 1024,
});
const custom = store.getState() as any;
expect(custom.projectConfig.canvasPreset).toBe("custom");
expect(custom.projectConfig.canvasWidthPx).toBe(1024);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect } from "vitest";
import React from "react";
import ReactDOMServer from "react-dom/server";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import { buildStaticHtml } from "@/app/api/export/route";
describe("프리뷰와 정적 내보내기 일치 (고수준)", () => {
it("여러 블록이 섞인 페이지에서 핵심 텍스트는 프리뷰와 정적 HTML 모두에 존재해야 한다", () => {
const blocks: Block[] = [
{
id: "text_root",
type: "text",
props: {
text: "텍스트 블록",
align: "left",
size: "base",
},
} as any,
{
id: "btn_root",
type: "button",
props: {
label: "버튼 A",
href: "#",
align: "center",
},
} as any,
{
id: "img_root",
type: "image",
props: {
src: "/api/image/sample-id",
alt: "이미지 설명",
align: "center",
widthMode: "auto",
borderRadius: "md",
},
} as any,
{
id: "sec_1",
type: "section",
props: {
background: "primary",
paddingY: "lg",
columns: [
{
id: "sec_1_col_1",
span: 12,
},
],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
},
} as any,
{
id: "sec_1_text",
type: "text",
props: {
text: "섹션 안 텍스트",
align: "left",
size: "base",
},
sectionId: "sec_1",
columnId: "sec_1_col_1",
} as any,
{
id: "sec_1_btn",
type: "button",
props: {
label: "섹션 버튼",
href: "#",
align: "left",
},
sectionId: "sec_1",
columnId: "sec_1_col_1",
} as any,
{
id: "divider_1",
type: "divider",
props: {
align: "center",
thickness: "medium",
widthMode: "full",
},
} as any,
{
id: "list_1",
type: "list",
props: {
items: ["리스트 아이템 1", "리스트 아이템 2"],
ordered: false,
align: "left",
},
} as any,
{
id: "form_1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
// v1 fallback 필드를 사용해 양쪽 렌더러가 동일한 폼 필드를 가지도록 한다.
fields: [
{ id: "f_name", name: "name", label: "폼 이름", type: "text", required: true },
{ id: "f_email", name: "email", label: "폼 이메일", type: "email", required: true },
{ id: "f_msg", name: "message", label: "폼 메시지", type: "textarea", required: true },
],
fieldIds: [],
submitButtonId: null,
formWidthMode: "full",
},
} as any,
{
id: "input_standalone",
type: "formInput",
props: {
label: "입력(단독)",
formFieldName: "single_input",
required: true,
},
} as any,
{
id: "select_standalone",
type: "formSelect",
props: {
label: "선택(단독)",
formFieldName: "single_select",
options: [
{ label: "옵션 A", value: "a" },
{ label: "옵션 B", value: "b" },
],
required: false,
},
} as any,
{
id: "checkbox_standalone",
type: "formCheckbox",
props: {
groupLabel: "체크 그룹",
formFieldName: "checks",
options: [
{ label: "체크 A", value: "a" },
{ label: "체크 B", value: "b" },
],
required: false,
},
} as any,
{
id: "radio_standalone",
type: "formRadio",
props: {
groupLabel: "라디오 그룹",
formFieldName: "radios",
options: [
{ label: "라디오 A", value: "a" },
{ label: "라디오 B", value: "b" },
],
required: false,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "프리뷰-내보내기 고수준 테스트",
slug: "preview-export-parity",
canvasPreset: "full",
};
const previewHtml = ReactDOMServer.renderToString(
React.createElement(PublicPageRenderer, { blocks }),
);
const staticHtml = buildStaticHtml(blocks, projectConfig);
const importantStrings = [
"텍스트 블록",
"버튼 A",
"이미지 설명",
"섹션 안 텍스트",
"섹션 버튼",
"리스트 아이템 1",
"폼 이름",
"폼 이메일",
"폼 메시지",
"입력(단독)",
"선택(단독)",
"체크 A",
"라디오 A",
];
for (const text of importantStrings) {
expect(previewHtml).toContain(text);
expect(staticHtml).toContain(text);
}
});
});
@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image