soulseek: download bulk actions, queue position, per-folder download
- downloads: split into active/completed with a bulk toolbar (retry errored, cancel all, remove completed) mirroring slskd's downloads page - downloads: queued rows hover to show place-in-queue and click to force a refresh - search results: add a per-folder "Download folder (N)" button alongside the per-user download-all Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, ResultRow[]>();
|
||||
const MAX_USERS = 200;
|
||||
const resultsCache = new Map<string, ResultUser[]>();
|
||||
|
||||
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<string>;
|
||||
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<number>(SLSKD_REFRESH_CHANNEL, 0);
|
||||
const [rows, setRows] = useState<ResultRow[] | null>(() => resultsCache.get(search.id) ?? null);
|
||||
const cachedInit = resultsCache.get(search.id) ?? null;
|
||||
const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Users start expanded; toggling collapses them.
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
|
||||
const [queued, setQueued] = useState<Set<string>>(new Set());
|
||||
const [filters, setFilters] = useState<Filters>(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<SlskdResponse[]>(`/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 (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
@@ -115,62 +171,218 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{search.searchText}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{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}` : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{error && <p className="p-4 text-sm text-red-500">Could not load results: {error}</p>}
|
||||
{!error && rows === null && (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
|
||||
{/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */}
|
||||
{users && users.length > 0 && (
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/20 px-3 py-2.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={filters.text}
|
||||
onChange={(ev) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, text: '' }))}
|
||||
className="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Pill active={filters.freeOnly} onClick={() => setFilters((f) => ({ ...f, freeOnly: !f.freeOnly }))}>
|
||||
<Zap className="h-3 w-3" /> Free slots
|
||||
</Pill>
|
||||
<Pill active={filters.hideLocked} onClick={() => setFilters((f) => ({ ...f, hideLocked: !f.hideLocked }))}>
|
||||
<Lock className="h-3 w-3" /> Hide locked
|
||||
</Pill>
|
||||
<span className="mx-0.5 h-4 w-px bg-border" />
|
||||
{EXT_PILLS.map((ext) => (
|
||||
<Pill key={ext} active={filters.exts.has(ext)} onClick={() => toggleExt(ext)}>
|
||||
{ext.toUpperCase()}
|
||||
</Pill>
|
||||
))}
|
||||
<span className="mx-0.5 h-4 w-px bg-border" />
|
||||
{BITRATE_PILLS.map((br) => (
|
||||
<Pill key={br} active={filters.minBitrate === br} onClick={() => setMinBitrate(br)}>
|
||||
≥{br}
|
||||
</Pill>
|
||||
))}
|
||||
{anyFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||
className="ml-1 inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{error && <p className="p-1 text-sm text-red-500">Could not load results: {error}</p>}
|
||||
{!error && users === null && (
|
||||
<div className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading results…
|
||||
</div>
|
||||
)}
|
||||
{!error && rows && rows.length === 0 && <p className="p-4 text-sm text-muted-foreground">No results stored.</p>}
|
||||
{shown.length > 0 && (
|
||||
<div className="divide-y">
|
||||
{shown.map((row) => {
|
||||
const key = rowKey(row);
|
||||
const isQueued = queued.has(key);
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 truncate font-medium">
|
||||
{row.isLocked && <Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||
<span className="truncate">{basename(row.filename)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span className="truncate">{row.username}</span>
|
||||
<span>· {formatSize(row.size)}</span>
|
||||
{row.extension && <span>· {row.extension}</span>}
|
||||
{row.bitRate ? <span>· {row.bitRate} kbps</span> : null}
|
||||
{row.hasFreeUploadSlot ? (
|
||||
<span className="text-green-600">· free slot</span>
|
||||
) : (
|
||||
<span>· queue {row.queueLength}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(row)}
|
||||
disabled={row.isLocked || isQueued}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isQueued ? 'Queued' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!error && users && users.length === 0 && <p className="p-1 text-sm text-muted-foreground">No results stored.</p>}
|
||||
{!error && users && users.length > 0 && filtered && filtered.length === 0 && (
|
||||
<p className="p-1 text-sm text-muted-foreground">No files match the current filters.</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{shown.map((user) => (
|
||||
<UserCard
|
||||
key={user.username}
|
||||
user={user}
|
||||
open={open.has(user.username)}
|
||||
onToggle={() => toggle(user.username)}
|
||||
queued={queued}
|
||||
onDownload={queue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type UserCardProps = {
|
||||
user: ResultUser;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
queued: Set<string>;
|
||||
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 ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-500">
|
||||
<Zap className="h-3 w-3" /> free
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-3 w-3" /> {user.queueLength}
|
||||
</span>
|
||||
)}
|
||||
{user.uploadSpeed > 0 && <span>{formatSpeed(user.uploadSpeed)}</span>}
|
||||
<span className="tabular-nums">{user.fileCount.toLocaleString()} files</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader open={open} onToggle={onToggle} title={user.username} meta={meta} />
|
||||
{open && (
|
||||
<CardBody>
|
||||
{user.folders.map((folder) => (
|
||||
<FolderBlock key={folder.path} username={user.username} folder={folder} queued={queued} onDownload={onDownload} />
|
||||
))}
|
||||
{allFiles.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(user.username, allFiles)}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.02] py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> Download all ({allFiles.filter((f) => !f.isLocked).length})
|
||||
</button>
|
||||
)}
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type FolderBlockProps = {
|
||||
username: string;
|
||||
folder: ResultFolder;
|
||||
queued: Set<string>;
|
||||
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 (
|
||||
<SubCard>
|
||||
<SubCardHeader
|
||||
open={open}
|
||||
onToggle={() => setOpen((v) => !v)}
|
||||
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
|
||||
label={folder.label}
|
||||
title={folder.path}
|
||||
meta={`${folder.files.length} · ${formatSize(folder.size)}`}
|
||||
action={
|
||||
folder.files.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(username, folder.files)}
|
||||
title="Download folder"
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{open && (
|
||||
<>
|
||||
<RowList>
|
||||
{folder.files.map((file) => {
|
||||
const isQueued = queued.has(fileKey(username, file.filename));
|
||||
const duration = formatDuration(file.length);
|
||||
return (
|
||||
<div key={file.filename} className="flex items-center gap-3 px-3 py-1.5 pl-8 text-sm text-zinc-200">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate">
|
||||
{file.isLocked && <Lock className="h-3 w-3 shrink-0 text-zinc-500" />}
|
||||
<span className="truncate">{file.name}</span>
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-zinc-500">
|
||||
{file.bitRate ? <span>{file.bitRate} kbps</span> : null}
|
||||
{duration && <span>{duration}</span>}
|
||||
<span className="w-16 text-right">{formatSize(file.size)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(username, [file])}
|
||||
disabled={file.isLocked || isQueued}
|
||||
title={isQueued ? 'Queued' : 'Download'}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40"
|
||||
>
|
||||
{isQueued ? <Loader2 className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RowList>
|
||||
{unlocked > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownload(username, folder.files)}
|
||||
className="flex w-full items-center justify-center gap-1.5 border-t border-white/5 py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> Download folder ({unlocked})
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SubCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<TransferPhase, number>; total: number };
|
||||
|
||||
const PHASE_ORDER: Record<TransferPhase, number> = { downloading: 0, queued: 1, failed: 2, done: 3 };
|
||||
const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done'];
|
||||
|
||||
const phaseStyle: Record<TransferPhase, { label: string; dot: string; bar: string }> = {
|
||||
downloading: { label: 'Downloading', dot: 'bg-blue-500', bar: 'bg-blue-500' },
|
||||
@@ -32,17 +40,39 @@ const phaseStyle: Record<TransferPhase, { label: string; dot: string; bar: strin
|
||||
failed: { label: 'Failed', dot: 'bg-red-500', bar: 'bg-red-500' },
|
||||
};
|
||||
|
||||
const flatten = (users: SlskdDownloadUser[]): Row[] => {
|
||||
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<TransferPhase, number> = { 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<SlskdApplication | null>(null);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [data, setData] = useState<SlskdDownloadUser[]>([]);
|
||||
// Users are open by default; collapsing a live-updating list is opt-in so new users appear expanded.
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(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<TransferPhase, number> = { 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<SlskdApplication>('/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<SlskdDownloadUser[]>('/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:<user>" / "c:<user>") 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<string>) =>
|
||||
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<string, { filename: string; size: number }[]>();
|
||||
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<SlskdTransfer>(
|
||||
`/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 = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-3 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Downloads {rows.length > 0 && `(${rows.length})`}
|
||||
</div>
|
||||
{(active.length > 0 || completed.length > 0) && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<BulkButton icon={<RotateCcw className="h-3.5 w-3.5" />} label="Retry errored" count={totals.failed} onClick={retryErrored} />
|
||||
<BulkButton icon={<Ban className="h-3.5 w-3.5" />} label="Cancel all" count={inFlight} onClick={cancelAll} />
|
||||
<BulkButton icon={<Trash2 className="h-3.5 w-3.5" />} label="Remove completed" count={totals.done} onClick={clearAllCompleted} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{rows.length === 0 ? (
|
||||
{active.length === 0 && completed.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No downloads yet. Search and queue a file to see it here.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{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 (
|
||||
<div key={row.id} className="rounded-lg border p-2.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${style.dot}`} />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{basename(row.filename)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(row)}
|
||||
title={row.phase === 'downloading' || row.phase === 'queued' ? 'Cancel' : 'Clear'}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className={`h-full ${style.bar}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{row.username}</span>
|
||||
<span>· {style.label}</span>
|
||||
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
|
||||
{speed && <span>· {speed}</span>}
|
||||
{row.phase === 'queued' && row.placeInQueue ? <span>· #{row.placeInQueue} in queue</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{active.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="px-0.5 text-xs font-medium uppercase tracking-wide text-zinc-500">
|
||||
Active ({active.length})
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{active.map((user) => (
|
||||
<UserCard
|
||||
key={user.username}
|
||||
user={user}
|
||||
open={!collapsed.has(`a:${user.username}`)}
|
||||
onToggle={() => toggle(`a:${user.username}`)}
|
||||
onRemove={remove}
|
||||
onPosition={refreshPosition}
|
||||
onClearCompleted={() => clearCompleted(user)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{completed.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-zinc-500">
|
||||
Completed ({doneCount})
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAllCompleted}
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-zinc-500 transition hover:bg-white/5 hover:text-zinc-100"
|
||||
>
|
||||
<X className="h-3 w-3" /> Clear all
|
||||
</button>
|
||||
</div>
|
||||
{completed.map((user) => (
|
||||
<UserCard
|
||||
key={user.username}
|
||||
user={user}
|
||||
open={!collapsed.has(`c:${user.username}`)}
|
||||
onToggle={() => toggle(`c:${user.username}`)}
|
||||
onRemove={remove}
|
||||
onPosition={refreshPosition}
|
||||
onClearCompleted={() => clearCompleted(user)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type BulkButtonProps = { icon: ReactNode; label: string; count: number; onClick: () => void };
|
||||
|
||||
const BulkButton = ({ icon, label, count, onClick }: BulkButtonProps) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={count === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.02] px-2.5 py-1 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100 disabled:cursor-default disabled:opacity-40 disabled:hover:bg-white/[0.02] disabled:hover:text-zinc-300"
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{count > 0 && <span className="rounded bg-white/10 px-1 tabular-nums text-zinc-400">{count}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
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) => (
|
||||
<span key={p} className="inline-flex items-center gap-1" title={phaseStyle[p].label}>
|
||||
<span className={`h-2 w-2 rounded-full ${phaseStyle[p].dot}`} />
|
||||
{user.counts[p]}
|
||||
</span>
|
||||
));
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader open={open} onToggle={onToggle} title={user.username} meta={meta} />
|
||||
{open && (
|
||||
<CardBody>
|
||||
{user.dirs.map((dir) => (
|
||||
<FolderBlock key={dir.directory} dir={dir} onRemove={onRemove} onPosition={onPosition} />
|
||||
))}
|
||||
{finished > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCompleted}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.02] py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> Clear completed ({finished})
|
||||
</button>
|
||||
)}
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type FolderBlockProps = { dir: DlDir; onRemove: (row: Row) => void; onPosition: (row: Row) => void };
|
||||
|
||||
const FolderBlock = ({ dir, onRemove, onPosition }: FolderBlockProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<SubCard>
|
||||
<SubCardHeader
|
||||
open={open}
|
||||
onToggle={() => setOpen((v) => !v)}
|
||||
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
|
||||
label={dir.label}
|
||||
title={dir.directory}
|
||||
meta={`${dir.files.length} · ${formatSize(dir.size)}`}
|
||||
/>
|
||||
{open && (
|
||||
<RowList>
|
||||
{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 (
|
||||
<div key={row.id} className="px-3 py-2 pl-8 text-sm text-zinc-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${style.dot}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{basename(row.filename)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row)}
|
||||
title={isRemovable(row.phase) ? 'Clear' : 'Cancel'}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 hover:bg-white/10 hover:text-zinc-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div className={`h-full ${style.bar}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-zinc-500">
|
||||
{row.phase === 'queued' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPosition(row)}
|
||||
title={
|
||||
row.placeInQueue
|
||||
? `Place in queue: #${row.placeInQueue} · click to refresh`
|
||||
: 'Queued · click to fetch place in queue'
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded text-amber-400/90 transition hover:text-amber-300"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{row.placeInQueue ? `#${row.placeInQueue} in queue` : 'Queued'}
|
||||
</button>
|
||||
) : (
|
||||
<span>{style.label}</span>
|
||||
)}
|
||||
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
|
||||
{speed && <span>· {speed}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RowList>
|
||||
)}
|
||||
</SubCard>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user