ERP-node/frontend/components/report/designer/modals/ImageLayoutTabs.tsx

363 lines
13 KiB
TypeScript

"use client";
/**
* ImageLayoutTabs.tsx — 이미지 컴포넌트 설정 탭
*
* [역할]
* - 모달 내 이미지 핵심 설정: 업로드 / 자르기(Crop) / 맞춤 방식 / 캡션
* - 시각 스타일(투명도·모서리·회전 등)은 우측 패널에서 관리
*/
import React, { useState, useRef, useMemo, useCallback } from "react";
import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Upload,
Image as ImageIcon,
Loader2,
Trash2,
Link,
Crop as CropIcon,
RotateCcw,
AlignCenter,
} from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { getFullImageUrl } from "@/lib/api/client";
import { useToast } from "@/hooks/use-toast";
import { Section as SharedSection, TabContent, Field, FieldGroup, Grid, InfoBox } from "./shared";
import type { ComponentConfig } from "@/types/report";
interface ImageLayoutTabsProps {
component: ComponentConfig;
}
const OBJECT_FIT_OPTIONS = [
{ value: "contain", label: "비율 유지 (포함)", desc: "전체가 보이도록 축소" },
{ value: "cover", label: "영역 채우기", desc: "꽉 채우고 넘침 잘림" },
{ value: "fill", label: "늘리기", desc: "비율 무시 영역 맞춤" },
{ value: "none", label: "원본 크기", desc: "원본 그대로 표시" },
] as const;
export function ImageLayoutTabs({ component }: ImageLayoutTabsProps) {
const { updateComponent } = useReportDesigner();
const [uploadingImage, setUploadingImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const update = useCallback(
(updates: Partial<ComponentConfig>) => {
updateComponent(component.id, updates);
},
[component.id, updateComponent],
);
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast({ title: "오류", description: "이미지 파일만 업로드 가능합니다.", variant: "destructive" });
return;
}
if (file.size > 10 * 1024 * 1024) {
toast({ title: "오류", description: "파일 크기는 10MB 이하여야 합니다.", variant: "destructive" });
return;
}
try {
setUploadingImage(true);
const result = await reportApi.uploadImage(file);
if (result.success) {
update({
imageUrl: result.data.fileUrl,
imageCropX: undefined,
imageCropY: undefined,
imageCropWidth: undefined,
imageCropHeight: undefined,
});
toast({ title: "성공", description: "이미지가 업로드되었습니다." });
}
} catch {
toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive" });
} finally {
setUploadingImage(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleRemoveImage = () => {
update({ imageUrl: "", imageCropX: undefined, imageCropY: undefined, imageCropWidth: undefined, imageCropHeight: undefined });
toast({ title: "알림", description: "이미지가 제거되었습니다." });
};
const previewUrl = useMemo(() => {
if (!component.imageUrl) return null;
return getFullImageUrl(component.imageUrl);
}, [component.imageUrl]);
return (
<TabContent>
{/* 이미지 업로드 */}
<SharedSection emphasis icon={<Upload className="h-3.5 w-3.5" />} title="이미지 소스">
<FieldGroup>
<Field label="이미지 파일" help="JPG, PNG, GIF, WEBP 파일을 업로드하세요 (최대 10MB)">
<div className="flex gap-4">
<div className="flex h-[120px] w-[140px] shrink-0 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-blue-200 bg-white">
{previewUrl ? (
<img src={previewUrl} alt="미리보기" className="h-full w-full object-contain" />
) : (
<div className="flex flex-col items-center gap-1 text-gray-400">
<ImageIcon className="h-8 w-8" />
<span className="text-xs"></span>
</div>
)}
</div>
<div className="flex flex-1 flex-col justify-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
disabled={uploadingImage}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
className="h-9 w-full text-xs"
>
{uploadingImage ? (
<><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> ...</>
) : (
<><Upload className="mr-1.5 h-3.5 w-3.5" />{component.imageUrl ? "이미지 변경" : "이미지 업로드"}</>
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveImage}
disabled={!component.imageUrl}
className="h-9 w-full text-xs text-red-500 hover:bg-red-50 hover:text-red-600 disabled:text-gray-300"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
</div>
</Field>
{component.imageUrl && (
<InfoBox variant="blue">
<p className="truncate">
<Link className="mr-1 inline h-3 w-3" />
{component.imageUrl}
</p>
</InfoBox>
)}
</FieldGroup>
</SharedSection>
{/* 이미지 자르기 */}
{previewUrl && (
<SharedSection icon={<CropIcon className="h-3.5 w-3.5" />} title="이미지 자르기 (크롭)">
<ImageCropEditor
imageUrl={previewUrl}
component={component}
onUpdate={update}
/>
</SharedSection>
)}
{/* 표시 방식 */}
<SharedSection icon={<AlignCenter className="h-3.5 w-3.5" />} title="표시 방식">
<FieldGroup>
<Field label="이미지 맞춤 방식">
<Select
value={component.objectFit || "contain"}
onValueChange={(v) => update({ objectFit: v as ComponentConfig["objectFit"] })}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OBJECT_FIT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span>{opt.label}</span>
<span className="ml-1.5 text-[11px] text-gray-400">{opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="대체 텍스트 (alt)">
<Input
value={component.imageAlt || ""}
onChange={(e) => update({ imageAlt: e.target.value })}
placeholder="이미지 설명 (접근성)"
className="h-9 text-sm"
/>
</Field>
</FieldGroup>
</SharedSection>
{/* 캡션 */}
<SharedSection icon={<ImageIcon className="h-3.5 w-3.5" />} title="캡션">
<FieldGroup>
<Field label="캡션 텍스트" help="이미지 아래에 설명 텍스트를 표시합니다">
<Input
value={component.imageCaption || ""}
onChange={(e) => update({ imageCaption: e.target.value })}
placeholder="이미지에 표시할 설명 텍스트"
className="h-9 text-sm"
/>
</Field>
{component.imageCaption && (
<Grid cols={2}>
<Field label="위치">
<Select
value={component.imageCaptionPosition || "bottom"}
onValueChange={(v) => update({ imageCaptionPosition: v as "top" | "bottom" })}
>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="정렬">
<Select
value={component.imageCaptionAlign || "center"}
onValueChange={(v) => update({ imageCaptionAlign: v as "left" | "center" | "right" })}
>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</Field>
</Grid>
)}
</FieldGroup>
</SharedSection>
<InfoBox variant="gray">
, , , .
</InfoBox>
</TabContent>
);
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* 이미지 자르기(Crop) 에디터
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
interface ImageCropEditorProps {
imageUrl: string;
component: ComponentConfig;
onUpdate: (updates: Partial<ComponentConfig>) => void;
}
function ImageCropEditor({ imageUrl, component, onUpdate }: ImageCropEditorProps) {
const imgRef = useRef<HTMLImageElement>(null);
const hasCrop = component.imageCropWidth != null && component.imageCropHeight != null;
const [crop, setCrop] = useState<Crop | undefined>(() => {
if (hasCrop) {
return {
unit: "%" as const,
x: component.imageCropX ?? 0,
y: component.imageCropY ?? 0,
width: component.imageCropWidth ?? 100,
height: component.imageCropHeight ?? 100,
};
}
return undefined;
});
const handleCropComplete = useCallback(
(_pixelCrop: PixelCrop, percentCrop: Crop) => {
if (percentCrop.width < 1 || percentCrop.height < 1) return;
onUpdate({
imageCropX: Math.round(percentCrop.x * 100) / 100,
imageCropY: Math.round(percentCrop.y * 100) / 100,
imageCropWidth: Math.round(percentCrop.width * 100) / 100,
imageCropHeight: Math.round(percentCrop.height * 100) / 100,
});
},
[onUpdate],
);
const handleResetCrop = () => {
setCrop(undefined);
onUpdate({
imageCropX: undefined,
imageCropY: undefined,
imageCropWidth: undefined,
imageCropHeight: undefined,
});
};
return (
<div className="space-y-3">
<div className="overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={handleCropComplete}
>
<img
ref={imgRef}
src={imageUrl}
alt="자르기 편집"
style={{ maxWidth: "100%", maxHeight: 300, display: "block" }}
/>
</ReactCrop>
</div>
<div className="flex items-center justify-between">
<p className="text-[11px] text-gray-400">
{hasCrop
? `자르기 영역: ${Math.round(component.imageCropWidth ?? 0)}% x ${Math.round(component.imageCropHeight ?? 0)}%`
: "드래그하여 자르기 영역을 지정하세요"}
</p>
{hasCrop && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleResetCrop}
className="h-7 text-xs text-gray-500"
>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
)}
</div>
</div>
);
}
export function getShadowStyle(shadow: string): string {
switch (shadow) {
case "sm": return "0 1px 2px rgba(0,0,0,0.05)";
case "md": return "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)";
case "lg": return "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)";
default: return "none";
}
}