o2o-infinith-demo/src/components/plan/MyAssetUpload.tsx

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>
);
}