이미지 파일 업로드 기능
This commit is contained in:
@@ -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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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`,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user