diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 618a488..0a9106c 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -10,7 +10,28 @@ import type { TextBlockProps, ButtonBlockProps, SectionBlockProps, + VideoBlockProps, + ListBlockProps, } from "@/features/editor/state/editorStore"; +import { + normalizeVideoSourceUrl, + resolveVideoPlatform, + buildVideoEmbedUrl, + computeVideoExportTokens, +} from "@/features/editor/utils/videoHelpers"; +import { computeTextPbTokens } from "@/features/editor/utils/textHelpers"; +import { computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers"; +import { computeImageExportStyles } from "@/features/editor/utils/imageHelpers"; +import { computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers"; +import { computeListExportTokens } from "@/features/editor/utils/listHelpers"; +import { + computeFormInputExportTokens, + computeFormSelectExportTokens, + computeFormCheckboxExportTokens, + computeFormRadioExportTokens, + computeFormBlockExportTokens, +} from "@/features/editor/utils/formHelpers"; +import { computeDividerExportTokens } from "@/features/editor/utils/dividerHelpers"; type ExportRequestBody = { blocks: Block[]; @@ -71,114 +92,43 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): 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 = "pb-text-color-strong"; - const inlineStyles: string[] = []; - - 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] ?? colorClass; - } else if ( - props.colorMode === "custom" && - typeof props.colorCustom === "string" && - props.colorCustom.trim() !== "" - ) { - inlineStyles.push(`color:${props.colorCustom.trim()}`); - } - - // 블록 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다. - if (typeof (props as any).backgroundColorCustom === "string" && (props as any).backgroundColorCustom.trim() !== "") { - inlineStyles.push(`background-color:${(props as any).backgroundColorCustom.trim()}`); - } - - let maxWidthClass = ""; - const maxWidthMode = props.maxWidthMode ?? "scale"; - const maxWidthScale = props.maxWidthScale ?? "none"; - if (maxWidthMode === "scale") { - if (maxWidthScale === "prose") { - maxWidthClass = "pb-text-maxw-prose"; - } else if (maxWidthScale === "narrow") { - maxWidthClass = "pb-text-maxw-narrow"; - } - } - - const decoClasses: string[] = []; - if (props.underline) decoClasses.push("pb-underline"); - if (props.strike) decoClasses.push("pb-line-through"); - if (props.italic) decoClasses.push("pb-italic"); + const tokens = computeTextPbTokens({ + text, + align: props.align ?? "left", + size: props.size ?? "base", + fontSizeMode: props.fontSizeMode ?? "scale", + fontSizeScale: props.fontSizeScale ?? undefined, + fontSizeCustom: props.fontSizeCustom ?? undefined, + lineHeightMode: props.lineHeightMode ?? "scale", + lineHeightScale: props.lineHeightScale ?? undefined, + lineHeightCustom: props.lineHeightCustom ?? undefined, + fontWeightMode: props.fontWeightMode ?? "scale", + fontWeightScale: props.fontWeightScale ?? undefined, + fontWeightCustom: props.fontWeightCustom ?? undefined, + colorMode: props.colorMode ?? undefined, + colorPalette: props.colorPalette ?? undefined, + colorCustom: props.colorCustom ?? undefined, + backgroundColorCustom: (props as any).backgroundColorCustom ?? undefined, + maxWidthMode: props.maxWidthMode ?? "scale", + maxWidthScale: props.maxWidthScale ?? undefined, + underline: props.underline ?? false, + strike: props.strike ?? false, + italic: props.italic ?? false, + }); const classes = [ - alignClass, - sizeClass, - leadingClass, - weightClass, - colorClass, - maxWidthClass, - "pb-whitespace-pre-wrap", - ...decoClasses, + tokens.alignClass, + tokens.sizeClass, + tokens.leadingClass, + tokens.weightClass, + tokens.colorClass, + tokens.maxWidthClass, + ...tokens.extraClasses, ] .filter(Boolean) .join(" "); - const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join(";")}"` : ""; + const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : ""; return `

${escapeHtml(text)}

`; } @@ -188,140 +138,128 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const href = typeof props.href === "string" ? props.href : "#"; const label = typeof props.label === "string" ? props.label : ""; - const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left"; - const alignClass = - align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left"; + const tokens = computeButtonPbTokens({ + align: props.align ?? "left", + size: props.size ?? "md", + variant: props.variant ?? "solid", + colorPalette: props.colorPalette ?? "primary", + borderRadius: props.borderRadius ?? "md", + fullWidth: props.fullWidth ?? false, + widthMode: props.widthMode ?? undefined, + widthPx: props.widthPx ?? undefined, + paddingX: props.paddingX ?? undefined, + paddingY: props.paddingY ?? undefined, + fillColorCustom: props.fillColorCustom ?? undefined, + strokeColorCustom: props.strokeColorCustom ?? undefined, + textColorCustom: props.textColorCustom ?? undefined, + }); - const sizeToken = props.size ?? "md"; - const sizeMap: Record, string> = { - xs: "pb-btn-size-xs", - sm: "pb-btn-size-sm", - md: "pb-btn-size-md", - lg: "pb-btn-size-lg", - xl: "pb-btn-size-xl", - }; - const sizeClass = sizeMap[sizeToken] ?? "pb-btn-size-md"; + const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : ""; - const variant = props.variant ?? "solid"; - const palette = props.colorPalette ?? "primary"; - const variantClass = `pb-btn-variant-${variant}-${palette}`; - - const radiusToken = props.borderRadius ?? "md"; - const radiusMap: Record, string> = { - none: "pb-btn-radius-none", - sm: "pb-btn-radius-sm", - md: "pb-btn-radius-md", - lg: "pb-btn-radius-lg", - full: "pb-btn-radius-full", - }; - const radiusClass = radiusMap[radiusToken] ?? ""; - - const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto"); - - const styleParts: string[] = []; - if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { - styleParts.push(`width:${props.widthPx}px`); - } - if (typeof props.paddingX === "number") { - styleParts.push(`padding-left:${props.paddingX}px`); - styleParts.push(`padding-right:${props.paddingX}px`); - } - if (typeof props.paddingY === "number") { - styleParts.push(`padding-top:${props.paddingY}px`); - styleParts.push(`padding-bottom:${props.paddingY}px`); - } - if (props.fillColorCustom && props.fillColorCustom.trim() !== "") { - styleParts.push(`background-color:${props.fillColorCustom}`); - } - if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") { - styleParts.push(`border-color:${props.strokeColorCustom}`); - } - if (props.textColorCustom && props.textColorCustom.trim() !== "") { - styleParts.push(`color:${props.textColorCustom}`); - } - const styleAttr = styleParts.length > 0 ? ` style="${styleParts.join(";")}"` : ""; - - const btnClasses = ["pb-btn-base", sizeClass, variantClass, radiusClass] + const btnClasses = ["pb-btn-base", tokens.sizeClass, tokens.variantClass, tokens.radiusClass] .filter(Boolean) .join(" "); - return ``; } + if (block.type === "video") { + const props: any = block.props ?? {}; + const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? ""); + + const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto"); + const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true }); + + const tokens = computeVideoExportTokens(props, { escapeAttr }); + + const outerStyleAttr = + tokens.outerStyleParts.length > 0 ? ` style="${tokens.outerStyleParts.join(";")}"` : ""; + const wrapperStyleAttr = + tokens.wrapperStyleParts.length > 0 ? ` style="${tokens.wrapperStyleParts.join(";")}"` : ""; + const videoStyleAttr = + tokens.videoStyleParts.length > 0 ? ` style="${tokens.videoStyleParts.join(";")}"` : ""; + + const isEmbed = platform === "youtube" || platform === "vimeo"; + + if (isEmbed) { + const titleRaw = typeof (props as any).titleText === "string" ? (props as any).titleText.trim() : ""; + const iframeTitle = titleRaw !== "" ? titleRaw : "비디오"; + + return `
`; + } + + const controlsAttr = props.controls === false ? "" : " controls"; + const autoplayAttr = props.autoplay ? " autoplay" : ""; + const loopAttr = props.loop ? " loop" : ""; + const mutedAttr = props.muted ? " muted" : ""; + const posterRaw = typeof (props as any).posterImageSrc === "string" ? (props as any).posterImageSrc.trim() : ""; + const posterAttr = posterRaw !== "" ? ` poster="${escapeAttr(posterRaw)}"` : ""; + const ariaRaw = typeof (props as any).ariaLabel === "string" ? (props as any).ariaLabel.trim() : ""; + const ariaAttr = ariaRaw !== "" ? ` aria-label="${escapeAttr(ariaRaw)}"` : ""; + const startDataAttr = + typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0 + ? ` data-start-seconds="${(props as any).startTimeSec}"` + : ""; + const endDataAttr = + typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0 + ? ` data-end-seconds="${(props as any).endTimeSec}"` + : ""; + const captionRaw = typeof (props as any).captionText === "string" ? (props as any).captionText.trim() : ""; + const captionHtml = captionRaw !== "" ? `

${escapeHtml(captionRaw)}

` : ""; + + return `
${captionHtml}`; + } + 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 : ""; - const wrapperStyleParts: string[] = []; - const imgStyleParts: string[] = []; + const styles = computeImageExportStyles({ + backgroundColorCustom: + typeof props.backgroundColorCustom === "string" ? props.backgroundColorCustom : undefined, + widthMode: props.widthMode ?? "auto", + widthPx: typeof props.widthPx === "number" ? props.widthPx : undefined, + borderRadius: props.borderRadius ?? "md", + borderRadiusPx: typeof props.borderRadiusPx === "number" ? props.borderRadiusPx : undefined, + }); - if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { - wrapperStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`); - } - - const widthMode = props.widthMode ?? "auto"; - if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { - imgStyleParts.push(`width:${props.widthPx}px`); - } - - const radiusToken = props.borderRadius ?? "md"; - const fallbackRadiusPx = - radiusToken === "none" - ? 0 - : radiusToken === "sm" - ? 4 - : radiusToken === "lg" - ? 16 - : radiusToken === "full" - ? 9999 - : 8; - const radiusPx = - typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0 - ? props.borderRadiusPx - : fallbackRadiusPx; - if (typeof radiusPx === "number" && radiusPx >= 0) { - imgStyleParts.push(`border-radius:${radiusPx === 9999 ? "9999px" : `${radiusPx}px`}`); - } - - const wrapperStyleAttr = wrapperStyleParts.length > 0 ? ` style="${wrapperStyleParts.join(";")}"` : ""; - const imgStyleAttr = imgStyleParts.length > 0 ? ` style="${imgStyleParts.join(";")}"` : ""; + const wrapperStyleAttr = + styles.wrapperStyleParts.length > 0 ? ` style="${styles.wrapperStyleParts.join(";")}"` : ""; + const imgStyleAttr = styles.imgStyleParts.length > 0 ? ` style="${styles.imgStyleParts.join(";")}"` : ""; return `${escapeAttr(alt)}`; } if (block.type === "form") { const props = (block.props ?? {}) as FormBlockProps; + const tokens = computeFormBlockExportTokens(block, blocks); + const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : []; + const fallbackFields = tokens.fallbackFields; - const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : []; - const controllerFields = fieldIds - .map((id) => blocks.find((b) => b.id === id)) - .filter((b): b is Block => Boolean(b)) - .filter( - (b) => - b.type === "formInput" || - b.type === "formSelect" || - b.type === "formCheckbox" || - b.type === "formRadio", - ); + const hasControllerFields = controllerFields.length > 0; + const hasFallbackFields = fallbackFields.length > 0; - const fallbackFields = Array.isArray(props.fields) ? props.fields : []; - - const fields = controllerFields.length > 0 ? controllerFields : null; + // FormBlock 이 컨트롤러/필드 정보를 전혀 가지고 있지 않으면 정적 HTML 에서는 아무 것도 렌더하지 않는다. + if (!hasControllerFields && !hasFallbackFields) { + return ""; + } const formParts: string[] = []; - const formStyleParts: string[] = []; - if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { - formStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`); - } - const formStyleAttr = formStyleParts.length > 0 ? ` style="${formStyleParts.join(";")}"` : ""; + const formStyleAttr = tokens.formStyleParts.length > 0 ? ` style="${tokens.formStyleParts.join(";")}"` : ""; formParts.push(`
`); - if (fields) { - for (const fieldBlock of fields) { + if (hasControllerFields) { + for (const fieldBlock of controllerFields) { const anyProps: any = fieldBlock.props ?? {}; const name = typeof anyProps.formFieldName === "string" ? anyProps.formFieldName : fieldBlock.id; const label = @@ -379,7 +317,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): formParts.push(``); } - } else if (fallbackFields.length > 0) { + } else if (hasFallbackFields) { for (const field of fallbackFields) { const required = field.required ? " required" : ""; const label = field.label ?? field.name; @@ -410,54 +348,23 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): 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 `
`; + const tokens = computeDividerExportTokens(props, { escapeAttr }); + return `
`; } if (block.type === "list") { - const props: any = block.props ?? {}; - const ordered = Boolean(props.ordered); - const Tag = ordered ? "ol" : "ul"; + const props = (block.props ?? {}) as ListBlockProps; - const items: string[] = []; - if (Array.isArray(props.itemsTree) && props.itemsTree.length > 0) { - const walk = (nodes: any[]) => { - for (const node of nodes) { - if (typeof node.text === "string" && node.text.trim() !== "") { - items.push(node.text); - } - if (Array.isArray(node.children) && node.children.length > 0) { - walk(node.children); - } - } - }; - walk(props.itemsTree); - } else if (Array.isArray(props.items)) { - for (const raw of props.items) { - if (typeof raw === "string" && raw.trim() !== "") { - items.push(raw); - } - } - } + const tokens = computeListExportTokens(props); - if (items.length === 0) { + if (tokens.items.length === 0) { return ""; } - const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left"; - const lis = items.map((text) => `
  • ${escapeHtml(text)}
  • `).join(""); + const lis = tokens.items.map((text) => `
  • ${escapeHtml(text)}
  • `).join(""); + const listStyleAttr = tokens.listStyleParts.join(";"); - const listStyleParts: string[] = [`text-align:${align}`]; - if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { - listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`); - } - const listStyleAttr = listStyleParts.join(";"); - - return `<${Tag} class="pb-list" style="${listStyleAttr}">${lis}`; + return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}`; } if (block.type === "formInput") { @@ -468,69 +375,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const type = props.inputType === "email" ? "email" : "text"; const required = props.required ? " required" : ""; - const textColorRaw = - typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== "" - ? props.textColorCustom.trim() - : ""; - const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : ""; - - const inputStyleParts: string[] = []; - if (textColorRaw) { - inputStyleParts.push(`color:${escapeAttr(textColorRaw)}`); - } - - if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { - inputStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); - } - - if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { - inputStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); - } - - if (typeof props.paddingX === "number" && props.paddingX >= 0) { - const px = props.paddingX; - inputStyleParts.push(`padding-left:${px}px`); - inputStyleParts.push(`padding-right:${px}px`); - } - - if (typeof props.paddingY === "number" && props.paddingY >= 0) { - const py = props.paddingY; - inputStyleParts.push(`padding-top:${py}px`); - inputStyleParts.push(`padding-bottom:${py}px`); - } - - const widthMode = - typeof props.widthMode === "string" - ? props.widthMode - : props.fullWidth - ? "full" - : "auto"; - if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { - inputStyleParts.push(`width:${props.widthPx}px`); - } - - const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md"; - const radiusPx = - radiusToken === "none" - ? 0 - : radiusToken === "sm" - ? 2 - : radiusToken === "lg" - ? 6 - : radiusToken === "full" - ? 9999 - : 4; - inputStyleParts.push(`border-radius:${radiusPx}px`); - - const inputStyleAttr = - inputStyleParts.length > 0 ? ` style=\"${inputStyleParts.join(";")}\"` : ""; + const tokens = computeFormInputExportTokens(props, { escapeAttr }); return [ '
    ', - ``, + ``, ``, + )}"${required}${tokens.inputStyleAttr} />`, "
    ", ].join(""); } @@ -543,67 +395,12 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const required = props.required ? " required" : ""; const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : []; - const textColorRaw = - typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== "" - ? props.textColorCustom.trim() - : ""; - const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : ""; - - const selectStyleParts: string[] = []; - if (textColorRaw) { - selectStyleParts.push(`color:${escapeAttr(textColorRaw)}`); - } - - const widthMode = - typeof props.widthMode === "string" - ? props.widthMode - : props.fullWidth - ? "full" - : "auto"; - if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { - selectStyleParts.push(`width:${props.widthPx}px`); - } - - if (typeof props.paddingX === "number" && props.paddingX >= 0) { - const px = props.paddingX; - selectStyleParts.push(`padding-left:${px}px`); - selectStyleParts.push(`padding-right:${px}px`); - } - - if (typeof props.paddingY === "number" && props.paddingY >= 0) { - const py = props.paddingY; - selectStyleParts.push(`padding-top:${py}px`); - selectStyleParts.push(`padding-bottom:${py}px`); - } - - if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { - selectStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); - } - - if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { - selectStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); - } - - const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md"; - const radiusPx = - radiusToken === "none" - ? 0 - : radiusToken === "sm" - ? 2 - : radiusToken === "lg" - ? 6 - : radiusToken === "full" - ? 9999 - : 4; - selectStyleParts.push(`border-radius:${radiusPx}px`); - - const selectStyleAttr = - selectStyleParts.length > 0 ? ` style=\"${selectStyleParts.join(";")}\"` : ""; + const tokens = computeFormSelectExportTokens(props, { escapeAttr }); const parts: string[] = []; parts.push('
    '); - parts.push(``); - parts.push(``); for (const opt of options) { parts.push( ``, @@ -624,61 +421,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): : name; const options = Array.isArray(props.options) ? props.options : []; - const textColorRaw = - typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== "" - ? props.textColorCustom.trim() - : ""; - const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : ""; - - const optionStyleParts: string[] = []; - if (textColorRaw) { - optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`); - } - - if (typeof props.paddingX === "number" && props.paddingX >= 0) { - const px = props.paddingX; - optionStyleParts.push(`padding-left:${px}px`); - optionStyleParts.push(`padding-right:${px}px`); - } - - if (typeof props.paddingY === "number" && props.paddingY >= 0) { - const py = props.paddingY; - optionStyleParts.push(`padding-top:${py}px`); - optionStyleParts.push(`padding-bottom:${py}px`); - } - - if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { - optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); - } - - if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { - optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); - optionStyleParts.push("border-width:1px"); - optionStyleParts.push("border-style:solid"); - } - - const checkboxRadiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md"; - const checkboxRadiusPx = - checkboxRadiusToken === "none" - ? 0 - : checkboxRadiusToken === "sm" - ? 2 - : checkboxRadiusToken === "lg" - ? 6 - : checkboxRadiusToken === "full" - ? 9999 - : 4; - optionStyleParts.push(`border-radius:${checkboxRadiusPx}px`); - - const optionStyleAttr = - optionStyleParts.length > 0 ? ` style=\"${optionStyleParts.join(";")}\"` : ""; + const tokens = computeFormCheckboxExportTokens(props, { escapeAttr }); const parts: string[] = []; parts.push('
    '); - parts.push(``); + parts.push(``); for (const opt of options) { parts.push( - ``, ); @@ -697,61 +447,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): : name; const options = Array.isArray(props.options) ? props.options : []; - const textColorRaw = - typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== "" - ? props.textColorCustom.trim() - : ""; - const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : ""; - - const optionStyleParts: string[] = []; - if (textColorRaw) { - optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`); - } - - if (typeof props.paddingX === "number" && props.paddingX >= 0) { - const px = props.paddingX; - optionStyleParts.push(`padding-left:${px}px`); - optionStyleParts.push(`padding-right:${px}px`); - } - - if (typeof props.paddingY === "number" && props.paddingY >= 0) { - const py = props.paddingY; - optionStyleParts.push(`padding-top:${py}px`); - optionStyleParts.push(`padding-bottom:${py}px`); - } - - if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { - optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); - } - - if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { - optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); - optionStyleParts.push("border-width:1px"); - optionStyleParts.push("border-style:solid"); - } - - const radioRadiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md"; - const radioRadiusPx = - radioRadiusToken === "none" - ? 0 - : radioRadiusToken === "sm" - ? 2 - : radioRadiusToken === "lg" - ? 6 - : radioRadiusToken === "full" - ? 9999 - : 4; - optionStyleParts.push(`border-radius:${radioRadiusPx}px`); - - const optionStyleAttr = - optionStyleParts.length > 0 ? ` style=\"${optionStyleParts.join(";")}\"` : ""; + const tokens = computeFormRadioExportTokens(props, { escapeAttr }); const parts: string[] = []; parts.push('
    '); - parts.push(``); + parts.push(``); for (const opt of options) { parts.push( - ``, ); @@ -781,39 +484,26 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): ? 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 tokens = computeSectionExportTokens(props); - 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 sectionStyleAttr = + tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : ""; - const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" "); - const sectionStyleParts: string[] = []; - // 섹션 커스텀 배경색: backgroundColorCustom 이 설정된 경우에만 인라인 스타일로 적용하여 토큰 기반 배경 클래스를 오버라이드한다. - if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { - sectionStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`); - } - const sectionStyleAttr = sectionStyleParts.length > 0 ? ` style="${sectionStyleParts.join(";")}"` : ""; + bodyParts.push( + `
    ${tokens.backgroundVideoHtml}
    `, + ); - bodyParts.push(`
    `); - for (const col of columns) { - const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id); + for (const column of columns) { + const columnBlocks = blocks.filter( + (b) => b.sectionId === section.id && b.columnId === column.id, + ); bodyParts.push('
    '); for (const block of columnBlocks) { bodyParts.push(renderBlock(block)); } bodyParts.push("
    "); } + bodyParts.push("
    "); } @@ -854,7 +544,7 @@ export async function POST(request: Request) { const zip = new JSZip(); const imageIds = new Set(); - const imageRegex = /\/api\/image\/([^"'>\s]+)/g; + const imageRegex = /\/api\/image\/([^"'>\s)]+)/g; let match: RegExpExecArray | null; // eslint-disable-next-line no-cond-assign while ((match = imageRegex.exec(html)) !== null) { @@ -877,6 +567,30 @@ export async function POST(request: Request) { } } + const videoIds = new Set(); + const videoRegex = /\/api\/video\/([^"'>\s]+)/g; + let videoMatch: RegExpExecArray | null; + // eslint-disable-next-line no-cond-assign + while ((videoMatch = videoRegex.exec(html)) !== null) { + if (videoMatch[1]) { + videoIds.add(videoMatch[1]); + } + } + + for (const id of videoIds) { + const filePath = path.join(UPLOAD_DIR, id); + try { + const bytes = await fs.readFile(filePath); + zip.file(`videos/${id}`, bytes); + + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const srcRegex = new RegExp(`/api/video/${escapedId}`, "g"); + html = html.replace(srcRegex, `./videos/${id}`); + } catch { + // 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다. + } + } + zip.file("index.html", html); try { diff --git a/src/app/api/video/[id]/route.ts b/src/app/api/video/[id]/route.ts new file mode 100644 index 0000000..279be57 --- /dev/null +++ b/src/app/api/video/[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", "video", ":id"] 형태를 기대한다. + const last = segments[segments.length - 1]; + if (last && last !== "video") { + id = last; + } + } catch { + // URL 파싱 실패 시에는 그대로 둔다. + } + } + + if (!id) { + return NextResponse.json({ message: "id 파라미터가 필요합니다." }, { status: 400 }); + } + + // Referer 기반으로 간단한 핫링크 방지: 동일 호스트에서 온 요청만 허용한다. + try { + const requestUrl = new URL(request.url); + const referer = request.headers.get("referer"); + + if (!referer) { + return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 }); + } + + const refererUrl = new URL(referer); + if (refererUrl.host !== requestUrl.host) { + return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 }); + } + } catch (error) { + console.error("비디오 Referer 검사 중 오류", error); + return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 }); + } + + const filePath = path.join(UPLOAD_DIR, id); + + try { + const bytes = await fs.readFile(filePath); + + // 기본 MIME 타입은 mp4 로 두되, 가능하면 Asset 메타데이터에서 실제 타입을 읽어온다. + let mimeType = "video/mp4"; + if (prisma) { + try { + const asset = await prisma.asset.findUnique({ where: { id } }); + const meta: any = asset?.meta ?? null; + const candidate = typeof meta?.mimeType === "string" ? meta.mimeType.trim() : ""; + if (candidate) { + mimeType = candidate; + } + } catch (error) { + console.error("비디오 메타데이터 조회 실패 - 기본 MIME 타입으로 응답합니다.", error); + } + } + + return new NextResponse(bytes, { + status: 200, + headers: { + "Content-Type": mimeType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch (error: any) { + if (error && typeof error === "object" && (error as any).code === "ENOENT") { + return NextResponse.json({ message: "비디오를 찾을 수 없습니다." }, { status: 404 }); + } + + console.error("비디오 파일 읽기 중 오류", error); + return NextResponse.json({ message: "비디오 로드 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/api/video/route.ts b/src/app/api/video/route.ts new file mode 100644 index 0000000..0e64a1f --- /dev/null +++ b/src/app/api/video/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: "video", + url: relativePath, + meta: { + sourceType: "uploaded", + originalName: (file as any).name ?? "upload", + mimeType: (file as any).type ?? "application/octet-stream", + }, + }, + }); + } catch (error) { + console.error("비디오 Asset DB 저장 실패 - 파일 시스템만 사용합니다.", error); + } + } + + return NextResponse.json( + { + id, + servedUrl: `/api/video/${id}`, + }, + { status: 201 }, + ); + } catch (error) { + console.error("비디오 업로드 처리 중 오류", error); + return NextResponse.json({ message: "비디오 업로드 중 오류가 발생했습니다." }, { status: 500 }); + } +} diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 9c74b47..185e5ea 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -28,6 +28,7 @@ import type { TextBlockProps, ButtonBlockProps, ImageBlockProps, + VideoBlockProps, SectionBlockProps, DividerBlockProps, ListBlockProps, @@ -40,6 +41,23 @@ import type { ListItemNode, ProjectConfig, } from "@/features/editor/state/editorStore"; +import { + normalizeVideoSourceUrl, + resolveVideoPlatform, + buildVideoEmbedUrl, + computeVideoEditorTokens, +} from "@/features/editor/utils/videoHelpers"; +import { computeTextEditorTokens } from "@/features/editor/utils/textHelpers"; +import { computeButtonPbTokens, computeButtonEditorTokens } from "@/features/editor/utils/buttonHelpers"; +import { computeDividerEditorTokens } from "@/features/editor/utils/dividerHelpers"; +import { computeListEditorTokens } from "@/features/editor/utils/listHelpers"; +import { computeImageEditorTokens } from "@/features/editor/utils/imageHelpers"; +import { computeSectionEditorTokens } from "@/features/editor/utils/sectionHelpers"; +import { + computeFormInputEditorTokens, + computeFormSelectEditorTokens, + computeFormOptionGroupEditorTokens, +} from "@/features/editor/utils/formHelpers"; import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel"; import { TextPropertiesPanel } from "./panels/TextPropertiesPanel"; import { ListPropertiesPanel } from "./panels/ListPropertiesPanel"; @@ -143,7 +161,14 @@ export default function EditorPage() { }); if (!response.ok) { - console.error("정적 파일 내보내기 실패", await response.text().catch(() => "")); + const text = await response.text().catch(() => ""); + const truncated = text.length > 500 ? `${text.slice(0, 500)}...` : text; + console.error( + "정적 파일 내보내기 실패", + response.status, + response.statusText, + truncated, + ); return; } @@ -949,85 +974,16 @@ function SortableEditorBlock({ if (block.type === "text") { const textProps = block.props as TextBlockProps; + const textTokens = computeTextEditorTokens(textProps); - // 정렬: pb-text-* - alignClass = - textProps.align === "center" - ? "pb-text-center" - : textProps.align === "right" - ? "pb-text-right" - : "pb-text-left"; - - // 폰트 크기: scale/custom + 기존 size 값 fallback - const fontSizeMode = textProps.fontSizeMode ?? "scale"; - const fallbackScale = - textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base"; - const fontSizeScale = textProps.fontSizeScale ?? fallbackScale; - - // 스케일/커스텀 모드와 무관하게 pb-text-* 스케일 클래스는 항상 유지한다. - sizeClass = `pb-text-${fontSizeScale}`; - - // custom 모드에서는 inline 스타일로 실제 폰트 크기를 덮어쓴다. - if (fontSizeMode === "custom" && textProps.fontSizeCustom) { - textStyleOverrides.fontSize = textProps.fontSizeCustom; - } - - // 줄 간격: scale/custom - const lineHeightMode = textProps.lineHeightMode ?? "scale"; - const lineHeightScale = textProps.lineHeightScale ?? "normal"; - if (lineHeightMode === "scale") { - leadingClass = `pb-leading-${lineHeightScale}`; - } else if (lineHeightMode === "custom" && textProps.lineHeightCustom) { - textStyleOverrides.lineHeight = textProps.lineHeightCustom; - } - - // 굵기: scale/custom - const fontWeightMode = textProps.fontWeightMode ?? "scale"; - const fontWeightScale = textProps.fontWeightScale ?? "normal"; - if (fontWeightMode === "scale") { - weightClass = `pb-font-${fontWeightScale}`; - } else if (fontWeightMode === "custom" && textProps.fontWeightCustom) { - textStyleOverrides.fontWeight = textProps.fontWeightCustom as CSSProperties["fontWeight"]; - } - - // 색상: colorCustom이 있으면 항상 우선 사용, 없으면 팔레트 - const colorPalette = textProps.colorPalette ?? "default"; - if (textProps.colorCustom && textProps.colorCustom.trim() !== "") { - textStyleOverrides.color = textProps.colorCustom; - } else { - colorClass = `pb-text-color-${colorPalette}`; - } - - // 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다. - if (textProps.backgroundColorCustom && textProps.backgroundColorCustom.trim() !== "") { - textStyleOverrides.backgroundColor = textProps.backgroundColorCustom.trim(); - } - - // 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋 - const maxWidthScale = textProps.maxWidthScale ?? "none"; - if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") { - textStyleOverrides.maxWidth = textProps.maxWidthCustom; - } else if (maxWidthScale === "prose") { - maxWidthClass = "pb-text-maxw-prose"; - } else if (maxWidthScale === "narrow") { - maxWidthClass = "pb-text-maxw-narrow"; - } - - // 글자 간격: 커스텀 값이 있으면 em 단위 그대로 사용 - if (textProps.letterSpacingCustom && textProps.letterSpacingCustom.trim() !== "") { - textStyleOverrides.letterSpacing = textProps.letterSpacingCustom; - } - - // 텍스트 장식: 밑줄/가운데줄/이탤릭 - if (textProps.underline) { - decorationClass += " pb-underline"; - } - if (textProps.strike) { - decorationClass += " pb-line-through"; - } - if (textProps.italic) { - decorationClass += " pb-italic"; - } + alignClass = textTokens.alignClass; + sizeClass = textTokens.sizeClass; + leadingClass = textTokens.leadingClass; + weightClass = textTokens.weightClass; + colorClass = textTokens.colorClass; + maxWidthClass = textTokens.maxWidthClass; + decorationClass = textTokens.decorationClass; + Object.assign(textStyleOverrides, textTokens.styleOverrides); } else if (block.type === "button") { const buttonProps = block.props as ButtonBlockProps; const align = buttonProps.align ?? "left"; @@ -1051,7 +1007,12 @@ function SortableEditorBlock({ : listProps.align === "right" ? "pb-text-right" : "pb-text-left"; - } else if (block.type === "section") { + } else if (block.type === "video") { + const videoProps = block.props as VideoBlockProps; + const align = videoProps.align ?? "center"; + alignClass = + align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left"; + } else if (block.type === "section") { // 섹션 블록: 텍스트 크기 대신 섹션 자체에 패딩/배경을 입히므로 텍스트 클래스는 기본값만 alignClass = "pb-text-left"; } else if (block.type === "form") { @@ -1143,33 +1104,13 @@ function SortableEditorBlock({ const inputType = inputProps.inputType ?? "text"; // 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다. - const fieldStyle: CSSProperties = {}; - if (inputProps.textColorCustom && inputProps.textColorCustom.trim() !== "") { - fieldStyle.color = inputProps.textColorCustom; - } - if (inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== "") { - fieldStyle.backgroundColor = inputProps.fillColorCustom; - } - if (inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== "") { - fieldStyle.borderColor = inputProps.strokeColorCustom; - } - const widthMode = inputProps.widthMode ?? "full"; - if (widthMode === "fixed" && typeof inputProps.widthPx === "number" && inputProps.widthPx > 0) { - fieldStyle.width = `${inputProps.widthPx}px`; - } - const radius = inputProps.borderRadius ?? "md"; - if (radius === "none") fieldStyle.borderRadius = 0; - else if (radius === "sm") fieldStyle.borderRadius = 4; - else if (radius === "lg") fieldStyle.borderRadius = 9999; - else if (radius === "full") fieldStyle.borderRadius = 9999; - // md 는 기본 tailwind rounded 와 비슷한 값으로 둔다 (별도 스타일 없음이면 클래스에 맡긴다). + const inputTokens = computeFormInputEditorTokens(inputProps); + const fieldStyle = inputTokens.fieldStyle; const labelLayout = inputProps.labelLayout ?? "stacked"; const isInline = labelLayout === "inline"; - const align = inputProps.align ?? "left"; - const inputAlignClass = - align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left"; + const inputAlignClass = inputTokens.inputAlignClass; // 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다. return ( @@ -1186,24 +1127,7 @@ function SortableEditorBlock({ )} {(() => { // widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다. - let widthClass = "w-full"; - if (isInline) { - if (widthMode === "fixed") { - // 고정 너비일 때는 flex 레이아웃의 너비 확장을 막기 위해 flex-none 으로 둔다. - widthClass = "flex-none"; - } else if (widthMode === "full") { - widthClass = "flex-1"; - } else { - // auto: 인라인 레이아웃에서 내용 기반 너비 사용 - widthClass = ""; - } - } else { - if (widthMode === "fixed") { - widthClass = ""; // 고정 너비는 style.width 로만 제어 - } else { - widthClass = "w-full"; - } - } + const widthClass = inputTokens.widthClass; return (
    0 - ) { - fieldStyle.width = `${selectProps.widthPx}px`; - } - const selectRadius = selectProps.borderRadius ?? "md"; - if (selectRadius === "none") fieldStyle.borderRadius = 0; - else if (selectRadius === "sm") fieldStyle.borderRadius = 4; - else if (selectRadius === "lg" || selectRadius === "full") fieldStyle.borderRadius = 9999; + const selectTokens = computeFormSelectEditorTokens(selectProps); + const fieldStyle = selectTokens.fieldStyle; // 에디터에서는 폼 셀렉트 블록이 라벨 + 셀렉트 박스로 보이도록 렌더링한다. return ( @@ -1308,28 +1212,8 @@ function SortableEditorBlock({ const groupLabelMode = radioProps.groupLabelMode ?? "text"; - const fieldStyle: CSSProperties = {}; - if (radioProps.textColorCustom && radioProps.textColorCustom.trim() !== "") { - fieldStyle.color = radioProps.textColorCustom; - } - if (radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== "") { - fieldStyle.backgroundColor = radioProps.fillColorCustom; - } - if (radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== "") { - fieldStyle.borderColor = radioProps.strokeColorCustom; - } - const radioWidthMode = radioProps.widthMode ?? "full"; - if ( - radioWidthMode === "fixed" && - typeof radioProps.widthPx === "number" && - radioProps.widthPx > 0 - ) { - fieldStyle.width = `${radioProps.widthPx}px`; - } - const radioRadius = radioProps.borderRadius ?? "md"; - if (radioRadius === "none") fieldStyle.borderRadius = 0; - else if (radioRadius === "sm") fieldStyle.borderRadius = 4; - else if (radioRadius === "lg" || radioRadius === "full") fieldStyle.borderRadius = 9999; + const radioTokens = computeFormOptionGroupEditorTokens(radioProps); + const fieldStyle = radioTokens.fieldStyle; return (
    @@ -1385,28 +1269,8 @@ function SortableEditorBlock({ const groupLabelMode = checkboxProps.groupLabelMode ?? "text"; - const fieldStyle: CSSProperties = {}; - if (checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== "") { - fieldStyle.color = checkboxProps.textColorCustom; - } - if (checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== "") { - fieldStyle.backgroundColor = checkboxProps.fillColorCustom; - } - if (checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== "") { - fieldStyle.borderColor = checkboxProps.strokeColorCustom; - } - const checkboxWidthMode = checkboxProps.widthMode ?? "full"; - if ( - checkboxWidthMode === "fixed" && - typeof checkboxProps.widthPx === "number" && - checkboxProps.widthPx > 0 - ) { - fieldStyle.width = `${checkboxProps.widthPx}px`; - } - const checkboxRadius = checkboxProps.borderRadius ?? "md"; - if (checkboxRadius === "none") fieldStyle.borderRadius = 0; - else if (checkboxRadius === "sm") fieldStyle.borderRadius = 4; - else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999; + const checkboxTokens = computeFormOptionGroupEditorTokens(checkboxProps); + const fieldStyle = checkboxTokens.fieldStyle; return (
    @@ -1449,71 +1313,31 @@ function SortableEditorBlock({ })()} {block.type === "button" && (() => { const buttonProps = block.props as ButtonBlockProps; - const size = buttonProps.size ?? "md"; - const variant = buttonProps.variant ?? "solid"; - const colorPalette = buttonProps.colorPalette ?? "primary"; - const radius = buttonProps.borderRadius ?? "md"; - const baseFullWidth = buttonProps.fullWidth ?? false; - const widthMode = buttonProps.widthMode ?? (baseFullWidth ? "full" : "auto"); - const isFullWidth = widthMode === "full" || baseFullWidth; - const sizeClassBtn = - size === "xs" - ? "pb-btn-size-xs" - : size === "sm" - ? "pb-btn-size-sm" - : size === "lg" - ? "pb-btn-size-lg" - : size === "xl" - ? "pb-btn-size-xl" - : "pb-btn-size-md"; - const radiusClassBtn = - radius === "none" - ? "pb-btn-radius-none" - : radius === "sm" - ? "pb-btn-radius-sm" - : radius === "lg" - ? "pb-btn-radius-lg" - : radius === "full" - ? "pb-btn-radius-full" - : "pb-btn-radius-md"; - const variantClassBtn = `pb-btn-variant-${variant}-${colorPalette}`; + const pbTokens = computeButtonPbTokens({ + align: buttonProps.align ?? "left", + size: buttonProps.size ?? "md", + variant: buttonProps.variant ?? "solid", + colorPalette: buttonProps.colorPalette ?? "primary", + borderRadius: buttonProps.borderRadius ?? "md", + fullWidth: buttonProps.fullWidth ?? false, + widthMode: buttonProps.widthMode ?? undefined, + widthPx: typeof buttonProps.widthPx === "number" ? buttonProps.widthPx : undefined, + paddingX: typeof buttonProps.paddingX === "number" ? buttonProps.paddingX : undefined, + paddingY: typeof buttonProps.paddingY === "number" ? buttonProps.paddingY : undefined, + fillColorCustom: buttonProps.fillColorCustom ?? undefined, + strokeColorCustom: buttonProps.strokeColorCustom ?? undefined, + textColorCustom: buttonProps.textColorCustom ?? undefined, + }); - const buttonStyle: CSSProperties = {}; - if (buttonProps.fontSizeCustom && buttonProps.fontSizeCustom.trim() !== "") { - buttonStyle.fontSize = buttonProps.fontSizeCustom; - } - if (buttonProps.lineHeightCustom && buttonProps.lineHeightCustom.trim() !== "") { - buttonStyle.lineHeight = buttonProps.lineHeightCustom; - } - if (buttonProps.letterSpacingCustom && buttonProps.letterSpacingCustom.trim() !== "") { - buttonStyle.letterSpacing = buttonProps.letterSpacingCustom; - } - if (buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== "") { - buttonStyle.backgroundColor = buttonProps.fillColorCustom; - } - if (buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== "") { - buttonStyle.borderColor = buttonProps.strokeColorCustom; - } - if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") { - buttonStyle.color = buttonProps.textColorCustom; - } - if (widthMode === "fixed" && typeof buttonProps.widthPx === "number" && buttonProps.widthPx > 0) { - buttonStyle.width = `${buttonProps.widthPx}px`; - } - if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) { - buttonStyle.paddingInline = `${buttonProps.paddingX}px`; - } - if (typeof buttonProps.paddingY === "number" && buttonProps.paddingY >= 0) { - buttonStyle.paddingBlock = `${buttonProps.paddingY}px`; - } + const editorTokens = computeButtonEditorTokens(buttonProps); return ( -
    +
    ); })()} + {block.type === "video" && (() => { + const videoProps = block.props as VideoBlockProps; + const normalizedUrl = normalizeVideoSourceUrl(videoProps.sourceUrl ?? ""); + const platform = resolveVideoPlatform(normalizedUrl, videoProps.platform ?? "auto"); + const embedUrl = buildVideoEmbedUrl(normalizedUrl, platform, { enableVimeoEmbed: false }); + const tokens = computeVideoEditorTokens(videoProps); + + const startTimeSec = + typeof videoProps.startTimeSec === "number" && videoProps.startTimeSec >= 0 + ? videoProps.startTimeSec + : null; + const endTimeSec = + typeof videoProps.endTimeSec === "number" && videoProps.endTimeSec >= 0 + ? videoProps.endTimeSec + : null; + const isEmbed = platform === "youtube" || platform === "vimeo"; + + return ( +
    +
    +
    + {isEmbed ? ( +