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); // Click-outside-to-close. iOS Safari does not synthesize `mousedown` from // touch reliably, so we listen for `touchstart` alongside `mousedown` so the // panel dismisses on the first tap on touch devices. Mirrors the pattern // used in AudioInputSection.tsx. useEffect(() => { if (!open) return; const onPointerDown = (e: MouseEvent | TouchEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('touchstart', onPointerDown, { passive: true }); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('touchstart', onPointerDown); }; }, [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 && ( // Desktop: anchor below the trigger via `absolute right-0 top-full`. // Mobile (<768px): switch to `fixed` so the panel pins to the viewport // right edge with an 8px margin instead of inheriting the trigger's // x-position. This prevents left-edge clipping on narrow viewports // (e.g. 320px) where the trigger sits well to the left of the // viewport's right edge — `right-0` would otherwise anchor the // panel to the trigger's right and let the 300px panel extend off // the left of the viewport.
{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') && ( )}
); }