텍스트 스타일 및 프리뷰 렌더링 정리
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
+93 -2
View File
@@ -1,14 +1,105 @@
import { NextResponse } from "next/server";
import type { FormBlockProps } from "@/features/editor/state/editorStore";
export async function POST(req: Request) {
const formData = await req.formData();
// 기본 필드(name/email/message)는 그대로 읽어둔다.
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
console.log("[forms/submit]", { name, email, message });
// 에디터에서 넘겨준 폼 설정(JSON) 읽기
const rawConfig = formData.get("__config");
let config: FormBlockProps | null = null;
if (typeof rawConfig === "string") {
try {
config = JSON.parse(rawConfig) as FormBlockProps;
} catch {
config = null;
}
}
const submitTarget = config?.submitTarget ?? "internal";
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
if (submitTarget === "internal") {
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
console.log("[forms/submit][internal]", { name, email, message });
return NextResponse.json({ ok: true });
}
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
if (submitTarget === "webhook") {
const destinationUrl = config?.destinationUrl;
if (!destinationUrl) {
return NextResponse.json({ ok: false, error: "destinationUrl_missing" }, { status: 400 });
}
const payloadFormat = config?.payloadFormat ?? "form";
let body: string;
const headers: Record<string, string> = {
...(config?.headers ?? {}),
};
if (payloadFormat === "json") {
const jsonPayload: Record<string, string> = {};
formData.forEach((value, key) => {
if (key !== "__config") {
jsonPayload[key] = String(value);
}
});
if (config?.extraParams) {
Object.entries(config.extraParams).forEach(([k, v]) => {
jsonPayload[k] = v;
});
}
headers["Content-Type"] = "application/json";
body = JSON.stringify(jsonPayload);
} else {
// 기본 동작: FormData -> x-www-form-urlencoded 로 변환 (많은 webhook/Apps Script가 이 형식을 사용)
const payload = new URLSearchParams();
formData.forEach((value, key) => {
if (key !== "__config") {
payload.append(key, String(value));
}
});
if (config?.extraParams) {
Object.entries(config.extraParams).forEach(([k, v]) => {
payload.append(k, v);
});
}
headers["Content-Type"] = "application/x-www-form-urlencoded";
body = payload.toString();
}
try {
const res = await fetch(destinationUrl, {
method: config?.method ?? "POST",
headers,
body,
});
if (!res.ok) {
console.error("[forms/submit][webhook] remote error", res.status, await res.text().catch(() => ""));
return NextResponse.json({ ok: false, error: "webhook_failed" }, { status: 502 });
}
} catch (error) {
console.error("[forms/submit][webhook] fetch error", error);
return NextResponse.json({ ok: false, error: "webhook_exception" }, { status: 500 });
}
console.log("[forms/submit][webhook] forwarded", { name, email, message, destinationUrl });
return NextResponse.json({ ok: true });
}
// 그 외 알 수 없는 모드는 internal 과 동일하게 처리
console.warn("[forms/submit] unknown submitTarget, fallback to internal", submitTarget);
console.log("[forms/submit][internal]", { name, email, message });
return NextResponse.json({ ok: true });
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { ReactNode } from "react";
import {
DndContext,
DragOverlay,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { rectIntersection } from "@dnd-kit/core";
import type { Block, ButtonBlockProps, ImageBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
interface EditorCanvasProps {
blocks: Block[];
rootBlocks: Block[];
selectedBlockId: string | null;
editingBlockId: string | null;
editingText: string;
activeDragId: string | null;
sensors: any;
onCanvasEmptyClick: () => void;
renderBlocks: (targetBlocks: Block[]) => ReactNode;
handleDragStart: (event: DragStartEvent) => void;
handleDragEnd: (event: DragEndEvent) => void;
handleDragCancel: () => void;
}
export function EditorCanvas(props: EditorCanvasProps) {
const {
blocks,
rootBlocks,
activeDragId,
sensors,
onCanvasEmptyClick,
renderBlocks,
handleDragStart,
handleDragEnd,
handleDragCancel,
} = props;
return (
<div
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
data-testid="editor-canvas"
onClick={(event) => {
if (event.target === event.currentTarget) {
onCanvasEmptyClick();
}
}}
>
{blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
"텍스트 블록 추가" .
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={rectIntersection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={rootBlocks.map((b) => b.id)}
strategy={verticalListSortingStrategy}
>
{renderBlocks(rootBlocks)}
</SortableContext>
<DragOverlay>
{activeDragId && (
<DragPreview block={blocks.find((b) => b.id === activeDragId) ?? null} />
)}
</DragOverlay>
</DndContext>
)}
</div>
);
}
interface DragPreviewProps {
block: Block | null;
}
function DragPreview({ block }: DragPreviewProps) {
if (!block) return null;
return (
<div className="pointer-events-none rounded border border-sky-500 bg-slate-900/80 px-3 py-2 text-xs text-slate-100 shadow-lg opacity-90 min-w-[160px] max-w-[260px]">
<div className="text-[10px] uppercase tracking-wide text-sky-300 mb-1">
{block.type === "text"
? "Text"
: block.type === "button"
? "Button"
: block.type === "image"
? "Image"
: block.type === "divider"
? "Divider"
: block.type === "list"
? "List"
: "Section"}
</div>
<div className="truncate">
{block.type === "text"
? (block.props as TextBlockProps).text || "텍스트 블록"
: block.type === "button"
? (block.props as ButtonBlockProps).label || "버튼 블록"
: block.type === "image"
? (block.props as ImageBlockProps).alt || "이미지 블록"
: block.type === "list"
? "리스트 블록"
: block.type === "divider"
? "구분선 블록"
: "섹션 블록"}
</div>
</div>
);
}
@@ -0,0 +1,276 @@
"use client";
import type { Block, FormCheckboxBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
interface FormCheckboxPropertiesPanelProps {
block: Block;
selectedBlockId: string | null;
updateBlock: (id: string, partial: any) => void;
}
export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBlock }: FormCheckboxPropertiesPanelProps) {
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const checkboxProps = block.props as FormCheckboxBlockProps;
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={checkboxProps.groupLabelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelMode: e.target.value,
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={checkboxProps.groupLabel ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabel: e.target.value,
} as any)
}
/>
</label>
{checkboxProps.groupLabelMode === "image" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={checkboxProps.groupLabelImageUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelImageUrl: e.target.value,
} as any)
}
/>
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={checkboxProps.formFieldName ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
formFieldName: e.target.value,
} as any)
}
/>
</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-400"></span>
<button
type="button"
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 hover:bg-slate-800"
onClick={() => {
const next = Array.isArray((checkboxProps as any).options)
? [...(checkboxProps as any).options]
: [];
next.push({
label: "새 옵션",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
>
</button>
</div>
<div className="space-y-2">
{(((checkboxProps as any).options ?? []) as any[]).map((opt, index) => (
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
<div className="flex items-center gap-1">
<input
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="라벨"
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
label: e.target.value,
};
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
/>
<input
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="값(value)"
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
value: e.target.value,
};
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
/>
<button
type="button"
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
onClick={() => {
const next = (((checkboxProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
>
×
</button>
</div>
<div className="flex flex-col gap-1">
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="옵션 이미지 URL (선택)"
value={opt.labelImageUrl ?? ""}
onChange={(e) => {
const next = [...(((checkboxProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
labelImageUrl: e.target.value,
};
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
/>
</div>
</div>
))}
</div>
</div>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
checked={Boolean(checkboxProps.required)}
onChange={(e) =>
updateBlock(selectedBlockId, {
required: e.target.checked,
} as any)
}
/>
<span className="text-slate-400"> </span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="체크박스 텍스트 색상 피커"
ariaLabelHexInput="체크박스 텍스트 색상 HEX"
value={
checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== ""
? checkboxProps.textColorCustom
: "#f9fafb"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
textColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
textColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label="배경 색상"
ariaLabelColorInput="체크박스 배경 색상 피커"
ariaLabelHexInput="체크박스 배경 색상 HEX"
value={
checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== ""
? checkboxProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
fillColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
fillColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label="테두리 색상"
ariaLabelColorInput="체크박스 테두리 색상 피커"
ariaLabelHexInput="체크박스 테두리 색상 HEX"
value={
checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== ""
? checkboxProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
strokeColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
strokeColorCustom: item.color,
} as any);
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
value={(() => {
const r = checkboxProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
})()}
min={0}
max={8}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
updateBlock(selectedBlockId, {
borderRadius: next,
} as any);
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
interface FormControllerPanelProps {
block: Block; // type === "form"
blocks: Block[];
selectedBlockId: string | null;
updateBlock: (id: string, partial: any) => void;
}
export function FormControllerPanel({ block, blocks, selectedBlockId, updateBlock }: FormControllerPanelProps) {
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const formProps = block.props as FormBlockProps;
return (
<div className="space-y-4 text-xs">
<p className="text-slate-400">
<code className="font-mono text-[11px]">/api/forms/submit</code>
, Webhook .
</p>
<div className="space-y-2">
<div className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={formProps.submitTarget ?? "internal"}
onChange={(e) =>
updateBlock(selectedBlockId, {
submitTarget: e.target.value as FormBlockProps["submitTarget"],
} as any)
}
>
<option value="internal"> (Webhook )</option>
<option value="webhook"> Webhook / Google Sheets </option>
</select>
</div>
{formProps.submitTarget === "webhook" && (
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-400">Webhook / Google Sheets URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
placeholder="예: https://script.google.com/macros/s/.../exec"
value={formProps.destinationUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
destinationUrl: e.target.value,
} as any)
}
/>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-slate-400"> </span>
<select
className="w-40 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={formProps.payloadFormat ?? "form"}
onChange={(e) =>
updateBlock(selectedBlockId, {
payloadFormat: e.target.value as any,
} as any)
}
>
<option value="form"> (x-www-form-urlencoded)</option>
<option value="json">JSON (application/json)</option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-slate-400">HTTP </span>
<select
className="w-28 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={formProps.method ?? "POST"}
onChange={(e) =>
updateBlock(selectedBlockId, {
method: e.target.value as FormBlockProps["method"],
} as any)
}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400">Authorization ()</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
placeholder="예: Bearer xxxxxx"
value={formProps.headers?.Authorization ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
headers: {
...(formProps.headers ?? {}),
Authorization: e.target.value,
},
} as any)
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> (key=value , )</span>
<textarea
className="w-full h-16 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500"
placeholder={"예:\nsource=landing\nformId=contact-hero"}
value={Object.entries(formProps.extraParams ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n")}
onChange={(e) => {
const lines = e.target.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const next: Record<string, string> = {};
for (const line of lines) {
const idx = line.indexOf("=");
if (idx > 0) {
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key) next[key] = value;
}
}
updateBlock(selectedBlockId, {
extraParams: next,
} as any);
}}
/>
</label>
</div>
)}
</div>
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<fieldset className="space-y-2" aria-label="폼 필드 매핑">
<legend className="text-[11px] text-slate-400 mb-1"> </legend>
{blocks
.filter((b) =>
b.type === "formInput" ||
b.type === "formSelect" ||
b.type === "formCheckbox" ||
b.type === "formRadio",
)
.map((fieldBlock) => {
const fieldId = fieldBlock.id;
const fieldLabel = (fieldBlock.props as any).label ??
(fieldBlock.props as any).groupLabel ??
(fieldBlock.props as any).formFieldName ??
fieldId;
const checked = (formProps.fieldIds ?? []).includes(fieldId);
return (
<label
key={fieldId}
className="flex items-center gap-2 text-[11px] text-slate-200"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-600 bg-slate-950"
checked={checked}
onChange={(e) => {
const current = formProps.fieldIds ?? [];
const next = e.target.checked
? [...current, fieldId]
: current.filter((id) => id !== fieldId);
updateBlock(selectedBlockId, {
fieldIds: next,
} as any);
}}
/>
<span>{fieldLabel}</span>
</label>
);
})}
</fieldset>
<div className="space-y-1">
<span className="text-[11px] text-slate-400">Submit </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="Submit 버튼"
value={formProps.submitButtonId ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
submitButtonId: e.target.value || null,
} as any)
}
>
<option value=""> </option>
{blocks
.filter((b) => b.type === "button")
.map((buttonBlock) => {
const btnProps = buttonBlock.props as ButtonBlockProps;
return (
<option key={buttonBlock.id} value={buttonBlock.id}>
{btnProps.label || buttonBlock.id}
</option>
);
})}
</select>
</div>
</div>
</div>
);
}
@@ -0,0 +1,290 @@
"use client";
import type { Block, FormInputBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
interface FormInputPropertiesPanelProps {
block: Block;
selectedBlockId: string | null;
updateBlock: (id: string, partial: any) => void;
}
export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }: FormInputPropertiesPanelProps) {
if (!selectedBlockId || block.id !== selectedBlockId || block.type !== "formInput") return null;
const inputProps = block.props as FormInputBlockProps;
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={inputProps.labelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelMode: e.target.value,
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={inputProps.label ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
label: e.target.value,
} as any)
}
/>
</label>
{inputProps.labelMode === "image" && (
<>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={inputProps.labelImageUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelImageUrl: e.target.value,
} as any)
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={inputProps.labelImageAlt ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelImageAlt: e.target.value,
} as any)
}
/>
</label>
</>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={inputProps.formFieldName ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
formFieldName: e.target.value,
} as any)
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="필드 타입"
value={inputProps.inputType ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
inputType: e.target.value,
} as any)
}
>
<option value="text"></option>
<option value="email"></option>
<option value="textarea"> </option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400">Placeholder</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="Placeholder"
value={inputProps.placeholder ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
placeholder: e.target.value,
} as any)
}
/>
</label>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
checked={Boolean(inputProps.required)}
onChange={(e) =>
updateBlock(selectedBlockId, {
required: e.target.checked,
} as any)
}
/>
<span className="text-slate-400"> </span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={inputProps.align ?? "left"}
onChange={(e) =>
updateBlock(selectedBlockId, {
align: e.target.value,
} as any)
}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</label>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span></span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={inputProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
</select>
</label>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={inputProps.widthMode ?? "full"}
onChange={(e) =>
updateBlock(selectedBlockId, {
widthMode: e.target.value,
} as any)
}
>
<option value="auto"></option>
<option value="full"> </option>
<option value="fixed"> </option>
</select>
</label>
{(inputProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
unitLabel="(px)"
value={inputProps.widthPx ?? 240}
min={80}
max={800}
step={10}
presets={[
{ id: "sm", label: "작게", value: 200 },
{ id: "md", label: "보통", value: 280 },
{ id: "lg", label: "넓게", value: 360 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
/>
)}
<ColorPickerField
label="필드 텍스트 색상"
ariaLabelColorInput="필드 텍스트 색상 피커"
ariaLabelHexInput="필드 텍스트 색상 HEX"
value={
inputProps.textColorCustom && inputProps.textColorCustom.trim() !== ""
? inputProps.textColorCustom
: "#f9fafb"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
textColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
textColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label="필드 채움 색상"
ariaLabelColorInput="필드 채움 색상 피커"
ariaLabelHexInput="필드 채움 색상 HEX"
value={
inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== ""
? inputProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
fillColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
fillColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label="필드 테두리 색상"
ariaLabelColorInput="필드 테두리 색상 피커"
ariaLabelHexInput="필드 테두리 색상 HEX"
value={
inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== ""
? inputProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
strokeColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
strokeColorCustom: item.color,
} as any);
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
value={(() => {
const r = inputProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
})()}
min={0}
max={8}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
updateBlock(selectedBlockId, {
borderRadius: next,
} as any);
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,276 @@
"use client";
import type { Block, FormRadioBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
interface FormRadioPropertiesPanelProps {
block: Block;
selectedBlockId: string | null;
updateBlock: (id: string, partial: any) => void;
}
export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }: FormRadioPropertiesPanelProps) {
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const radioProps = block.props as FormRadioBlockProps;
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={radioProps.groupLabelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelMode: e.target.value,
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={radioProps.groupLabel ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabel: e.target.value,
} as any)
}
/>
</label>
{radioProps.groupLabelMode === "image" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={radioProps.groupLabelImageUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelImageUrl: e.target.value,
} as any)
}
/>
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={radioProps.formFieldName ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
formFieldName: e.target.value,
} as any)
}
/>
</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-400"></span>
<button
type="button"
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 hover:bg-slate-800"
onClick={() => {
const next = Array.isArray((radioProps as any).options)
? [...(radioProps as any).options]
: [];
next.push({
label: "새 옵션",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
>
</button>
</div>
<div className="space-y-2">
{(((radioProps as any).options ?? []) as any[]).map((opt, index) => (
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
<div className="flex items-center gap-1">
<input
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="라벨"
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
label: e.target.value,
};
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
/>
<input
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="값(value)"
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
value: e.target.value,
};
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
/>
<button
type="button"
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
onClick={() => {
const next = (((radioProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
>
×
</button>
</div>
<div className="flex flex-col gap-1">
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="옵션 이미지 URL (선택)"
value={opt.labelImageUrl ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
labelImageUrl: e.target.value,
};
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
/>
</div>
</div>
))}
</div>
</div>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
checked={Boolean(radioProps.required)}
onChange={(e) =>
updateBlock(selectedBlockId, {
required: e.target.checked,
} as any)
}
/>
<span className="text-slate-400"> </span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="라디오 텍스트 색상 피커"
ariaLabelHexInput="라디오 텍스트 색상 HEX"
value={
radioProps.textColorCustom && radioProps.textColorCustom.trim() !== ""
? radioProps.textColorCustom
: "#f9fafb"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
textColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
textColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label="배경 색상"
ariaLabelColorInput="라디오 배경 색상 피커"
ariaLabelHexInput="라디오 배경 색상 HEX"
value={
radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== ""
? radioProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
fillColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
fillColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label="테두리 색상"
ariaLabelColorInput="라디오 테두리 색상 피커"
ariaLabelHexInput="라디오 테두리 색상 HEX"
value={
radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== ""
? radioProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
strokeColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
strokeColorCustom: item.color,
} as any);
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
value={(() => {
const r = radioProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
})()}
min={0}
max={8}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
updateBlock(selectedBlockId, {
borderRadius: next,
} as any);
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,243 @@
"use client";
import type { Block, FormSelectBlockProps } from "@/features/editor/state/editorStore";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
interface FormSelectPropertiesPanelProps {
block: Block;
selectedBlockId: string | null;
updateBlock: (id: string, partial: any) => void;
}
export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock }: FormSelectPropertiesPanelProps) {
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const selectProps = block.props as FormSelectBlockProps;
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<select
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
value={selectProps.labelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelMode: e.target.value,
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={selectProps.label ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
label: e.target.value,
} as any)
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={selectProps.formFieldName ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
formFieldName: e.target.value,
} as any)
}
/>
</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-400"></span>
<button
type="button"
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 hover:bg-slate-800"
onClick={() => {
const next = Array.isArray((selectProps as any).options)
? [...(selectProps as any).options]
: [];
next.push({
label: "새 옵션",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
>
</button>
</div>
<div className="space-y-2">
{(((selectProps as any).options ?? []) as any[]).map((opt, index) => (
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
<div className="flex items-center gap-1">
<input
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="라벨"
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((selectProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
label: e.target.value,
};
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
/>
<input
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
placeholder="값(value)"
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((selectProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
value: e.target.value,
};
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
/>
<button
type="button"
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
onClick={() => {
const next = (((selectProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
>
×
</button>
</div>
</div>
))}
</div>
</div>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
checked={Boolean(selectProps.required)}
onChange={(e) =>
updateBlock(selectedBlockId, {
required: e.target.checked,
} as any)
}
/>
<span className="text-slate-400"> </span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<ColorPickerField
label="필드 텍스트 색상"
ariaLabelColorInput="셀렉트 텍스트 색상 피커"
ariaLabelHexInput="셀렉트 텍스트 색상 HEX"
value={
selectProps.textColorCustom && selectProps.textColorCustom.trim() !== ""
? selectProps.textColorCustom
: "#f9fafb"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
textColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
textColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label="필드 채움 색상"
ariaLabelColorInput="셀렉트 채움 색상 피커"
ariaLabelHexInput="셀렉트 채움 색상 HEX"
value={
selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== ""
? selectProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
fillColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
fillColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label="필드 테두리 색상"
ariaLabelColorInput="셀렉트 테두리 색상 피커"
ariaLabelHexInput="셀렉트 테두리 색상 HEX"
value={
selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== ""
? selectProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
strokeColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
strokeColorCustom: item.color,
} as any);
}}
/>
<NumericPropertyControl
label="필드 모서리 둥글기"
value={(() => {
const r = selectProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
})()}
min={0}
max={8}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 4 },
{ id: "lg", label: "크게", value: 6 },
{ id: "full", label: "완전 둥글게", value: 8 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
updateBlock(selectedBlockId, {
borderRadius: next,
} as any);
}}
/>
</div>
</div>
);
}
+353 -402
View File
@@ -32,6 +32,11 @@ import type {
DividerBlockProps,
ListBlockProps,
FormBlockProps,
FormFieldConfig,
FormInputBlockProps,
FormSelectBlockProps,
FormCheckboxBlockProps,
FormRadioBlockProps,
} from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
@@ -39,6 +44,9 @@ import { ListPropertiesPanel } from "./panels/ListPropertiesPanel";
import { DividerPropertiesPanel } from "./panels/DividerPropertiesPanel";
import { ImagePropertiesPanel } from "./panels/ImagePropertiesPanel";
import { SectionPropertiesPanel } from "./panels/SectionPropertiesPanel";
import { BlocksSidebar } from "./panels/BlocksSidebar";
import { PropertiesSidebar } from "./panels/PropertiesSidebar";
import { EditorCanvas } from "./EditorCanvas";
export default function EditorPage() {
const blocks = useEditorStore((state) => state.blocks);
@@ -50,6 +58,10 @@ export default function EditorPage() {
const addListBlock = useEditorStore((state) => state.addListBlock);
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
const addFormBlock = useEditorStore((state) => state.addFormBlock);
const addFormInputBlock = useEditorStore((state) => (state as any).addFormInputBlock);
const addFormSelectBlock = useEditorStore((state) => (state as any).addFormSelectBlock);
const addFormCheckboxBlock = useEditorStore((state) => (state as any).addFormCheckboxBlock);
const addFormRadioBlock = useEditorStore((state) => (state as any).addFormRadioBlock);
const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection);
const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection);
const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection);
@@ -566,315 +578,33 @@ export default function EditorPage() {
</div>
</header>
<section className="flex flex-1 overflow-hidden">
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
<h2 className="font-medium"></h2>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addTextBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addButtonBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addImageBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addDividerBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addListBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addSectionBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
onClick={addFormBlock}
>
</button>
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<h3 className="text-[11px] font-medium text-slate-300">릿</h3>
<button
type="button"
className="w-full rounded border border-sky-700 bg-sky-950 px-3 py-2 text-left text-xs text-sky-100 hover:bg-sky-900"
onClick={addHeroTemplateSection}
>
Hero 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addFeaturesTemplateSection}
>
Features 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addCtaTemplateSection}
>
CTA 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addFaqTemplateSection}
>
FAQ 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addPricingTemplateSection}
>
Pricing 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addTestimonialsTemplateSection}
>
Testimonials 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addBlogTemplateSection}
>
Blog 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addTeamTemplateSection}
>
Team 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addFooterTemplateSection}
>
Footer 릿
</button>
</div>
<p className="text-xs text-slate-500">
Text / Image / Button / Divider / List / Section .
</p>
</aside>
<div
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
data-testid="editor-canvas"
onClick={(event) => {
// 캔버스의 빈 공간을 클릭했을 때만 선택을 해제한다.
if (event.target === event.currentTarget) {
selectBlock(null);
}
}}
>
{blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
"텍스트 블록 추가" .
</div>
) : (
<DndContext
sensors={sensors}
// 컬럼/드롭존 하이라이트가 마우스 위치와 더욱 직관적으로 일치하도록
// 가장 가까운 중심점 대신 실제 마우스가 겹치는 영역 기준(rectIntersection)으로 계산한다.
collisionDetection={rectIntersection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{/* 루트 레벨 블록들만 정렬 대상으로 둔다. 섹션 내부 컬럼 블록은 각 컬럼 별 SortableContext에서 관리한다. */}
<SortableContext
items={rootBlocks.map((b) => b.id)}
strategy={verticalListSortingStrategy}
>
{renderBlocks(rootBlocks)}
{/* 루트 레벨 맨 아래 드롭존: 블록을 캔버스 최하단으로 이동 */}
{rootBlocks.length > 0 && (
<BlockDropzone id="dropzone-after-root" testId="root-bottom-dropzone" />
)}
</SortableContext>
{/* 드래그 중에는 실제 블록 대신 일관된 크기의 고스트 카드를 오버레이로 보여준다. */}
<DragOverlay>
{activeDragId && (
<DragPreview block={blocks.find((b) => b.id === activeDragId) ?? null} />
)}
</DragOverlay>
</DndContext>
)}
</div>
<aside className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto">
<h2 className="font-medium mb-2"> </h2>
{selectedBlockId ? (
<div className="space-y-3">
<div className="flex gap-2 text-[11px]">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
onClick={() => removeBlock(selectedBlockId)}
>
</button>
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => duplicateBlock(selectedBlockId)}
>
</button>
</div>
{(() => {
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
if (!selectedBlock) return null;
if (selectedBlock.type === "text") {
const textProps = selectedBlock.props as TextBlockProps;
return (
<TextPropertiesPanel
textProps={textProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
editingBlockId={editingBlockId}
setEditingText={setEditingText}
/>
);
}
if (selectedBlock.type === "divider") {
const dividerProps = selectedBlock.props as DividerBlockProps;
return (
<DividerPropertiesPanel
dividerProps={dividerProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "image") {
const imageProps = selectedBlock.props as ImageBlockProps;
return (
<ImagePropertiesPanel
imageProps={imageProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "section") {
const sectionProps = selectedBlock.props as SectionBlockProps;
return (
<SectionPropertiesPanel
sectionProps={sectionProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "list") {
const listProps = selectedBlock.props as ListBlockProps;
return (
<ListPropertiesPanel
listProps={listProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "button") {
const buttonProps = selectedBlock.props as ButtonBlockProps;
return (
<ButtonPropertiesPanel
buttonProps={buttonProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "form") {
const formProps = selectedBlock.props as FormBlockProps;
return (
<div className="space-y-3 text-xs">
<p className="text-slate-400">
/api/forms/submit .
</p>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={formProps.successMessage ?? "성공적으로 전송되었습니다."}
onChange={(e) =>
updateBlock(selectedBlockId, {
successMessage: e.target.value,
} as any)
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={formProps.errorMessage ?? "전송 중 오류가 발생했습니다."}
onChange={(e) =>
updateBlock(selectedBlockId, {
errorMessage: e.target.value,
} as any)
}
/>
</label>
</div>
);
}
return null;
})()}
</div>
) : (
<p className="text-xs text-slate-500">
.
</p>
)}
</aside>
<BlocksSidebar />
<EditorCanvas
blocks={blocks}
rootBlocks={rootBlocks}
selectedBlockId={selectedBlockId}
editingBlockId={editingBlockId}
editingText={editingText}
activeDragId={activeDragId}
sensors={sensors}
onCanvasEmptyClick={() => selectBlock(null)}
renderBlocks={renderBlocks}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragCancel={handleDragCancel}
/>
<PropertiesSidebar
blocks={blocks}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
removeBlock={removeBlock}
duplicateBlock={duplicateBlock}
editingBlockId={editingBlockId}
setEditingText={setEditingText}
/>
</section>
</main>
);
{activeModal === "project" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
@@ -997,8 +727,6 @@ export default function EditorPage() {
</div>
</div>
)}
</main>
);
}
interface DragPreviewProps {
@@ -1048,97 +776,6 @@ interface TextInlineToolbarProps {
onChangeSize: (value: TextBlockProps["size"]) => void;
}
function TextInlineToolbar({ align, size, onChangeAlign, onChangeSize }: TextInlineToolbarProps) {
return (
<div className="flex items-center justify-between gap-2 text-[10px] text-slate-300">
<div className="inline-flex items-center gap-1">
<span className="text-slate-500"></span>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
align === "left"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="왼쪽 정렬"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeAlign("left")}
>
</button>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
align === "center"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="가운데 정렬"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeAlign("center")}
>
</button>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
align === "right"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="오른쪽 정렬"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeAlign("right")}
>
</button>
</div>
<div className="inline-flex items-center gap-1">
<span className="text-slate-500"></span>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
size === "sm"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="글자 작게"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeSize("sm")}
>
</button>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
size === "base"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="글자 보통"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeSize("base")}
>
</button>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
size === "lg"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="글자 크게"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeSize("lg")}
>
</button>
</div>
</div>
);
}
interface SortableEditorBlockProps {
block: Block;
isSelected: boolean;
@@ -1341,9 +978,323 @@ function SortableEditorBlock({
autoFocus
/>
) : (
<div className="whitespace-pre-wrap">{(block.props as TextBlockProps).text}</div>
<div
className={`whitespace-pre-wrap ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass}`}
style={textStyleOverrides}
>
{(block.props as TextBlockProps).text}
</div>
)
)}
{block.type === "formInput" && (() => {
const inputProps = block.props as FormInputBlockProps;
const inputType = inputProps.inputType ?? "text";
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
const fieldStyle: CSSProperties = {};
if (inputProps.textColorCustom && inputProps.textColorCustom.trim() !== "") {
fieldStyle.color = inputProps.textColorCustom;
}
if (inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = inputProps.fillColorCustom;
}
if (inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = inputProps.strokeColorCustom;
}
const widthMode = inputProps.widthMode ?? "full";
if (widthMode === "fixed" && typeof inputProps.widthPx === "number" && inputProps.widthPx > 0) {
fieldStyle.width = `${inputProps.widthPx}px`;
}
const radius = inputProps.borderRadius ?? "md";
if (radius === "none") fieldStyle.borderRadius = 0;
else if (radius === "sm") fieldStyle.borderRadius = 4;
else if (radius === "lg") fieldStyle.borderRadius = 9999;
else if (radius === "full") fieldStyle.borderRadius = 9999;
// md 는 기본 tailwind rounded 와 비슷한 값으로 둔다 (별도 스타일 없음이면 클래스에 맡긴다).
const labelLayout = inputProps.labelLayout ?? "stacked";
const isInline = labelLayout === "inline";
const align = inputProps.align ?? "left";
const inputAlignClass =
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
return (
<div className={`text-xs ${isInline ? "flex items-center gap-2" : "flex flex-col gap-1"}`}>
{inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inputProps.labelImageUrl}
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
)}
{(() => {
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
let widthClass = "w-full";
if (isInline) {
if (widthMode === "fixed") {
// 고정 너비일 때는 flex 레이아웃의 너비 확장을 막기 위해 flex-none 으로 둔다.
widthClass = "flex-none";
} else if (widthMode === "full") {
widthClass = "flex-1";
} else {
// auto: 인라인 레이아웃에서 내용 기반 너비 사용
widthClass = "";
}
} else {
if (widthMode === "fixed") {
widthClass = ""; // 고정 너비는 style.width 로만 제어
} else {
widthClass = "w-full";
}
}
return (
<div
data-testid="form-input-field"
style={fieldStyle}
className={`${widthClass} rounded border border-slate-700 bg-slate-950`}
>
{inputType === "textarea" ? (
<textarea
className={`w-full min-h-[60px] bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
className={`w-full bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
type={inputType === "email" ? "email" : "text"}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
);
})()}
</div>
);
})()}
{block.type === "formSelect" && (() => {
const selectProps = block.props as FormSelectBlockProps;
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
? selectProps.options
: [
{ label: "옵션 1", value: "option_1" },
{ label: "옵션 2", value: "option_2" },
];
const fieldStyle: CSSProperties = {};
if (selectProps.textColorCustom && selectProps.textColorCustom.trim() !== "") {
fieldStyle.color = selectProps.textColorCustom;
}
if (selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = selectProps.fillColorCustom;
}
if (selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = selectProps.strokeColorCustom;
}
const selectWidthMode = selectProps.widthMode ?? "full";
if (
selectWidthMode === "fixed" &&
typeof selectProps.widthPx === "number" &&
selectProps.widthPx > 0
) {
fieldStyle.width = `${selectProps.widthPx}px`;
}
const selectRadius = selectProps.borderRadius ?? "md";
if (selectRadius === "none") fieldStyle.borderRadius = 0;
else if (selectRadius === "sm") fieldStyle.borderRadius = 4;
else if (selectRadius === "lg" || selectRadius === "full") fieldStyle.borderRadius = 9999;
// 에디터에서는 폼 셀렉트 블록이 라벨 + 셀렉트 박스로 보이도록 렌더링한다.
return (
<div className="flex flex-col gap-1 text-xs">
{selectProps.labelMode === "image" && selectProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={selectProps.labelImageUrl}
alt={selectProps.labelImageAlt || selectProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200">{selectProps.label}</span>
)}
<div
style={fieldStyle}
className="w-full rounded border border-slate-700 bg-slate-950"
>
<select
className="w-full bg-transparent px-2 py-1 text-xs outline-none"
aria-label={selectProps.label}
defaultValue={options[0]?.value}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
})()}
{block.type === "formRadio" && (() => {
const radioProps = block.props as FormRadioBlockProps;
const options = Array.isArray(radioProps.options) && radioProps.options.length > 0
? radioProps.options
: [
{ label: "옵션 A", value: "option_a" },
{ label: "옵션 B", value: "option_b" },
];
const groupLabelMode = radioProps.groupLabelMode ?? "text";
const fieldStyle: CSSProperties = {};
if (radioProps.textColorCustom && radioProps.textColorCustom.trim() !== "") {
fieldStyle.color = radioProps.textColorCustom;
}
if (radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = radioProps.fillColorCustom;
}
if (radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = radioProps.strokeColorCustom;
}
const radioWidthMode = radioProps.widthMode ?? "full";
if (
radioWidthMode === "fixed" &&
typeof radioProps.widthPx === "number" &&
radioProps.widthPx > 0
) {
fieldStyle.width = `${radioProps.widthPx}px`;
}
const radioRadius = radioProps.borderRadius ?? "md";
if (radioRadius === "none") fieldStyle.borderRadius = 0;
else if (radioRadius === "sm") fieldStyle.borderRadius = 4;
else if (radioRadius === "lg" || radioRadius === "full") fieldStyle.borderRadius = 9999;
return (
<div className="flex flex-col gap-1 text-xs">
{/* 그룹 타이틀은 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && radioProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={radioProps.groupLabelImageUrl}
alt={radioProps.groupLabel || "폼 그룹 타이틀"}
className="h-4 w-auto text-slate-200"
/>
) : (
<span className="text-slate-200">{radioProps.groupLabel}</span>
)}
<div
style={fieldStyle}
className="flex flex-col gap-1 rounded border border-slate-700 bg-slate-950"
>
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2 text-slate-200">
<input
type="radio"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
name={radioProps.formFieldName}
value={opt.value}
readOnly
/>
{/* 옵션 옆 라벨은 옵션에 개별 이미지 URL 이 있는 경우 이미지로 렌더링한다. */}
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || radioProps.groupLabel || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span>{opt.label}</span>
)}
</label>
))}
</div>
</div>
);
})()}
{block.type === "formCheckbox" && (() => {
const checkboxProps = block.props as FormCheckboxBlockProps;
const options = Array.isArray(checkboxProps.options) && checkboxProps.options.length > 0
? checkboxProps.options
: [
{ label: "체크 1", value: "check_1" },
{ label: "체크 2", value: "check_2" },
];
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
const fieldStyle: CSSProperties = {};
if (checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== "") {
fieldStyle.color = checkboxProps.textColorCustom;
}
if (checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = checkboxProps.fillColorCustom;
}
if (checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = checkboxProps.strokeColorCustom;
}
const checkboxWidthMode = checkboxProps.widthMode ?? "full";
if (
checkboxWidthMode === "fixed" &&
typeof checkboxProps.widthPx === "number" &&
checkboxProps.widthPx > 0
) {
fieldStyle.width = `${checkboxProps.widthPx}px`;
}
const checkboxRadius = checkboxProps.borderRadius ?? "md";
if (checkboxRadius === "none") fieldStyle.borderRadius = 0;
else if (checkboxRadius === "sm") fieldStyle.borderRadius = 4;
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
return (
<div className="flex flex-col gap-1 text-xs text-slate-200">
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={checkboxProps.groupLabelImageUrl}
alt={checkboxProps.groupLabel || "폼 그룹 타이틀"}
className="h-4 w-auto text-slate-200"
/>
) : (
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
)}
<div className="flex flex-col gap-1">
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
name={checkboxProps.formFieldName}
value={opt.value}
readOnly
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || checkboxProps.groupLabel || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span>{opt.label}</span>
)}
</label>
))}
</div>
</div>
);
})()}
{block.type === "button" && (() => {
const buttonProps = block.props as ButtonBlockProps;
const size = buttonProps.size ?? "md";
+227
View File
@@ -0,0 +1,227 @@
"use client";
import { useCallback } from "react";
import { useEditorStore } from "@/features/editor/state/editorStore";
// 좌측 블록/폼/템플릿 추가 사이드바를 분리한 컴포넌트
export function BlocksSidebar() {
const addTextBlock = useEditorStore((state) => state.addTextBlock);
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
const addImageBlock = useEditorStore((state) => state.addImageBlock);
const addDividerBlock = useEditorStore((state) => state.addDividerBlock);
const addListBlock = useEditorStore((state) => state.addListBlock);
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
const addFormBlock = useEditorStore((state) => state.addFormBlock);
const addFormInputBlock = useEditorStore((state) => (state as any).addFormInputBlock);
const addFormSelectBlock = useEditorStore((state) => (state as any).addFormSelectBlock);
const addFormCheckboxBlock = useEditorStore((state) => (state as any).addFormCheckboxBlock);
const addFormRadioBlock = useEditorStore((state) => (state as any).addFormRadioBlock);
const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection);
const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection);
const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection);
const addFaqTemplateSection = useEditorStore((state) => state.addFaqTemplateSection);
const addPricingTemplateSection = useEditorStore((state) => state.addPricingTemplateSection);
const addTestimonialsTemplateSection = useEditorStore((state) => state.addTestimonialsTemplateSection);
const addBlogTemplateSection = useEditorStore((state) => state.addBlogTemplateSection);
const addTeamTemplateSection = useEditorStore((state) => state.addTeamTemplateSection);
const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection);
// 버튼 핸들러를 useCallback으로 래핑해 불필요한 재생성을 줄인다.
const handleAddText = useCallback(() => addTextBlock(), [addTextBlock]);
const handleAddButton = useCallback(() => addButtonBlock(), [addButtonBlock]);
const handleAddImage = useCallback(() => addImageBlock(), [addImageBlock]);
const handleAddDivider = useCallback(() => addDividerBlock(), [addDividerBlock]);
const handleAddList = useCallback(() => addListBlock(), [addListBlock]);
const handleAddSection = useCallback(() => addSectionBlock(), [addSectionBlock]);
const handleAddFormBlock = useCallback(() => addFormBlock(), [addFormBlock]);
const handleAddFormInput = useCallback(() => addFormInputBlock(), [addFormInputBlock]);
const handleAddFormSelect = useCallback(() => addFormSelectBlock(), [addFormSelectBlock]);
const handleAddFormRadio = useCallback(() => addFormRadioBlock(), [addFormRadioBlock]);
const handleAddFormCheckbox = useCallback(() => addFormCheckboxBlock(), [addFormCheckboxBlock]);
const handleAddHeroTemplate = useCallback(
() => addHeroTemplateSection(),
[addHeroTemplateSection],
);
const handleAddFeaturesTemplate = useCallback(
() => addFeaturesTemplateSection(),
[addFeaturesTemplateSection],
);
const handleAddCtaTemplate = useCallback(() => addCtaTemplateSection(), [addCtaTemplateSection]);
const handleAddFaqTemplate = useCallback(() => addFaqTemplateSection(), [addFaqTemplateSection]);
const handleAddPricingTemplate = useCallback(
() => addPricingTemplateSection(),
[addPricingTemplateSection],
);
const handleAddTestimonialsTemplate = useCallback(
() => addTestimonialsTemplateSection(),
[addTestimonialsTemplateSection],
);
const handleAddBlogTemplate = useCallback(() => addBlogTemplateSection(), [addBlogTemplateSection]);
const handleAddTeamTemplate = useCallback(() => addTeamTemplateSection(), [addTeamTemplateSection]);
const handleAddFooterTemplate = useCallback(
() => addFooterTemplateSection(),
[addFooterTemplateSection],
);
return (
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
<h2 className="font-medium"></h2>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddText}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddButton}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddImage}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddDivider}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddList}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddSection}
>
</button>
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<h3 className="text-[11px] font-medium text-slate-300"> </h3>
<button
type="button"
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
onClick={handleAddFormBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormInput}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormSelect}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormRadio}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFormCheckbox}
>
</button>
</div>
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<h3 className="text-[11px] font-medium text-slate-300">릿</h3>
<button
type="button"
className="w-full rounded border border-sky-700 bg-sky-950 px-3 py-2 text-left text-xs text-sky-100 hover:bg-sky-900"
onClick={handleAddHeroTemplate}
>
Hero 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFeaturesTemplate}
>
Features 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddCtaTemplate}
>
CTA 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFaqTemplate}
>
FAQ 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddPricingTemplate}
>
Pricing 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddTestimonialsTemplate}
>
Testimonials 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddBlogTemplate}
>
Blog 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddTeamTemplate}
>
Team 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFooterTemplate}
>
Footer 릿
</button>
</div>
<p className="text-xs text-slate-500">
Text / Image / Button / Divider / List / Section .
</p>
</aside>
);
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./TextPropertiesPanel";
import { ListPropertiesPanel } from "./ListPropertiesPanel";
import { DividerPropertiesPanel } from "./DividerPropertiesPanel";
import { ImagePropertiesPanel } from "./ImagePropertiesPanel";
import { SectionPropertiesPanel } from "./SectionPropertiesPanel";
import { FormInputPropertiesPanel } from "../forms/FormInputPropertiesPanel";
import { FormSelectPropertiesPanel } from "../forms/FormSelectPropertiesPanel";
import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPanel";
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
import { FormControllerPanel } from "../forms/FormControllerPanel";
interface PropertiesSidebarProps {
blocks: Block[];
selectedBlockId: string | null;
updateBlock: (id: string, partial: any) => void;
removeBlock: (id: string) => void;
duplicateBlock: (id: string) => void;
// 텍스트 속성 패널에서 사용하는 인라인 편집 상태를 그대로 전달한다.
editingBlockId: string | null;
setEditingText: (value: string) => void;
}
// 우측 속성 패널을 분리한 컴포넌트
export function PropertiesSidebar(props: PropertiesSidebarProps) {
const {
blocks,
selectedBlockId,
updateBlock,
removeBlock,
duplicateBlock,
editingBlockId,
setEditingText,
} = props;
return (
<aside
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto"
onKeyDownCapture={(e) => {
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
e.stopPropagation();
}}
>
<h2 className="font-medium mb-2"> </h2>
{selectedBlockId ? (
<div className="space-y-3">
<div className="flex gap-2 text-[11px]">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
onClick={() => removeBlock(selectedBlockId)}
>
</button>
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => duplicateBlock(selectedBlockId)}
>
</button>
</div>
{(() => {
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
if (!selectedBlock) return null;
if (selectedBlock.type === "text") {
const textProps = selectedBlock.props as TextBlockProps;
return (
<TextPropertiesPanel
textProps={textProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
editingBlockId={editingBlockId}
setEditingText={setEditingText}
/>
);
}
if (selectedBlock.type === "button") {
const buttonProps = selectedBlock.props as ButtonBlockProps;
return (
<ButtonPropertiesPanel
buttonProps={buttonProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "list") {
const listProps = selectedBlock.props as ListBlockProps;
return (
<ListPropertiesPanel
listProps={listProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "divider") {
const dividerProps = selectedBlock.props as any;
return (
<DividerPropertiesPanel
dividerProps={dividerProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "image") {
const imageProps = selectedBlock.props as any;
return (
<ImagePropertiesPanel
imageProps={imageProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "section") {
const sectionProps = selectedBlock.props as SectionBlockProps;
return (
<SectionPropertiesPanel
sectionProps={sectionProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "formInput") {
return (
<FormInputPropertiesPanel
block={selectedBlock}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "formSelect") {
return (
<FormSelectPropertiesPanel
block={selectedBlock}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "formCheckbox") {
return (
<FormCheckboxPropertiesPanel
block={selectedBlock}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "formRadio") {
return (
<FormRadioPropertiesPanel
block={selectedBlock}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "form") {
return (
<FormControllerPanel
block={selectedBlock}
blocks={blocks}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
return null;
})()}
</div>
) : (
<p className="text-xs text-slate-500"> .</p>
)}
</aside>
);
}
@@ -31,6 +31,8 @@ export type ColorPickerFieldProps = {
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
// 투명
{ id: "transparent", label: "투명", color: "transparent" },
// 기본/중립 계열
{ id: "default", label: "기본", color: "#e5e7eb" },
{ id: "muted", label: "연한", color: "#9ca3af" },
@@ -72,6 +74,8 @@ export function ColorPickerField({
onPaletteSelect,
}: ColorPickerFieldProps) {
const [open, setOpen] = useState(false);
const colorInputValue = value && value.startsWith("#") ? value : "#ffffff";
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
@@ -91,20 +95,26 @@ export function ColorPickerField({
};
// 현재 선택된 팔레트 정보를 계산한다.
const selectedPalette =
selectedPaletteId && palette.length > 0
? palette.find((item) => item.id === selectedPaletteId) ?? null
: null;
// 1) selectedPaletteId 가 넘어오면 ID 기준으로 찾고,
// 2) 없으면 현재 value 와 color 가 일치하는 팔레트 항목을 사용한다.
let selectedPalette: ColorPaletteItem | null = null;
if (palette.length > 0) {
if (selectedPaletteId) {
selectedPalette = palette.find((item) => item.id === selectedPaletteId) ?? null;
} else if (value) {
selectedPalette = palette.find((item) => item.color === value) ?? null;
}
}
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<div 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"}
value={colorInputValue}
onChange={handleColorInputChange}
/>
<input
@@ -143,7 +153,8 @@ export function ColorPickerField({
? "bg-sky-900/40 text-sky-100"
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
}`}
onClick={() => {
onClick={(event) => {
event.stopPropagation();
handlePaletteClick(item);
setOpen(false);
}}
@@ -162,6 +173,6 @@ export function ColorPickerField({
</div>
) : null}
</div>
</label>
</div>
);
}
@@ -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 ? (
+362 -7
View File
@@ -10,8 +10,19 @@ import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록
export type BlockType = "text" | "button" | "image" | "section" | "divider" | "list" | "form";
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록/폼 요소 블록
export type BlockType =
| "text"
| "button"
| "image"
| "section"
| "divider"
| "list"
| "form"
| "formInput"
| "formSelect"
| "formCheckbox"
| "formRadio";
// 텍스트 블록 속성
export interface TextBlockProps {
@@ -129,13 +140,124 @@ export interface SectionBlockProps {
}>;
}
// 폼 블록 속성 (1차 MVP: 고정 contact 폼)
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
export interface FormFieldStyleProps {
align?: "left" | "center" | "right";
size?: "xs" | "sm" | "md" | "lg" | "xl";
fullWidth?: boolean;
// 필드 너비 및 레이아웃
widthMode?: "auto" | "full" | "fixed";
widthPx?: number;
labelLayout?: "stacked" | "inline";
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 필드 텍스트 색상/타이포그라피
fontSizeCustom?: string;
lineHeightCustom?: string;
letterSpacingCustom?: string;
textColorCustom?: string;
// 필드 배경/테두리 색상
fillColorCustom?: string;
strokeColorCustom?: string;
}
// 폼 입력 블록 속성
export type FormLabelMode = "text" | "image";
export interface FormInputBlockProps extends FormFieldStyleProps {
label: string;
formFieldName: string;
required?: boolean;
inputType?: "text" | "email" | "textarea";
placeholder?: string;
labelMode?: FormLabelMode;
labelImageUrl?: string;
labelImageAlt?: string;
}
// 폼 셀렉트 옵션/블록 속성
export interface FormSelectOption {
label: string;
value: string;
}
export interface FormSelectBlockProps extends FormFieldStyleProps {
label: string;
formFieldName: string;
options: FormSelectOption[];
required?: boolean;
labelMode?: FormLabelMode;
labelImageUrl?: string;
labelImageAlt?: string;
}
// 폼 체크박스 옵션/블록 속성 (여러 체크박스를 한 그룹으로 표현)
export interface FormCheckboxOption {
label: string;
value: string;
// 각 체크박스 옵션도 개별 이미지 라벨을 가질 수 있도록 확장한다.
labelImageUrl?: string;
}
export interface FormCheckboxBlockProps extends FormFieldStyleProps {
groupLabel: string;
formFieldName: string;
options: FormCheckboxOption[];
required?: boolean;
// 그룹 타이틀 자체도 텍스트/이미지 모드를 가질 수 있도록 분리한다.
groupLabelMode?: FormLabelMode;
groupLabelImageUrl?: string;
}
// 폼 라디오 그룹 옵션/블록 속성
export interface FormRadioOption {
label: string;
value: string;
// 각 라디오 옵션도 개별 이미지 라벨을 가질 수 있도록 확장한다.
labelImageUrl?: string;
}
export interface FormRadioBlockProps extends FormFieldStyleProps {
groupLabel: string;
formFieldName: string;
options: FormRadioOption[];
required?: boolean;
// 라디오 그룹 타이틀의 텍스트/이미지 모드
groupLabelMode?: FormLabelMode;
groupLabelImageUrl?: string;
}
// 폼 필드 설정
export interface FormFieldConfig {
id: string;
name: string; // 실제 전송 키 (예: "email")
label: string; // UI 라벨 (예: "이메일")
type: "text" | "email" | "textarea";
required?: boolean;
}
// 폼 블록 속성
export type FormSubmitTarget = "internal" | "webhook";
export type FormPayloadFormat = "form" | "json";
export interface FormBlockProps {
kind: "contact";
// 전송 대상: 현재는 internal 만 지원, 이후 webhook 등 확장 가능
submitTarget: "internal";
// 전송 대상: internal 또는 webhook(외부 URL, Google Sheets 포함)
submitTarget: FormSubmitTarget;
// 성공/실패 메시지
successMessage?: string;
errorMessage?: string;
// webhook 설정 (Google Sheets 포함)
destinationUrl?: string; // POST 보낼 URL
method?: "POST" | "GET"; // 기본 POST
// webhook 전송 포맷: 기본은 x-www-form-urlencoded(form).
payloadFormat?: FormPayloadFormat;
headers?: Record<string, string>; // Authorization 등
extraParams?: Record<string, string>; // 항상 함께 보내는 추가 파라미터(formId 등)
// 폼 필드 목록
fields?: FormFieldConfig[];
// v2 컨트롤러: 개별 폼 요소 블록과 제출 버튼을 연결하기 위한 ID 목록
fieldIds?: string[];
submitButtonId?: string | null;
}
// 공통 블록 모델
@@ -149,7 +271,11 @@ export interface Block {
| SectionBlockProps
| DividerBlockProps
| ListBlockProps
| FormBlockProps;
| FormBlockProps
| FormInputBlockProps
| FormSelectBlockProps
| FormCheckboxBlockProps
| FormRadioBlockProps;
// 레이아웃 트리 상 위치 정보 (루트 텍스트/버튼/이미지 블록은 null)
sectionId?: string | null;
columnId?: string | null;
@@ -168,6 +294,10 @@ export interface EditorState {
addListBlock: () => void;
addSectionBlock: () => void;
addFormBlock: () => void;
addFormInputBlock: () => void;
addFormSelectBlock: () => void;
addFormCheckboxBlock: () => void;
addFormRadioBlock: () => void;
addHeroTemplateSection: () => void;
addFeaturesTemplateSection: () => void;
addCtaTemplateSection: () => void;
@@ -179,7 +309,14 @@ export interface EditorState {
addFooterTemplateSection: () => void;
updateBlock: (
id: string,
partial: Partial<TextBlockProps> | Partial<ButtonBlockProps>,
partial:
| Partial<TextBlockProps>
| Partial<ButtonBlockProps>
| Partial<FormBlockProps>
| Partial<FormInputBlockProps>
| Partial<FormSelectBlockProps>
| Partial<FormCheckboxBlockProps>
| Partial<FormRadioBlockProps>,
) => void;
selectBlock: (id: string | null) => void;
replaceBlocks: (blocks: Block[]) => void;
@@ -195,6 +332,25 @@ export interface EditorState {
let idCounter = 0;
const createId = () => `blk_${Date.now()}_${idCounter++}`;
const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
const regex = new RegExp(`^${prefix}-(\\d+)$`);
let max = 0;
for (const b of blocks) {
const anyProps: any = b.props;
const name = anyProps?.formFieldName;
if (typeof name !== "string") continue;
const match = name.match(regex);
if (!match) continue;
const n = Number(match[1] ?? 0);
if (!Number.isNaN(n) && n > max) {
max = n;
}
}
return `${prefix}-${max + 1}`;
};
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
const createEditorState = (set: any, get: any): EditorState => ({
@@ -449,6 +605,155 @@ const createEditorState = (set: any, get: any): EditorState => ({
}));
},
// 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다
addFormInputBlock: () => {
const id = createId();
const { blocks } = get();
const label = "입력 필드";
const formFieldName = getNextFormFieldName(blocks, "text");
const newBlock: Block = {
id,
type: "formInput",
props: {
label,
formFieldName,
required: false,
inputType: "text",
placeholder: "",
labelMode: "text",
borderRadius: "md",
fillColorCustom: "",
strokeColorCustom: "",
textColorCustom: "",
widthMode: "full",
labelLayout: "stacked",
},
sectionId: null,
columnId: null,
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 폼 셀렉트 블록 추가: 루트에 기본 label/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
addFormSelectBlock: () => {
const id = createId();
const { blocks } = get();
const label = "선택 필드";
const formFieldName = getNextFormFieldName(blocks, "select");
const options: FormSelectOption[] = [
{ label: "옵션 1", value: "option_1" },
{ label: "옵션 2", value: "option_2" },
];
const newBlock: Block = {
id,
type: "formSelect",
props: {
label,
formFieldName,
options,
required: false,
labelMode: "text",
borderRadius: "md",
fillColorCustom: "",
strokeColorCustom: "",
textColorCustom: "",
widthMode: "full",
labelLayout: "stacked",
},
sectionId: null,
columnId: null,
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 폼 체크박스 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
addFormCheckboxBlock: () => {
const id = createId();
const { blocks } = get();
const groupLabel = "체크박스";
const formFieldName = getNextFormFieldName(blocks, "checkbox");
const options: FormCheckboxOption[] = [
{ label: "체크 1", value: "check_1" },
{ label: "체크 2", value: "check_2" },
];
const newBlock: Block = {
id,
type: "formCheckbox",
props: {
groupLabel,
formFieldName,
options,
required: false,
groupLabelMode: "text",
borderRadius: "md",
fillColorCustom: "",
strokeColorCustom: "",
textColorCustom: "",
widthMode: "full",
labelLayout: "stacked",
},
sectionId: null,
columnId: null,
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 폼 라디오 그룹 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
addFormRadioBlock: () => {
const id = createId();
const { blocks } = get();
const groupLabel = "라디오 그룹";
const formFieldName = getNextFormFieldName(blocks, "radio");
const options: FormRadioOption[] = [
{ label: "옵션 A", value: "option_a" },
{ label: "옵션 B", value: "option_b" },
];
const newBlock: Block = {
id,
type: "formRadio",
props: {
groupLabel,
formFieldName,
options,
required: false,
groupLabelMode: "text",
borderRadius: "md",
fillColorCustom: "",
strokeColorCustom: "",
textColorCustom: "",
widthMode: "full",
labelLayout: "stacked",
},
sectionId: null,
columnId: null,
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 구분선 블록 추가: 기본 정렬/두께와 함께 생성 후 선택 상태로 만든다
addDividerBlock: () => {
const id = createId();
@@ -554,6 +859,56 @@ const createEditorState = (set: any, get: any): EditorState => ({
}));
},
// 폼 블록 추가: 기본 contact 폼 설정과 함께 생성 후 선택 상태로 만든다
addFormBlock: () => {
const id = createId();
const fields: FormFieldConfig[] = [
{
id: `${id}_field_name`,
name: "name",
label: "이름",
type: "text",
required: true,
},
{
id: `${id}_field_email`,
name: "email",
label: "이메일",
type: "email",
required: true,
},
{
id: `${id}_field_message`,
name: "message",
label: "메시지",
type: "textarea",
required: true,
},
];
const newBlock: Block = {
id,
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "성공적으로 전송되었습니다.",
errorMessage: "전송 중 오류가 발생했습니다.",
fields,
fieldIds: [],
submitButtonId: null,
},
sectionId: null,
columnId: null,
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다
addButtonBlock: () => {
const id = createId();