Files
page-builder/src/app/editor/panels/ImagePropertiesPanel.tsx
T
jaybe 672cca5271
CI / test (push) Failing after 41m13s
CI / pr_and_merge (push) Has been skipped
1차 싱크 완료
2025-11-24 21:32:37 +09:00

239 lines
8.8 KiB
TypeScript

"use client";
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
export type ImagePropertiesPanelProps = {
imageProps: ImageBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<ImageBlockProps>) => void;
};
export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock }: ImagePropertiesPanelProps) {
const source: "url" | "upload" =
imageProps.sourceType === "asset" || (imageProps.src && imageProps.src.startsWith("/api/image/"))
? "upload"
: "url";
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>이미지 소스</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="이미지 소스"
value={source}
onChange={(e) => {
const next = e.target.value as "url" | "upload";
if (next === "url") {
updateBlock(selectedBlockId, {
sourceType: "externalUrl",
assetId: null,
} as any);
} else {
updateBlock(selectedBlockId, {
sourceType: "asset",
} as any);
}
}}
>
<option value="url">URL</option>
<option value="upload">파일 업로드</option>
</select>
</label>
</div>
{source === "url" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>이미지 URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="이미지 URL"
value={imageProps.src}
onChange={(e) => {
const value = e.target.value;
updateBlock(selectedBlockId, {
src: value,
sourceType: "externalUrl",
assetId: null,
} as any);
}}
/>
</label>
</div>
)}
{source === "upload" && (
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>이미지 파일 업로드</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, {
src: servedUrl,
sourceType: "asset",
assetId: data.id,
} as any);
} catch (error) {
console.error("이미지 업로드 중 오류", error);
} finally {
event.target.value = "";
}
}}
/>
</label>
</div>
)}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>대체 텍스트</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="대체 텍스트"
value={imageProps.alt}
onChange={(e) => {
updateBlock(selectedBlockId, { alt: e.target.value } as any);
}}
/>
</label>
</div>
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
<h4 className="text-[11px] font-semibold text-slate-200">이미지 스타일</h4>
{/* 카드 배경색 */}
<div className="space-y-1">
<ColorPickerField
label="카드 배경색"
ariaLabelColorInput="이미지 카드 배경색 피커"
ariaLabelHexInput="이미지 카드 배경색 HEX"
value={imageProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
const next = hex && hex.trim().length > 0 ? hex : undefined;
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
}}
palette={TEXT_COLOR_PALETTE}
/>
</div>
{/* 정렬 */}
<label className="flex flex-col gap-1">
<span>정렬</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="이미지 정렬"
value={imageProps.align ?? "center"}
onChange={(e) =>
updateBlock(selectedBlockId, {
align: e.target.value as ImageBlockProps["align"],
} as any)
}
>
<option value="left">왼쪽</option>
<option value="center">가운데</option>
<option value="right">오른쪽</option>
</select>
</label>
{/* 너비 모드 */}
<label className="flex flex-col gap-1">
<span>너비 모드</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="이미지 너비 모드"
value={imageProps.widthMode ?? "auto"}
onChange={(e) =>
updateBlock(selectedBlockId, {
widthMode: e.target.value as ImageBlockProps["widthMode"],
} as any)
}
>
<option value="auto">내용에 맞춤</option>
<option value="fixed">고정 너비 (px)</option>
</select>
</label>
{(imageProps.widthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label="고정 너비 (px)"
unitLabel="(px)"
value={imageProps.widthPx ?? 320}
min={40}
max={1200}
step={10}
presets={[
{ id: "sm", label: "작게", value: 240 },
{ id: "md", label: "보통", value: 320 },
{ id: "lg", label: "넓게", value: 480 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
widthPx: v,
} as any);
}}
/>
)}
{/* 모서리 둥글기 */}
<NumericPropertyControl
label="모서리 둥글기"
value={(() => {
if (typeof imageProps.borderRadiusPx === "number") {
return imageProps.borderRadiusPx;
}
const r = imageProps.borderRadius ?? "md";
// 토큰만 있는 기존 데이터의 경우, 토큰별 대표값으로 초기화한다.
return r === "none" ? 0 : r === "sm" ? 20 : r === "lg" ? 120 : r === "full" ? 180 : 60;
})()}
min={0}
max={200}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 20 },
{ id: "md", label: "보통", value: 60 },
{ id: "lg", label: "크게", value: 120 },
{ id: "full", label: "완전 둥글게", value: 180 },
]}
onChangeValue={(v) => {
// 0~50 범위를 토큰으로 매핑한다.
const nextToken =
v <= 0 ? "none" : v <= 30 ? "sm" : v <= 90 ? "md" : v <= 150 ? "lg" : "full";
updateBlock(selectedBlockId, {
borderRadius: nextToken,
borderRadiusPx: v,
} as any);
}}
/>
</div>
</>
);
}