239 lines
8.8 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|