Files
soulseek/web/SearchView.tsx
officer c11f45c033 soulseek, extracted from the platform as a plugin
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.
2026-08-19 02:55:02 +00:00

277 lines
12 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react';
import { SearchResults, dropCachedResults } from './SearchResults';
import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared';
import { randomId } from 'helpers/random-id';
// The 'search' section — a search input plus the history of past searches (GET /searches). Which search
// is open lives in `?search=<id>`, so rows are real links and the back button returns to the history.
//
// Submitting opens the new search's results straight away, the way slskd's own web UI does. That is not
// only a nicety: a history row is only as fresh as the last list load, so a search you had just started
// sat at 0 responses looking stalled, while the results view polls for as long as the search runs.
const HISTORY_POLL_MS = 4000;
// A running search nobody is watching shouldn't poll forever (~3 minutes).
const MAX_HISTORY_POLLS = 45;
const byNewest = (list: SlskdSearchSummary[]) => [...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
// slskd accepts a client-supplied id (its own UI depends on that), but don't bet the navigation on it:
// prefer an id the server has actually stored, then fall back to the newest search with this text.
type CreatedIdParams = { list: SlskdSearchSummary[] | null; sentId: string; returnedId?: string; text: string };
const createdSearchId = ({ list, sentId, returnedId, text }: CreatedIdParams) => {
if (!list) return returnedId ?? sentId;
const known = new Set(list.map((s) => s.id));
if (returnedId && known.has(returnedId)) return returnedId;
if (known.has(sentId)) return sentId;
return list.find((s) => s.searchText === text)?.id ?? returnedId ?? sentId;
};
export const SearchView = () => {
const client = useClient();
const [params, setParams] = useSearchParams();
const [query, setQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
const [history, setHistory] = useState<SlskdSearchSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const selectedId = params.get(SEARCH_PARAM);
const openSearch = useCallback(
(id: string | null) =>
setParams((prev) => {
const next = new URLSearchParams(prev);
if (id) next.set(SEARCH_PARAM, id);
else next.delete(SEARCH_PARAM);
return next;
}),
[setParams],
);
const fetchHistory = useCallback(async () => {
const list = await client.get<SlskdSearchSummary[]>('/soulseek/api/v0/searches');
return byNewest(list ?? []);
// useClient() is a fresh object each render — keep this callback stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const load = useCallback(() => {
setError(null);
fetchHistory()
.then(setHistory)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [fetchHistory]);
// Re-read whenever the history is what's on screen — including a browser Back out of a search, which
// no callback of ours sees.
useEffect(() => {
if (!selectedId) load();
}, [selectedId, load]);
// A row's counters only move when the list is re-read, so keep re-reading while a search is running —
// but not while its results are open, since that view polls on its own.
const running = !!history?.some((s) => !s.isComplete);
useEffect(() => {
if (!running || selectedId) return;
let polls = 0;
const timer = setInterval(() => {
polls += 1;
if (polls > MAX_HISTORY_POLLS) {
clearInterval(timer);
return;
}
load();
}, HISTORY_POLL_MS);
return () => clearInterval(timer);
}, [running, selectedId, load]);
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
const text = query.trim();
if (!text || submitting) return;
setSubmitting(true);
const sentId = randomId();
try {
const created = await client.post<Partial<SlskdSearchSummary>>('/soulseek/api/v0/searches', {
id: sentId,
searchText: text,
});
// A failed reload shouldn't read as a failed search — fall through with what the POST told us.
const list = await fetchHistory().catch(() => null);
if (list) setHistory(list);
setQuery('');
openSearch(createdSearchId({ list, sentId, returnedId: created?.id, text }));
} catch (err) {
toast.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setSubmitting(false);
}
};
const remove = async (id: string) => {
setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev);
dropCachedResults(id);
if (selectedId === id) openSearch(null);
client.delete(`/soulseek/api/v0/searches/${id}`).catch(() => load());
};
// slskd has no bulk delete (0.26.0 exposes only DELETE /searches/{id}), so clearing fans out one
// request per search and reconciles with a reload — partial failures come back on their own.
const clearAll = async () => {
const ids = history?.map((s) => s.id) ?? [];
if (!ids.length || clearing) return;
setConfirmClear(false);
setClearing(true);
setHistory([]);
openSearch(null);
ids.forEach(dropCachedResults);
const settled = await Promise.allSettled(ids.map((id) => client.delete(`/soulseek/api/v0/searches/${id}`)));
const failed = settled.filter((r) => r.status === 'rejected').length;
if (failed) toast.error(`${failed} of ${ids.length} searches could not be removed.`);
setClearing(false);
load();
};
// The destructive click is two-step rather than a modal (matching the other Soulseek panels, which
// confirm nothing) — and the armed state disarms itself so it can't sit there waiting to be hit.
useEffect(() => {
if (!confirmClear) return;
const timer = setTimeout(() => setConfirmClear(false), 4000);
return () => clearTimeout(timer);
}, [confirmClear]);
// The row we came from, when we have it — a search opened by URL (or just created) resolves its own.
const selected = selectedId ? (history?.find((s) => s.id === selectedId) ?? null) : null;
const hrefFor = (id: string) => {
const next = new URLSearchParams(params);
next.set(SEARCH_PARAM, id);
return `?${next.toString()}`;
};
if (selectedId) return <SearchResults searchId={selectedId} initial={selected} onBack={() => openSearch(null)} />;
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header + search field */}
<div className="shrink-0 border-b bg-background/60 px-6 py-5">
<h1 className="mb-3 text-lg font-semibold">Search</h1>
<form onSubmit={submit} className="flex gap-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(ev) => setQuery(ev.target.value)}
placeholder="Search artists, albums, tracks…"
className="h-11 w-full rounded-xl border border-border bg-card pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
/>
</div>
<button
type="submit"
disabled={submitting || !query.trim()}
className="flex h-11 items-center gap-2 rounded-xl bg-primary px-5 text-sm font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Search
</button>
</form>
</div>
{/* History */}
<div className="flex shrink-0 items-center justify-between px-6 pb-2 pt-4">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-foreground">
<History className="h-3.5 w-3.5" />
Recent searches
{history && <span>· {history.length}</span>}
</div>
<div className="flex items-center gap-1">
{!!history?.length && (
<button
type="button"
onClick={() => (confirmClear ? clearAll() : setConfirmClear(true))}
onBlur={() => setConfirmClear(false)}
disabled={clearing}
title={confirmClear ? `Delete all ${history.length} searches` : 'Clear all searches'}
className={`flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition disabled:opacity-50 ${
confirmClear ? 'bg-destructive/15 text-destructive hover:bg-destructive/25' : 'text-foreground hover:bg-muted'
}`}
>
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
{clearing ? 'Clearing…' : confirmClear ? `Delete ${history.length}?` : 'Clear all'}
</button>
)}
<button
type="button"
onClick={load}
title="Refresh"
className="flex h-7 w-7 items-center justify-center rounded-md text-foreground transition hover:bg-muted"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
{error && <p className="px-2 py-3 text-sm text-destructive">Could not load history: {error}</p>}
{!error && history === null && (
<div className="flex items-center gap-2 px-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
</div>
)}
{!error && history?.length === 0 && (
<div className="flex flex-col items-center gap-2 px-2 py-16 text-center text-sm text-muted-foreground">
<History className="h-8 w-8 opacity-40" />
No searches yet — try one above.
</div>
)}
{history && history.length > 0 && (
<ul className="flex flex-col gap-2">
{history.map((s) => (
<li
key={s.id}
className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm transition-colors hover:bg-muted"
>
{/* The row is an anchor so it opens with cmd-click and keyboard like any link; the remove
button is its sibling, never nested inside it. */}
<Link to={hrefFor(s.id)} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted/40 text-muted-foreground">
{s.isComplete ? <Search className="h-4 w-4" /> : <Loader2 className="h-4 w-4 animate-spin" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{s.searchText}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
<span>{formatWhen(s.startedAt)}</span>
<span className="inline-flex items-center gap-1">
<Users className="h-3 w-3" />
{s.responseCount.toLocaleString()}
</span>
<span className="inline-flex items-center gap-1">
<FileAudio className="h-3 w-3" />
{s.fileCount.toLocaleString()}
</span>
</div>
</div>
</Link>
<button
type="button"
onClick={() => remove(s.id)}
title="Remove search"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-4 w-4" />
</button>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</li>
))}
</ul>
)}
</div>
</div>
);
};