import { useState, useEffect, useMemo } from 'react'; import { useClient } from 'hooks/useClient'; import { RefreshCw, X, Folder, ArrowUpFromLine } from 'lucide-react'; import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards'; import { basename, folderLabel, formatSize, formatSpeed, transferPhase, toneSolid, PHASE_TONE, type SlskdTransfer, type SlskdTransferUser, type TransferPhase, } from './shared'; // Uploads panel — the mirror image of Downloads: what peers are pulling from us. GET /transfers/uploads // returns the same user → directories → files shape, so we group and render it the same way (collapsible // card per peer, files by folder, a progress bar per file). Basic version: one flat list, per-row // cancel/clear; no retry/queue-position controls (those are download-side concerns). const POLL_MS = 1500; type Row = SlskdTransfer & { phase: TransferPhase }; type UpDir = { directory: string; label: string; files: Row[]; size: number }; type UpUser = { username: string; dirs: UpDir[]; counts: Record; total: number }; const PHASE_ORDER: Record = { downloading: 0, queued: 1, failed: 2, done: 3 }; const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done']; const PHASE_LABEL: Record = { downloading: 'Uploading', queued: 'Queued', done: 'Done', failed: 'Failed', }; const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed'; const group = (users: SlskdTransferUser[]): UpUser[] => { const out: UpUser[] = []; for (const user of users) { const dirs: UpDir[] = []; for (const dir of user.directories ?? []) { const files: Row[] = (dir.files ?? []).map((f) => ({ ...f, phase: transferPhase(f.state) })); if (files.length === 0) continue; files.sort( (a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)), ); dirs.push({ directory: dir.directory, label: folderLabel(dir.directory), files, size: files.reduce((n, f) => n + f.size, 0), }); } if (dirs.length === 0) continue; dirs.sort((a, b) => a.directory.localeCompare(b.directory)); const counts: Record = { downloading: 0, queued: 0, failed: 0, done: 0 }; let total = 0; for (const dir of dirs) for (const f of dir.files) (counts[f.phase]++, total++); out.push({ username: user.username, dirs, counts, total }); } out.sort( (a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username), ); return out; }; export const SoulseekUploads = () => { const client = useClient(); const [data, setData] = useState([]); const [loaded, setLoaded] = useState(false); const [collapsed, setCollapsed] = useState>(new Set()); const users = useMemo(() => group(data), [data]); useEffect(() => { let cancelled = false; const tick = () => client .get('/soulseek/api/v0/transfers/uploads') .then((u) => !cancelled && (setData(u), setLoaded(true))) .catch(() => {}); tick(); const timer = setInterval(tick, POLL_MS); return () => { cancelled = true; clearInterval(timer); }; // useClient() is a fresh object each render — poll on a stable interval only. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const toggle = (name: string) => setCollapsed((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); const dropIds = (ids: Set) => setData((prev) => prev.map((u) => ({ ...u, directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })), })), ); // Abort an in-flight upload, or clear a finished/failed one from the queue. const remove = async (row: Row) => { const done = isRemovable(row.phase); try { await client.delete( `/soulseek/api/v0/transfers/uploads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`, ); dropIds(new Set([row.id])); } catch { /* next poll reconciles */ } }; return (
Uploads · {users.length} peer{users.length === 1 ? '' : 's'} {!loaded && }
{users.length === 0 ? (

{loaded ? 'No uploads. When a peer downloads a shared file it shows up here.' : 'Loading uploads…'}

) : (
{users.map((user) => ( toggle(user.username)} onRemove={remove} /> ))}
)}
); }; type UserCardProps = { user: UpUser; open: boolean; onToggle: () => void; onRemove: (row: Row) => void }; const UserCard = ({ user, open, onToggle, onRemove }: UserCardProps) => { const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => ( {user.counts[p]} )); return ( {open && ( {user.dirs.map((dir) => ( ))} )} ); }; type FolderBlockProps = { dir: UpDir; onRemove: (row: Row) => void }; const FolderBlock = ({ dir, onRemove }: FolderBlockProps) => { const [open, setOpen] = useState(true); return ( setOpen((v) => !v)} icon={} label={dir.label} title={dir.directory} meta={`${dir.files.length} · ${formatSize(dir.size)}`} /> {open && ( {dir.files.map((row) => { const dot = toneSolid[PHASE_TONE[row.phase]]; const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete))); const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : ''; return (
{basename(row.filename)}
{PHASE_LABEL[row.phase]} {row.size > 0 && · {formatSize(row.size)}} {speed && · {speed}}
); })} )} ); };