"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 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(null); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [error, setError] = useState(null); // 업로드 직후 미리보기를 위한 로컬 상태 const [localPreviewUrls, setLocalPreviewUrls] = useState([]); // 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 (
{/* 메인 업로드 박스 - 이미지가 있으면 박스 안에 표시 */}
!disabled && !firstFile && inputRef.current?.click()} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} > handleFileSelect(e.target.files)} className="hidden" /> {firstFile ? ( // 파일이 있으면 박스 안에 표시
{isFirstFileImage ? ( // 이미지 미리보기 업로드된 이미지 ) : ( // 일반 파일
{firstFile.split("/").pop()}
)} {/* 호버 시 액션 버튼 */}
{isFirstFileImage && ( )}
) : isUploading ? (
업로드 중...
) : (
클릭 또는 파일을 드래그하세요
최대 {formatFileSize(maxSize)} {accept !== "*" && ` (${accept})`}
)}
{/* 에러 메시지 */} {error && (
{error}
)} {/* 추가 파일 목록 (multiple일 때 2번째 파일부터) */} {multiple && files.length > 1 && (
{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 (
{isImage ? ( {`파일 ) : ( )}
); })} {/* 추가 버튼 */}
!disabled && inputRef.current?.click()} >
)}
); }); 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 = "/files/upload", className }, ref) => { const inputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [previewUrl, setPreviewUrl] = useState(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 (
{/* 메인 업로드 박스 - 첫 번째 이미지가 있으면 박스 안에 표시 */}
!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); }} > handleFileSelect(e.target.files)} className="hidden" /> {mainImage ? ( // 이미지가 있으면 박스 안에 표시
업로드된 이미지 {/* 호버 시 액션 버튼 */}
) : isUploading ? (
업로드 중...
) : (
클릭 또는 파일을 드래그하세요 최대 {Math.round(maxSize / 1024 / 1024)} MB (*/*)
)}
{/* 추가 이미지 목록 (multiple일 때만) */} {multiple && additionalImages.length > 0 && (
{additionalImages.map((src, index) => (
{`이미지
))} {/* 추가 버튼 */}
!disabled && inputRef.current?.click()} >
)}
); }); 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"; /** * 메인 V2Media 컴포넌트 */ export const V2Media = forwardRef( (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 ( ); 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()}
); } ); V2Media.displayName = "V2Media"; export default V2Media;