"use client"; import { useState, useRef, useEffect } from "react"; import { X, CheckCircle, XCircle, Download, Trash2, GripVertical, } from "lucide-react"; import { DownloadJob } from "@/hooks/useDownloadStatus"; import { GradientSpinner } from "./ui/GradientSpinner"; import { cn } from "@/utils/cn"; import { useDownloadContext } from "@/lib/download-context"; import { api } from "@/lib/api"; import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery"; export function DownloadNotifications() { const { downloadStatus } = useDownloadContext(); const [isOpen, setIsOpen] = useState(false); const [dismissed, setDismissed] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const containerRef = useRef(null); const [isClearing, setIsClearing] = useState(false); const [deletedIds, setDeletedIds] = useState>(new Set()); const isMobile = useIsMobile(); const isTablet = useIsTablet(); const isMobileOrTablet = isMobile || isTablet; // Handle drag start - only from header const handleMouseDown = (e: React.MouseEvent) => { // Don't drag when clicking buttons or links const target = e.target as HTMLElement; if (target.closest("button") || target.closest("a")) return; // Prevent default to avoid text selection during drag e.preventDefault(); setIsDragging(true); setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y, }); }; // Handle drag move useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isDragging) return; const newX = e.clientX - dragStart.x; const newY = e.clientY - dragStart.y; // Get window and element dimensions const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; const elementWidth = containerRef.current?.offsetWidth || 384; const elementHeight = containerRef.current?.offsetHeight || 400; // Starting position in viewport (bottom-24 = 96px from bottom, right-4 = 16px from right) const startRight = 16; // right-4 in Tailwind const startBottom = 96; // bottom-24 in Tailwind // Calculate bounds that allow dragging across entire viewport // Keep at least 80px of the header visible const minX = -(windowWidth - startRight - 80); // Can drag far left const maxX = windowWidth - startRight - 80; // Can drag far right const minY = -(windowHeight - startBottom - 80); // Can drag to top const maxY = windowHeight - startBottom - 80; // Can drag to bottom const constrainedX = Math.max(minX, Math.min(newX, maxX)); const constrainedY = Math.max(minY, Math.min(newY, maxY)); setPosition({ x: constrainedX, y: constrainedY }); }; const handleMouseUp = () => { setIsDragging(false); }; if (isDragging) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); } return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [isDragging, dragStart, position]); // Auto-open when there are active downloads or failures const shouldShow = downloadStatus.hasActiveDownloads || downloadStatus.failedDownloads.length > 0 || downloadStatus.recentDownloads.length > 0; useEffect(() => { if (shouldShow) { setIsOpen(true); setDismissed(false); } else { setIsOpen(false); } }, [shouldShow]); const shouldRender = (shouldShow && !dismissed) || isOpen; if (!shouldRender) return null; // Function to manually close modal (even if there are downloads) const handleClose = () => { setIsOpen(false); setDismissed(true); }; // Function to clear completed/failed downloads const handleClearCompleted = async () => { try { const jobIds = [ ...downloadStatus.recentDownloads.map((j) => j.id), ...downloadStatus.failedDownloads.map((j) => j.id), ]; if (jobIds.length === 0) { setDismissed(true); setIsOpen(false); return; } setIsClearing(true); await Promise.all( jobIds.map((id) => api .deleteDownload(id) .catch((error) => console.error(`Failed to delete job ${id}`, error) ) ) ); setIsClearing(false); setDismissed(true); setIsOpen(false); } catch (error) { setIsClearing(false); console.error("Failed to clear downloads:", error); } }; const allJobs = [ ...downloadStatus.activeDownloads, ...downloadStatus.recentDownloads, ] .filter((job) => !deletedIds.has(job.id)) // Filter out optimistically deleted jobs .sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); // Mobile: Compact floating pill at top if (isMobileOrTablet) { return (
{/* Compact Header */}
Downloads {downloadStatus.hasActiveDownloads && ( {downloadStatus.activeDownloads.length}{" "} active )}
{/* Compact Download List - max 3 visible */}
{allJobs.length === 0 ? (
No downloads
) : (
{allJobs.slice(0, 5).map((job) => ( setDeletedIds((prev) => new Set(prev).add(id) ) } /> ))} {allJobs.length > 5 && (
+{allJobs.length - 5} more
)}
)}
{/* Footer - Clear button */} {(downloadStatus.recentDownloads.length > 0 || downloadStatus.failedDownloads.length > 0) && (
)}
); } // Desktop: Full draggable panel return (
{/* Header */}

Downloads

{downloadStatus.hasActiveDownloads && ( {downloadStatus.activeDownloads.length} active )}
{/* Download List */}
{allJobs.length === 0 ? (
No recent downloads
) : (
{allJobs.map((job) => ( setDeletedIds((prev) => new Set(prev).add(id) ) } /> ))}
)}
{/* Footer - Clear button */} {(downloadStatus.recentDownloads.length > 0 || downloadStatus.failedDownloads.length > 0) && (
)}
); } function DownloadJobItem({ job, onDelete, }: { job: DownloadJob; onDelete?: (id: string) => void; }) { const [isDeleting, setIsDeleting] = useState(false); const getStatusIcon = () => { switch (job.status) { case "completed": return ; case "failed": return ; case "processing": case "pending": return ; default: return ; } }; const getStatusColor = () => { switch (job.status) { case "completed": return "text-green-400"; case "failed": return "text-red-400"; case "processing": return "text-blue-400"; case "pending": return "text-yellow-400"; default: return "text-white/40"; } }; const handleDelete = async () => { try { setIsDeleting(true); // Optimistically remove from UI onDelete?.(job.id); // Then delete from backend await api.deleteDownload(job.id); } catch (error) { console.error("Failed to delete download:", error); setIsDeleting(false); } }; // Show delete button for completed, failed, or stuck processing jobs const canDelete = job.status === "completed" || job.status === "failed" || job.status === "processing"; return (
{getStatusIcon()}

{job.subject}

{job.status} {job.type}
{job.error && (

{job.error}

)}
{canDelete && ( )}
); } // Compact version for mobile function DownloadJobItemCompact({ job, onDelete, }: { job: DownloadJob; onDelete?: (id: string) => void; }) { const getStatusIcon = () => { switch (job.status) { case "completed": return ; case "failed": return ; case "processing": case "pending": return ; default: return ; } }; return (
{getStatusIcon()}

{job.subject}

{job.status}
); }