비디오 태그 및 폼 블록 정리
This commit is contained in:
+42
-98
@@ -160,6 +160,23 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
||||
|
||||
// Form 컨트롤러(FormBlock)와 개별 필드 블록(formInput/formSelect/...) 를 연결하기 위한 맵.
|
||||
// - key: 필드 블록 id (fieldIds 에 포함된 id)
|
||||
// - value: 해당 필드가 속한 <form> 요소의 DOM id (예: form_form_1)
|
||||
const fieldIdToFormId = new Map<string, string>();
|
||||
for (const block of blocks) {
|
||||
if (block.type !== "form") continue;
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
||||
if (fieldIds.length === 0) continue;
|
||||
const formDomId = `form_${block.id}`;
|
||||
for (const fieldId of fieldIds) {
|
||||
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
||||
fieldIdToFormId.set(fieldId, formDomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBlock = (block: Block): string => {
|
||||
if (block.type === "text") {
|
||||
const props = (block.props ?? {}) as TextBlockProps;
|
||||
@@ -316,107 +333,24 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const tokens = computeFormBlockExportTokens(block, blocks);
|
||||
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
|
||||
const fallbackFields = tokens.fallbackFields;
|
||||
|
||||
const hasControllerFields = controllerFields.length > 0;
|
||||
const hasFallbackFields = fallbackFields.length > 0;
|
||||
|
||||
// FormBlock 이 컨트롤러/필드 정보를 전혀 가지고 있지 않으면 정적 HTML 에서는 아무 것도 렌더하지 않는다.
|
||||
if (!hasControllerFields && !hasFallbackFields) {
|
||||
// FormBlock 이 컨트롤러 필드 정보를 전혀 가지고 있지 않으면
|
||||
// Export 레이어에서는 아무 것도 렌더하지 않는다 (fallback fields 기반 pb-form 도 생성하지 않음).
|
||||
if (!hasControllerFields) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const formParts: string[] = [];
|
||||
const formStyleAttr = tokens.formStyleParts.length > 0 ? ` style="${tokens.formStyleParts.join(";")}"` : "";
|
||||
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
|
||||
// - Export 에서는 레이아웃/스타일을 가지지 않고, id/메서드만 가지는 최소한의 <form> 요소만 만든다.
|
||||
const formId = `form_${block.id}`;
|
||||
const method = props.method ?? "POST";
|
||||
const methodAttr = ` method="${method.toLowerCase()}"`;
|
||||
|
||||
formParts.push(`<form class="pb-form" method="post"${formStyleAttr}>`);
|
||||
|
||||
if (hasControllerFields) {
|
||||
for (const fieldBlock of controllerFields) {
|
||||
const anyProps: any = fieldBlock.props ?? {};
|
||||
const name = typeof anyProps.formFieldName === "string" ? anyProps.formFieldName : fieldBlock.id;
|
||||
const label =
|
||||
typeof anyProps.label === "string" || typeof anyProps.groupLabel === "string"
|
||||
? (anyProps.label ?? anyProps.groupLabel)
|
||||
: name;
|
||||
|
||||
const textColorRaw =
|
||||
typeof anyProps.textColorCustom === "string" && anyProps.textColorCustom.trim() !== ""
|
||||
? anyProps.textColorCustom.trim()
|
||||
: "";
|
||||
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
||||
const fieldTextStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
|
||||
|
||||
formParts.push(`<div class="pb-form-field">`);
|
||||
formParts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label ?? "")}</label>`);
|
||||
|
||||
if (fieldBlock.type === "formInput") {
|
||||
const type = anyProps.inputType === "email" ? "email" : "text";
|
||||
const required = anyProps.required ? " required" : "";
|
||||
formParts.push(
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label ?? "",
|
||||
)}"${required}${fieldTextStyleAttr} />`,
|
||||
);
|
||||
} else if (fieldBlock.type === "formSelect") {
|
||||
const required = anyProps.required ? " required" : "";
|
||||
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${fieldTextStyleAttr}>`);
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||
);
|
||||
}
|
||||
formParts.push(`</select>`);
|
||||
} else if (fieldBlock.type === "formCheckbox") {
|
||||
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
} else if (fieldBlock.type === "formRadio") {
|
||||
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
for (const opt of options) {
|
||||
formParts.push(
|
||||
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
formParts.push(`</div>`);
|
||||
}
|
||||
} else if (hasFallbackFields) {
|
||||
for (const field of fallbackFields) {
|
||||
const required = field.required ? " required" : "";
|
||||
const label = field.label ?? field.name;
|
||||
formParts.push(`<div class="pb-form-field">`);
|
||||
formParts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
|
||||
if (field.type === "textarea") {
|
||||
formParts.push(
|
||||
`<textarea class="pb-textarea" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
)}"${required}></textarea>`,
|
||||
);
|
||||
} else {
|
||||
formParts.push(
|
||||
`<input class="pb-input" type="${field.type}" name="${escapeAttr(
|
||||
field.name,
|
||||
)}" placeholder="${escapeAttr(label)}"${required} />`,
|
||||
);
|
||||
}
|
||||
formParts.push(`</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
formParts.push(`<button type="submit" class="pb-btn-base pb-btn-size-md pb-btn-variant-solid-primary">제출</button>`);
|
||||
formParts.push(`</form>`);
|
||||
|
||||
return formParts.join("");
|
||||
const parts: string[] = [];
|
||||
parts.push(`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}></form>`);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
if (block.type === "divider") {
|
||||
@@ -449,13 +383,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const required = props.required ? " required" : "";
|
||||
|
||||
const tokens = computeFormInputExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
return [
|
||||
'<div class="pb-form-field">',
|
||||
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
)}"${required}${tokens.inputStyleAttr} />`,
|
||||
)}"${required}${tokens.inputStyleAttr}${formAttr} />`,
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
@@ -469,11 +405,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormSelectExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}>`);
|
||||
parts.push(
|
||||
`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}${formAttr}>`,
|
||||
);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||
@@ -495,6 +435,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const options = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormCheckboxExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
@@ -503,7 +445,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
@@ -521,6 +463,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const options = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormRadioExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
@@ -529,7 +473,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
}
|
||||
parts.push("</div>");
|
||||
|
||||
@@ -17,6 +17,41 @@ export default function PreviewPage() {
|
||||
mainStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
||||
}
|
||||
|
||||
const handleExportZip = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
blocks,
|
||||
projectConfig,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const slug = (projectConfig?.slug ?? "").trim() || "page-builder-export";
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${slug}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("프리뷰 ZIP 내보내기 중 오류", error);
|
||||
}
|
||||
};
|
||||
|
||||
const widthPx =
|
||||
typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0
|
||||
? projectConfig.canvasWidthPx
|
||||
@@ -43,12 +78,23 @@ export default function PreviewPage() {
|
||||
<h1 className="text-xl font-semibold">Page Preview</h1>
|
||||
<p className="text-xs text-slate-400">빌더로 만든 페이지를 미리 보는 화면</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/editor"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
에디터로 돌아가기
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
void handleExportZip();
|
||||
}}
|
||||
>
|
||||
페이지 파일로 내보내기 (ZIP)
|
||||
</button>
|
||||
<Link
|
||||
href="/editor"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
에디터로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링하되, projectConfig 에 따라 최대 폭을 제한한다. */}
|
||||
<section className="flex flex-1 overflow-auto">
|
||||
|
||||
@@ -103,6 +103,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
const [formMessage, setFormMessage] = useState<string>("");
|
||||
|
||||
const renderBlock = (block: Block) => {
|
||||
if ((block as any).type === "form") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (block.type === "text") {
|
||||
const props = block.props as TextBlockProps;
|
||||
const tokens = computeTextPublicTokens(props);
|
||||
@@ -398,6 +402,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
const props = block.props as VideoBlockProps;
|
||||
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
|
||||
|
||||
if (!rawUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
|
||||
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
|
||||
|
||||
|
||||
@@ -408,6 +408,12 @@ body {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
/* Form 컨트롤러용 최소 폼: 레이아웃에 영향이 없도록 margin/padding 을 0 으로 고정한다. */
|
||||
.pb-form-controller {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pb-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user