ERP-node/frontend/components/unified/UnifiedMedia.tsx

576 lines
16 KiB
TypeScript
Raw Normal View History

2025-12-19 15:44:38 +09:00
"use client";
/**
* UnifiedMedia
*
*
* - file: 파일
* - image: 이미지 /
* - video: 비디오
* - audio: 오디오
*/
import React, { forwardRef, useCallback, useRef, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { UnifiedMediaProps } from "@/types/unified-components";
import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2 } from "lucide-react";
/**
*
*/
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 = "/api/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 files = Array.isArray(value) ? value : value ? [value] : [];
// 파일 선택 핸들러
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("file", file);
const response = await fetch(uploadEndpoint, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`업로드 실패: ${file.name}`);
}
const data = await response.json();
if (data.success && data.url) {
uploadedUrls.push(data.url);
} else if (data.filePath) {
uploadedUrls.push(data.filePath);
}
}
if (multiple) {
onChange?.([...files, ...uploadedUrls]);
} else {
onChange?.(uploadedUrls[0] || "");
}
} catch (err) {
setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다");
} finally {
setIsUploading(false);
}
}, [files, 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) => {
const newFiles = files.filter((_, i) => i !== index);
onChange?.(multiple ? newFiles : "");
}, [files, multiple, onChange]);
return (
<div ref={ref} className={cn("space-y-3", className)}>
{/* 업로드 영역 */}
<div
className={cn(
"border-2 border-dashed rounded-lg p-6 text-center transition-colors",
isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "cursor-pointer hover:border-primary/50"
)}
onClick={() => !disabled && 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"
/>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<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">
<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>
)}
{/* 업로드된 파일 목록 */}
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted/50 rounded-md"
>
<File className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-sm truncate">{file.split("/").pop()}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleRemove(index)}
>
<X className="h-3 w-3" />
</Button>
</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 = "/api/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);
const images = Array.isArray(value) ? value : value ? [value] : [];
// 파일 선택 핸들러
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("file", file);
const response = await fetch(uploadEndpoint, {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
if (data.success && data.url) {
uploadedUrls.push(data.url);
} else if (data.filePath) {
uploadedUrls.push(data.filePath);
}
}
}
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]);
return (
<div ref={ref} className={cn("space-y-3", className)}>
{/* 이미지 미리보기 */}
{preview && images.length > 0 && (
<div className={cn(
"grid gap-2",
multiple ? "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" : "grid-cols-1"
)}>
{images.map((src, index) => (
<div key={index} className="relative group aspect-square rounded-lg overflow-hidden border">
<img
src={src}
alt={`이미지 ${index + 1}`}
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 gap-2">
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
onClick={() => window.open(src, "_blank")}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-8 w-8"
onClick={() => handleRemove(index)}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 업로드 버튼 */}
{(!images.length || multiple) && (
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 text-center transition-colors",
isDragging && "border-primary bg-primary/5",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "cursor-pointer hover:border-primary/50"
)}
onClick={() => !disabled && 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"
/>
{isUploading ? (
<div className="flex items-center justify-center gap-2">
<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 items-center justify-center gap-2">
<ImageIcon className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{multiple ? "추가" : "선택"}
</span>
</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";
/**
* UnifiedMedia
*/
export const UnifiedMedia = forwardRef<HTMLDivElement, UnifiedMediaProps>(
(props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "image" as const };
// 타입별 미디어 컴포넌트 렌더링
const renderMedia = () => {
const isDisabled = disabled || readonly;
const mediaType = config.type || "image";
switch (mediaType) {
case "file":
return (
<FileUploader
value={value}
onChange={onChange}
multiple={config.multiple}
accept={config.accept}
maxSize={config.maxSize}
disabled={isDisabled}
uploadEndpoint={config.uploadEndpoint}
/>
);
case "image":
return (
<ImageUploader
value={value}
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;
return (
<div
ref={ref}
id={id}
className="flex flex-col"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{showLabel && (
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize,
color: style?.labelColor,
fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom,
}}
className="text-sm font-medium flex-shrink-0"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div className="flex-1 min-h-0">
{renderMedia()}
</div>
</div>
);
}
);
UnifiedMedia.displayName = "UnifiedMedia";
export default UnifiedMedia;