텍스트 스타일 및 프리뷰 렌더링 정리
CI / test (push) Successful in 11m5s
CI / pr_and_merge (push) Successful in 1m40s
CI / test (pull_request) Failing after 35m42s
CI / pr_and_merge (pull_request) Has been skipped

This commit is contained in:
2025-11-20 23:36:47 +09:00
parent 9e40ee405c
commit 2f59e3781e
19 changed files with 3831 additions and 556 deletions
@@ -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 ? (