soulseek: nav sections, search results subpanel, stable client deps
- split into nav (vertical section menu) + view panels via soulseek:section channel - search view: history list + input, opens results subpanel for a past search - search results load stored responses (no re-run), session-cached, ranked - remove client from effect/callback deps (useClient is a fresh object per render) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,15 +4,15 @@ import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /soulseek uses the Workspace/Panel system (like /chat and /music): two panels — the search browser
|
||||
// (soulseek-search) and the download queue (soulseek-transfers) — coordinating via the 'soulseek:refresh'
|
||||
// panel channel. Both talk to slskd through the /api/slskd auth proxy.
|
||||
// /soulseek uses the Workspace/Panel system (like /chat and /music): a section nav (soulseek-nav) on
|
||||
// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section'
|
||||
// channel. Both talk to slskd through the /api/slskd auth proxy.
|
||||
|
||||
const ALLOWED_APP_TYPES = new Set<string | null>(['soulseek-search', 'soulseek-transfers', null]);
|
||||
const ALLOWED_APP_TYPES = new Set<string | null>(['soulseek-nav', 'soulseek-view', null]);
|
||||
|
||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'soulseek-search' };
|
||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'soulseek-view' };
|
||||
}
|
||||
const children = node.children.map((c) => {
|
||||
const fixed = normalizeLayout(c.node);
|
||||
@@ -23,7 +23,9 @@ function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||
}
|
||||
|
||||
export const SoulseekScreen = () => {
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/soulseek', defaultLayout);
|
||||
// v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
|
||||
// layout doesn't resurrect as two mismatched panels.
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/soulseek-v2', defaultLayout);
|
||||
|
||||
const workspace = useMemo(() => {
|
||||
const fixed = normalizeLayout(rawWorkspace.value);
|
||||
|
||||
@@ -5,7 +5,7 @@ export const defaultLayout: LayoutNode = {
|
||||
id: 'soulseek-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'soulseek-search', appType: 'soulseek-search' }, size: 55 },
|
||||
{ node: { type: 'panel', id: 'soulseek-transfers', appType: 'soulseek-transfers' }, size: 45 },
|
||||
{ node: { type: 'panel', id: 'soulseek-nav', appType: 'soulseek-nav' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'soulseek-view', appType: 'soulseek-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect } 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 {
|
||||
SLSKD_REFRESH_CHANNEL,
|
||||
basename,
|
||||
extOf,
|
||||
formatSize,
|
||||
type ResultRow,
|
||||
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
|
||||
// is a later step).
|
||||
|
||||
const MAX_ROWS = 300;
|
||||
const resultsCache = new Map<string, ResultRow[]>();
|
||||
|
||||
const rowKey = (row: ResultRow) => `${row.username}::${row.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
};
|
||||
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [queued, setQueued] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const cached = resultsCache.get(search.id);
|
||||
if (cached) {
|
||||
setRows(cached);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setRows(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);
|
||||
})
|
||||
.catch((err) => !cancelled && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// useClient() returns a fresh object each render, so depend only on the search id.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search.id]);
|
||||
|
||||
const download = async (row: ResultRow) => {
|
||||
const key = rowKey(row);
|
||||
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));
|
||||
bumpRefresh(Date.now());
|
||||
toast.success(`Queued ${basename(row.filename)}`);
|
||||
} catch (err) {
|
||||
toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const shown = rows ? rows.slice(0, MAX_ROWS) : [];
|
||||
|
||||
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="truncate text-sm font-semibold">{search.searchText}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{rows === null && !error
|
||||
? 'Loading results…'
|
||||
: `${(rows?.length ?? 0).toLocaleString()} files from ${search.responseCount.toLocaleString()} users${
|
||||
rows && rows.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">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
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 { SearchResults } from './SearchResults';
|
||||
import { formatWhen, type SlskdSearchSummary } from './shared';
|
||||
|
||||
// The 'search' section — a search input plus the history of past searches (GET /searches). Submitting
|
||||
// creates a new search on slskd and refreshes the list. (Loading a search's results is a later step.)
|
||||
|
||||
export const SearchView = () => {
|
||||
const client = useClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [history, setHistory] = useState<SlskdSearchSummary[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<SlskdSearchSummary | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setError(null);
|
||||
client
|
||||
.get<SlskdSearchSummary[]>('/slskd/api/v0/searches')
|
||||
.then((list) => setHistory([...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
// useClient() is a fresh object each render — keep this callback stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const text = query.trim();
|
||||
if (!text || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await client.post('/slskd/api/v0/searches', { searchText: text });
|
||||
setQuery('');
|
||||
load();
|
||||
} 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);
|
||||
client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load());
|
||||
};
|
||||
|
||||
if (selected) return <SearchResults search={selected} onBack={() => setSelected(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 bg-background pl-10 pr-3 text-sm 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-muted-foreground">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Recent searches
|
||||
{history && <span className="text-muted-foreground/70">· {history.length}</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
title="Refresh"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</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-red-500">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-1">
|
||||
{history.map((s) => (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelected(s)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
setSelected(s);
|
||||
}
|
||||
}}
|
||||
className="group flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted 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">{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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
remove(s.id);
|
||||
}}
|
||||
title="Remove"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { LayoutGrid, Search, ArrowDownToLine, ArrowUpFromLine, Hash, MessageCircle, Users, Server } from 'lucide-react';
|
||||
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
|
||||
|
||||
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes
|
||||
// the active section to the 'soulseek:section' channel; SoulseekView (right) renders the matching UI.
|
||||
|
||||
const ICONS: Record<SoulseekSectionId, LucideIcon> = {
|
||||
dashboard: LayoutGrid,
|
||||
search: Search,
|
||||
downloads: ArrowDownToLine,
|
||||
uploads: ArrowUpFromLine,
|
||||
rooms: Hash,
|
||||
chat: MessageCircle,
|
||||
users: Users,
|
||||
system: Server,
|
||||
};
|
||||
|
||||
export const SoulseekNav = () => {
|
||||
const [section, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'search');
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-white shadow-sm ring-1 ring-black/5">
|
||||
<img src="/slskd.png" alt="" className="h-5 w-5 object-contain" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Soulseek</div>
|
||||
<div className="truncate text-xs text-muted-foreground">slskd client</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{SOULSEEK_SECTIONS.map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
const active = section === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setSection(id)}
|
||||
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { toast } from 'sonner';
|
||||
import { Search, Download, Lock, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
SLSKD_REFRESH_CHANNEL,
|
||||
basename,
|
||||
extOf,
|
||||
formatSize,
|
||||
type ResultRow,
|
||||
type SlskdResponse,
|
||||
type SlskdSearch,
|
||||
} from './shared';
|
||||
|
||||
// Left panel of the /soulseek workspace — search slskd and enqueue downloads. A search is a create →
|
||||
// poll-until-complete → fetch-responses cycle (slskd resolves in ~1-2s). Responses arrive grouped by
|
||||
// peer and can be huge (hundreds of users, tens of thousands of files), so we flatten, rank by
|
||||
// availability, and cap the render. Queuing a download bumps the refresh channel so the transfers
|
||||
// panel (right) reflects it immediately.
|
||||
|
||||
const MAX_ROWS = 200;
|
||||
const POLL_MS = 600;
|
||||
const POLL_TIMEOUT_MS = 20_000;
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const rowKey = (row: ResultRow) => `${row.username}::${row.filename}`;
|
||||
|
||||
export const SoulseekSearch = () => {
|
||||
const client = useClient();
|
||||
const [, bumpRefresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<ResultRow[]>([]);
|
||||
const [meta, setMeta] = useState<{ total: number; users: number } | null>(null);
|
||||
const [queued, setQueued] = useState<Set<string>>(new Set());
|
||||
|
||||
const runSearch = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const text = query.trim();
|
||||
if (!text || searching) return;
|
||||
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
setRows([]);
|
||||
setMeta(null);
|
||||
setQueued(new Set());
|
||||
|
||||
try {
|
||||
const created = await client.post<SlskdSearch>('/slskd/api/v0/searches', { searchText: text });
|
||||
const id = created.id;
|
||||
|
||||
let state = created;
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
while (!state.isComplete && Date.now() < deadline) {
|
||||
await sleep(POLL_MS);
|
||||
state = await client.get<SlskdSearch>(`/slskd/api/v0/searches/${id}`);
|
||||
}
|
||||
|
||||
const responses = await client.get<SlskdResponse[]>(`/slskd/api/v0/searches/${id}/responses`);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
setMeta({ total: flat.length, users: responses.length });
|
||||
setRows(flat.slice(0, MAX_ROWS));
|
||||
|
||||
// slskd retains searches server-side; drop this one now that we have its responses.
|
||||
client.delete(`/slskd/api/v0/searches/${id}`).catch(() => {});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (row: ResultRow) => {
|
||||
const key = rowKey(row);
|
||||
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));
|
||||
bumpRefresh(Date.now());
|
||||
toast.success(`Queued ${basename(row.filename)}`);
|
||||
} catch (err) {
|
||||
toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b p-3">
|
||||
<div className="mb-3 flex items-center gap-2 px-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white">
|
||||
<img src="/slskd.png" alt="" className="h-5 w-5 object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-tight">Soulseek</h1>
|
||||
<p className="text-xs text-muted-foreground">Search the Soulseek network</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={runSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
placeholder="Artists, albums, tracks…"
|
||||
className="w-full rounded-md border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={searching || !query.trim()}
|
||||
className="flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-red-500">Search failed: {error}</p>}
|
||||
{meta && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{meta.total.toLocaleString()} files from {meta.users.toLocaleString()} users
|
||||
{meta.total > rows.length && ` — showing the top ${rows.length}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{rows.length > 0 && (
|
||||
<div className="divide-y">
|
||||
{rows.map((row) => {
|
||||
const key = rowKey(row);
|
||||
const isQueued = queued.has(key);
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 px-3 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 hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isQueued ? 'Queued' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && !error && meta && rows.length === 0 && (
|
||||
<p className="p-4 text-sm text-muted-foreground">No results.</p>
|
||||
)}
|
||||
{!meta && !searching && !error && (
|
||||
<p className="p-4 text-sm text-muted-foreground">Search for something to get started.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -62,7 +62,9 @@ export const SoulseekTransfers = () => {
|
||||
.then((data) => setApp(data))
|
||||
.catch((err) => setStatusError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setStatusLoading(false));
|
||||
}, [client]);
|
||||
// useClient() is a fresh object each render — keep this callback stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
@@ -82,7 +84,9 @@ export const SoulseekTransfers = () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [client, refresh]);
|
||||
// useClient() is a fresh object each render — poll on the refresh nonce only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refresh]);
|
||||
|
||||
// For an active transfer this cancels it; for a finished/failed one it clears it from the queue.
|
||||
const remove = async (row: Row) => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
|
||||
import { SearchView } from './SearchView';
|
||||
import { SoulseekTransfers } from './SoulseekTransfers';
|
||||
|
||||
// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. Only Search
|
||||
// (history + input) and Downloads (transfers) are built so far; the rest are placeholders.
|
||||
|
||||
const Placeholder = ({ id }: { id: SoulseekSectionId }) => {
|
||||
const label = SOULSEEK_SECTIONS.find((s) => s.id === id)?.label ?? id;
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Construction className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold">{label}</div>
|
||||
<div className="text-sm text-muted-foreground">Coming soon</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SoulseekView = () => {
|
||||
const [section] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'search');
|
||||
|
||||
switch (section) {
|
||||
case 'search':
|
||||
return <SearchView />;
|
||||
case 'downloads':
|
||||
return <SoulseekTransfers />;
|
||||
default:
|
||||
return <Placeholder id={section} />;
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { Search, ArrowDownToLine } from 'lucide-react';
|
||||
import { SoulseekSearch } from './SoulseekSearch';
|
||||
import { SoulseekTransfers } from './SoulseekTransfers';
|
||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||
import { SoulseekNav } from './SoulseekNav';
|
||||
import { SoulseekView } from './SoulseekView';
|
||||
|
||||
export { SoulseekSearch, SoulseekTransfers };
|
||||
export { SoulseekNav, SoulseekView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'soulseek-search', name: 'Search', icon: Search, component: SoulseekSearch, availableOnPanel: false },
|
||||
{ key: 'soulseek-transfers', name: 'Transfers', icon: ArrowDownToLine, component: SoulseekTransfers, availableOnPanel: false },
|
||||
{ key: 'soulseek-nav', name: 'Soulseek', icon: PanelLeft, component: SoulseekNav, availableOnPanel: false },
|
||||
{ key: 'soulseek-view', name: 'Soulseek', icon: LayoutGrid, component: SoulseekView, availableOnPanel: false },
|
||||
];
|
||||
|
||||
@@ -65,8 +65,59 @@ export type SlskdTransfer = {
|
||||
export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] };
|
||||
export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] };
|
||||
|
||||
// The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads
|
||||
// it and renders the matching UI. Mirrors slskd's own top-menu sections.
|
||||
export const SOULSEEK_SECTION_CHANNEL = 'soulseek:section';
|
||||
export type SoulseekSectionId =
|
||||
| 'dashboard'
|
||||
| 'search'
|
||||
| 'downloads'
|
||||
| 'uploads'
|
||||
| 'rooms'
|
||||
| 'chat'
|
||||
| 'users'
|
||||
| 'system';
|
||||
export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'search', label: 'Search' },
|
||||
{ id: 'downloads', label: 'Downloads' },
|
||||
{ id: 'uploads', label: 'Uploads' },
|
||||
{ id: 'rooms', label: 'Rooms' },
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'system', label: 'System' },
|
||||
];
|
||||
|
||||
// A past search, as listed by GET /searches (no responses inlined).
|
||||
export type SlskdSearchSummary = {
|
||||
id: string;
|
||||
searchText: string;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
fileCount: number;
|
||||
lockedFileCount: number;
|
||||
responseCount: number;
|
||||
isComplete: boolean;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path;
|
||||
|
||||
// Compact "time ago" for search-history rows.
|
||||
export const formatWhen = (iso?: string): string => {
|
||||
if (!iso) return '';
|
||||
const t = new Date(iso).getTime();
|
||||
if (!Number.isFinite(t)) return '';
|
||||
const s = Math.round((Date.now() - t) / 1000);
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return d < 7 ? `${d}d ago` : new Date(iso).toLocaleDateString();
|
||||
};
|
||||
|
||||
export const formatSize = (bytes: number) => {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
Reference in New Issue
Block a user