303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
import { useState, useRef, useCallback, type DragEvent, type ChangeEvent } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
|
import { VideoFilled, FileTextFilled } from '../icons/FilledIcons';
|
|
|
|
// ─── Types ───
|
|
|
|
type UploadCategory = 'all' | 'image' | 'video' | 'text';
|
|
|
|
interface UploadedAsset {
|
|
id: string;
|
|
file: File;
|
|
category: 'image' | 'video' | 'text';
|
|
previewUrl: string | null;
|
|
name: string;
|
|
size: string;
|
|
uploadedAt: Date;
|
|
}
|
|
|
|
// ─── Helpers ───
|
|
|
|
function categorize(file: File): 'image' | 'video' | 'text' {
|
|
if (file.type.startsWith('image/')) return 'image';
|
|
if (file.type.startsWith('video/')) return 'video';
|
|
return 'text';
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
|
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
function uid() {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
const categoryConfig: Record<UploadCategory, { label: string }> = {
|
|
all: { label: '전체' },
|
|
image: { label: 'Image' },
|
|
video: { label: 'Video' },
|
|
text: { label: 'Text' },
|
|
};
|
|
|
|
const categoryBadge: Record<'image' | 'video' | 'text', string> = {
|
|
image: 'bg-[#F3F0FF] text-[#4A3A7C] shadow-[2px_3px_6px_rgba(155,138,212,0.12)]',
|
|
video: 'bg-[#FFF0F0] text-[#7C3A4B] shadow-[2px_3px_6px_rgba(212,136,154,0.12)]',
|
|
text: 'bg-[#FFF6ED] text-[#7C5C3A] shadow-[2px_3px_6px_rgba(212,168,114,0.12)]',
|
|
};
|
|
|
|
const ACCEPT_MAP: Record<string, string> = {
|
|
'image/*': '.jpg,.jpeg,.png,.gif,.webp,.svg',
|
|
'video/*': '.mp4,.mov,.webm,.avi',
|
|
'text/*': '.txt,.md,.doc,.docx,.pdf,.csv,.json',
|
|
};
|
|
const ALL_ACCEPT = Object.values(ACCEPT_MAP).join(',');
|
|
|
|
// ─── Component ───
|
|
|
|
export default function MyAssetUpload() {
|
|
const [assets, setAssets] = useState<UploadedAsset[]>([]);
|
|
const [activeFilter, setActiveFilter] = useState<UploadCategory>('all');
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const processFiles = useCallback((files: FileList | File[]) => {
|
|
const newAssets: UploadedAsset[] = Array.from(files).map((file) => {
|
|
const cat = categorize(file);
|
|
const previewUrl =
|
|
cat === 'image' || cat === 'video'
|
|
? URL.createObjectURL(file)
|
|
: null;
|
|
return {
|
|
id: uid(),
|
|
file,
|
|
category: cat,
|
|
previewUrl,
|
|
name: file.name,
|
|
size: formatSize(file.size),
|
|
uploadedAt: new Date(),
|
|
};
|
|
});
|
|
setAssets((prev) => [...newAssets, ...prev]);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(
|
|
(e: DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
if (e.dataTransfer.files.length) processFiles(e.dataTransfer.files);
|
|
},
|
|
[processFiles],
|
|
);
|
|
|
|
const handleInputChange = useCallback(
|
|
(e: ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files?.length) {
|
|
processFiles(e.target.files);
|
|
e.target.value = '';
|
|
}
|
|
},
|
|
[processFiles],
|
|
);
|
|
|
|
const removeAsset = useCallback((id: string) => {
|
|
setAssets((prev) => {
|
|
const found = prev.find((a) => a.id === id);
|
|
if (found?.previewUrl) URL.revokeObjectURL(found.previewUrl);
|
|
return prev.filter((a) => a.id !== id);
|
|
});
|
|
}, []);
|
|
|
|
const filtered =
|
|
activeFilter === 'all' ? assets : assets.filter((a) => a.category === activeFilter);
|
|
|
|
const counts = {
|
|
all: assets.length,
|
|
image: assets.filter((a) => a.category === 'image').length,
|
|
video: assets.filter((a) => a.category === 'video').length,
|
|
text: assets.filter((a) => a.category === 'text').length,
|
|
};
|
|
|
|
return (
|
|
<SectionWrapper
|
|
id="my-asset-upload"
|
|
title="My Assets"
|
|
subtitle="나의 에셋 업로드"
|
|
>
|
|
{/* Drop Zone */}
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
|
onDragLeave={() => setIsDragOver(false)}
|
|
onDrop={handleDrop}
|
|
onClick={() => inputRef.current?.click()}
|
|
className={`relative rounded-2xl border-2 border-dashed p-10 md:p-14 text-center cursor-pointer transition-all mb-8 ${
|
|
isDragOver
|
|
? 'border-[#9B8AD4] bg-[#F3F0FF]/60 scale-[1.01]'
|
|
: 'border-slate-200 bg-slate-50/50 hover:border-[#D5CDF5] hover:bg-[#F3F0FF]/20'
|
|
}`}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple
|
|
accept={ALL_ACCEPT}
|
|
onChange={handleInputChange}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Upload Icon */}
|
|
<div className="flex justify-center mb-4">
|
|
<div className="w-14 h-14 rounded-2xl bg-[#F3F0FF] flex items-center justify-center shadow-[3px_4px_12px_rgba(155,138,212,0.15)]">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
|
<path
|
|
d="M12 16V4M12 4L8 8M12 4L16 8"
|
|
stroke="#9B8AD4"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
<path
|
|
d="M4 14V18C4 19.1 4.9 20 6 20H18C19.1 20 20 19.1 20 18V14"
|
|
stroke="#9B8AD4"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-[#0A1128] font-semibold mb-1">
|
|
파일을 드래그하거나 클릭하여 업로드
|
|
</p>
|
|
<p className="text-sm text-slate-400">
|
|
Image, Video, Text 파일 지원 (JPG, PNG, MP4, MOV, TXT, PDF, DOC 등)
|
|
</p>
|
|
|
|
{/* File Type Badges */}
|
|
<div className="flex justify-center gap-2 mt-4">
|
|
{(['image', 'video', 'text'] as const).map((cat) => (
|
|
<span
|
|
key={cat}
|
|
className={`rounded-full px-3 py-1 text-xs font-medium ${categoryBadge[cat]}`}
|
|
>
|
|
{cat === 'image' ? 'Image' : cat === 'video' ? 'Video' : 'Text'}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter Tabs + Count */}
|
|
{assets.length > 0 && (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-2 mb-6">
|
|
{(Object.keys(categoryConfig) as UploadCategory[]).map((key) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setActiveFilter(key)}
|
|
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
|
activeFilter === key
|
|
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
|
|
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 shadow-[2px_3px_6px_rgba(0,0,0,0.04)]'
|
|
}`}
|
|
>
|
|
{categoryConfig[key].label}
|
|
<span className="ml-2 opacity-70">{counts[key]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Uploaded Assets Grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
<AnimatePresence mode="popLayout">
|
|
{filtered.map((asset) => (
|
|
<motion.div
|
|
key={asset.id}
|
|
layout
|
|
className="bg-white rounded-2xl border border-slate-100 overflow-hidden shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow group"
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
transition={{ duration: 0.25 }}
|
|
>
|
|
{/* Preview Area */}
|
|
<div className="relative h-40 bg-slate-50 flex items-center justify-center overflow-hidden">
|
|
{asset.category === 'image' && asset.previewUrl && (
|
|
<img
|
|
src={asset.previewUrl}
|
|
alt={asset.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
{asset.category === 'video' && asset.previewUrl && (
|
|
<video
|
|
src={asset.previewUrl}
|
|
className="w-full h-full object-cover"
|
|
muted
|
|
playsInline
|
|
onMouseOver={(e) => (e.target as HTMLVideoElement).play()}
|
|
onMouseOut={(e) => {
|
|
const v = e.target as HTMLVideoElement;
|
|
v.pause();
|
|
v.currentTime = 0;
|
|
}}
|
|
/>
|
|
)}
|
|
{asset.category === 'text' && (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<FileTextFilled size={36} className="text-[#D4A872]" />
|
|
<span className="text-xs text-slate-400 font-medium">
|
|
{asset.name.split('.').pop()?.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Remove Button */}
|
|
<button
|
|
onClick={() => removeAsset(asset.id)}
|
|
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-white/90 backdrop-blur-sm border border-slate-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-[#FFF0F0]"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
<path d="M2 2L10 10M10 2L2 10" stroke="#7C3A4B" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Category Badge */}
|
|
<span
|
|
className={`absolute top-2 left-2 rounded-full px-3 py-1 text-xs font-semibold ${categoryBadge[asset.category]}`}
|
|
>
|
|
{asset.category === 'image'
|
|
? 'Image'
|
|
: asset.category === 'video'
|
|
? 'Video'
|
|
: 'Text'}
|
|
</span>
|
|
|
|
{/* Video Duration Overlay */}
|
|
{asset.category === 'video' && (
|
|
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/50 backdrop-blur-sm rounded-full px-2 py-1">
|
|
<VideoFilled size={10} className="text-white" />
|
|
<span className="text-xs text-white font-medium">Video</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="p-4">
|
|
<p className="text-sm font-medium text-[#0A1128] truncate mb-1">
|
|
{asset.name}
|
|
</p>
|
|
<p className="text-xs text-slate-400">{asset.size}</p>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</>
|
|
)}
|
|
</SectionWrapper>
|
|
);
|
|
}
|