텍스트 스타일 및 프리뷰 렌더링 정리
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user