텍스트 스타일 및 프리뷰 렌더링 정리
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
@@ -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>
);
}