363 lines
13 KiB
TypeScript
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";
|
|
}
|
|
}
|