soulseek: always-visible search delete, clear all, brighter header

The per-search remove button was hover-gated with opacity-0, so it read as
absent until you happened to hover the row. Make it always visible and red on
hover, and add a Clear all next to refresh. slskd has no bulk delete (only
DELETE /searches/{id}), so clearing fans out one request per search, drops each
cached result set, and reconciles with a reload — partial failures surface in a
toast. The destructive click arms once and disarms itself after a few seconds
instead of opening a modal, matching the other panels, which confirm nothing.

That header row sits outside any card, where the muted zinc greys wash out
against dark mode's green background — switch the whole level to white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 23:36:42 +00:00
co-authored by Claude Opus 4.8
parent b00608f3b3
commit ef541bbc1b
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight } from 'lucide-react';
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react';
import { SearchResults, dropCachedResults } from './SearchResults';
import { formatWhen, type SlskdSearchSummary } from './shared';
@@ -15,6 +15,8 @@ export const SearchView = () => {
const [history, setHistory] = useState<SlskdSearchSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<SlskdSearchSummary | null>(null);
const [clearing, setClearing] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const load = useCallback(() => {
setError(null);
@@ -52,6 +54,30 @@ export const SearchView = () => {
client.delete(`/slskd/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([]);
ids.forEach(dropCachedResults);
const settled = await Promise.allSettled(ids.map((id) => client.delete(`/slskd/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]);
if (selected) return <SearchResults search={selected} onBack={() => setSelected(null)} />;
return (
@@ -82,19 +108,36 @@ export const SearchView = () => {
{/* 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-zinc-500">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-white">
<History className="h-3.5 w-3.5" />
Recent searches
{history && <span className="text-zinc-600">· {history.length}</span>}
{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 text-white transition disabled:opacity-50 ${
confirmClear ? 'bg-red-500/20 hover:bg-red-500/30' : 'hover:bg-white/10'
}`}
>
{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-white transition hover:bg-white/10"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
<button
type="button"
onClick={load}
title="Refresh"
className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-500 transition hover:bg-white/5 hover:text-zinc-100"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
@@ -124,7 +167,7 @@ export const SearchView = () => {
setSelected(s);
}
}}
className="group flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2.5 shadow-sm transition-colors hover:bg-white/5"
className="flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2.5 shadow-sm transition-colors hover:bg-white/5"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/5 text-zinc-400">
{s.isComplete ? <Search className="h-4 w-4" /> : <Loader2 className="h-4 w-4 animate-spin" />}
@@ -149,8 +192,8 @@ export const SearchView = () => {
ev.stopPropagation();
remove(s.id);
}}
title="Remove"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-400 opacity-0 transition hover:bg-white/10 hover:text-zinc-100 group-hover:opacity-100"
title="Remove search"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-500 transition hover:bg-red-500/10 hover:text-red-400"
>
<X className="h-4 w-4" />
</button>