"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(null); const { toast } = useToast(); const update = useCallback( (updates: Partial) => { updateComponent(component.id, updates); }, [component.id, updateComponent], ); const handleImageUpload = async (e: React.ChangeEvent) => { 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 ( {/* 이미지 업로드 */} } title="이미지 소스">
{previewUrl ? ( 미리보기 ) : (
미리보기
)}
{component.imageUrl && (

{component.imageUrl}

)}
{/* 이미지 자르기 */} {previewUrl && ( } title="이미지 자르기 (크롭)"> )} {/* 표시 방식 */} } title="표시 방식"> update({ imageAlt: e.target.value })} placeholder="이미지 설명 (접근성)" className="h-9 text-sm" /> {/* 캡션 */} } title="캡션"> update({ imageCaption: e.target.value })} placeholder="이미지에 표시할 설명 텍스트" className="h-9 text-sm" /> {component.imageCaption && ( )} 투명도, 테두리, 모서리, 회전 등 스타일 설정은 우측 패널에서 변경할 수 있습니다.
); } /* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ * 이미지 자르기(Crop) 에디터 * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ interface ImageCropEditorProps { imageUrl: string; component: ComponentConfig; onUpdate: (updates: Partial) => void; } function ImageCropEditor({ imageUrl, component, onUpdate }: ImageCropEditorProps) { const imgRef = useRef(null); const hasCrop = component.imageCropWidth != null && component.imageCropHeight != null; const [crop, setCrop] = useState(() => { 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 (
setCrop(percentCrop)} onComplete={handleCropComplete} > 자르기 편집

{hasCrop ? `자르기 영역: ${Math.round(component.imageCropWidth ?? 0)}% x ${Math.round(component.imageCropHeight ?? 0)}%` : "드래그하여 자르기 영역을 지정하세요"}

{hasCrop && ( )}
); } 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"; } }