576 lines
16 KiB
TypeScript
576 lines
16 KiB
TypeScript
|
|
"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;
|
||
|
|
|