import { useState, useRef, useEffect, useMemo } from 'react'; import { useTransferStore, type Transfer } from '../../stores/transferStore'; function fmt(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; } export function TransferIndicator() { // Subscribe to the Map directly. `listVisible()` returns a fresh array each call, // which would force a re-render on every store mutation. The Map reference only // changes when transfers are added/removed/updated, so useMemo recomputes only // when needed. const transfersMap = useTransferStore((s) => s.transfers); const visible = useMemo( () => Array.from(transfersMap.values()).filter((t) => t.tray), [transfersMap], ); const pause = useTransferStore((s) => s.pauseUpload); const resume = useTransferStore((s) => s.resumeUpload); const abort = useTransferStore((s) => s.abortUpload); const pauseDl = useTransferStore((s) => s.pauseDownload); const resumeDl = useTransferStore((s) => s.resumeDownload); const abortDl = useTransferStore((s) => s.abortDownload); const remove = useTransferStore((s) => s.remove); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onClick = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, [open]); const active = visible.filter( (t) => t.state === 'active' || t.state === 'paused' || t.state === 'queued', ); const failed = visible.some((t) => t.state === 'failed'); const idle = visible.length === 0; const badgeColor = failed ? 'bg-accent-rose' : 'bg-accent-amber'; return (
{open && (
{visible.length} transfer{visible.length === 1 ? '' : 's'}
{visible.length === 0 && (
No active transfers.
)}
{visible.map((t) => ( (t.type === 'upload' ? pause(t.id) : pauseDl(t.id))} onResume={() => { if (t.type === 'upload') void resume(t.id); else void resumeDl(t.id); }} onAbort={() => (t.type === 'upload' ? abort(t.id) : abortDl(t.id))} onDismiss={() => remove(t.id)} /> ))}
)}
); } interface TransferRowProps { transfer: Transfer; onPause: () => void; onResume: () => void; onAbort: () => void; onDismiss: () => void; } function TransferRow({ transfer, onPause, onResume, onAbort, onDismiss }: TransferRowProps) { const pct = transfer.progress.total > 0 ? Math.min(100, Math.round((transfer.progress.loaded / transfer.progress.total) * 100)) : 0; const barColor = transfer.state === 'failed' ? 'bg-accent-rose' : transfer.state === 'completed' ? 'bg-accent-mint' : 'bg-accent-mint/70'; return (
{transfer.type === 'upload' ? '↑' : '↓'} {transfer.file.name}
{transfer.state === 'completed' ? 'Done' : transfer.state === 'failed' ? `Failed: ${transfer.error?.message ?? 'unknown'}` : transfer.state === 'aborted' ? 'Aborted' : `${fmt(transfer.progress.loaded)} / ${fmt(transfer.progress.total)}`} {transfer.state === 'active' && ( )} {transfer.state === 'paused' && ( )} {(transfer.state === 'active' || transfer.state === 'paused' || transfer.state === 'queued') && ( )} {(transfer.state === 'completed' || transfer.state === 'failed' || transfer.state === 'aborted') && ( )}
); }