diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx index 75cd1de0..414b5770 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx @@ -1,54 +1,77 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { toast } from 'sonner'; -import { ArrowLeft, Download, Lock, Loader2 } from 'lucide-react'; +import { ArrowLeft, Download, Lock, Loader2, Folder, Zap, Users, Search, X } from 'lucide-react'; +import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards'; import { SLSKD_REFRESH_CHANNEL, basename, - extOf, formatSize, - type ResultRow, + formatSpeed, + formatDuration, + groupResponses, + type ResultUser, + type ResultFolder, + type ResultFile, type SlskdResponse, type SlskdSearchSummary, } from './shared'; // The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses), -// never re-runs the search. Responses can be huge, so we flatten, rank by availability, and cap the -// render. Results are memoised per search id for the session so re-opening is instant (a proper cache +// never re-runs the search. Presented the way slskd does: grouped by responder (user) → folder → files, +// each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus +// per-folder download actions. Grouped result is memoised per search id for the session (a proper cache // is a later step). -const MAX_ROWS = 300; -const resultsCache = new Map(); +const MAX_USERS = 200; +const resultsCache = new Map(); -const rowKey = (row: ResultRow) => `${row.username}::${row.filename}`; +const fileKey = (username: string, filename: string) => `${username}::${filename}`; -const flatten = (responses: SlskdResponse[]): ResultRow[] => { - const flat: ResultRow[] = []; - for (const resp of responses) { - for (const file of resp.files ?? []) { - flat.push({ - username: resp.username, - filename: file.filename, - size: file.size, - extension: extOf(file), - isLocked: !!file.isLocked, - bitRate: file.bitRate, - hasFreeUploadSlot: resp.hasFreeUploadSlot, - queueLength: resp.queueLength, - uploadSpeed: resp.uploadSpeed, - }); +// Client-side filtering over already-loaded results (mirrors slskd's filter tokens, no re-query). +const LOSSLESS = new Set(['flac', 'wav', 'aiff', 'aif', 'ape', 'alac', 'wv']); +const EXT_PILLS = ['flac', 'mp3', 'wav', 'm4a', 'ogg']; +const BITRATE_PILLS = [256, 320]; + +type Filters = { + text: string; + freeOnly: boolean; + hideLocked: boolean; + exts: Set; + minBitrate: number | null; +}; +const EMPTY_FILTERS: Filters = { text: '', freeOnly: false, hideLocked: false, exts: new Set(), minBitrate: null }; + +const filtersActive = (f: Filters) => + f.text.trim() !== '' || f.freeOnly || f.hideLocked || f.exts.size > 0 || f.minBitrate !== null; + +const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => { + const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean); + const pos = terms.filter((t) => !t.startsWith('-')); + const neg = terms.filter((t) => t.startsWith('-')).map((t) => t.slice(1)).filter(Boolean); + const fileOk = (file: ResultFile) => { + if (f.hideLocked && file.isLocked) return false; + if (f.exts.size && !f.exts.has(file.extension)) return false; + if (f.minBitrate !== null && !((file.bitRate ?? 0) >= f.minBitrate || LOSSLESS.has(file.extension))) return false; + const hay = file.filename.toLowerCase(); + if (pos.some((t) => !hay.includes(t))) return false; + if (neg.some((t) => hay.includes(t))) return false; + return true; + }; + const out: ResultUser[] = []; + for (const user of users) { + if (f.freeOnly && !user.hasFreeUploadSlot) continue; + const folders: ResultFolder[] = []; + for (const folder of user.folders) { + const files = folder.files.filter(fileOk); + if (files.length) folders.push({ ...folder, files, size: files.reduce((n, x) => n + x.size, 0) }); + } + if (folders.length) { + out.push({ ...user, folders, fileCount: folders.reduce((n, fd) => n + fd.files.length, 0) }); } } - // Rank the most-downloadable first: free upload slot, then unlocked, then faster peer, then size. - flat.sort( - (a, b) => - Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) || - Number(a.isLocked) - Number(b.isLocked) || - b.uploadSpeed - a.uploadSpeed || - b.size - a.size, - ); - return flat; + return out; }; type SearchResultsProps = { search: SlskdSearchSummary; onBack: () => void }; @@ -56,26 +79,34 @@ type SearchResultsProps = { search: SlskdSearchSummary; onBack: () => void }; export const SearchResults = ({ search, onBack }: SearchResultsProps) => { const client = useClient(); const [, bumpRefresh] = usePanelChannel(SLSKD_REFRESH_CHANNEL, 0); - const [rows, setRows] = useState(() => resultsCache.get(search.id) ?? null); + const cachedInit = resultsCache.get(search.id) ?? null; + const [users, setUsers] = useState(cachedInit); const [error, setError] = useState(null); + // Users start expanded; toggling collapses them. + const [open, setOpen] = useState>(() => new Set(cachedInit?.map((u) => u.username))); const [queued, setQueued] = useState>(new Set()); + const [filters, setFilters] = useState(EMPTY_FILTERS); + + const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]); useEffect(() => { const cached = resultsCache.get(search.id); if (cached) { - setRows(cached); + setUsers(cached); + setOpen(new Set(cached.map((u) => u.username))); return; } let cancelled = false; - setRows(null); + setUsers(null); setError(null); client .get(`/slskd/api/v0/searches/${search.id}/responses`) .then((responses) => { if (cancelled) return; - const flat = flatten(responses); - resultsCache.set(search.id, flat); - setRows(flat); + const grouped = groupResponses(responses); + resultsCache.set(search.id, grouped); + setUsers(grouped); + setOpen(new Set(grouped.map((u) => u.username))); }) .catch((err) => !cancelled && setError(err instanceof Error ? err.message : String(err))); return () => { @@ -85,21 +116,46 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [search.id]); - const download = async (row: ResultRow) => { - const key = rowKey(row); + const toggle = (username: string) => + setOpen((prev) => { + const next = new Set(prev); + if (next.has(username)) next.delete(username); + else next.add(username); + return next; + }); + + const queue = async (username: string, files: ResultFile[]) => { + const usable = files.filter((f) => !f.isLocked); + if (usable.length === 0) return; try { - await client.post(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}`, [ - { filename: row.filename, size: row.size }, - ]); - setQueued((prev) => new Set(prev).add(key)); + await client.post( + `/slskd/api/v0/transfers/downloads/${encodeURIComponent(username)}`, + usable.map((f) => ({ filename: f.filename, size: f.size })), + ); + setQueued((prev) => { + const next = new Set(prev); + usable.forEach((f) => next.add(fileKey(username, f.filename))); + return next; + }); bumpRefresh(Date.now()); - toast.success(`Queued ${basename(row.filename)}`); + toast.success(usable.length === 1 ? `Queued ${basename(usable[0]!.filename)}` : `Queued ${usable.length} files`); } catch (err) { toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`); } }; - const shown = rows ? rows.slice(0, MAX_ROWS) : []; + const toggleExt = (ext: string) => + setFilters((f) => { + const exts = new Set(f.exts); + if (exts.has(ext)) exts.delete(ext); + else exts.add(ext); + return { ...f, exts }; + }); + const setMinBitrate = (n: number) => setFilters((f) => ({ ...f, minBitrate: f.minBitrate === n ? null : n })); + + const shown = filtered ? filtered.slice(0, MAX_USERS) : []; + const totalFiles = filtered?.reduce((n, u) => n + u.fileCount, 0) ?? 0; + const anyFilter = filtersActive(filters); return (
@@ -115,62 +171,218 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
{search.searchText}
- {rows === null && !error + {users === null && !error ? 'Loading results…' - : `${(rows?.length ?? 0).toLocaleString()} files from ${search.responseCount.toLocaleString()} users${ - rows && rows.length > shown.length ? ` — showing top ${shown.length}` : '' - }`} + : `${totalFiles.toLocaleString()} files from ${(filtered?.length ?? 0).toLocaleString()} users${ + anyFilter ? ' (filtered)' : '' + }${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
-
- {error &&

Could not load results: {error}

} - {!error && rows === null && ( -
+ {/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */} + {users && users.length > 0 && ( +
+
+ + setFilters((f) => ({ ...f, text: ev.target.value }))} + placeholder="Filter files… (space-separated, prefix - to exclude)" + className="h-9 w-full rounded-lg border bg-background pl-9 pr-8 text-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20" + /> + {filters.text && ( + + )} +
+
+ setFilters((f) => ({ ...f, freeOnly: !f.freeOnly }))}> + Free slots + + setFilters((f) => ({ ...f, hideLocked: !f.hideLocked }))}> + Hide locked + + + {EXT_PILLS.map((ext) => ( + toggleExt(ext)}> + {ext.toUpperCase()} + + ))} + + {BITRATE_PILLS.map((br) => ( + setMinBitrate(br)}> + ≥{br} + + ))} + {anyFilter && ( + + )} +
+
+ )} + +
+ {error &&

Could not load results: {error}

} + {!error && users === null && ( +
Loading results…
)} - {!error && rows && rows.length === 0 &&

No results stored.

} - {shown.length > 0 && ( -
- {shown.map((row) => { - const key = rowKey(row); - const isQueued = queued.has(key); - return ( -
-
-
- {row.isLocked && } - {basename(row.filename)} -
-
- {row.username} - · {formatSize(row.size)} - {row.extension && · {row.extension}} - {row.bitRate ? · {row.bitRate} kbps : null} - {row.hasFreeUploadSlot ? ( - · free slot - ) : ( - · queue {row.queueLength} - )} -
-
- -
- ); - })} -
+ {!error && users && users.length === 0 &&

No results stored.

} + {!error && users && users.length > 0 && filtered && filtered.length === 0 && ( +

No files match the current filters.

)} + +
+ {shown.map((user) => ( + toggle(user.username)} + queued={queued} + onDownload={queue} + /> + ))} +
); }; + +type UserCardProps = { + user: ResultUser; + open: boolean; + onToggle: () => void; + queued: Set; + onDownload: (username: string, files: ResultFile[]) => void; +}; + +const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) => { + const allFiles = user.folders.flatMap((f) => f.files); + const meta = ( + <> + {user.hasFreeUploadSlot ? ( + + free + + ) : ( + + {user.queueLength} + + )} + {user.uploadSpeed > 0 && {formatSpeed(user.uploadSpeed)}} + {user.fileCount.toLocaleString()} files + + ); + return ( + + + {open && ( + + {user.folders.map((folder) => ( + + ))} + {allFiles.length > 1 && ( + + )} + + )} + + ); +}; + +type FolderBlockProps = { + username: string; + folder: ResultFolder; + queued: Set; + onDownload: (username: string, files: ResultFile[]) => void; +}; + +const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps) => { + const [open, setOpen] = useState(true); + const unlocked = folder.files.filter((f) => !f.isLocked).length; + return ( + + setOpen((v) => !v)} + icon={} + label={folder.label} + title={folder.path} + meta={`${folder.files.length} · ${formatSize(folder.size)}`} + action={ + folder.files.length > 1 ? ( + + ) : null + } + /> + {open && ( + <> + + {folder.files.map((file) => { + const isQueued = queued.has(fileKey(username, file.filename)); + const duration = formatDuration(file.length); + return ( +
+ + {file.isLocked && } + {file.name} + +
+ {file.bitRate ? {file.bitRate} kbps : null} + {duration && {duration}} + {formatSize(file.size)} +
+ +
+ ); + })} +
+ {unlocked > 1 && ( + + )} + + )} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx index 27eed44c..4b0e70c6 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx @@ -1,10 +1,14 @@ -import { useState, useEffect, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { toast } from 'sonner'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; -import { RefreshCw, X, Radio } from 'lucide-react'; +import { RefreshCw, X, Radio, Folder, RotateCcw, Ban, Trash2 } from 'lucide-react'; +import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards'; import { SLSKD_REFRESH_CHANNEL, basename, + folderLabel, formatSize, formatSpeed, transferPhase, @@ -15,15 +19,19 @@ import { } from './shared'; // Right panel of the /soulseek workspace — the connection status strip plus the live download queue. -// Downloads are polled from slskd (GET /transfers/downloads, nested user → directories → files), -// flattened, and ordered active-first. Enqueuing from the search panel bumps the refresh channel so -// the list updates without waiting for the next poll tick. +// Downloads are polled from slskd (GET /transfers/downloads, nested user → directories → files) and +// presented the same way as search results: a collapsible card per user, files grouped by folder. +// Enqueuing from the search panel bumps the refresh channel so the list updates without waiting for the +// next poll tick. const POLL_MS = 1500; type Row = SlskdTransfer & { phase: TransferPhase }; +type DlDir = { directory: string; label: string; files: Row[]; size: number }; +type DlUser = { username: string; dirs: DlDir[]; counts: Record; total: number }; const PHASE_ORDER: Record = { downloading: 0, queued: 1, failed: 2, done: 3 }; +const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done']; const phaseStyle: Record = { downloading: { label: 'Downloading', dot: 'bg-blue-500', bar: 'bg-blue-500' }, @@ -32,17 +40,39 @@ const phaseStyle: Record { - const rows: Row[] = []; +const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed'; + +// Group raw download users → dirs → files, keeping only files whose phase passes `keep`. Users/dirs +// that end up empty are dropped, so passing a done/not-done predicate splits the queue into sections. +const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boolean): DlUser[] => { + const out: DlUser[] = []; for (const user of users) { + const dirs: DlDir[] = []; for (const dir of user.directories ?? []) { - for (const file of dir.files ?? []) { - rows.push({ ...file, phase: transferPhase(file.state) }); - } + const files: Row[] = (dir.files ?? []) + .map((f) => ({ ...f, phase: transferPhase(f.state) })) + .filter((f) => keep(f.phase)); + 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 }); } - rows.sort((a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || b.percentComplete - a.percentComplete); - return rows; + // Active users first, then alphabetical. + out.sort((a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username)); + return out; }; export const SoulseekTransfers = () => { @@ -52,14 +82,31 @@ export const SoulseekTransfers = () => { const [app, setApp] = useState(null); const [statusError, setStatusError] = useState(null); const [statusLoading, setStatusLoading] = useState(true); - const [rows, setRows] = useState([]); + const [data, setData] = useState([]); + // Users are open by default; collapsing a live-updating list is opt-in so new users appear expanded. + const [collapsed, setCollapsed] = useState>(new Set()); + + // Two sections: everything still in flight (downloading / queued / failed) on top, finished + // (succeeded) collected at the bottom. A user with a mix appears in both. + const active = useMemo(() => group(data, (p) => p !== 'done'), [data]); + const completed = useMemo(() => group(data, (p) => p === 'done'), [data]); + const doneCount = useMemo(() => completed.reduce((n, u) => n + u.total, 0), [completed]); + + // Queue-wide phase tallies drive the bulk toolbar. Cancelled transfers collapse to `failed`, so the + // three actions target disjoint sets: retry → failed, cancel → in-flight, remove → done. + const totals = useMemo(() => { + const t: Record = { downloading: 0, queued: 0, failed: 0, done: 0 }; + for (const u of data) for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++; + return t; + }, [data]); + const inFlight = totals.downloading + totals.queued; const loadStatus = useCallback(() => { setStatusLoading(true); setStatusError(null); client .get('/slskd/api/v0/application') - .then((data) => setApp(data)) + .then((d) => setApp(d)) .catch((err) => setStatusError(err instanceof Error ? err.message : String(err))) .finally(() => setStatusLoading(false)); // useClient() is a fresh object each render — keep this callback stable. @@ -76,7 +123,7 @@ export const SoulseekTransfers = () => { const tick = () => client .get('/slskd/api/v0/transfers/downloads') - .then((users) => !cancelled && setRows(flatten(users))) + .then((u) => !cancelled && setData(u)) .catch(() => {}); tick(); const timer = setInterval(tick, POLL_MS); @@ -88,19 +135,121 @@ export const SoulseekTransfers = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh]); + // Collapse keys are section-qualified ("a:" / "c:") so the same peer can be open in the + // active section and collapsed in completed independently. + const toggle = (key: string) => + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + 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)) })) })), + ); + // For an active transfer this cancels it; for a finished/failed one it clears it from the queue. const remove = async (row: Row) => { - const done = row.phase === 'done' || row.phase === 'failed'; + const done = isRemovable(row.phase); try { await client.delete( `/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`, ); - setRows((prev) => prev.filter((r) => r.id !== row.id)); + dropIds(new Set([row.id])); } catch { /* next poll reconciles */ } }; + const clearCompleted = async (user: DlUser) => { + const finished = user.dirs.flatMap((d) => d.files).filter((f) => isRemovable(f.phase)); + if (finished.length === 0) return; + await Promise.allSettled( + finished.map((f) => + client.delete(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(user.username)}/${f.id}?remove=true`), + ), + ); + dropIds(new Set(finished.map((f) => f.id))); + }; + + const clearAllCompleted = async () => { + const finished = completed.flatMap((u) => u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id })))); + if (finished.length === 0) return; + await Promise.allSettled( + finished.map((f) => + client.delete(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(f.username)}/${f.id}?remove=true`), + ), + ); + dropIds(new Set(finished.map((f) => f.id))); + }; + + // Cancel every in-flight transfer (no ?remove — they become cancelled/failed and the poll reconciles). + const cancelAll = async () => { + const targets: { username: string; id: string }[] = []; + for (const u of data) + for (const d of u.directories ?? []) + for (const f of d.files ?? []) { + const p = transferPhase(f.state); + if (p === 'downloading' || p === 'queued') targets.push({ username: u.username, id: f.id }); + } + if (targets.length === 0) return; + await Promise.allSettled( + targets.map((t) => client.delete(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(t.username)}/${t.id}`)), + ); + }; + + // Re-enqueue every errored/cancelled transfer, grouped into one POST per peer. + const retryErrored = async () => { + const byUser = new Map(); + for (const u of data) + for (const d of u.directories ?? []) + for (const f of d.files ?? []) + if (transferPhase(f.state) === 'failed') { + const files = byUser.get(u.username) ?? []; + files.push({ filename: f.filename, size: f.size }); + byUser.set(u.username, files); + } + if (byUser.size === 0) return; + const n = [...byUser.values()].reduce((s, a) => s + a.length, 0); + try { + await Promise.all( + [...byUser].map(([username, files]) => + client.post(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(username)}`, files), + ), + ); + toast.success(n === 1 ? 'Retrying 1 download' : `Retrying ${n} downloads`); + } catch (err) { + toast.error(`Retry failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + // Force slskd to re-request this download's place in the peer's queue, then patch the fresh value in. + const refreshPosition = async (row: Row) => { + try { + const t = await client.get( + `/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}/position`, + ); + setData((prev) => + prev.map((u) => + u.username !== row.username + ? u + : { + ...u, + directories: (u.directories ?? []).map((d) => ({ + ...d, + files: (d.files ?? []).map((f) => (f.id === row.id ? { ...f, placeInQueue: t.placeInQueue } : f)), + })), + }, + ), + ); + toast.success(t.placeInQueue ? `Place in queue: #${t.placeInQueue}` : 'Queue position refreshed'); + } catch (err) { + toast.error(`Couldn't fetch position: ${err instanceof Error ? err.message : String(err)}`); + } + }; + const server = app?.server; const connected = server?.isConnected ?? false; @@ -132,50 +281,189 @@ export const SoulseekTransfers = () => {
-
- Downloads {rows.length > 0 && `(${rows.length})`} -
+ {(active.length > 0 || completed.length > 0) && ( +
+ } label="Retry errored" count={totals.failed} onClick={retryErrored} /> + } label="Cancel all" count={inFlight} onClick={cancelAll} /> + } label="Remove completed" count={totals.done} onClick={clearAllCompleted} /> +
+ )}
- {rows.length === 0 ? ( + {active.length === 0 && completed.length === 0 ? (

No downloads yet. Search and queue a file to see it here.

) : ( -
- {rows.map((row) => { - const style = phaseStyle[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)} - -
-
-
-
-
- {row.username} - · {style.label} - {row.size > 0 && · {formatSize(row.size)}} - {speed && · {speed}} - {row.phase === 'queued' && row.placeInQueue ? · #{row.placeInQueue} in queue : null} -
+
+ {active.length > 0 && ( +
+
+ Active ({active.length})
- ); - })} + {active.map((user) => ( + toggle(`a:${user.username}`)} + onRemove={remove} + onPosition={refreshPosition} + onClearCompleted={() => clearCompleted(user)} + /> + ))} +
+ )} + {completed.length > 0 && ( +
+
+
+ Completed ({doneCount}) +
+ +
+ {completed.map((user) => ( + toggle(`c:${user.username}`)} + onRemove={remove} + onPosition={refreshPosition} + onClearCompleted={() => clearCompleted(user)} + /> + ))} +
+ )}
)}
); }; + +type BulkButtonProps = { icon: ReactNode; label: string; count: number; onClick: () => void }; + +const BulkButton = ({ icon, label, count, onClick }: BulkButtonProps) => ( + +); + +type UserCardProps = { + user: DlUser; + open: boolean; + onToggle: () => void; + onRemove: (row: Row) => void; + onPosition: (row: Row) => void; + onClearCompleted: () => void; +}; + +const UserCard = ({ user, open, onToggle, onRemove, onPosition, onClearCompleted }: UserCardProps) => { + const finished = user.counts.done + user.counts.failed; + const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => ( + + + {user.counts[p]} + + )); + return ( + + + {open && ( + + {user.dirs.map((dir) => ( + + ))} + {finished > 0 && ( + + )} + + )} + + ); +}; + +type FolderBlockProps = { dir: DlDir; onRemove: (row: Row) => void; onPosition: (row: Row) => void }; + +const FolderBlock = ({ dir, onRemove, onPosition }: 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 style = phaseStyle[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)} + +
+
+
+
+
+ {row.phase === 'queued' ? ( + + ) : ( + {style.label} + )} + {row.size > 0 && · {formatSize(row.size)}} + {speed && · {speed}} +
+
+ ); + })} + + )} + + ); +};