'use client';
import * as React from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Upload, CheckCircle2, ChevronDown, ChevronUp, RefreshCw, AlertCircle } from 'lucide-react';
interface UploadItem {
id: string;
file: File;
progress: number;
status: 'uploading' | 'complete' | 'error';
}
export interface DropboxUploadProps {
className?: string;
onUploadStart?: (files: File[]) => void;
onUploadComplete?: (file: File) => void;
simulateDelay?: number;
maxFiles?: number;
}
export function DropboxUpload({
className,
onUploadStart,
onUploadComplete,
simulateDelay = 300,
maxFiles,
}: DropboxUploadProps) {
const [uploads, setUploads] = React.useState<UploadItem[]>([]);
const [isDragOver, setIsDragOver] = React.useState(false);
const [isPanelOpen, setIsPanelOpen] = React.useState(false);
const [isMinimized, setIsMinimized] = React.useState(false);
const [filter, setFilter] = React.useState<'all' | 'completed' | 'skipped' | 'failed'>('all');
const dragCounter = React.useRef(0);
const cancelFns = React.useRef<Map<string, () => void>>(new Map());
const fileInputRef = React.useRef<HTMLInputElement>(null);
const addUpload = React.useCallback(
(files: FileList | File[]) => {
const fileArray = Array.from(files);
let items = fileArray.map(
(file): UploadItem => ({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
progress: 0,
status: 'uploading',
}),
);
if (maxFiles) {
items = items.slice(0, maxFiles);
}
setUploads((prev) => [...prev, ...items]);
setIsPanelOpen(true);
setIsMinimized(false); // Auto-expand when new uploads start
onUploadStart?.(items.map((i) => i.file));
for (const item of items) {
let progress = 0;
const interval = setInterval(() => {
const increment = Math.random() * 15 + 5;
progress = Math.min(100, progress + increment);
const p = Math.round(progress);
setUploads((prev) => prev.map((u) => (u.id === item.id ? { ...u, progress: p } : u)));
if (progress >= 100) {
clearInterval(interval);
cancelFns.current.delete(item.id);
setUploads((prev) => prev.map((u) => (u.id === item.id ? { ...u, status: 'complete' } : u)));
onUploadComplete?.(item.file);
}
}, simulateDelay);
cancelFns.current.set(item.id, () => clearInterval(interval));
}
},
[maxFiles, onUploadStart, onUploadComplete, simulateDelay],
);
const removeUpload = React.useCallback((id: string) => {
cancelFns.current.get(id)?.();
cancelFns.current.delete(id);
setUploads((prev) => prev.filter((u) => u.id !== id));
}, []);
const cancelAll = React.useCallback(() => {
uploads.forEach((u) => {
if (u.status === 'uploading') {
cancelFns.current.get(u.id)?.();
cancelFns.current.delete(u.id);
}
});
setUploads([]);
setIsPanelOpen(false);
}, [uploads]);
const clearAll = React.useCallback(() => {
setUploads([]);
setIsPanelOpen(false);
}, []);
const handleDragEnter = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true);
}
}, []);
const handleDragLeave = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragOver(false);
}
}, []);
const handleDrop = React.useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
dragCounter.current = 0;
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
addUpload(e.dataTransfer.files);
}
},
[addUpload],
);
// Filtered uploads logic
const filteredUploads = React.useMemo(() => {
return uploads.filter((u) => {
if (filter === 'all') return true;
if (filter === 'completed') return u.status === 'complete';
if (filter === 'failed') return u.status === 'error';
return false; // 'skipped' or fallback
});
}, [uploads, filter]);
// Statistics for the blue footer bar
const uploadingItems = React.useMemo(() => uploads.filter((u) => u.status === 'uploading'), [uploads]);
const uploadingCount = uploadingItems.length;
const secondsLeft = React.useMemo(() => {
if (uploadingCount === 0) return 0;
const avgProgress = uploadingItems.reduce((acc, curr) => acc + curr.progress, 0) / uploadingCount;
const pctRemaining = 100 - avgProgress;
// Each step is simulateDelay (ms) and averages ~10% progress increment
const stepsLeft = pctRemaining / 10;
return Math.max(1, Math.round((stepsLeft * simulateDelay) / 1000));
}, [uploadingItems, uploadingCount, simulateDelay]);
return (
<div
className={`relative h-svh w-full flex flex-col items-center justify-center bg-[#0a0a0a] ${className ?? ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={handleDrop}
>
{/* Hidden File Input */}
<input
type="file"
ref={fileInputRef}
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
addUpload(e.target.files);
}
}}
multiple
className="hidden"
/>
{/* Main Drop Zone */}
<div
onClick={() => fileInputRef.current?.click()}
className={`
relative flex flex-col items-center justify-center gap-4
w-full max-w-lg h-64 rounded-2xl border-2 border-dashed
transition-all duration-300 cursor-pointer select-none
${isDragOver
? 'border-blue-500 bg-blue-500/5 scale-[1.02] shadow-[0_0_20px_rgba(59,130,246,0.15)]'
: 'border-zinc-800 hover:border-zinc-700 hover:bg-zinc-900/50'
}
`}
>
<motion.div
animate={isDragOver ? { scale: 1.1, y: -4 } : { scale: 1, y: 0 }}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
>
<Upload className={`size-12 ${isDragOver ? 'text-blue-500' : 'text-zinc-500'}`} />
</motion.div>
<div className="text-center">
<p className={`text-sm font-semibold ${isDragOver ? 'text-blue-500' : 'text-zinc-300'}`}>
{isDragOver ? 'Drop files here' : 'Drag & drop files here'}
</p>
<p className="text-xs text-zinc-500 mt-1">or click to browse</p>
</div>
</div>
{/* Uploads Panel (Popper) */}
<AnimatePresence>
{isPanelOpen && uploads.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
className="fixed bottom-4 right-4 z-50 w-96 bg-[#141414] text-[#f5f5f5] rounded-2xl shadow-[0_12px_40px_rgba(0,0,0,0.6)] border border-zinc-800/80 overflow-hidden flex flex-col font-sans"
>
{!isMinimized && (
<>
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<span className="text-base font-bold text-white">Uploads</span>
<div className="flex items-center gap-2">
<button
onClick={uploadingCount > 0 ? cancelAll : clearAll}
className="text-xs font-semibold px-3 py-1.5 rounded-full border border-zinc-800 hover:border-zinc-700 text-zinc-200 hover:text-white transition-colors bg-transparent cursor-pointer"
>
{uploadingCount > 0 ? 'Cancel all' : 'Clear all'}
</button>
<button
onClick={() => setIsMinimized(true)}
className="text-zinc-400 hover:text-white p-1 hover:bg-zinc-800/50 rounded-full transition-colors cursor-pointer"
title="Minimize"
>
<ChevronDown className="size-4.5" />
</button>
</div>
</div>
{/* Filter Pills */}
<div className="flex gap-1.5 px-4 py-2 flex-wrap">
{(['all', 'completed', 'skipped', 'failed'] as const).map((tab) => {
const label = {
all: 'All uploads',
completed: 'Completed',
skipped: 'Skipped',
failed: 'Failed',
}[tab];
const isActive = filter === tab;
return (
<button
key={tab}
onClick={() => setFilter(tab)}
className={`text-xs px-3 py-1 rounded-full border transition-all cursor-pointer ${
isActive
? 'border-white text-white font-semibold bg-transparent'
: 'border-transparent bg-[#242424] text-zinc-400 hover:bg-[#2e2e2e] hover:text-zinc-200'
}`}
>
{label}
</button>
);
})}
</div>
{/* Upload Destination Link */}
<div className="px-4 py-1.5 text-xs text-zinc-400 font-medium">
Uploading to{' '}
<span className="underline font-bold text-white cursor-pointer hover:text-blue-400 transition-colors">
Design Spells
</span>
</div>
{/* Items List */}
<div className="max-h-72 overflow-y-auto mt-2 border-t border-zinc-900/60 divide-y divide-zinc-900/40">
{filteredUploads.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-zinc-500 text-xs">
No uploads in this filter
</div>
) : (
filteredUploads.map((item) => {
const extension = item.file.name.split('.').pop()?.toUpperCase() || 'FILE';
const nameWithoutExtension =
item.file.name.substring(0, item.file.name.lastIndexOf('.')) || item.file.name;
return (
<div
key={item.id}
className="relative flex flex-col pt-3.5 pb-4.5 group"
>
<div className="flex items-start justify-between px-4 gap-3">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="mt-0.5 shrink-0">
{item.status === 'complete' ? (
<CheckCircle2 className="size-4.5 text-emerald-500 fill-emerald-500/10" />
) : item.status === 'error' ? (
<AlertCircle className="size-4.5 text-rose-500" />
) : (
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1.8, ease: 'linear' }}
className="inline-block"
>
<RefreshCw className="size-4.5 text-zinc-400" />
</motion.div>
)}
</div>
<div className="min-w-0 flex-1">
<span className="text-xs font-semibold text-white truncate block">
{nameWithoutExtension}
</span>
<div className="flex items-center gap-1.5 mt-1">
<span className="bg-[#242424] text-zinc-400 text-[10px] font-bold px-1.5 py-0.5 rounded leading-none">
{extension}
</span>
<span className="text-[11px] text-zinc-400">
{item.status === 'complete'
? 'Complete'
: item.status === 'error'
? 'Failed'
: item.progress > 90
? 'Finalizing...'
: `${item.progress}%`}
</span>
</div>
</div>
</div>
{item.status === 'uploading' && (
<button
onClick={() => removeUpload(item.id)}
className="text-zinc-400 hover:text-white bg-[#242424] hover:bg-[#2e2e2e] transition-colors shrink-0 text-xs px-3 py-1 rounded-full font-semibold cursor-pointer"
>
Cancel
</button>
)}
</div>
{/* Item Progress Bar */}
{item.status === 'uploading' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-zinc-900 overflow-hidden">
<motion.div
className="h-full bg-[#0061FE]"
initial={{ width: 0 }}
animate={{ width: `${item.progress}%` }}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
/>
</div>
)}
</div>
);
})
)}
</div>
</>
)}
{/* Blue Status Footer */}
<div
className={`bg-[#0061FE] text-white px-4 py-3 flex items-center justify-between transition-all select-none ${
isMinimized ? 'cursor-pointer hover:bg-[#0057e6]' : ''
}`}
onClick={() => {
if (isMinimized) {
setIsMinimized(false);
}
}}
>
<div className="flex items-center gap-3">
<div className="bg-[#0052d9] p-1.5 rounded-lg shrink-0">
{uploadingCount > 0 ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 2, ease: 'linear' }}
className="inline-block"
>
<RefreshCw className="size-4 text-white" />
</motion.div>
) : (
<Upload className="size-4 text-white" />
)}
</div>
<div>
<p className="text-xs font-semibold">
{uploadingCount > 0
? `Uploading ${uploadingCount} item${uploadingCount > 1 ? 's' : ''}`
: `${uploads.length} item${uploads.length > 1 ? 's' : ''} uploaded`}
</p>
<p className="text-[10px] text-blue-200">
{uploadingCount > 0 ? `${secondsLeft} second${secondsLeft !== 1 ? 's' : ''} left` : 'Finished'}
</p>
</div>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => fileInputRef.current?.click()}
className="bg-transparent hover:bg-white/10 text-white border border-white/20 hover:border-white/40 text-xs px-3 py-1.5 rounded-full font-semibold flex items-center gap-1 transition-all cursor-pointer"
>
Add <ChevronDown className="size-3" />
</button>
{isMinimized && (
<button
onClick={() => setIsMinimized(false)}
className="text-white/80 hover:text-white p-1 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
title="Expand"
>
<ChevronUp className="size-4" />
</button>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}