video 블록 및 리펙터링
This commit is contained in:
+203
-489
@@ -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<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 = "pb-text-color-strong";
|
||||
const inlineStyles: string[] = [];
|
||||
|
||||
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] ?? 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 `<p class="${classes}"${styleAttr}>${escapeHtml(text)}</p>`;
|
||||
}
|
||||
@@ -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<NonNullable<ButtonBlockProps["size"]>, string> = {
|
||||
xs: "pb-btn-size-xs",
|
||||
sm: "pb-btn-size-sm",
|
||||
md: "pb-btn-size-md",
|
||||
lg: "pb-btn-size-lg",
|
||||
xl: "pb-btn-size-xl",
|
||||
};
|
||||
const sizeClass = sizeMap[sizeToken] ?? "pb-btn-size-md";
|
||||
const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
||||
|
||||
const variant = props.variant ?? "solid";
|
||||
const palette = props.colorPalette ?? "primary";
|
||||
const variantClass = `pb-btn-variant-${variant}-${palette}`;
|
||||
|
||||
const radiusToken = props.borderRadius ?? "md";
|
||||
const radiusMap: Record<NonNullable<ButtonBlockProps["borderRadius"]>, string> = {
|
||||
none: "pb-btn-radius-none",
|
||||
sm: "pb-btn-radius-sm",
|
||||
md: "pb-btn-radius-md",
|
||||
lg: "pb-btn-radius-lg",
|
||||
full: "pb-btn-radius-full",
|
||||
};
|
||||
const radiusClass = radiusMap[radiusToken] ?? "";
|
||||
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
|
||||
const styleParts: string[] = [];
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
styleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
if (typeof props.paddingX === "number") {
|
||||
styleParts.push(`padding-left:${props.paddingX}px`);
|
||||
styleParts.push(`padding-right:${props.paddingX}px`);
|
||||
}
|
||||
if (typeof props.paddingY === "number") {
|
||||
styleParts.push(`padding-top:${props.paddingY}px`);
|
||||
styleParts.push(`padding-bottom:${props.paddingY}px`);
|
||||
}
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
styleParts.push(`background-color:${props.fillColorCustom}`);
|
||||
}
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
styleParts.push(`border-color:${props.strokeColorCustom}`);
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
styleParts.push(`color:${props.textColorCustom}`);
|
||||
}
|
||||
const styleAttr = styleParts.length > 0 ? ` style="${styleParts.join(";")}"` : "";
|
||||
|
||||
const btnClasses = ["pb-btn-base", sizeClass, variantClass, radiusClass]
|
||||
const btnClasses = ["pb-btn-base", tokens.sizeClass, tokens.variantClass, tokens.radiusClass]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return `<div class="${alignClass}"><a href="${escapeAttr(
|
||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
||||
href,
|
||||
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></div>`;
|
||||
}
|
||||
|
||||
if (block.type === "video") {
|
||||
const props: any = block.props ?? {};
|
||||
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
|
||||
|
||||
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
|
||||
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
|
||||
|
||||
const tokens = computeVideoExportTokens(props, { escapeAttr });
|
||||
|
||||
const outerStyleAttr =
|
||||
tokens.outerStyleParts.length > 0 ? ` style="${tokens.outerStyleParts.join(";")}"` : "";
|
||||
const wrapperStyleAttr =
|
||||
tokens.wrapperStyleParts.length > 0 ? ` style="${tokens.wrapperStyleParts.join(";")}"` : "";
|
||||
const videoStyleAttr =
|
||||
tokens.videoStyleParts.length > 0 ? ` style="${tokens.videoStyleParts.join(";")}"` : "";
|
||||
|
||||
const isEmbed = platform === "youtube" || platform === "vimeo";
|
||||
|
||||
if (isEmbed) {
|
||||
const titleRaw = typeof (props as any).titleText === "string" ? (props as any).titleText.trim() : "";
|
||||
const iframeTitle = titleRaw !== "" ? titleRaw : "비디오";
|
||||
|
||||
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><iframe title="${escapeAttr(
|
||||
iframeTitle,
|
||||
)}" src="${escapeAttr(
|
||||
embedUrl,
|
||||
)}" class="pb-video-frame" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe></div></div>`;
|
||||
}
|
||||
|
||||
const controlsAttr = props.controls === false ? "" : " controls";
|
||||
const autoplayAttr = props.autoplay ? " autoplay" : "";
|
||||
const loopAttr = props.loop ? " loop" : "";
|
||||
const mutedAttr = props.muted ? " muted" : "";
|
||||
const posterRaw = typeof (props as any).posterImageSrc === "string" ? (props as any).posterImageSrc.trim() : "";
|
||||
const posterAttr = posterRaw !== "" ? ` poster="${escapeAttr(posterRaw)}"` : "";
|
||||
const ariaRaw = typeof (props as any).ariaLabel === "string" ? (props as any).ariaLabel.trim() : "";
|
||||
const ariaAttr = ariaRaw !== "" ? ` aria-label="${escapeAttr(ariaRaw)}"` : "";
|
||||
const startDataAttr =
|
||||
typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0
|
||||
? ` data-start-seconds="${(props as any).startTimeSec}"`
|
||||
: "";
|
||||
const endDataAttr =
|
||||
typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0
|
||||
? ` data-end-seconds="${(props as any).endTimeSec}"`
|
||||
: "";
|
||||
const captionRaw = typeof (props as any).captionText === "string" ? (props as any).captionText.trim() : "";
|
||||
const captionHtml = captionRaw !== "" ? `<p class="pb-video-caption">${escapeHtml(captionRaw)}</p>` : "";
|
||||
|
||||
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><video class="pb-video-frame" src="${escapeAttr(
|
||||
rawUrl,
|
||||
)}"${posterAttr}${ariaAttr}${startDataAttr}${endDataAttr}${videoStyleAttr}${controlsAttr}${autoplayAttr}${loopAttr}${mutedAttr}></video></div>${captionHtml}</div>`;
|
||||
}
|
||||
|
||||
if (block.type === "image") {
|
||||
const props: any = block.props ?? {};
|
||||
const src = typeof props.src === "string" ? props.src : "";
|
||||
const alt = typeof props.alt === "string" ? props.alt : "";
|
||||
|
||||
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 `<div${wrapperStyleAttr}><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${imgStyleAttr} /></div>`;
|
||||
}
|
||||
|
||||
if (block.type === "form") {
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const tokens = computeFormBlockExportTokens(block, blocks);
|
||||
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
|
||||
const 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(`<form class="pb-form" method="post"${formStyleAttr}>`);
|
||||
|
||||
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(`</div>`);
|
||||
}
|
||||
} 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 `<hr class="pb-divider" style="border:0;border-bottom:${thickness} solid ${escapeAttr(
|
||||
color,
|
||||
)};margin:${marginY}px 0;" />`;
|
||||
const tokens = computeDividerExportTokens(props, { escapeAttr });
|
||||
return `<hr class="pb-divider" style="${tokens.style}" />`;
|
||||
}
|
||||
|
||||
if (block.type === "list") {
|
||||
const props: any = block.props ?? {};
|
||||
const ordered = Boolean(props.ordered);
|
||||
const Tag = ordered ? "ol" : "ul";
|
||||
const props = (block.props ?? {}) as ListBlockProps;
|
||||
|
||||
const items: string[] = [];
|
||||
if (Array.isArray(props.itemsTree) && props.itemsTree.length > 0) {
|
||||
const walk = (nodes: any[]) => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.text === "string" && node.text.trim() !== "") {
|
||||
items.push(node.text);
|
||||
}
|
||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(props.itemsTree);
|
||||
} else if (Array.isArray(props.items)) {
|
||||
for (const raw of props.items) {
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
items.push(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
const tokens = computeListExportTokens(props);
|
||||
|
||||
if (items.length === 0) {
|
||||
if (tokens.items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
||||
const lis = items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
||||
const lis = tokens.items.map((text) => `<li>${escapeHtml(text)}</li>`).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}</${Tag}>`;
|
||||
return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}</${tokens.Tag}>`;
|
||||
}
|
||||
|
||||
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 [
|
||||
'<div class="pb-form-field">',
|
||||
`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
)}"${required}${inputStyleAttr} />`,
|
||||
)}"${required}${tokens.inputStyleAttr} />`,
|
||||
"</div>",
|
||||
].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('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${selectStyleAttr}>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}>`);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||
@@ -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('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
@@ -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('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
@@ -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<SectionBlockProps["background"], string> = {
|
||||
default: "pb-section-bg-default",
|
||||
muted: "pb-section-bg-muted",
|
||||
primary: "pb-section-bg-primary",
|
||||
};
|
||||
const bgClass = bgClassMap[bgToken] ?? "pb-section-bg-default";
|
||||
const tokens = computeSectionExportTokens(props);
|
||||
|
||||
const pyToken = props.paddingY ?? "md";
|
||||
const pyClassMap: Record<SectionBlockProps["paddingY"], string> = {
|
||||
sm: "pb-section-py-sm",
|
||||
md: "pb-section-py-md",
|
||||
lg: "pb-section-py-lg",
|
||||
};
|
||||
const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md";
|
||||
const sectionStyleAttr =
|
||||
tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : "";
|
||||
|
||||
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
||||
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(
|
||||
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"><div class="pb-section-columns">`,
|
||||
);
|
||||
|
||||
bodyParts.push(`<section class="${sectionClasses}"${sectionStyleAttr}><div class="pb-section-inner"><div class="pb-section-columns">`);
|
||||
for (const col of columns) {
|
||||
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
||||
for (const column of columns) {
|
||||
const columnBlocks = blocks.filter(
|
||||
(b) => b.sectionId === section.id && b.columnId === column.id,
|
||||
);
|
||||
bodyParts.push('<div class="pb-section-column">');
|
||||
for (const block of columnBlocks) {
|
||||
bodyParts.push(renderBlock(block));
|
||||
}
|
||||
bodyParts.push("</div>");
|
||||
}
|
||||
|
||||
bodyParts.push("</div></div></section>");
|
||||
}
|
||||
|
||||
@@ -854,7 +544,7 @@ export async function POST(request: Request) {
|
||||
const zip = new JSZip();
|
||||
|
||||
const imageIds = new Set<string>();
|
||||
const imageRegex = /\/api\/image\/([^"'>\s]+)/g;
|
||||
const imageRegex = /\/api\/image\/([^"'>\s)]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = imageRegex.exec(html)) !== null) {
|
||||
@@ -877,6 +567,30 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const videoIds = new Set<string>();
|
||||
const videoRegex = /\/api\/video\/([^"'>\s]+)/g;
|
||||
let videoMatch: RegExpExecArray | null;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((videoMatch = videoRegex.exec(html)) !== null) {
|
||||
if (videoMatch[1]) {
|
||||
videoIds.add(videoMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of videoIds) {
|
||||
const filePath = path.join(UPLOAD_DIR, id);
|
||||
try {
|
||||
const bytes = await fs.readFile(filePath);
|
||||
zip.file(`videos/${id}`, bytes);
|
||||
|
||||
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const srcRegex = new RegExp(`/api/video/${escapedId}`, "g");
|
||||
html = html.replace(srcRegex, `./videos/${id}`);
|
||||
} catch {
|
||||
// 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다.
|
||||
}
|
||||
}
|
||||
|
||||
zip.file("index.html", html);
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 비디오 MIME 타입은 기본값으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request, ctx: Params) {
|
||||
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
||||
let id: string | undefined = ctx?.params?.id;
|
||||
if (!id) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "video", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "video") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ message: "id 파라미터가 필요합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Referer 기반으로 간단한 핫링크 방지: 동일 호스트에서 온 요청만 허용한다.
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
const referer = request.headers.get("referer");
|
||||
|
||||
if (!referer) {
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
|
||||
const refererUrl = new URL(referer);
|
||||
if (refererUrl.host !== requestUrl.host) {
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("비디오 Referer 검사 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, id);
|
||||
|
||||
try {
|
||||
const bytes = await fs.readFile(filePath);
|
||||
|
||||
// 기본 MIME 타입은 mp4 로 두되, 가능하면 Asset 메타데이터에서 실제 타입을 읽어온다.
|
||||
let mimeType = "video/mp4";
|
||||
if (prisma) {
|
||||
try {
|
||||
const asset = await prisma.asset.findUnique({ where: { id } });
|
||||
const meta: any = asset?.meta ?? null;
|
||||
const candidate = typeof meta?.mimeType === "string" ? meta.mimeType.trim() : "";
|
||||
if (candidate) {
|
||||
mimeType = candidate;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("비디오 메타데이터 조회 실패 - 기본 MIME 타입으로 응답합니다.", error);
|
||||
}
|
||||
}
|
||||
|
||||
return new NextResponse(bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error && typeof error === "object" && (error as any).code === "ENOENT") {
|
||||
return NextResponse.json({ message: "비디오를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
console.error("비디오 파일 읽기 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 로드 중 오류가 발생했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
// 업로드된 비디오 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 비디오 업로드는 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!file || typeof (file as any).arrayBuffer !== "function") {
|
||||
return NextResponse.json({ message: "file 필드는 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 디렉터리 생성 실패", error);
|
||||
return NextResponse.json({ message: "업로드 디렉터리를 생성할 수 없습니다." }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await (file as any).arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
const id = randomUUID();
|
||||
|
||||
// 파일 경로는 uploads/<id> 형태로 저장한다.
|
||||
const relativePath = path.join("uploads", id);
|
||||
const filePath = path.join(process.cwd(), relativePath);
|
||||
|
||||
await fs.writeFile(filePath, bytes);
|
||||
|
||||
// 가능하다면 Asset 테이블에도 메타데이터를 남긴다.
|
||||
if (prisma) {
|
||||
try {
|
||||
const project = await prisma.project.upsert({
|
||||
where: { slug: "assets-global" },
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
title: "Assets Global",
|
||||
slug: "assets-global",
|
||||
contentJson: [],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.asset.create({
|
||||
data: {
|
||||
id,
|
||||
projectId: project.id,
|
||||
kind: "video",
|
||||
url: relativePath,
|
||||
meta: {
|
||||
sourceType: "uploaded",
|
||||
originalName: (file as any).name ?? "upload",
|
||||
mimeType: (file as any).type ?? "application/octet-stream",
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("비디오 Asset DB 저장 실패 - 파일 시스템만 사용합니다.", error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id,
|
||||
servedUrl: `/api/video/${id}`,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 처리 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 업로드 중 오류가 발생했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user