ERP-node/frontend/components/v2/V2Media.tsx

792 lines
26 KiB
TypeScript

"use client";
/**
* V2Media
*
* 통합 미디어 컴포넌트
* - file: 파일 업로드
* - image: 이미지 업로드/표시
* - video: 비디오
* - audio: 오디오
*/
import React, { forwardRef, useCallback, useRef, useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { V2MediaProps } from "@/types/v2-components";
import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus } from "lucide-react";
import { apiClient } from "@/lib/api/client";
/**
* 파일 크기 포맷팅
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
/**
* 파일 타입 아이콘 가져오기
*/
function getFileIcon(type: string) {
if (type.startsWith("image/")) return ImageIcon;
if (type.startsWith("video/")) return Video;
if (type.startsWith("audio/")) return Music;
return File;
}
/**
* 파일 업로드 컴포넌트
*/
const FileUploader = forwardRef<HTMLDivElement, {
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
accept?: string;
maxSize?: number;
disabled?: boolean;
uploadEndpoint?: string;
className?: string;
}>(({
value,
onChange,
multiple = false,
accept = "*",
maxSize = 10485760, // 10MB
disabled,
uploadEndpoint = "/files/upload",
className
}, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 업로드 직후 미리보기를 위한 로컬 상태
const [localPreviewUrls, setLocalPreviewUrls] = useState<string[]>([]);
// objid를 미리보기 URL로 변환
const toPreviewUrl = (val: any): string => {
if (!val) return "";
const strVal = String(val);
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`;
return strVal;
};
// value를 URL 형태의 files 배열로 변환
const rawFiles = Array.isArray(value) ? value : value ? [value] : [];
const filesFromValue = rawFiles.map(toPreviewUrl).filter(Boolean);
console.log("[FileUploader] value:", value, "rawFiles:", rawFiles, "filesFromValue:", filesFromValue, "localPreviewUrls:", localPreviewUrls);
// value가 변경되면 로컬 상태 초기화
useEffect(() => {
if (filesFromValue.length > 0) {
setLocalPreviewUrls([]);
}
}, [filesFromValue.length]);
// 최종 files: value에서 온 파일 + 로컬 미리보기 (중복 제거)
const files = filesFromValue.length > 0 ? filesFromValue : localPreviewUrls;
console.log("[FileUploader] final files:", files);
// 파일 선택 핸들러
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) return;
setError(null);
const fileArray = Array.from(selectedFiles);
// 크기 검증
for (const file of fileArray) {
if (file.size > maxSize) {
setError(`파일 크기가 ${formatFileSize(maxSize)}를 초과합니다: ${file.name}`);
return;
}
}
setIsUploading(true);
try {
const uploadedUrls: string[] = [];
for (const file of fileArray) {
const formData = new FormData();
formData.append("files", file);
const response = await apiClient.post(uploadEndpoint, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const data = response.data;
console.log("[FileUploader] 업로드 응답:", data);
// 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] }
if (data.success && data.files && data.files.length > 0) {
const uploadedFile = data.files[0];
const objid = String(uploadedFile.objid);
uploadedUrls.push(objid);
// 즉시 미리보기를 위해 로컬 상태에 URL 저장
const previewUrl = `/api/files/preview/${objid}`;
setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]);
} else if (data.objid) {
const objid = String(data.objid);
uploadedUrls.push(objid);
const previewUrl = `/api/files/preview/${objid}`;
setLocalPreviewUrls(prev => multiple ? [...prev, previewUrl] : [previewUrl]);
} else if (data.url) {
uploadedUrls.push(data.url);
setLocalPreviewUrls(prev => multiple ? [...prev, data.url] : [data.url]);
} else if (data.filePath) {
uploadedUrls.push(data.filePath);
setLocalPreviewUrls(prev => multiple ? [...prev, data.filePath] : [data.filePath]);
}
}
if (multiple) {
const newValue = [...filesFromValue, ...uploadedUrls];
console.log("[FileUploader] onChange called with:", newValue);
onChange?.(newValue);
} else {
const newValue = uploadedUrls[0] || "";
console.log("[FileUploader] onChange called with:", newValue);
onChange?.(newValue);
}
} catch (err) {
setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다");
} finally {
setIsUploading(false);
}
}, [filesFromValue, multiple, maxSize, uploadEndpoint, onChange]);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
// 파일 삭제 핸들러
const handleRemove = useCallback((index: number) => {
// 로컬 미리보기도 삭제
setLocalPreviewUrls(prev => prev.filter((_, i) => i !== index));
// value에서 온 파일 삭제
const newFiles = filesFromValue.filter((_, i) => i !== index);
onChange?.(multiple ? newFiles : "");
}, [filesFromValue, multiple, onChange]);
// 첫 번째 파일이 이미지인지 확인
const firstFile = files[0];
const isFirstFileImage = firstFile && (
/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(firstFile) ||
firstFile.includes("/preview/") ||
firstFile.includes("/api/files/preview/")
);
return (
<div ref={ref} className={cn("flex flex-col h-full w-full gap-2", className)}>
{/* 메인 업로드 박스 - 이미지가 있으면 박스 안에 표시 */}
<div
className={cn(
"relative flex flex-1 flex-col items-center justify-center border-2 border-dashed rounded-lg text-center transition-colors overflow-hidden min-h-[120px]",
isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed",
!disabled && !firstFile && "cursor-pointer hover:border-primary/50",
firstFile && "border-solid border-muted"
)}
onClick={() => !disabled && !firstFile && inputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{firstFile ? (
// 파일이 있으면 박스 안에 표시
<div className="relative w-full h-full group flex items-center justify-center">
{isFirstFileImage ? (
// 이미지 미리보기
<img
src={firstFile}
alt="업로드된 이미지"
className="max-w-full max-h-full object-contain"
/>
) : (
// 일반 파일
<div className="flex flex-col items-center gap-2 p-4">
<File className="h-12 w-12 text-muted-foreground" />
<span className="text-sm text-muted-foreground truncate max-w-[200px]">
{firstFile.split("/").pop()}
</span>
</div>
)}
{/* 호버 시 액션 버튼 */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
{isFirstFileImage && (
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); window.open(firstFile, "_blank"); }}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
disabled={disabled}
>
<Upload className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); handleRemove(0); }}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
) : isUploading ? (
<div className="flex flex-col items-center gap-2 p-4">
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 p-4">
<Upload className="h-8 w-8 text-muted-foreground" />
<div className="text-sm">
<span className="font-medium text-primary"></span>
<span className="text-muted-foreground"> </span>
</div>
<div className="text-xs text-muted-foreground">
{formatFileSize(maxSize)}
{accept !== "*" && ` (${accept})`}
</div>
</div>
)}
</div>
{/* 에러 메시지 */}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
{/* 추가 파일 목록 (multiple일 때 2번째 파일부터) */}
{multiple && files.length > 1 && (
<div className="grid grid-cols-4 gap-2">
{files.slice(1).map((file, index) => {
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(file) ||
file.includes("/preview/") ||
file.includes("/api/files/preview/");
return (
<div key={index} className="relative group rounded-lg overflow-hidden border aspect-square flex items-center justify-center bg-muted/50">
{isImage ? (
<img src={file} alt={`파일 ${index + 2}`} className="w-full h-full object-cover" />
) : (
<File className="h-6 w-6 text-muted-foreground" />
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button
variant="destructive"
size="icon"
className="h-6 w-6"
onClick={() => handleRemove(index + 1)}
disabled={disabled}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
{/* 추가 버튼 */}
<div
className={cn(
"flex items-center justify-center border-2 border-dashed rounded-lg aspect-square cursor-pointer hover:border-primary/50",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && inputRef.current?.click()}
>
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
</div>
)}
</div>
);
});
FileUploader.displayName = "FileUploader";
/**
* 이미지 업로드/표시 컴포넌트
*/
const ImageUploader = forwardRef<HTMLDivElement, {
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
accept?: string;
maxSize?: number;
preview?: boolean;
disabled?: boolean;
uploadEndpoint?: string;
className?: string;
}>(({
value,
onChange,
multiple = false,
accept = "image/*",
maxSize = 10485760,
preview = true,
disabled,
uploadEndpoint = "/files/upload",
className
}, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// objid를 미리보기 URL로 변환
const toPreviewUrl = (val: any): string => {
if (!val) return "";
const strVal = String(val);
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`;
return strVal;
};
// value를 URL 형태의 images 배열로 변환
const rawImages = Array.isArray(value) ? value : value ? [value] : [];
const images = rawImages.map(toPreviewUrl).filter(Boolean);
// 파일 선택 핸들러
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) return;
setIsUploading(true);
try {
const fileArray = Array.from(selectedFiles);
const uploadedUrls: string[] = [];
for (const file of fileArray) {
// 미리보기 생성
if (preview) {
const reader = new FileReader();
reader.onload = () => setPreviewUrl(reader.result as string);
reader.readAsDataURL(file);
}
const formData = new FormData();
formData.append("files", file);
try {
const response = await apiClient.post(uploadEndpoint, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const data = response.data;
// 백엔드 응답: { success: true, files: [{ filePath, objid, ... }] }
if (data.success && data.files && data.files.length > 0) {
const uploadedFile = data.files[0];
// objid만 저장 (DB 저장용) - 표시는 V2MediaRenderer에서 URL로 변환
uploadedUrls.push(String(uploadedFile.objid));
} else if (data.objid) {
uploadedUrls.push(String(data.objid));
} else if (data.url) {
uploadedUrls.push(data.url);
} else if (data.filePath) {
uploadedUrls.push(data.filePath);
}
} catch (err) {
console.error("이미지 업로드 실패:", err);
}
}
if (multiple) {
onChange?.([...images, ...uploadedUrls]);
} else {
onChange?.(uploadedUrls[0] || "");
}
} finally {
setIsUploading(false);
setPreviewUrl(null);
}
}, [images, multiple, preview, uploadEndpoint, onChange]);
// 이미지 삭제 핸들러
const handleRemove = useCallback((index: number) => {
const newImages = images.filter((_, i) => i !== index);
onChange?.(multiple ? newImages : "");
}, [images, multiple, onChange]);
// 첫 번째 이미지 (메인 박스에 표시)
const mainImage = images[0];
// 추가 이미지들 (multiple일 때만)
const additionalImages = multiple ? images.slice(1) : [];
return (
<div ref={ref} className={cn("flex h-full w-full flex-col gap-2", className)}>
{/* 메인 업로드 박스 - 첫 번째 이미지가 있으면 박스 안에 표시 */}
<div
className={cn(
"relative flex flex-1 flex-col items-center justify-center border-2 border-dashed rounded-lg text-center transition-colors overflow-hidden",
isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed",
!disabled && !mainImage && "cursor-pointer hover:border-primary/50",
mainImage && "border-solid border-muted"
)}
onClick={() => !disabled && !mainImage && inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{mainImage ? (
// 이미지가 있으면 박스 안에 표시
<div className="relative w-full h-full group">
<img
src={mainImage}
alt="업로드된 이미지"
className="w-full h-full object-contain"
/>
{/* 호버 시 액션 버튼 */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); window.open(mainImage, "_blank"); }}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
disabled={disabled}
>
<Upload className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); handleRemove(0); }}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
) : isUploading ? (
<div className="flex items-center justify-center gap-2 p-4">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 p-4">
<Upload className="h-8 w-8 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
</span>
<span className="text-xs text-muted-foreground">
{Math.round(maxSize / 1024 / 1024)} MB (*/*)
</span>
</div>
)}
</div>
{/* 추가 이미지 목록 (multiple일 때만) */}
{multiple && additionalImages.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{additionalImages.map((src, index) => (
<div key={index} className="relative group rounded-lg overflow-hidden border aspect-square">
<img
src={src}
alt={`이미지 ${index + 2}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button
variant="destructive"
size="icon"
className="h-6 w-6"
onClick={() => handleRemove(index + 1)}
disabled={disabled}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
))}
{/* 추가 버튼 */}
<div
className={cn(
"flex items-center justify-center border-2 border-dashed rounded-lg aspect-square cursor-pointer hover:border-primary/50",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => !disabled && inputRef.current?.click()}
>
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
</div>
)}
</div>
);
});
ImageUploader.displayName = "ImageUploader";
/**
* 비디오 컴포넌트
*/
const VideoPlayer = forwardRef<HTMLDivElement, {
value?: string;
className?: string;
}>(({ value, className }, ref) => {
if (!value) {
return (
<div
ref={ref}
className={cn(
"aspect-video flex items-center justify-center border rounded-lg bg-muted/50",
className
)}
>
<Video className="h-8 w-8 text-muted-foreground" />
</div>
);
}
return (
<div ref={ref} className={cn("aspect-video rounded-lg overflow-hidden", className)}>
<video
src={value}
controls
className="w-full h-full object-cover"
/>
</div>
);
});
VideoPlayer.displayName = "VideoPlayer";
/**
* 오디오 컴포넌트
*/
const AudioPlayer = forwardRef<HTMLDivElement, {
value?: string;
className?: string;
}>(({ value, className }, ref) => {
if (!value) {
return (
<div
ref={ref}
className={cn(
"h-12 flex items-center justify-center border rounded-lg bg-muted/50",
className
)}
>
<Music className="h-5 w-5 text-muted-foreground" />
</div>
);
}
return (
<div ref={ref} className={cn("", className)}>
<audio
src={value}
controls
className="w-full"
/>
</div>
);
});
AudioPlayer.displayName = "AudioPlayer";
/**
* 메인 V2Media 컴포넌트
*/
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
(props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "image" as const };
// objid를 미리보기 URL로 변환하는 함수
const toPreviewUrl = (val: any): string => {
if (!val) return "";
const strVal = String(val);
if (strVal.startsWith("/") || strVal.startsWith("http")) return strVal;
if (/^\d+$/.test(strVal)) return `/api/files/preview/${strVal}`;
return strVal;
};
// value를 URL로 변환 (배열 또는 단일 값)
const convertedValue = Array.isArray(value)
? value.map(toPreviewUrl)
: value ? toPreviewUrl(value) : value;
console.log("[V2Media] original value:", value, "-> converted:", convertedValue, "onChange:", typeof onChange);
// 타입별 미디어 컴포넌트 렌더링
const renderMedia = () => {
const isDisabled = disabled || readonly;
const mediaType = config.type || "image";
switch (mediaType) {
case "file":
return (
<FileUploader
value={convertedValue}
onChange={onChange}
multiple={config.multiple}
accept={config.accept}
maxSize={config.maxSize}
disabled={isDisabled}
uploadEndpoint={config.uploadEndpoint}
/>
);
case "image":
return (
<ImageUploader
value={convertedValue}
onChange={onChange}
multiple={config.multiple}
accept={config.accept || "image/*"}
maxSize={config.maxSize}
preview={config.preview}
disabled={isDisabled}
uploadEndpoint={config.uploadEndpoint}
/>
);
case "video":
return (
<VideoPlayer
value={typeof value === "string" ? value : value?.[0]}
/>
);
case "audio":
return (
<AudioPlayer
value={typeof value === "string" ? value : value?.[0]}
/>
);
default:
return (
<FileUploader
value={value}
onChange={onChange}
disabled={isDisabled}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
return (
<div
ref={ref}
id={id}
className="relative w-full"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="h-full w-full">
{renderMedia()}
</div>
</div>
);
}
);
V2Media.displayName = "V2Media";
export default V2Media;