= {};
+ 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 });
}
diff --git a/src/app/editor/EditorCanvas.tsx b/src/app/editor/EditorCanvas.tsx
new file mode 100644
index 0000000..6349621
--- /dev/null
+++ b/src/app/editor/EditorCanvas.tsx
@@ -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 (
+ {
+ if (event.target === event.currentTarget) {
+ onCanvasEmptyClick();
+ }
+ }}
+ >
+ {blocks.length === 0 ? (
+
+ 왼쪽에서 "텍스트 블록 추가" 버튼을 눌러 블록을 추가해 보세요.
+
+ ) : (
+
+ b.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {renderBlocks(rootBlocks)}
+
+
+ {activeDragId && (
+ b.id === activeDragId) ?? null} />
+ )}
+
+
+ )}
+
+ );
+}
+
+interface DragPreviewProps {
+ block: Block | null;
+}
+
+function DragPreview({ block }: DragPreviewProps) {
+ if (!block) return null;
+
+ return (
+
+
+ {block.type === "text"
+ ? "Text"
+ : block.type === "button"
+ ? "Button"
+ : block.type === "image"
+ ? "Image"
+ : block.type === "divider"
+ ? "Divider"
+ : block.type === "list"
+ ? "List"
+ : "Section"}
+
+
+ {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"
+ ? "구분선 블록"
+ : "섹션 블록"}
+
+
+ );
+}
diff --git a/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx b/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx
new file mode 100644
index 0000000..0cf056b
--- /dev/null
+++ b/src/app/editor/forms/FormCheckboxPropertiesPanel.tsx
@@ -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 (
+
+
체크박스 필드
+
+
+ 그룹 타이틀 타입
+
+ updateBlock(selectedBlockId, {
+ groupLabelMode: e.target.value,
+ } as any)
+ }
+ >
+ 텍스트
+ 이미지
+
+
+
+
+ 그룹 타이틀
+
+ updateBlock(selectedBlockId, {
+ groupLabel: e.target.value,
+ } as any)
+ }
+ />
+
+
+ {checkboxProps.groupLabelMode === "image" && (
+
+ 그룹 타이틀 이미지 URL
+
+ updateBlock(selectedBlockId, {
+ groupLabelImageUrl: e.target.value,
+ } as any)
+ }
+ />
+
+ )}
+
+
+ 전송 키
+
+ updateBlock(selectedBlockId, {
+ formFieldName: e.target.value,
+ } as any)
+ }
+ />
+
+
+
+
+ 옵션
+ {
+ 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);
+ }}
+ >
+ 옵션 추가
+
+
+
+ {(((checkboxProps as any).options ?? []) as any[]).map((opt, index) => (
+
+
+ {
+ const next = [...(((checkboxProps as any).options ?? []) as any[])];
+ next[index] = {
+ ...next[index],
+ label: e.target.value,
+ };
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ />
+ {
+ const next = [...(((checkboxProps as any).options ?? []) as any[])];
+ next[index] = {
+ ...next[index],
+ value: e.target.value,
+ };
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ />
+ {
+ const next = (((checkboxProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ >
+ ×
+
+
+
+ {
+ const next = [...(((checkboxProps as any).options ?? []) as any[])];
+ next[index] = {
+ ...next[index],
+ labelImageUrl: e.target.value,
+ };
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ />
+
+
+ ))}
+
+
+
+
+
+ updateBlock(selectedBlockId, {
+ required: e.target.checked,
+ } as any)
+ }
+ />
+ 필수 필드
+
+
+
+
필드 스타일
+ {
+ updateBlock(selectedBlockId, {
+ textColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ textColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ updateBlock(selectedBlockId, {
+ fillColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ fillColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ updateBlock(selectedBlockId, {
+ strokeColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ strokeColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ 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);
+ }}
+ />
+
+
+ );
+}
diff --git a/src/app/editor/forms/FormControllerPanel.tsx b/src/app/editor/forms/FormControllerPanel.tsx
new file mode 100644
index 0000000..12587b1
--- /dev/null
+++ b/src/app/editor/forms/FormControllerPanel.tsx
@@ -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 (
+
+
+ 이 폼은 퍼블릭 페이지에서 /api/forms/submit
+ 엔드포인트로 먼저 전송된 뒤, 설정에 따라 내부 처리 또는 Webhook 으로 전달됩니다.
+
+
+
+
+ 전송 대상
+
+ updateBlock(selectedBlockId, {
+ submitTarget: e.target.value as FormBlockProps["submitTarget"],
+ } as any)
+ }
+ >
+ 서버 처리 전용 (Webhook 없이 사용)
+ 외부 Webhook / Google Sheets 로 전달
+
+
+
+ {formProps.submitTarget === "webhook" && (
+
+
+ Webhook / Google Sheets URL
+
+ updateBlock(selectedBlockId, {
+ destinationUrl: e.target.value,
+ } as any)
+ }
+ />
+
+
+ 전송 포맷
+
+ updateBlock(selectedBlockId, {
+ payloadFormat: e.target.value as any,
+ } as any)
+ }
+ >
+ 폼 데이터 (x-www-form-urlencoded)
+ JSON (application/json)
+
+
+
+ HTTP 메서드
+
+ updateBlock(selectedBlockId, {
+ method: e.target.value as FormBlockProps["method"],
+ } as any)
+ }
+ >
+ POST
+ GET
+
+
+
+ Authorization 토큰 (옵션)
+
+ updateBlock(selectedBlockId, {
+ headers: {
+ ...(formProps.headers ?? {}),
+ Authorization: e.target.value,
+ },
+ } as any)
+ }
+ />
+
+
+ 추가 파라미터 (key=value 형식, 줄바꿈으로 구분)
+
+
+ )}
+
+
+
+
폼 컨트롤러
+
+
+ 폼 필드 매핑
+ {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 (
+
+ {
+ const current = formProps.fieldIds ?? [];
+ const next = e.target.checked
+ ? [...current, fieldId]
+ : current.filter((id) => id !== fieldId);
+
+ updateBlock(selectedBlockId, {
+ fieldIds: next,
+ } as any);
+ }}
+ />
+ {fieldLabel}
+
+ );
+ })}
+
+
+
+ Submit 버튼
+
+ updateBlock(selectedBlockId, {
+ submitButtonId: e.target.value || null,
+ } as any)
+ }
+ >
+ 선택 안 함
+ {blocks
+ .filter((b) => b.type === "button")
+ .map((buttonBlock) => {
+ const btnProps = buttonBlock.props as ButtonBlockProps;
+ return (
+
+ {btnProps.label || buttonBlock.id}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/app/editor/forms/FormInputPropertiesPanel.tsx b/src/app/editor/forms/FormInputPropertiesPanel.tsx
new file mode 100644
index 0000000..cd4a670
--- /dev/null
+++ b/src/app/editor/forms/FormInputPropertiesPanel.tsx
@@ -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 (
+
+
입력 필드
+
+ 라벨 타입
+
+ updateBlock(selectedBlockId, {
+ labelMode: e.target.value,
+ } as any)
+ }
+ >
+ 텍스트
+ 이미지
+
+
+
+ 필드 라벨
+
+ updateBlock(selectedBlockId, {
+ label: e.target.value,
+ } as any)
+ }
+ />
+
+ {inputProps.labelMode === "image" && (
+ <>
+
+ 라벨 이미지 URL
+
+ updateBlock(selectedBlockId, {
+ labelImageUrl: e.target.value,
+ } as any)
+ }
+ />
+
+
+ 라벨 이미지 대체 텍스트
+
+ updateBlock(selectedBlockId, {
+ labelImageAlt: e.target.value,
+ } as any)
+ }
+ />
+
+ >
+ )}
+
+ 전송 키
+
+ updateBlock(selectedBlockId, {
+ formFieldName: e.target.value,
+ } as any)
+ }
+ />
+
+
+ 필드 타입
+
+ updateBlock(selectedBlockId, {
+ inputType: e.target.value,
+ } as any)
+ }
+ >
+ 텍스트
+ 이메일
+ 긴 텍스트
+
+
+
+ Placeholder
+
+ updateBlock(selectedBlockId, {
+ placeholder: e.target.value,
+ } as any)
+ }
+ />
+
+
+
+ updateBlock(selectedBlockId, {
+ required: e.target.checked,
+ } as any)
+ }
+ />
+ 필수 필드
+
+
+
필드 스타일
+
+ 텍스트 정렬
+
+ updateBlock(selectedBlockId, {
+ align: e.target.value,
+ } as any)
+ }
+ >
+ 왼쪽
+ 가운데
+ 오른쪽
+
+
+
+ 레이아웃
+
+ updateBlock(selectedBlockId, {
+ labelLayout: e.target.value,
+ } as any)
+ }
+ >
+ 세로 (기본)
+ 가로 (인라인)
+
+
+
+ 필드 너비
+
+ updateBlock(selectedBlockId, {
+ widthMode: e.target.value,
+ } as any)
+ }
+ >
+ 자동
+ 전체 폭
+ 고정 값
+
+
+ {(inputProps.widthMode ?? "full") === "fixed" && (
+ {
+ updateBlock(selectedBlockId, {
+ widthPx: v,
+ } as any);
+ }}
+ />
+ )}
+ {
+ updateBlock(selectedBlockId, {
+ textColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ textColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ updateBlock(selectedBlockId, {
+ fillColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ fillColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ updateBlock(selectedBlockId, {
+ strokeColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ strokeColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ 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);
+ }}
+ />
+
+
+ );
+}
diff --git a/src/app/editor/forms/FormRadioPropertiesPanel.tsx b/src/app/editor/forms/FormRadioPropertiesPanel.tsx
new file mode 100644
index 0000000..7daa76f
--- /dev/null
+++ b/src/app/editor/forms/FormRadioPropertiesPanel.tsx
@@ -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 (
+
+
라디오 필드
+
+
+ 그룹 타이틀 타입
+
+ updateBlock(selectedBlockId, {
+ groupLabelMode: e.target.value,
+ } as any)
+ }
+ >
+ 텍스트
+ 이미지
+
+
+
+
+ 그룹 타이틀
+
+ updateBlock(selectedBlockId, {
+ groupLabel: e.target.value,
+ } as any)
+ }
+ />
+
+
+ {radioProps.groupLabelMode === "image" && (
+
+ 그룹 타이틀 이미지 URL
+
+ updateBlock(selectedBlockId, {
+ groupLabelImageUrl: e.target.value,
+ } as any)
+ }
+ />
+
+ )}
+
+
+ 전송 키
+
+ updateBlock(selectedBlockId, {
+ formFieldName: e.target.value,
+ } as any)
+ }
+ />
+
+
+
+
+ 옵션
+ {
+ 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);
+ }}
+ >
+ 옵션 추가
+
+
+
+ {(((radioProps as any).options ?? []) as any[]).map((opt, index) => (
+
+
+ {
+ const next = [...(((radioProps as any).options ?? []) as any[])];
+ next[index] = {
+ ...next[index],
+ label: e.target.value,
+ };
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ />
+ {
+ const next = [...(((radioProps as any).options ?? []) as any[])];
+ next[index] = {
+ ...next[index],
+ value: e.target.value,
+ };
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ />
+ {
+ const next = (((radioProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ >
+ ×
+
+
+
+ {
+ const next = [...(((radioProps as any).options ?? []) as any[])];
+ next[index] = {
+ ...next[index],
+ labelImageUrl: e.target.value,
+ };
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ />
+
+
+ ))}
+
+
+
+
+
+ updateBlock(selectedBlockId, {
+ required: e.target.checked,
+ } as any)
+ }
+ />
+ 필수 필드
+
+
+
+
필드 스타일
+ {
+ updateBlock(selectedBlockId, {
+ textColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ textColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ updateBlock(selectedBlockId, {
+ fillColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ fillColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ updateBlock(selectedBlockId, {
+ strokeColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ strokeColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ 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);
+ }}
+ />
+
+
+ );
+}
diff --git a/src/app/editor/forms/FormSelectPropertiesPanel.tsx b/src/app/editor/forms/FormSelectPropertiesPanel.tsx
new file mode 100644
index 0000000..2c43368
--- /dev/null
+++ b/src/app/editor/forms/FormSelectPropertiesPanel.tsx
@@ -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 (
+
+
셀렉트 필드
+
+
+ 라벨 타입
+
+ updateBlock(selectedBlockId, {
+ labelMode: e.target.value,
+ } as any)
+ }
+ >
+ 텍스트
+ 이미지
+
+
+
+
+ 필드 라벨
+
+ updateBlock(selectedBlockId, {
+ label: e.target.value,
+ } as any)
+ }
+ />
+
+
+
+ 전송 키
+
+ updateBlock(selectedBlockId, {
+ formFieldName: e.target.value,
+ } as any)
+ }
+ />
+
+
+
+
+ 옵션
+ {
+ 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);
+ }}
+ >
+ 옵션 추가
+
+
+
+ {(((selectProps as any).options ?? []) as any[]).map((opt, index) => (
+
+
+ {
+ const next = [...(((selectProps as any).options ?? []) as any[])];
+ next[index] = {
+ ...next[index],
+ label: e.target.value,
+ };
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ />
+ {
+ const next = [...(((selectProps as any).options ?? []) as any[])];
+ next[index] = {
+ ...next[index],
+ value: e.target.value,
+ };
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ />
+ {
+ const next = (((selectProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
+ updateBlock(selectedBlockId, {
+ options: next,
+ } as any);
+ }}
+ >
+ ×
+
+
+
+ ))}
+
+
+
+
+
+ updateBlock(selectedBlockId, {
+ required: e.target.checked,
+ } as any)
+ }
+ />
+ 필수 필드
+
+
+
필드 스타일
+ {
+ updateBlock(selectedBlockId, {
+ textColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ textColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ updateBlock(selectedBlockId, {
+ fillColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ fillColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ updateBlock(selectedBlockId, {
+ strokeColorCustom: hex,
+ } as any);
+ }}
+ palette={TEXT_COLOR_PALETTE}
+ onPaletteSelect={(item) => {
+ updateBlock(selectedBlockId, {
+ strokeColorCustom: item.color,
+ } as any);
+ }}
+ />
+ {
+ 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);
+ }}
+ />
+
+
+ );
+}
diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx
index 9ec01a0..415bfaf 100644
--- a/src/app/editor/page.tsx
+++ b/src/app/editor/page.tsx
@@ -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() {