Seventh plugin out, and the first that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts had the router import and its mount commented out, so createSidecarProxy never ran, registerProxiedPrefix was never called, the slskd:server announcement fired into a void, and officer-slskd was not in the ecosystem at all. The permission entry had been reduced to api: [], which is why assertPermissionTotality was satisfied and the server booted clean. The UI rendered perfectly and every request 404'd. So this is a resurrection as well as a move. The mount is now /api/soulseek, derived via mountPrefix from the appName — all 47 frontend call sites repointed from /slskd/. The credential key in service_connections stays 'slskd': it names the daemon, not the route. Those two strings differ on purpose and conflating them reads the wrong row. The connection stays in core service_connections, as transmission does, so 'slskd' remains in a platform union this plugin cannot extend. Recorded in PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and the two halves of the cached browse tree, which slskd has no concept of. Those three tables had been dropped from the drizzle barrel, so bun db:push would have deleted them on any server holding data. 313 palette classes converted to the semantic tone set, including a shared PHASE_TONE the three transfer views previously each copied.
615 lines
26 KiB
TypeScript
615 lines
26 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
ArrowLeft,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Download,
|
|
Lock,
|
|
Loader2,
|
|
Folder,
|
|
Zap,
|
|
Users,
|
|
Search,
|
|
X,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards';
|
|
import { UserMenu } from './UserMenu';
|
|
import {
|
|
SLSKD_REFRESH_CHANNEL,
|
|
basename,
|
|
formatSize,
|
|
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. 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.
|
|
//
|
|
// Filtering is a way of *finding* a folder as much as of hiding files, so nothing it hides is out of
|
|
// reach: a matching folder expands to the whole album in place, and a matching peer expands to the rest
|
|
// of its folders. Download buttons always queue exactly what is on screen.
|
|
//
|
|
// Caching is stale-while-revalidate: the cached grouping paints immediately, then we always re-fetch in
|
|
// the background. Never serve the cache alone — opening a search before its first responses land used to
|
|
// freeze that empty snapshot forever. While the search is still running we also poll, so results fill in
|
|
// live; the header's refresh re-reads on demand.
|
|
|
|
const MAX_USERS = 200;
|
|
const POLL_MS = 2500;
|
|
// Safety net: a search whose completion we can't confirm shouldn't poll forever (~2 min of polling).
|
|
const MAX_POLLS = 48;
|
|
const resultsCache = new Map<string, ResultUser[]>();
|
|
|
|
export const dropCachedResults = (searchId: string) => resultsCache.delete(searchId);
|
|
|
|
const fileKey = (username: string, filename: string) => `${username}::${filename}`;
|
|
|
|
// 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;
|
|
|
|
// Filtering narrows *files*, but the reason to filter is usually to find a folder: you type one track
|
|
// name you know to pick your album out of 300 unrelated ones. So a filtered folder keeps a reference to
|
|
// the whole folder alongside its matching files, and can be expanded back to the full album in place —
|
|
// otherwise finding it would be the same act as hiding the rest of it.
|
|
type FolderMatch = { folder: ResultFolder; matched: ResultFile[]; matchedSize: number };
|
|
type UserMatch = { user: ResultUser; folders: FolderMatch[]; matchedCount: number };
|
|
|
|
const folderKey = (username: string, path: string) => `${username}::${path}`;
|
|
// Same reveal set, for "this peer's folders that matched nothing" — the discography around the album.
|
|
const userKey = (username: string) => `user::${username}`;
|
|
|
|
const applyFilters = (users: ResultUser[], f: Filters): UserMatch[] => {
|
|
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: UserMatch[] = [];
|
|
for (const user of users) {
|
|
if (f.freeOnly && !user.hasFreeUploadSlot) continue;
|
|
const folders: FolderMatch[] = [];
|
|
for (const folder of user.folders) {
|
|
const matched = folder.files.filter(fileOk);
|
|
if (matched.length) folders.push({ folder, matched, matchedSize: matched.reduce((n, x) => n + x.size, 0) });
|
|
}
|
|
if (folders.length) {
|
|
out.push({ user, folders, matchedCount: folders.reduce((n, fd) => n + fd.matched.length, 0) });
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// `initial` is the history row we came from, when there is one. Opening by URL — or landing here the
|
|
// moment a search is created — has no row yet, so the summary is fetched like everything else and the
|
|
// header fills in a beat later.
|
|
type SearchResultsProps = { searchId: string; initial?: SlskdSearchSummary | null; onBack: () => void };
|
|
|
|
export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) => {
|
|
const client = useClient();
|
|
const [, bumpRefresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
|
|
const cachedInit = resultsCache.get(searchId) ?? null;
|
|
const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [summary, setSummary] = useState<SlskdSearchSummary | null>(initial ?? null);
|
|
const [isComplete, setIsComplete] = useState(initial?.isComplete ?? false);
|
|
// 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);
|
|
// Folders the reader has expanded past the filter, keyed username::path.
|
|
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
|
// Usernames we've already auto-expanded — lets a revalidation expand newcomers without re-opening
|
|
// the cards the reader deliberately collapsed.
|
|
const seenRef = useRef<Set<string>>(new Set(cachedInit?.map((u) => u.username) ?? []));
|
|
const aliveRef = useRef(true);
|
|
const pollsRef = useRef(0);
|
|
|
|
const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]);
|
|
|
|
useEffect(() => {
|
|
aliveRef.current = true;
|
|
return () => {
|
|
aliveRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
const refresh = useCallback(
|
|
async (quiet: boolean) => {
|
|
if (!quiet) setLoading(true);
|
|
try {
|
|
// The summary tells us whether to keep polling; tolerate its absence rather than fail the load.
|
|
const [responses, meta] = await Promise.all([
|
|
client.get<SlskdResponse[]>(`/soulseek/api/v0/searches/${searchId}/responses`),
|
|
client.get<SlskdSearchSummary>(`/soulseek/api/v0/searches/${searchId}`).catch(() => null),
|
|
]);
|
|
if (!aliveRef.current) return;
|
|
const grouped = groupResponses(responses);
|
|
resultsCache.set(searchId, grouped);
|
|
setUsers(grouped);
|
|
setError(null);
|
|
setOpen((prev) => {
|
|
const next = new Set(prev);
|
|
for (const u of grouped) {
|
|
if (!seenRef.current.has(u.username)) {
|
|
seenRef.current.add(u.username);
|
|
next.add(u.username);
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
if (meta) {
|
|
setSummary(meta);
|
|
setIsComplete(meta.isComplete);
|
|
}
|
|
} catch (err) {
|
|
if (aliveRef.current) setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
if (aliveRef.current) setLoading(false);
|
|
}
|
|
},
|
|
// useClient() returns a fresh object each render, so depend only on the search id.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[searchId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const cached = resultsCache.get(searchId) ?? null;
|
|
seenRef.current = new Set(cached?.map((u) => u.username) ?? []);
|
|
pollsRef.current = 0;
|
|
setUsers(cached);
|
|
setOpen(new Set(cached?.map((u) => u.username)));
|
|
setRevealed(new Set());
|
|
setError(null);
|
|
// Assume running until the summary says otherwise — a search opened straight after being created has
|
|
// no row to tell us, and guessing "complete" would mean never polling for its first responses.
|
|
setIsComplete(initial?.id === searchId ? initial.isComplete : false);
|
|
// Quiet when something is already on screen — no spinner flash over usable results.
|
|
refresh(Boolean(cached));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [searchId, refresh]);
|
|
|
|
useEffect(() => {
|
|
if (isComplete) return;
|
|
const timer = setInterval(() => {
|
|
pollsRef.current += 1;
|
|
if (pollsRef.current > MAX_POLLS) {
|
|
clearInterval(timer);
|
|
return;
|
|
}
|
|
refresh(true);
|
|
}, POLL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [isComplete, refresh]);
|
|
|
|
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 toggleReveal = (key: string) =>
|
|
setRevealed((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
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(
|
|
`/soulseek/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(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 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.matchedCount, 0) ?? 0;
|
|
const anyFilter = filtersActive(filters);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
<div className="flex shrink-0 items-center gap-3 border-b bg-background/60 px-4 py-3">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
title="Back to history"
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate text-sm font-semibold">{summary?.searchText ?? initial?.searchText ?? '…'}</span>
|
|
{!isComplete && (
|
|
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> searching
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{users === null && !error
|
|
? 'Loading results…'
|
|
: `${totalFiles.toLocaleString()} files from ${(filtered?.length ?? 0).toLocaleString()} users${
|
|
anyFilter ? ' (filtered)' : ''
|
|
}${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => refresh(false)}
|
|
disabled={loading}
|
|
title="Reload results"
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:opacity-40"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 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-destructive">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 && users && users.length === 0 && (
|
|
<p className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
|
|
{isComplete ? (
|
|
'No results stored.'
|
|
) : (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Waiting for responses…
|
|
</>
|
|
)}
|
|
</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((match) => (
|
|
<UserCard
|
|
key={match.user.username}
|
|
match={match}
|
|
open={open.has(match.user.username)}
|
|
onToggle={() => toggle(match.user.username)}
|
|
queued={queued}
|
|
onDownload={queue}
|
|
revealed={revealed}
|
|
onToggleReveal={toggleReveal}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type UserCardProps = {
|
|
match: UserMatch;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
queued: Set<string>;
|
|
onDownload: (username: string, files: ResultFile[]) => void;
|
|
revealed: Set<string>;
|
|
onToggleReveal: (key: string) => void;
|
|
};
|
|
|
|
const UserCard = ({ match, open, onToggle, queued, onDownload, revealed, onToggleReveal }: UserCardProps) => {
|
|
const { user, folders, matchedCount } = match;
|
|
// A peer with the album usually has more of the artist, and the filter hid all of it. Expanding brings
|
|
// the non-matching folders back, each already whole.
|
|
const allFolders = revealed.has(userKey(user.username));
|
|
const hiddenFolders = user.folders.length - folders.length;
|
|
const visible: FolderMatch[] = allFolders
|
|
? user.folders.map(
|
|
(folder) =>
|
|
folders.find((fm) => fm.folder.path === folder.path) ?? {
|
|
folder,
|
|
matched: folder.files,
|
|
matchedSize: folder.size,
|
|
},
|
|
)
|
|
: folders;
|
|
// Every action queues what is on screen, expanded folders included — never the hidden remainder.
|
|
const shownFiles = visible.flatMap((fm) =>
|
|
revealed.has(folderKey(user.username, fm.folder.path)) ? fm.folder.files : fm.matched,
|
|
);
|
|
const meta = (
|
|
<>
|
|
{user.hasFreeUploadSlot ? (
|
|
<span className="inline-flex items-center gap-1 text-success">
|
|
<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">
|
|
{matchedCount < user.fileCount
|
|
? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files`
|
|
: `${user.fileCount.toLocaleString()} files`}
|
|
</span>
|
|
</>
|
|
);
|
|
return (
|
|
<Card>
|
|
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
|
|
{open && (
|
|
<CardBody>
|
|
{visible.map((fm) => (
|
|
<FolderBlock
|
|
key={fm.folder.path}
|
|
username={user.username}
|
|
match={fm}
|
|
revealed={revealed.has(folderKey(user.username, fm.folder.path))}
|
|
onToggleReveal={() => onToggleReveal(folderKey(user.username, fm.folder.path))}
|
|
queued={queued}
|
|
onDownload={onDownload}
|
|
/>
|
|
))}
|
|
{hiddenFolders > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggleReveal(userKey(user.username))}
|
|
className="flex items-center justify-center gap-1.5 rounded-lg border border-dashed border-border py-1.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
>
|
|
{allFolders ? (
|
|
<>
|
|
<ChevronUp className="h-3.5 w-3.5" /> Show only the {folders.length} matching{' '}
|
|
{folders.length === 1 ? 'folder' : 'folders'}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="h-3.5 w-3.5" /> Show this peer's other {hiddenFolders}{' '}
|
|
{hiddenFolders === 1 ? 'folder' : 'folders'}
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
{shownFiles.length > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onDownload(user.username, shownFiles)}
|
|
className="flex items-center justify-center gap-1.5 rounded-lg border border-border bg-muted/20 py-1.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
>
|
|
<Download className="h-3.5 w-3.5" /> Download all ({shownFiles.filter((f) => !f.isLocked).length})
|
|
</button>
|
|
)}
|
|
</CardBody>
|
|
)}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
type FolderBlockProps = {
|
|
username: string;
|
|
match: FolderMatch;
|
|
revealed: boolean;
|
|
onToggleReveal: () => void;
|
|
queued: Set<string>;
|
|
onDownload: (username: string, files: ResultFile[]) => void;
|
|
};
|
|
|
|
const FolderBlock = ({ username, match, revealed, onToggleReveal, queued, onDownload }: FolderBlockProps) => {
|
|
const { folder, matched, matchedSize } = match;
|
|
const [open, setOpen] = useState(true);
|
|
const files = revealed ? folder.files : matched;
|
|
const hidden = folder.files.length - matched.length;
|
|
const matchedNames = revealed && hidden > 0 ? new Set(matched.map((f) => f.filename)) : null;
|
|
const unlocked = 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-muted-foreground" />}
|
|
label={folder.label}
|
|
title={folder.path}
|
|
meta={`${hidden > 0 && !revealed ? `${files.length} of ${folder.files.length}` : files.length} · ${formatSize(
|
|
revealed ? folder.size : matchedSize,
|
|
)}`}
|
|
action={
|
|
files.length > 1 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onDownload(username, files)}
|
|
title={revealed || hidden === 0 ? 'Download folder' : `Download the ${files.length} matching files`}
|
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : null
|
|
}
|
|
/>
|
|
{open && (
|
|
<>
|
|
{/* The point of the whole exercise: the track you filtered for has found the album, so open the
|
|
album. Revealing also widens every download button below to the full folder. */}
|
|
{hidden > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={onToggleReveal}
|
|
className="flex w-full items-center justify-center gap-1.5 border-b border-border/50 bg-muted/20 py-1.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
>
|
|
{revealed ? (
|
|
<>
|
|
<ChevronUp className="h-3.5 w-3.5" /> Show only the {matched.length} matching
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="h-3.5 w-3.5" /> Show the whole folder ({folder.files.length} files)
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
<RowList>
|
|
{files.map((file) => {
|
|
const isQueued = queued.has(fileKey(username, file.filename));
|
|
const duration = formatDuration(file.length);
|
|
// In an expanded folder, keep the file you filtered for legible among its neighbours.
|
|
const isMatch = !matchedNames || matchedNames.has(file.filename);
|
|
return (
|
|
<div
|
|
key={file.filename}
|
|
className={`flex items-center gap-3 px-3 py-1.5 pl-8 text-sm ${
|
|
isMatch ? 'text-foreground' : 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
<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-muted-foreground" />}
|
|
<span className={`truncate ${isMatch && matchedNames ? 'font-medium text-foreground' : ''}`}>
|
|
{file.name}
|
|
</span>
|
|
</span>
|
|
<div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-muted-foreground">
|
|
{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-muted-foreground transition hover:bg-muted hover:text-foreground 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, files)}
|
|
className="flex w-full items-center justify-center gap-1.5 border-t border-border/50 py-1.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
{revealed || hidden === 0 ? `Download folder (${unlocked})` : `Download ${unlocked} matching`}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</SubCard>
|
|
);
|
|
};
|