792 lines
26 KiB
TypeScript
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;
|
|
|