diff --git a/package-lock.json b/package-lock.json index 6e6218b..e8c3606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8ad7d17..55bbda4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts new file mode 100644 index 0000000..bf153dd --- /dev/null +++ b/src/app/api/export/route.ts @@ -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, "'"); + +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, 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, 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, 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, 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 `

${escapeHtml(text)}

`; + } + + 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, 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, 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 ``; + } + + 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 `
${escapeAttr(alt)}
`; + } + + 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(`
`); + + 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(`
`); + formParts.push(``); + + if (fieldBlock.type === "formInput") { + const type = anyProps.inputType === "email" ? "email" : "text"; + const required = anyProps.required ? " required" : ""; + formParts.push( + ``, + ); + } else if (fieldBlock.type === "formSelect") { + const required = anyProps.required ? " required" : ""; + const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : []; + formParts.push(``); + } else if (fieldBlock.type === "formCheckbox") { + const options = Array.isArray(anyProps.options) ? anyProps.options : []; + for (const opt of options) { + formParts.push( + ``, + ); + } + } else if (fieldBlock.type === "formRadio") { + const options = Array.isArray(anyProps.options) ? anyProps.options : []; + for (const opt of options) { + formParts.push( + ``, + ); + } + } + + formParts.push(`
`); + } + } else if (fallbackFields.length > 0) { + for (const field of fallbackFields) { + const required = field.required ? " required" : ""; + const label = field.label ?? field.name; + formParts.push(`
`); + formParts.push(``); + if (field.type === "textarea") { + formParts.push( + ``, + ); + } else { + formParts.push( + ``, + ); + } + formParts.push(`
`); + } + } + + formParts.push(``); + formParts.push(`
`); + + 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 `
`; + } + + 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) => `
  • ${escapeHtml(text)}
  • `).join(""); + return `<${Tag} class="pb-list" style="text-align:${align};">${lis}`; + } + + 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 [ + '
    ', + ``, + ``, + "
    ", + ].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('
    '); + parts.push(``); + parts.push(`"); + parts.push("
    "); + + 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('
    '); + parts.push(``); + for (const opt of options) { + parts.push( + ``, + ); + } + parts.push("
    "); + + 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('
    '); + parts.push(``); + for (const opt of options) { + parts.push( + ``, + ); + } + parts.push("
    "); + + return parts.join(""); + } + + return ""; + }; + + const bodyParts: string[] = []; + + if (rootBlocks.length > 0) { + bodyParts.push('
    '); + for (const block of rootBlocks) { + bodyParts.push(renderBlock(block)); + } + bodyParts.push("
    "); + } + + 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 = { + 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 = { + 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(`
    `); + for (const col of columns) { + const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id); + bodyParts.push('
    '); + for (const block of columnBlocks) { + bodyParts.push(renderBlock(block)); + } + bodyParts.push("
    "); + } + bodyParts.push("
    "); + } + + const bodyContent = bodyParts.join("\n"); + + return ` + + + + + ${pageTitle} + ${headExtra} + + +
    +
    +${bodyContent} +
    +
    + ${trackingHtml} + +`; +}; + +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(); + 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`, + }, + }); +} diff --git a/src/app/api/image/[id]/route.ts b/src/app/api/image/[id]/route.ts new file mode 100644 index 0000000..5dff221 --- /dev/null +++ b/src/app/api/image/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/image/route.ts b/src/app/api/image/route.ts new file mode 100644 index 0000000..316a576 --- /dev/null +++ b/src/app/api/image/route.ts @@ -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/ 형태로 저장한다. + 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 }); + } +} diff --git a/src/app/editor/EditorCanvas.tsx b/src/app/editor/EditorCanvas.tsx index 6349621..35837c9 100644 --- a/src/app/editor/EditorCanvas.tsx +++ b/src/app/editor/EditorCanvas.tsx @@ -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 (
    - {blocks.length === 0 ? ( -
    - 왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요. -
    - ) : ( - - b.id)} - strategy={verticalListSortingStrategy} +
    + {blocks.length === 0 ? ( +
    + 왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요. +
    + ) : ( + - {renderBlocks(rootBlocks)} - - - {activeDragId && ( - b.id === activeDragId) ?? null} /> - )} - - - )} + b.id)} + strategy={verticalListSortingStrategy} + > + {renderBlocks(rootBlocks)} + + + {activeDragId && ( + b.id === activeDragId) ?? null} /> + )} + + + )} +
    ); } diff --git a/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx b/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx index 1903a46..7f116df 100644 --- a/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx +++ b/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx @@ -48,20 +48,91 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc /> - {checkboxProps.groupLabelMode === "image" && ( - - )} + {checkboxProps.groupLabelMode === "image" && (() => { + const source: "url" | "upload" = + checkboxProps.groupLabelImageSource ?? + (checkboxProps.groupLabelImageUrl && checkboxProps.groupLabelImageUrl.startsWith("/api/image/") + ? "upload" + : "url"); + + return ( +
    + + + {source === "url" && ( + + )} + + {source === "upload" && ( + + )} +
    + ); + })()}