Files
page-builder/src/app/editor/forms/FormRadioPropertiesPanel.tsx
T
jaybe 4840a530b6
CI / test (push) Failing after 5m41s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
오류 수정
2025-12-12 18:04:31 +09:00

674 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getEditorFormRadioPanelMessages } from "@/features/i18n/messages/editorFormRadioPanel";
interface FormRadioPropertiesPanelProps {
block: Block;
selectedBlockId: string | null;
updateBlock: (id: string, partial: any) => void;
}
export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }: FormRadioPropertiesPanelProps) {
const locale = useAppLocale();
const m = getEditorFormRadioPanelMessages(locale);
if (!selectedBlockId || block.id !== selectedBlockId) return null;
const radioProps = block.props as FormRadioBlockProps;
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
return (
<div className="space-y-3 text-xs">
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleTypeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.groupLabelMode ?? "text"}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelMode: e.target.value,
} as any)
}
>
<option value="text">{m.groupTitleTypeOptionText}</option>
<option value="image">{m.groupTitleTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={groupLabelDisplay}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelDisplay: e.target.value,
} as any)
}
>
<option value="visible">{m.groupTitleDisplayOptionVisible}</option>
<option value="hidden">{m.groupTitleDisplayOptionHidden}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.labelLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
labelLayout: e.target.value,
} as any)
}
>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span>{m.optionLayoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.optionLayout ?? "stacked"}
onChange={(e) =>
updateBlock(selectedBlockId, {
optionLayout: e.target.value,
} as any)
}
>
<option value="stacked">{m.optionLayoutOptionStacked}</option>
<option value="inline">{m.optionLayoutOptionInline}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (radioProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label={m.labelGapLabel}
unitLabel="(px)"
value={typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8}
min={0}
max={80}
step={2}
presets={[
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
labelGapPx: v,
} as any)
}
/>
)}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.groupLabel ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabel: e.target.value,
} as any)
}
/>
</label>
{radioProps.groupLabelMode === "image" && (() => {
const source: "url" | "upload" =
radioProps.groupLabelImageSource ??
(radioProps.groupLabelImageUrl && radioProps.groupLabelImageUrl.startsWith("/api/image/")
? "upload"
: "url");
return (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400">{m.groupTitleImageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={source}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelImageSource: e.target.value,
} as any)
}
>
<option value="url">{m.groupTitleImageSourceOptionUrl}</option>
<option value="upload">{m.groupTitleImageSourceOptionUpload}</option>
</select>
</label>
{source === "url" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400">{m.groupTitleImageUrlLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.groupLabelImageUrl ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelImageUrl: e.target.value,
} as any)
}
/>
</label>
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400">{m.groupTitleImageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label={m.groupTitleImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/image", {
method: "POST",
body: formData,
});
if (!response.ok) {
console.error("라디오 그룹 라벨 이미지 업로드 실패", await response.text());
return;
}
const data = (await response.json()) as { id: string; servedUrl?: string | null };
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
updateBlock(selectedBlockId, {
groupLabelImageUrl: servedUrl,
groupLabelImageSource: "upload",
} as any);
} catch (error) {
console.error("라디오 그룹 라벨 이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
)}
</div>
);
})()}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
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-500 dark:text-slate-400">{m.optionsLabel}</span>
<button
type="button"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
onClick={() => {
const next = Array.isArray((radioProps as any).options)
? [...(radioProps as any).options]
: [];
next.push({
label: "New option",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
options: next,
} as any);
}}
>
{m.addOptionButtonLabel}
</button>
</div>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.optionImageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={(() => {
if (radioProps.optionImageSource) return radioProps.optionImageSource;
const first = (((radioProps as any).options ?? []) as any[])[0];
if (first?.labelImageUrl && first.labelImageUrl.startsWith("/api/image/")) return "upload";
return "url";
})()}
onChange={(e) =>
updateBlock(selectedBlockId, {
optionImageSource: e.target.value,
} as any)
}
>
<option value="url">{m.optionImageSourceOptionUrl}</option>
<option value="upload">{m.optionImageSourceOptionUpload}</option>
</select>
</label>
{(((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="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1">
<input
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionLabelPlaceholder}
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-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionValuePlaceholder}
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-300 bg-white text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark: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">
{(() => {
const source: "url" | "upload" =
radioProps.optionImageSource ??
(opt.labelImageUrl && opt.labelImageUrl.startsWith("/api/image/") ? "upload" : "url");
return (
<>
{source === "url" && (
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder={m.optionImageUrlPlaceholder}
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);
}}
/>
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400">{m.optionImageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label={m.optionImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/image", {
method: "POST",
body: formData,
});
if (!response.ok) {
console.error("라디오 옵션 라벨 이미지 업로드 실패", await response.text());
return;
}
const data = (await response.json()) as { id: string; servedUrl?: string | null };
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
const next = [...(((radioProps as any).options ?? []) as any[])];
next[index] = {
...next[index],
labelImageUrl: servedUrl,
};
updateBlock(selectedBlockId, {
options: next,
optionImageSource: "upload",
} as any);
} catch (error) {
console.error("라디오 옵션 라벨 이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
)}
</>
);
})()}
</div>
</div>
))}
</div>
</div>
<label className="inline-flex items-center gap-2">
<span className="text-slate-500">{m.requiredNoticeText}</span>
</label>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
<h4 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.styleSectionTitle}</h4>
<NumericPropertyControl
label={m.textSizeLabel}
unitLabel={m.textSizeUnitLabel}
value={(() => {
const raw = radioProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
return 14;
})()}
min={8}
max={40}
step={1}
presets={[
{ id: "xs", label: "XS", value: 12 },
{ id: "sm", label: "S", value: 14 },
{ id: "md", label: "M", value: 16 },
{ id: "lg", label: "L", value: 18 },
{ id: "xl", label: "XL", value: 20 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
fontSizeCustom: `${v}px`,
} as any);
}}
/>
<NumericPropertyControl
label={m.lineHeightLabel}
unitLabel={m.lineHeightUnitLabel}
value={(() => {
const raw = radioProps.lineHeightCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
return 20;
})()}
min={8}
max={60}
step={1}
presets={[
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
lineHeightCustom: `${v}px`,
} as any);
}}
/>
<NumericPropertyControl
label={m.letterSpacingLabel}
unitLabel={m.letterSpacingUnitLabel}
value={(() => {
const raw = radioProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
return 0;
})()}
min={-10}
max={20}
step={0.5}
presets={[
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
letterSpacingCustom: `${v}px`,
} as any);
}}
/>
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
<span>Field width</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.widthMode ?? "full"}
onChange={(e) =>
updateBlock(selectedBlockId, {
widthMode: e.target.value,
} as any)
}
>
<option value="auto">Auto</option>
<option value="full">Full width</option>
<option value="fixed">Fixed width</option>
</select>
</label>
{(radioProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="Field fixed width"
unitLabel="(px)"
value={radioProps.widthPx ?? 240}
min={80}
max={800}
step={10}
presets={[
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
/>
)}
<NumericPropertyControl
label={m.paddingXLabel}
unitLabel={m.paddingXUnitLabel}
value={typeof radioProps.paddingX === "number" ? radioProps.paddingX : 12}
min={0}
max={60}
step={2}
presets={[
{ id: "xs", label: "Extra small", value: 6 },
{ id: "sm", label: "Small", value: 10 },
{ id: "md", label: "Medium", value: 12 },
{ id: "lg", label: "Large", value: 16 },
{ id: "xl", label: "Extra large", value: 20 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
paddingX: v,
} as any)
}
/>
<NumericPropertyControl
label={m.paddingYLabel}
unitLabel={m.paddingYUnitLabel}
value={typeof radioProps.paddingY === "number" ? radioProps.paddingY : 10}
min={0}
max={40}
step={1}
presets={[
{ id: "xs", label: "Extra small", value: 6 },
{ id: "sm", label: "Small", value: 8 },
{ id: "md", label: "Medium", value: 10 },
{ id: "lg", label: "Large", value: 12 },
{ id: "xl", label: "Extra large", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
paddingY: v,
} as any)
}
/>
<NumericPropertyControl
label={m.optionGapLabel}
unitLabel={m.optionGapUnitLabel}
value={typeof radioProps.optionGapPx === "number" ? radioProps.optionGapPx : 4}
min={0}
max={40}
step={1}
presets={[
{ id: "tight", label: "Tight", value: 2 },
{ id: "normal", label: "Normal", value: 4 },
{ id: "relaxed", label: "Relaxed", value: 8 },
{ id: "extra", label: "Extra", value: 12 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
optionGapPx: v,
} as any)
}
/>
<ColorPickerField
label={m.textColorLabel}
ariaLabelColorInput={m.textColorPickerAria}
ariaLabelHexInput={m.textColorHexAria}
value={
radioProps.textColorCustom && radioProps.textColorCustom.trim() !== ""
? radioProps.textColorCustom
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
textColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
textColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label={m.fillColorLabel}
ariaLabelColorInput={m.fillColorPickerAria}
ariaLabelHexInput={m.fillColorHexAria}
value={
radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== ""
? radioProps.fillColorCustom
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
fillColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
fillColorCustom: item.color,
} as any);
}}
/>
<ColorPickerField
label={m.strokeColorLabel}
ariaLabelColorInput={m.strokeColorPickerAria}
ariaLabelHexInput={m.strokeColorHexAria}
value={
radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== ""
? radioProps.strokeColorCustom
: ""
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
strokeColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
strokeColorCustom: item.color,
} as any);
}}
/>
<NumericPropertyControl
label={m.borderRadiusLabel}
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: m.borderRadiusPresetNone, value: 0 },
{ id: "sm", label: m.borderRadiusPresetSmall, value: 2 },
{ id: "md", label: m.borderRadiusPresetMedium, value: 4 },
{ id: "lg", label: m.borderRadiusPresetLarge, value: 6 },
{ id: "full", label: m.borderRadiusPresetFull, 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>
);
}