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.
277 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|