에디터 인라인/속성 패널 리팩터링 및 폼 전송 기본 기능 추가
CI / pr_and_merge (push) Blocked by required conditions
CI / test (push) Has been cancelled

This commit is contained in:
2025-11-20 00:53:39 +09:00
parent 211b0f8230
commit a6ef5f01cd
29 changed files with 3371 additions and 1052 deletions
@@ -0,0 +1,167 @@
import type { ChangeEvent } from "react";
import { useState } from "react";
export type ColorPaletteItem = {
// 팔레트 식별자
id: string;
// UI에 보여줄 이름
label: string;
// HEX 색상 값
color: string;
};
export type ColorPickerFieldProps = {
// 필드 라벨 텍스트
label: string;
// 컬러 인풋에 대한 접근성 라벨
ariaLabelColorInput: string;
// HEX 텍스트 인풋에 대한 접근성 라벨
ariaLabelHexInput: string;
// 현재 선택된 색상 값 (HEX)
value: string;
// 값 변경 콜백
onChange: (value: string) => void;
// 선택 가능한 팔레트 목록
palette?: ColorPaletteItem[];
// 현재 선택된 팔레트 ID (있다면 드롭다운 요약에 표시)
selectedPaletteId?: string;
// 팔레트 선택 시 호출되는 콜백 (팔레트 메타 정보까지 필요할 때 사용)
onPaletteSelect?: (item: ColorPaletteItem) => void;
};
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
// 기본/중립 계열
{ id: "default", label: "기본", color: "#e5e7eb" },
{ id: "muted", label: "연한", color: "#9ca3af" },
{ id: "strong", label: "강조", color: "#f9fafb" },
{ id: "neutral", label: "중립", color: "#94a3b8" },
// 브랜드/포인트 계열
{ id: "accent", label: "포인트", color: "#38bdf8" },
{ id: "accent-deep", label: "포인트 진하게", color: "#0ea5e9" },
{ id: "accent-soft", label: "포인트 연하게", color: "#bae6fd" },
// 상태 계열
{ id: "danger", label: "위험", color: "#f97373" },
{ id: "danger-deep", label: "위험 진하게", color: "#ef4444" },
{ id: "success", label: "성공", color: "#22c55e" },
{ id: "success-soft", label: "성공 연하게", color: "#bbf7d0" },
{ id: "warning", label: "경고", color: "#eab308" },
{ id: "info", label: "정보", color: "#0ea5e9" },
// 보라/핑크 계열
{ id: "purple", label: "보라", color: "#a855f7" },
{ id: "purple-soft", label: "연한 보라", color: "#e9d5ff" },
{ id: "pink", label: "핑크", color: "#ec4899" },
{ id: "pink-soft", label: "연한 핑크", color: "#f9a8d4" },
// 배경 대비용 어두운 텍스트
{ id: "dark", label: "어두운 텍스트", color: "#020617" },
{ id: "dark-muted", label: "어두운 연한", color: "#64748b" },
];
export function ColorPickerField({
label,
ariaLabelColorInput,
ariaLabelHexInput,
value,
onChange,
palette = [],
selectedPaletteId,
onPaletteSelect,
}: ColorPickerFieldProps) {
const [open, setOpen] = useState(false);
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
// 텍스트 인풋 변경 시 그대로 onChange로 전달한다.
const handleHexInputChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
// 팔레트 버튼 클릭 시 해당 팔레트의 색상으로 onChange를 호출한다.
const handlePaletteClick = (item: ColorPaletteItem) => {
onChange(item.color);
if (onPaletteSelect) {
onPaletteSelect(item);
}
};
// 현재 선택된 팔레트 정보를 계산한다.
const selectedPalette =
selectedPaletteId && palette.length > 0
? palette.find((item) => item.id === selectedPaletteId) ?? null
: null;
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>{label}</span>
<div className="flex items-center gap-2">
<input
type="color"
aria-label={ariaLabelColorInput}
className="h-8 w-8 rounded border border-slate-700 bg-slate-900 p-0"
value={value || "#ffffff"}
onChange={handleColorInputChange}
/>
<input
className="w-28 flex-none rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label={ariaLabelHexInput}
placeholder="예: #ff0000"
value={value}
onChange={handleHexInputChange}
/>
{palette.length > 0 ? (
<div className="relative text-[11px] flex-none">
<button
type="button"
className="w-28 rounded border border-slate-800 bg-slate-950/60 flex items-center justify-between px-2 py-1 text-left"
onClick={() => setOpen((prev) => !prev)}
>
<span className="flex items-center gap-1">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: selectedPalette?.color ?? value ?? "#ffffff" }}
/>
<span className="truncate text-slate-200">
{selectedPalette?.label ?? "색상 팔레트"}
</span>
</span>
<span className="text-slate-500 text-[10px]"></span>
</button>
{open ? (
<div className="absolute right-0 top-full mt-1 w-32 rounded border border-slate-800 bg-slate-950 max-h-40 overflow-auto z-10">
{palette.map((item) => (
<button
key={item.id}
type="button"
className={`w-full flex items-center justify-between px-2 py-1 text-left text-[11px] border-b border-slate-900 last:border-b-0 ${
selectedPaletteId === item.id
? "bg-sky-900/40 text-sky-100"
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
}`}
onClick={() => {
handlePaletteClick(item);
setOpen(false);
}}
>
<span className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span>{item.label}</span>
</span>
</button>
))}
</div>
) : null}
</div>
) : null}
</div>
</label>
);
}
@@ -0,0 +1,97 @@
import type { ChangeEvent } from "react";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
export type NumericPresetOption = {
// 프리셋 식별자 (예: "tight", "normal")
id: string;
// UI 에 표시할 라벨
label: string;
// UI 기준 값 (px, 배율 등)
value: number;
};
export type NumericPropertyControlProps = {
// 필드 라벨 (예: 글자 크기, 줄 간격)
label: string;
// 단위 설명 (예: px, 배율 등)
unitLabel?: string;
// 현재 UI 값
value: number;
min: number;
max: number;
step: number;
// 프리셋 목록 (없으면 select 를 렌더링하지 않음)
presets?: NumericPresetOption[];
// 값 변경 시 호출되는 콜백 (UI 값 기준)
onChangeValue: (value: number) => void;
};
export function NumericPropertyControl({
label,
unitLabel,
value,
min,
max,
step,
presets,
onChangeValue,
}: NumericPropertyControlProps) {
// 현재 값에 가장 가까운 프리셋 ID 를 계산한다.
const currentPresetId = (() => {
if (!presets || presets.length === 0) return "";
let bestId = presets[0].id;
let bestDist = Math.abs(presets[0].value - value);
for (let i = 1; i < presets.length; i += 1) {
const dist = Math.abs(presets[i].value - value);
if (dist < bestDist) {
bestDist = dist;
bestId = presets[i].id;
}
}
return bestId;
})();
const handlePresetChange = (event: ChangeEvent<HTMLSelectElement>) => {
if (!presets || presets.length === 0) return;
const id = event.target.value;
const found = presets.find((p) => p.id === id);
if (!found) return;
onChangeValue(found.value);
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>
{label}
{unitLabel ? ` ${unitLabel}` : ""}
</span>
<div className="flex items-center gap-2">
{presets && presets.length > 0 ? (
<select
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label={`${label} 프리셋`}
value={currentPresetId}
onChange={handlePresetChange}
>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
) : null}
<PropertySliderField
label=""
ariaLabelSlider={`${label} 슬라이더`}
ariaLabelInput={`${label} 커스텀${unitLabel ? ` ${unitLabel}` : ""}`}
value={Number.isFinite(value) ? value : 0}
min={min}
max={max}
step={step}
onChange={onChangeValue}
/>
</div>
</label>
);
}
@@ -0,0 +1,70 @@
import type { ChangeEvent } from "react";
export type PropertySliderFieldProps = {
// 슬라이더 그룹 전체 라벨 텍스트
label: string;
// 슬라이더 요소에 대한 접근성 라벨
ariaLabelSlider: string;
// 텍스트 입력 요소에 대한 접근성 라벨
ariaLabelInput: string;
// 현재 값 (슬라이더와 입력이 공유)
value: number;
// 최소 값
min: number;
// 최대 값
max: number;
// 슬라이더 스텝
step: number;
// 값 변경 콜백
onChange: (value: number) => void;
};
export function PropertySliderField({
label,
ariaLabelSlider,
ariaLabelInput,
value,
min,
max,
step,
onChange,
}: PropertySliderFieldProps) {
// 슬라이더 변경 시 상위에서 전달받은 onChange에 숫자 값으로 전달한다.
const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
const numeric = Number(event.target.value || 0);
onChange(numeric);
};
// 텍스트 입력 변경 시에도 숫자 값으로 파싱하여 onChange를 호출한다.
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
const numeric = Number(raw);
if (!Number.isNaN(numeric)) {
onChange(numeric);
}
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>{label}</span>
<div className="flex items-center gap-2">
<input
type="range"
min={min}
max={max}
step={step}
aria-label={ariaLabelSlider}
value={value}
onChange={handleSliderChange}
className="flex-1"
/>
</div>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label={ariaLabelInput}
value={Number.isFinite(value) ? value : ""}
onChange={handleInputChange}
/>
</label>
);
}
@@ -1,6 +1,14 @@
"use client";
import type { Block, TextBlockProps, ButtonBlockProps, ImageBlockProps, SectionBlockProps } from "@/features/editor/state/editorStore";
import { useState } from "react";
import type {
Block,
TextBlockProps,
ButtonBlockProps,
ImageBlockProps,
SectionBlockProps,
FormBlockProps,
} from "@/features/editor/state/editorStore";
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
interface PublicPageRendererProps {
@@ -11,6 +19,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [formMessage, setFormMessage] = useState<string>("");
const renderBlock = (block: Block) => {
if (block.type === "text") {
const props = block.props as TextBlockProps;
@@ -20,7 +31,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const sizeClass = props.size === "sm" ? "text-sm" : props.size === "lg" ? "text-2xl" : "text-base";
return (
<p key={block.id} className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed`}>
<p
key={block.id}
className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
>
{props.text}
</p>
);
@@ -35,11 +49,98 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
href={props.href}
className="inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 transition-colors"
>
{props.label}
<span className="whitespace-pre-wrap">{props.label}</span>
</a>
);
}
if (block.type === "form") {
const props = block.props as FormBlockProps;
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
try {
setFormStatus("submitting");
setFormMessage("");
const res = await fetch("/api/forms/submit", {
method: "POST",
body: data,
});
if (res.ok) {
setFormStatus("success");
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
form.reset();
} else {
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
} catch (error) {
console.error("[PublicPageRenderer] form submit error", error);
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
};
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>
</div>
<button
type="submit"
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" ? "전송 중..." : "폼 전송"}
</button>
{formStatus !== "idle" && formMessage ? (
<p
className={`text-xs mt-1 ${
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
}`}
>
{formMessage}
</p>
) : null}
</form>
);
}
if (block.type === "image") {
const props = block.props as ImageBlockProps;