텍스트 스타일 및 프리뷰 렌더링 정리
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import type {
|
||||
Block,
|
||||
TextBlockProps,
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
ImageBlockProps,
|
||||
SectionBlockProps,
|
||||
FormBlockProps,
|
||||
FormSelectOption,
|
||||
FormRadioOption,
|
||||
FormCheckboxOption,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
|
||||
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
|
||||
@@ -26,14 +29,48 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
if (block.type === "text") {
|
||||
const props = block.props as TextBlockProps;
|
||||
|
||||
// 정렬: 에디터와 동일하게 left/center/right 를 그대로 사용하되, 퍼블릭 뷰에서는 Tailwind text-* 클래스로 매핑한다.
|
||||
const alignClass =
|
||||
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
|
||||
const sizeClass = props.size === "sm" ? "text-sm" : props.size === "lg" ? "text-2xl" : "text-base";
|
||||
|
||||
// 폰트 크기: 에디터와 동일한 규칙(fontSizeMode/Scale + size fallback)을 사용하되,
|
||||
// 퍼블릭 뷰에서는 Tailwind text-* 유틸리티 또는 style.fontSize 로 매핑한다.
|
||||
const fontSizeMode = props.fontSizeMode ?? "scale";
|
||||
const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = props.fontSizeScale ?? fallbackScale;
|
||||
|
||||
const fontSizeScaleToClass: Record<
|
||||
NonNullable<TextBlockProps["fontSizeScale"]>,
|
||||
string
|
||||
> = {
|
||||
xs: "text-xs",
|
||||
sm: "text-sm",
|
||||
base: "text-base",
|
||||
lg: "text-lg",
|
||||
xl: "text-xl",
|
||||
"2xl": "text-2xl",
|
||||
"3xl": "text-3xl",
|
||||
};
|
||||
|
||||
let sizeClass = "";
|
||||
const textStyle: CSSProperties = {};
|
||||
|
||||
if (fontSizeMode === "scale") {
|
||||
sizeClass = fontSizeScaleToClass[fontSizeScale];
|
||||
} else if (fontSizeMode === "custom" && props.fontSizeCustom) {
|
||||
textStyle.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
|
||||
// 텍스트 색상: 에디터에서 설정한 colorCustom 이 있으면 그대로 사용하고, 없으면 기본 text-slate-50 클래스를 사용한다.
|
||||
if (props.colorCustom && props.colorCustom.trim() !== "") {
|
||||
textStyle.color = props.colorCustom;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
key={block.id}
|
||||
className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
|
||||
style={textStyle}
|
||||
>
|
||||
{props.text}
|
||||
</p>
|
||||
@@ -57,6 +94,96 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
if (block.type === "form") {
|
||||
const props = block.props as FormBlockProps;
|
||||
|
||||
// v2: fieldIds 가 설정되어 있으면 폼 요소 블록(formInput/formSelect/formCheckbox/formRadio)을 기반으로 필드를 구성한다.
|
||||
const hasControllerFields = props.fieldIds && props.fieldIds.length > 0;
|
||||
|
||||
const controllerFields = hasControllerFields
|
||||
? (props.fieldIds ?? [])
|
||||
.map((fieldId) => blocks.find((b) => b.id === fieldId))
|
||||
.filter((b): b is Block => Boolean(b))
|
||||
.filter(
|
||||
(b) =>
|
||||
b.type === "formInput" ||
|
||||
b.type === "formSelect" ||
|
||||
b.type === "formCheckbox" ||
|
||||
b.type === "formRadio",
|
||||
)
|
||||
.map((fieldBlock) => {
|
||||
const anyProps = fieldBlock.props as any;
|
||||
|
||||
if (fieldBlock.type === "formInput") {
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name: anyProps.formFieldName ?? fieldBlock.id,
|
||||
label: anyProps.label ?? anyProps.formFieldName ?? "입력 필드",
|
||||
type: "text" as const,
|
||||
required: Boolean(anyProps.required),
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldBlock.type === "formSelect") {
|
||||
const options: FormSelectOption[] = Array.isArray(anyProps.options)
|
||||
? anyProps.options
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name: anyProps.formFieldName ?? fieldBlock.id,
|
||||
label: anyProps.label ?? anyProps.formFieldName ?? "선택 필드",
|
||||
type: "select" as const,
|
||||
options,
|
||||
required: Boolean(anyProps.required),
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldBlock.type === "formCheckbox") {
|
||||
const options: FormCheckboxOption[] = Array.isArray(anyProps.options)
|
||||
? anyProps.options
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name: anyProps.formFieldName ?? fieldBlock.id,
|
||||
label: anyProps.groupLabel ?? anyProps.formFieldName ?? "체크박스",
|
||||
type: "checkbox" as const,
|
||||
options,
|
||||
groupLabelMode: anyProps.groupLabelMode,
|
||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
||||
required: Boolean(anyProps.required),
|
||||
};
|
||||
}
|
||||
|
||||
// formRadio
|
||||
const options: FormRadioOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
|
||||
return {
|
||||
id: fieldBlock.id,
|
||||
name: anyProps.formFieldName ?? fieldBlock.id,
|
||||
label: anyProps.groupLabel ?? anyProps.formFieldName ?? "라디오 그룹",
|
||||
type: "radio" as const,
|
||||
options,
|
||||
groupLabelMode: anyProps.groupLabelMode,
|
||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
||||
required: Boolean(anyProps.required),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const fields = hasControllerFields && controllerFields.length > 0
|
||||
? controllerFields
|
||||
: props.fields && props.fields.length > 0
|
||||
? props.fields
|
||||
: [
|
||||
{ id: `${block.id}_name`, name: "name", label: "이름", type: "text", required: true },
|
||||
{ id: `${block.id}_email`, name: "email", label: "이메일", type: "email", required: true },
|
||||
{ id: `${block.id}_message`, name: "message", label: "메시지", type: "textarea", required: true },
|
||||
];
|
||||
|
||||
// FormBlock 이 submitButtonId 를 가지고 있다면, 해당 버튼 블록의 label 을 submit 버튼 라벨로 사용한다.
|
||||
const mappedSubmitButton = blocks.find(
|
||||
(b) => b.type === "button" && b.id === props.submitButtonId,
|
||||
);
|
||||
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -89,35 +216,112 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
|
||||
return (
|
||||
<form key={block.id} className="space-y-3" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-200">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>이름</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name="name"
|
||||
placeholder="이름을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>이메일</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>메시지</span>
|
||||
<textarea
|
||||
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name="message"
|
||||
placeholder="전달할 내용을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
|
||||
<input type="hidden" name="__config" value={JSON.stringify(props)} />
|
||||
<div className="flex flex-col gap-2 text-xs text-slate-200">
|
||||
{fields.map((field: any) => (
|
||||
<label key={field.id} className="flex flex-col gap-1">
|
||||
<span>{field.label}</span>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
placeholder={field.label}
|
||||
required={field.required}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
defaultValue={field.options?.[0]?.value ?? ""}
|
||||
>
|
||||
{(field.options ?? []).map((opt: FormSelectOption) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === "checkbox" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* 그룹 타이틀: 텍스트/이미지 모드를 지원한다. alt 는 그룹 라벨 텍스트를 사용한다. */}
|
||||
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={field.groupLabelImageUrl}
|
||||
alt={field.label}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{field.label}</span>
|
||||
)}
|
||||
{(field.options ?? []).map((opt: FormCheckboxOption, index: number) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
||||
type="checkbox"
|
||||
name={field.name}
|
||||
value={opt.value}
|
||||
required={field.required && index === 0}
|
||||
/>
|
||||
{opt.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={opt.labelImageUrl}
|
||||
alt={opt.label || field.label || "체크박스 옵션"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{opt.label}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : field.type === "radio" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={field.groupLabelImageUrl}
|
||||
alt={field.label}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{field.label}</span>
|
||||
)}
|
||||
{(field.options ?? []).map((opt: FormRadioOption, index: number) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="radio"
|
||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||
name={field.name}
|
||||
value={opt.value}
|
||||
required={field.required && index === 0}
|
||||
/>
|
||||
{opt.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={opt.labelImageUrl}
|
||||
alt={opt.label || field.label || "라디오 옵션"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{opt.label}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
type={field.type === "email" ? "email" : "text"}
|
||||
placeholder={field.label}
|
||||
required={field.required}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -125,7 +329,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
disabled={formStatus === "submitting"}
|
||||
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{formStatus === "submitting" ? "전송 중..." : "폼 전송"}
|
||||
{formStatus === "submitting"
|
||||
? "전송 중..."
|
||||
: mappedSubmitLabel ?? "폼 전송"}
|
||||
</button>
|
||||
|
||||
{formStatus !== "idle" && formMessage ? (
|
||||
|
||||
Reference in New Issue
Block a user