"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 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(null); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [error, setError] = useState(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 (
{/* 업로드 영역 */}
!disabled && inputRef.current?.click()} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} > handleFileSelect(e.target.files)} className="hidden" /> {isUploading ? (
업로드 중...
) : (
클릭 또는 파일을 드래그하세요
최대 {formatFileSize(maxSize)} {accept !== "*" && ` (${accept})`}
)}
{/* 에러 메시지 */} {error && (
{error}
)} {/* 업로드된 파일 목록 */} {files.length > 0 && (
{files.map((file, index) => (
{file.split("/").pop()}
))}
)}
); }); FileUploader.displayName = "FileUploader"; /** * 이미지 업로드/표시 컴포넌트 */ const ImageUploader = forwardRef 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(null); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [previewUrl, setPreviewUrl] = useState(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 (
{/* 이미지 미리보기 */} {preview && images.length > 0 && (
{images.map((src, index) => (
{`이미지
))}
)} {/* 업로드 버튼 */} {(!images.length || multiple) && (
!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); }} > handleFileSelect(e.target.files)} className="hidden" /> {isUploading ? (
업로드 중...
) : (
이미지 {multiple ? "추가" : "선택"}
)}
)}
); }); ImageUploader.displayName = "ImageUploader"; /** * 비디오 컴포넌트 */ const VideoPlayer = forwardRef(({ value, className }, ref) => { if (!value) { return (
); } return (
); }); VideoPlayer.displayName = "VideoPlayer"; /** * 오디오 컴포넌트 */ const AudioPlayer = forwardRef(({ value, className }, ref) => { if (!value) { return (
); } return (
); }); AudioPlayer.displayName = "AudioPlayer"; /** * 메인 UnifiedMedia 컴포넌트 */ export const UnifiedMedia = forwardRef( (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 ( ); case "image": return ( ); case "video": return ( ); case "audio": return ( ); default: return ( ); } }; const showLabel = label && style?.labelDisplay !== false; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; return (
{showLabel && ( )}
{renderMedia()}
); } ); UnifiedMedia.displayName = "UnifiedMedia"; export default UnifiedMedia;