219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ImageProperties.tsx — 이미지 컴포넌트 설정
|
|
*
|
|
* - section="data": 모달 내 ImageLayoutTabs (업로드 / 자르기 / 맞춤 / 캡션 / 표시 조건)
|
|
* - section="style": 우측 패널 — 투명도, 모서리, 회전/반전, 캡션 스타일
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { ChevronRight, FlipHorizontal, FlipVertical } from "lucide-react";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import type { ComponentConfig } from "@/types/report";
|
|
import { ImageLayoutTabs } from "../modals/ImageLayoutTabs";
|
|
|
|
interface Props {
|
|
component: ComponentConfig;
|
|
section?: "style" | "data";
|
|
}
|
|
|
|
function StyleAccordion({
|
|
label,
|
|
isOpen,
|
|
onToggle,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="border-b border-gray-100">
|
|
<button
|
|
onClick={onToggle}
|
|
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
|
isOpen
|
|
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
|
: "bg-white text-gray-900 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<span className="text-xs font-bold">{label}</span>
|
|
<ChevronRight
|
|
className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`}
|
|
/>
|
|
</button>
|
|
{isOpen && (
|
|
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ImageProperties({ component, section }: Props) {
|
|
const { updateComponent } = useReportDesigner();
|
|
const showStyle = !section || section === "style";
|
|
const showData = !section || section === "data";
|
|
|
|
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["opacity"]));
|
|
const toggleSection = (id: string) => {
|
|
setOpenSections((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const update = (updates: Partial<ComponentConfig>) => {
|
|
updateComponent(component.id, updates);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{showData && <ImageLayoutTabs component={component} />}
|
|
|
|
{showStyle && (
|
|
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
|
{/* 투명도 & 모서리 */}
|
|
<StyleAccordion label="투명도 & 모서리" isOpen={openSections.has("opacity")} onToggle={() => toggleSection("opacity")}>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">
|
|
투명도 ({Math.round((component.imageOpacity ?? 1) * 100)}%)
|
|
</Label>
|
|
<Slider
|
|
value={[component.imageOpacity ?? 1]}
|
|
onValueChange={([v]) => update({ imageOpacity: v })}
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">
|
|
모서리 ({component.imageBorderRadius ?? 0}px)
|
|
</Label>
|
|
<Slider
|
|
value={[component.imageBorderRadius ?? 0]}
|
|
onValueChange={([v]) => update({ imageBorderRadius: v })}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
/>
|
|
<div className="mt-2 flex gap-1">
|
|
{[0, 4, 8, 16, 50, 100].map((v) => (
|
|
<Button
|
|
key={v}
|
|
type="button"
|
|
variant={component.imageBorderRadius === v ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => update({ imageBorderRadius: v })}
|
|
className="h-6 flex-1 px-1 text-[10px]"
|
|
>
|
|
{v === 0 ? "직각" : v === 100 ? "원형" : `${v}`}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 회전 & 반전 */}
|
|
<StyleAccordion label="회전 & 반전" isOpen={openSections.has("transform")} onToggle={() => toggleSection("transform")}>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">
|
|
회전 ({component.imageRotation || 0}°)
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Slider
|
|
value={[component.imageRotation || 0]}
|
|
onValueChange={([v]) => update({ imageRotation: v })}
|
|
min={0}
|
|
max={360}
|
|
step={1}
|
|
className="flex-1"
|
|
/>
|
|
<Input
|
|
type="number"
|
|
value={component.imageRotation || 0}
|
|
onChange={(e) => update({ imageRotation: parseInt(e.target.value) || 0 })}
|
|
className="h-7 w-14 text-center text-[10px]"
|
|
min={0}
|
|
max={360}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant={component.imageFlipH ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => update({ imageFlipH: !component.imageFlipH })}
|
|
className="h-8 flex-1 text-xs"
|
|
>
|
|
<FlipHorizontal className="mr-1 h-3.5 w-3.5" />좌우
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={component.imageFlipV ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => update({ imageFlipV: !component.imageFlipV })}
|
|
className="h-8 flex-1 text-xs"
|
|
>
|
|
<FlipVertical className="mr-1 h-3.5 w-3.5" />상하
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 캡션 스타일 (캡션 텍스트가 있을 때만) */}
|
|
{component.imageCaption && (
|
|
<StyleAccordion label="캡션 스타일" isOpen={openSections.has("caption")} onToggle={() => toggleSection("caption")}>
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">글자 크기</Label>
|
|
<Input
|
|
type="number"
|
|
value={component.imageCaptionFontSize || 12}
|
|
onChange={(e) => update({ imageCaptionFontSize: parseInt(e.target.value) || 12 })}
|
|
className="h-9 text-xs"
|
|
min={8}
|
|
max={32}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
|
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
|
<input
|
|
type="color"
|
|
value={component.imageCaptionColor || "#666666"}
|
|
onChange={(e) => update({ imageCaptionColor: e.target.value })}
|
|
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
|
/>
|
|
<Input
|
|
value={component.imageCaptionColor || "#666666"}
|
|
onChange={(e) => update({ imageCaptionColor: e.target.value })}
|
|
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|