Files
page-builder/src/app/editor/forms/FormRadioPropertiesPanel.tsx
T
jaybe 7a8ad7c057
CI / test (push) Failing after 10m34s
CI / pr_and_merge (push) Has been cancelled
이미지 파일 업로드 기능
2025-11-23 19:07:41 +09:00

598 lines
23 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";
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" && (() => {
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">그룹 타이틀 이미지 소스</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={source}
onChange={(e) =>
updateBlock(selectedBlockId, {
groupLabelImageSource: e.target.value,
} as any)
}
>
<option value="url">URL</option>
<option value="upload">파일 업로드</option>
</select>
</label>
{source === "url" && (
<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>
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400">그룹 타이틀 이미지 파일 업로드</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="그룹 타이틀 이미지 파일 업로드"
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-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">
<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={(() => {
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">URL</option>
<option value="upload">파일 업로드</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="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">
{(() => {
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-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);
}}
/>
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400">옵션 이미지 파일 업로드</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="옵션 이미지 파일 업로드"
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">
<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>
{/* 라디오 타이포 px 값을 직접 입력받아 em 스케일 변환 로직과 연동한다 */}
<NumericPropertyControl
label="라디오 텍스트 크기 (px)"
unitLabel="(px)"
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);
}}
/>
{/* 줄간격 px 입력을 노출하여 preview 에서 em 변환 근거로 사용 */}
<NumericPropertyControl
label="라디오 줄간격 (px)"
unitLabel="(px)"
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: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
lineHeightCustom: `${v}px`,
} as any);
}}
/>
{/* 자간 px 입력 컨트롤로 프리뷰 적용 근거 제공 */}
<NumericPropertyControl
label="라디오 자간 (px)"
unitLabel="(px)"
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: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
letterSpacingCustom: `${v}px`,
} as any);
}}
/>
<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={radioProps.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>
{(radioProps.widthMode ?? "full") === "fixed" && (
<NumericPropertyControl
label="필드 고정 너비"
unitLabel="(px)"
value={radioProps.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);
}}
/>
)}
<NumericPropertyControl
label="라디오 가로 패딩 (px)"
unitLabel="(px)"
value={typeof radioProps.paddingX === "number" ? radioProps.paddingX : 12}
min={0}
max={60}
step={2}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 10 },
{ id: "md", label: "보통", value: 12 },
{ id: "lg", label: "넓게", value: 16 },
{ id: "xl", label: "아주 넓게", value: 20 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
paddingX: v,
} as any)
}
/>
<NumericPropertyControl
label="라디오 세로 패딩 (px)"
unitLabel="(px)"
value={typeof radioProps.paddingY === "number" ? radioProps.paddingY : 10}
min={0}
max={40}
step={1}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 8 },
{ id: "md", label: "보통", value: 10 },
{ id: "lg", label: "넓게", value: 12 },
{ id: "xl", label: "아주 넓게", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
paddingY: v,
} as any)
}
/>
<NumericPropertyControl
label="라디오 옵션 간격 (px)"
unitLabel="(px)"
value={typeof radioProps.optionGapPx === "number" ? radioProps.optionGapPx : 4}
min={0}
max={40}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 2 },
{ id: "normal", label: "보통", value: 4 },
{ id: "relaxed", label: "느슨", value: 8 },
{ id: "extra", label: "넓게", value: 12 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
optionGapPx: v,
} as any)
}
/>
<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>
);
}