soulseek: dashboard, uploads, rooms, chat, users, and system panels
- dashboard: connection health, live download/upload tallies, recent searches - uploads: mirror of downloads (what peers pull from us), per-row cancel/clear - rooms: join chat rooms, transcript + composer, available-room datalist - chat: private 1:1 conversations with unread badges and self-aligned bubbles - users: look up a peer's presence/profile and browse their shared folders - system: server connection state with connect/disconnect and version info - view header: +/- zoom controls persisted per panel - search view: restyle history to the shared dark (zinc) theme Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -60,12 +60,12 @@ export const SearchView = () => {
|
||||
<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" />
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<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"
|
||||
className="h-11 w-full rounded-xl border border-white/10 bg-zinc-950 pl-10 pr-3 text-sm text-zinc-100 placeholder:text-zinc-500 shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -81,16 +81,16 @@ 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-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Recent searches
|
||||
{history && <span className="text-muted-foreground/70">· {history.length}</span>}
|
||||
{history && <span className="text-zinc-600">· {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"
|
||||
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>
|
||||
@@ -99,18 +99,18 @@ export const SearchView = () => {
|
||||
<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">
|
||||
<div className="flex items-center gap-2 px-2 py-6 text-sm text-zinc-500">
|
||||
<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">
|
||||
<div className="flex flex-col items-center gap-2 px-2 py-16 text-center text-sm text-zinc-500">
|
||||
<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">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{history.map((s) => (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
@@ -123,14 +123,14 @@ export const SearchView = () => {
|
||||
setSelected(s);
|
||||
}
|
||||
}}
|
||||
className="group flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-muted/60"
|
||||
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"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<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" />}
|
||||
</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">
|
||||
<div className="truncate text-sm font-medium text-zinc-100">{s.searchText}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-zinc-500">
|
||||
<span>{formatWhen(s.startedAt)}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
@@ -149,11 +149,11 @@ export const SearchView = () => {
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-zinc-600" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { MessageCircle, Send, Plus, RefreshCw, X } from 'lucide-react';
|
||||
import { formatClock, type SlskdConversation, type SlskdPrivateMessage } from './shared';
|
||||
|
||||
// Chat panel — private (1:1) messaging. GET /conversations lists the peers we have threads with; GET
|
||||
// /conversations/{user}/messages returns a thread (polled while it's open). Sending POSTs a bare JSON
|
||||
// string body to /conversations/{user}, matching slskd's [FromBody] string endpoint; viewing a thread
|
||||
// PUTs /conversations/{user} to acknowledge (clear the unread badge). Basic version: a peer rail on the
|
||||
// left, transcript + composer on the right.
|
||||
|
||||
const POLL_MS = 2500;
|
||||
|
||||
const isMine = (m: SlskdPrivateMessage) => (m.direction ?? '').toLowerCase() === 'out';
|
||||
|
||||
export const SoulseekChat = () => {
|
||||
const client = useClient();
|
||||
const [conversations, setConversations] = useState<SlskdConversation[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<SlskdPrivateMessage[]>([]);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [peerName, setPeerName] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
try {
|
||||
const list = await client.get<SlskdConversation[]>('/slskd/api/v0/conversations');
|
||||
setConversations(list);
|
||||
setSelected((cur) => cur ?? list[0]?.username ?? null);
|
||||
} catch {
|
||||
/* status panel surfaces connection errors */
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, [loadConversations]);
|
||||
|
||||
// Poll the selected thread's messages while it's open, and acknowledge to clear its unread badge.
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const tick = () =>
|
||||
client
|
||||
.get<SlskdPrivateMessage[]>(`/slskd/api/v0/conversations/${encodeURIComponent(selected)}/messages`)
|
||||
.then((m) => {
|
||||
if (cancelled) return;
|
||||
setMessages(m);
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.username === selected ? { ...c, unAcknowledgedMessageCount: 0 } : c)),
|
||||
);
|
||||
client.put(`/slskd/api/v0/conversations/${encodeURIComponent(selected)}`).catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
tick();
|
||||
const timer = setInterval(tick, POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
// useClient() is a fresh object each render — re-poll only when the selected peer changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected]);
|
||||
|
||||
// Stick to the bottom as new messages arrive / when switching threads.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length, selected]);
|
||||
|
||||
const openPeer = (name: string) => {
|
||||
const target = name.trim();
|
||||
if (!target) return;
|
||||
setPeerName('');
|
||||
setSelected(target);
|
||||
setConversations((prev) => (prev.some((c) => c.username === target) ? prev : [...prev, { username: target }]));
|
||||
};
|
||||
|
||||
const close = async (name: string) => {
|
||||
try {
|
||||
await client.delete(`/slskd/api/v0/conversations/${encodeURIComponent(name)}`);
|
||||
} catch {
|
||||
/* optimistic — drop it locally regardless */
|
||||
}
|
||||
setConversations((prev) => {
|
||||
const next = prev.filter((c) => c.username !== name);
|
||||
setSelected((cur) => (cur === name ? next[0]?.username ?? null : cur));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text || !selected) return;
|
||||
setDraft('');
|
||||
try {
|
||||
await client.post(`/slskd/api/v0/conversations/${encodeURIComponent(selected)}`, text);
|
||||
} catch (err) {
|
||||
setDraft(text);
|
||||
toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Peer rail */}
|
||||
<div className="flex w-56 shrink-0 flex-col border-r bg-muted/20">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Conversations</span>
|
||||
<button type="button" onClick={loadConversations} title="Refresh" className="rounded p-1 hover:bg-muted">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-2">
|
||||
{conversations.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">No conversations yet.</p>
|
||||
)}
|
||||
{conversations.map((c) => {
|
||||
const active = c.username === selected;
|
||||
const unread = c.unAcknowledgedMessageCount ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={c.username}
|
||||
className={`group flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm transition-colors ${
|
||||
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(c.username)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||
>
|
||||
<MessageCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{c.username}</span>
|
||||
</button>
|
||||
{unread > 0 && !active && (
|
||||
<span className="shrink-0 rounded-full bg-primary px-1.5 text-[10px] font-medium tabular-nums text-primary-foreground">
|
||||
{unread}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close(c.username)}
|
||||
title={`Close ${c.username}`}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-black/10 hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Start a conversation */}
|
||||
<div className="mt-auto border-t p-2">
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
openPeer(peerName);
|
||||
}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
value={peerName}
|
||||
onChange={(ev) => setPeerName(ev.target.value)}
|
||||
placeholder="Message a user…"
|
||||
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!peerName.trim()}
|
||||
title="Open conversation"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript + composer */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{!selected ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<MessageCircle className="h-8 w-8" />
|
||||
<p className="text-sm">Pick a conversation, or message a user to start one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
||||
<MessageCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{selected}</span>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No messages yet. Say hello.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{messages.map((m, i) => {
|
||||
const mine = isMine(m);
|
||||
return (
|
||||
<div key={m.id || `${m.timestamp}-${i}`} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-3 py-1.5 text-sm ${
|
||||
mine ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="break-words">{m.message}</div>
|
||||
<div className={`mt-0.5 text-right text-[10px] tabular-nums ${mine ? 'text-primary-foreground/70' : 'text-muted-foreground'}`}>
|
||||
{formatClock(m.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
send();
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-2 border-t p-3"
|
||||
>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
placeholder={`Message ${selected}…`}
|
||||
className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim()}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { ArrowDownToLine, ArrowUpFromLine, Search, RefreshCw, Radio } from 'lucide-react';
|
||||
import {
|
||||
SOULSEEK_SECTION_CHANNEL,
|
||||
formatWhen,
|
||||
transferPhase,
|
||||
type SlskdApplication,
|
||||
type SlskdSearchSummary,
|
||||
type SlskdTransferUser,
|
||||
type SoulseekSectionId,
|
||||
type TransferPhase,
|
||||
} from './shared';
|
||||
|
||||
// Dashboard — the at-a-glance overview for the /soulseek workspace: connection health, live
|
||||
// download/upload tallies, and the most recent searches. Read-only aggregation over the same slskd
|
||||
// endpoints the other panels use; tiles publish to the section channel so clicking one jumps the view.
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
type Tally = Record<TransferPhase, number>;
|
||||
const emptyTally = (): Tally => ({ downloading: 0, queued: 0, done: 0, failed: 0 });
|
||||
const tally = (users: SlskdTransferUser[]): Tally => {
|
||||
const t = emptyTally();
|
||||
for (const u of users) for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++;
|
||||
return t;
|
||||
};
|
||||
|
||||
export const SoulseekDashboard = () => {
|
||||
const client = useClient();
|
||||
const [, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
||||
const [app, setApp] = useState<SlskdApplication | null>(null);
|
||||
const [downloads, setDownloads] = useState<Tally>(emptyTally);
|
||||
const [uploads, setUploads] = useState<Tally>(emptyTally);
|
||||
const [searches, setSearches] = useState<SlskdSearchSummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
const [a, d, u, s] = await Promise.allSettled([
|
||||
client.get<SlskdApplication>('/slskd/api/v0/application'),
|
||||
client.get<SlskdTransferUser[]>('/slskd/api/v0/transfers/downloads'),
|
||||
client.get<SlskdTransferUser[]>('/slskd/api/v0/transfers/uploads'),
|
||||
client.get<SlskdSearchSummary[]>('/slskd/api/v0/searches'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (a.status === 'fulfilled') setApp(a.value);
|
||||
if (d.status === 'fulfilled') setDownloads(tally(d.value));
|
||||
if (u.status === 'fulfilled') setUploads(tally(u.value));
|
||||
if (s.status === 'fulfilled')
|
||||
setSearches([...s.value].sort((x, y) => new Date(y.startedAt).getTime() - new Date(x.startedAt).getTime()));
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
// useClient() is a fresh object each render — poll on a stable interval only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const server = app?.server;
|
||||
const connected = server?.isConnected ?? false;
|
||||
const recent = useMemo(() => searches.slice(0, 6), [searches]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
||||
{/* Connection */}
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-zinc-950 px-4 py-3">
|
||||
<Radio className="h-4 w-4 shrink-0 text-zinc-400" />
|
||||
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-zinc-100">{server?.state ?? 'Unknown'}</div>
|
||||
{server?.address && <div className="truncate text-xs text-zinc-500">{server.address}</div>}
|
||||
</div>
|
||||
{app?.version?.current && (
|
||||
<span className="shrink-0 text-xs text-zinc-500">
|
||||
v{app.version.current}
|
||||
{app.version.isUpdateAvailable ? ' · update available' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Downloads */}
|
||||
<Section title="Downloads" icon={<ArrowDownToLine className="h-4 w-4" />} onClick={() => setSection('downloads')}>
|
||||
<Tile label="Downloading" value={downloads.downloading} dot="bg-blue-500" />
|
||||
<Tile label="Queued" value={downloads.queued} dot="bg-amber-500" />
|
||||
<Tile label="Completed" value={downloads.done} dot="bg-green-500" />
|
||||
<Tile label="Failed" value={downloads.failed} dot="bg-red-500" />
|
||||
</Section>
|
||||
|
||||
{/* Uploads */}
|
||||
<Section title="Uploads" icon={<ArrowUpFromLine className="h-4 w-4" />} onClick={() => setSection('uploads')}>
|
||||
<Tile label="Uploading" value={uploads.downloading} dot="bg-blue-500" />
|
||||
<Tile label="Queued" value={uploads.queued} dot="bg-amber-500" />
|
||||
<Tile label="Completed" value={uploads.done} dot="bg-green-500" />
|
||||
<Tile label="Failed" value={uploads.failed} dot="bg-red-500" />
|
||||
</Section>
|
||||
|
||||
{/* Recent searches */}
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5">
|
||||
<Search className="h-4 w-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-100">Recent searches</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSection('search')}
|
||||
className="ml-auto text-xs text-zinc-500 transition hover:text-zinc-200"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-zinc-500">No searches yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-white/5">
|
||||
{recent.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setSection('search')}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 text-left transition hover:bg-white/5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-zinc-200">{s.searchText}</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">{s.responseCount} responses</span>
|
||||
<span className="shrink-0 text-xs text-zinc-600">{formatWhen(s.startedAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps = { title: string; icon: React.ReactNode; onClick: () => void; children: React.ReactNode };
|
||||
|
||||
const Section = ({ title, icon, onClick, children }: SectionProps) => (
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-2 border-b border-white/10 px-4 py-2.5 text-left transition hover:bg-white/5"
|
||||
>
|
||||
<span className="text-zinc-400">{icon}</span>
|
||||
<span className="text-sm font-medium text-zinc-100">{title}</span>
|
||||
<RefreshCw className="ml-auto h-3 w-3 text-zinc-600" />
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2 p-3 sm:grid-cols-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
type TileProps = { label: string; value: number; dot: string };
|
||||
|
||||
const Tile = ({ label, value, dot }: TileProps) => (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<span className={`h-2 w-2 rounded-full ${dot}`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-xl font-semibold tabular-nums text-zinc-100">{value}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { Hash, Users, LogOut, Send, Plus, RefreshCw } from 'lucide-react';
|
||||
import { formatClock, type SlskdRoom, type SlskdRoomInfo } from './shared';
|
||||
|
||||
// Rooms panel — join Soulseek chat rooms and talk in them. GET /rooms/joined lists the room names we're
|
||||
// in; GET /rooms/joined/{name} inlines that room's users + messages (polled while it's open); GET
|
||||
// /rooms/available lists everything else to join. Messages/joins POST a bare JSON string body, matching
|
||||
// slskd's [FromBody] string endpoints. Basic version: a room rail on the left, transcript + composer on
|
||||
// the right.
|
||||
|
||||
const POLL_MS = 2500;
|
||||
const AVAILABLE_LIMIT = 250;
|
||||
|
||||
export const SoulseekRooms = () => {
|
||||
const client = useClient();
|
||||
const [joined, setJoined] = useState<string[]>([]);
|
||||
const [available, setAvailable] = useState<SlskdRoomInfo[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [room, setRoom] = useState<SlskdRoom | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [joinName, setJoinName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadJoined = useCallback(async () => {
|
||||
try {
|
||||
const names = await client.get<string[]>('/slskd/api/v0/rooms/joined');
|
||||
setJoined(names);
|
||||
setSelected((cur) => cur ?? names[0] ?? null);
|
||||
} catch {
|
||||
/* status panel surfaces connection errors */
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadAvailable = useCallback(async () => {
|
||||
try {
|
||||
setAvailable(await client.get<SlskdRoomInfo[]>('/slskd/api/v0/rooms/available'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJoined();
|
||||
loadAvailable();
|
||||
}, [loadJoined, loadAvailable]);
|
||||
|
||||
// Poll the selected room's transcript + user list while it's open.
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
setRoom(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const tick = () =>
|
||||
client
|
||||
.get<SlskdRoom>(`/slskd/api/v0/rooms/joined/${encodeURIComponent(selected)}`)
|
||||
.then((r) => !cancelled && setRoom(r))
|
||||
.catch(() => {});
|
||||
tick();
|
||||
const timer = setInterval(tick, POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
// useClient() is a fresh object each render — re-poll only when the selected room changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected]);
|
||||
|
||||
const messages = room?.messages ?? [];
|
||||
// Stick to the bottom as new messages arrive / when switching rooms.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length, selected]);
|
||||
|
||||
const notJoined = useMemo(() => {
|
||||
const have = new Set(joined);
|
||||
return available
|
||||
.filter((r) => !have.has(r.name))
|
||||
.sort((a, b) => b.userCount - a.userCount)
|
||||
.slice(0, AVAILABLE_LIMIT);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [available, joined]);
|
||||
|
||||
const join = async (name: string) => {
|
||||
const target = name.trim();
|
||||
if (!target || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await client.post('/slskd/api/v0/rooms/joined', target);
|
||||
setJoinName('');
|
||||
setJoined((prev) => (prev.includes(target) ? prev : [...prev, target]));
|
||||
setSelected(target);
|
||||
toast.success(`Joined ${target}`);
|
||||
} catch (err) {
|
||||
toast.error(`Couldn't join ${target}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const leave = async (name: string) => {
|
||||
try {
|
||||
await client.delete(`/slskd/api/v0/rooms/joined/${encodeURIComponent(name)}`);
|
||||
setJoined((prev) => {
|
||||
const next = prev.filter((n) => n !== name);
|
||||
setSelected((cur) => (cur === name ? next[0] ?? null : cur));
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(`Couldn't leave ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text || !selected) return;
|
||||
setDraft('');
|
||||
try {
|
||||
await client.post(`/slskd/api/v0/rooms/joined/${encodeURIComponent(selected)}/messages`, text);
|
||||
} catch (err) {
|
||||
setDraft(text);
|
||||
toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Room rail */}
|
||||
<div className="flex w-56 shrink-0 flex-col border-r bg-muted/20">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Joined</span>
|
||||
<button type="button" onClick={loadJoined} title="Refresh" className="rounded p-1 hover:bg-muted">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-2">
|
||||
{joined.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">No rooms joined yet.</p>}
|
||||
{joined.map((name) => {
|
||||
const active = name === selected;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`group flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm transition-colors ${
|
||||
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<button type="button" onClick={() => setSelected(name)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
|
||||
<Hash className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => leave(name)}
|
||||
title={`Leave ${name}`}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-black/10 hover:text-foreground"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Join a room */}
|
||||
<div className="mt-auto border-t p-2">
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
join(joinName);
|
||||
}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
value={joinName}
|
||||
onChange={(ev) => setJoinName(ev.target.value)}
|
||||
list="slskd-available-rooms"
|
||||
placeholder="Join a room…"
|
||||
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50"
|
||||
/>
|
||||
<datalist id="slskd-available-rooms">
|
||||
{notJoined.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.userCount} users
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!joinName.trim() || busy}
|
||||
title="Join"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript + composer */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{!selected ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<Hash className="h-8 w-8" />
|
||||
<p className="text-sm">Join a room to start chatting.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
||||
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{selected}</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" /> {room?.users?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No messages yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{messages.map((m, i) => (
|
||||
<div key={`${m.timestamp}-${i}`} className="flex gap-2 text-sm">
|
||||
<span className="shrink-0 pt-0.5 text-xs tabular-nums text-muted-foreground">{formatClock(m.timestamp)}</span>
|
||||
<span className={`shrink-0 font-medium ${m.self ? 'text-primary' : 'text-foreground'}`}>{m.username}</span>
|
||||
<span className="min-w-0 break-words text-foreground/90">{m.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
send();
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-2 border-t p-3"
|
||||
>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
placeholder={`Message ${selected}…`}
|
||||
className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim()}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { Server, Radio, Plug, PlugZap, RefreshCw, Package, Loader2 } from 'lucide-react';
|
||||
import type { SlskdApplication, SlskdServerState } from './shared';
|
||||
|
||||
// System panel — connection health and controls for the slskd daemon. GET /application carries the
|
||||
// server block (state/address/username) and version info; PUT /server connects to Soulseek, DELETE
|
||||
// /server disconnects. Basic version: a connection card with connect/disconnect, and a version card.
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
export const SoulseekSystem = () => {
|
||||
const client = useClient();
|
||||
const [app, setApp] = useState<SlskdApplication | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setApp(await client.get<SlskdApplication>('/slskd/api/v0/application'));
|
||||
} catch {
|
||||
/* connection card will show the stale/empty state */
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const server: SlskdServerState | undefined = app?.server;
|
||||
const connected = server?.isConnected ?? false;
|
||||
|
||||
const connect = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await client.put('/slskd/api/v0/server');
|
||||
toast.success('Connecting to Soulseek…');
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.error(`Connect failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await client.delete('/slskd/api/v0/server');
|
||||
toast.success('Disconnected from Soulseek.');
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.error(`Disconnect failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const version = app?.version;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-100">System</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
title="Refresh"
|
||||
className="ml-auto rounded p-1 text-zinc-500 transition hover:bg-white/5 hover:text-zinc-200"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection */}
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||
<div className="flex items-center gap-3 border-b border-white/10 px-4 py-3">
|
||||
<Radio className="h-4 w-4 shrink-0 text-zinc-400" />
|
||||
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-zinc-100">{server?.state ?? 'Unknown'}</div>
|
||||
{server?.address && <div className="truncate text-xs text-zinc-500">{server.address}</div>}
|
||||
</div>
|
||||
{server?.username && <span className="shrink-0 text-xs text-zinc-400">{server.username}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={connect}
|
||||
disabled={busy || connected}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-200 transition hover:bg-white/10 disabled:opacity-40"
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PlugZap className="h-3.5 w-3.5" />}
|
||||
Connect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={disconnect}
|
||||
disabled={busy || !connected}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-200 transition hover:bg-white/10 disabled:opacity-40"
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plug className="h-3.5 w-3.5" />}
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5">
|
||||
<Package className="h-4 w-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-100">Version</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-3">
|
||||
<Field label="Current" value={version?.current ?? '—'} />
|
||||
<Field label="Latest" value={version?.latest ?? '—'} />
|
||||
<div className="col-span-2">
|
||||
{version?.isUpdateAvailable ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2 py-1 text-xs text-amber-400">
|
||||
<Package className="h-3.5 w-3.5" /> Update available
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">Up to date</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type FieldProps = { label: string; value: string };
|
||||
|
||||
const Field = ({ label, value }: FieldProps) => (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2">
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
<span className="truncate text-sm font-medium tabular-nums text-zinc-100">{value}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { RefreshCw, X, Folder, ArrowUpFromLine } from 'lucide-react';
|
||||
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards';
|
||||
import {
|
||||
basename,
|
||||
folderLabel,
|
||||
formatSize,
|
||||
formatSpeed,
|
||||
transferPhase,
|
||||
type SlskdTransfer,
|
||||
type SlskdTransferUser,
|
||||
type TransferPhase,
|
||||
} from './shared';
|
||||
|
||||
// Uploads panel — the mirror image of Downloads: what peers are pulling from us. GET /transfers/uploads
|
||||
// returns the same user → directories → files shape, so we group and render it the same way (collapsible
|
||||
// card per peer, files by folder, a progress bar per file). Basic version: one flat list, per-row
|
||||
// cancel/clear; no retry/queue-position controls (those are download-side concerns).
|
||||
|
||||
const POLL_MS = 1500;
|
||||
|
||||
type Row = SlskdTransfer & { phase: TransferPhase };
|
||||
type UpDir = { directory: string; label: string; files: Row[]; size: number };
|
||||
type UpUser = { username: string; dirs: UpDir[]; counts: Record<TransferPhase, number>; total: number };
|
||||
|
||||
const PHASE_ORDER: Record<TransferPhase, number> = { downloading: 0, queued: 1, failed: 2, done: 3 };
|
||||
const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done'];
|
||||
|
||||
const phaseStyle: Record<TransferPhase, { label: string; dot: string; bar: string }> = {
|
||||
downloading: { label: 'Uploading', dot: 'bg-blue-500', bar: 'bg-blue-500' },
|
||||
queued: { label: 'Queued', dot: 'bg-amber-500', bar: 'bg-amber-400' },
|
||||
done: { label: 'Done', dot: 'bg-green-500', bar: 'bg-green-500' },
|
||||
failed: { label: 'Failed', dot: 'bg-red-500', bar: 'bg-red-500' },
|
||||
};
|
||||
|
||||
const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed';
|
||||
|
||||
const group = (users: SlskdTransferUser[]): UpUser[] => {
|
||||
const out: UpUser[] = [];
|
||||
for (const user of users) {
|
||||
const dirs: UpDir[] = [];
|
||||
for (const dir of user.directories ?? []) {
|
||||
const files: Row[] = (dir.files ?? []).map((f) => ({ ...f, phase: transferPhase(f.state) }));
|
||||
if (files.length === 0) continue;
|
||||
files.sort(
|
||||
(a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)),
|
||||
);
|
||||
dirs.push({
|
||||
directory: dir.directory,
|
||||
label: folderLabel(dir.directory),
|
||||
files,
|
||||
size: files.reduce((n, f) => n + f.size, 0),
|
||||
});
|
||||
}
|
||||
if (dirs.length === 0) continue;
|
||||
dirs.sort((a, b) => a.directory.localeCompare(b.directory));
|
||||
const counts: Record<TransferPhase, number> = { downloading: 0, queued: 0, failed: 0, done: 0 };
|
||||
let total = 0;
|
||||
for (const dir of dirs) for (const f of dir.files) (counts[f.phase]++, total++);
|
||||
out.push({ username: user.username, dirs, counts, total });
|
||||
}
|
||||
out.sort(
|
||||
(a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username),
|
||||
);
|
||||
return out;
|
||||
};
|
||||
|
||||
export const SoulseekUploads = () => {
|
||||
const client = useClient();
|
||||
const [data, setData] = useState<SlskdTransferUser[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const users = useMemo(() => group(data), [data]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tick = () =>
|
||||
client
|
||||
.get<SlskdTransferUser[]>('/slskd/api/v0/transfers/uploads')
|
||||
.then((u) => !cancelled && (setData(u), setLoaded(true)))
|
||||
.catch(() => {});
|
||||
tick();
|
||||
const timer = setInterval(tick, POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
// useClient() is a fresh object each render — poll on a stable interval only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const toggle = (name: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
|
||||
const dropIds = (ids: Set<string>) =>
|
||||
setData((prev) =>
|
||||
prev.map((u) => ({
|
||||
...u,
|
||||
directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })),
|
||||
})),
|
||||
);
|
||||
|
||||
// Abort an in-flight upload, or clear a finished/failed one from the queue.
|
||||
const remove = async (row: Row) => {
|
||||
const done = isRemovable(row.phase);
|
||||
try {
|
||||
await client.delete(
|
||||
`/slskd/api/v0/transfers/uploads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`,
|
||||
);
|
||||
dropIds(new Set([row.id]));
|
||||
} catch {
|
||||
/* next poll reconciles */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b p-3 text-sm">
|
||||
<ArrowUpFromLine className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">Uploads</span>
|
||||
<span className="text-muted-foreground">· {users.length} peer{users.length === 1 ? '' : 's'}</span>
|
||||
{!loaded && <RefreshCw className="ml-auto h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loaded ? 'No uploads. When a peer downloads a shared file it shows up here.' : 'Loading uploads…'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{users.map((user) => (
|
||||
<UserCard
|
||||
key={user.username}
|
||||
user={user}
|
||||
open={!collapsed.has(user.username)}
|
||||
onToggle={() => toggle(user.username)}
|
||||
onRemove={remove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type UserCardProps = { user: UpUser; open: boolean; onToggle: () => void; onRemove: (row: Row) => void };
|
||||
|
||||
const UserCard = ({ user, open, onToggle, onRemove }: UserCardProps) => {
|
||||
const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => (
|
||||
<span key={p} className="inline-flex items-center gap-1" title={phaseStyle[p].label}>
|
||||
<span className={`h-2 w-2 rounded-full ${phaseStyle[p].dot}`} />
|
||||
{user.counts[p]}
|
||||
</span>
|
||||
));
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader open={open} onToggle={onToggle} title={user.username} meta={meta} />
|
||||
{open && (
|
||||
<CardBody>
|
||||
{user.dirs.map((dir) => (
|
||||
<FolderBlock key={dir.directory} dir={dir} onRemove={onRemove} />
|
||||
))}
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
type FolderBlockProps = { dir: UpDir; onRemove: (row: Row) => void };
|
||||
|
||||
const FolderBlock = ({ dir, onRemove }: FolderBlockProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<SubCard>
|
||||
<SubCardHeader
|
||||
open={open}
|
||||
onToggle={() => setOpen((v) => !v)}
|
||||
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
|
||||
label={dir.label}
|
||||
title={dir.directory}
|
||||
meta={`${dir.files.length} · ${formatSize(dir.size)}`}
|
||||
/>
|
||||
{open && (
|
||||
<RowList>
|
||||
{dir.files.map((row) => {
|
||||
const style = phaseStyle[row.phase];
|
||||
const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete)));
|
||||
const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : '';
|
||||
return (
|
||||
<div key={row.id} className="px-3 py-2 pl-8 text-sm text-zinc-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${style.dot}`} />
|
||||
<span className="min-w-0 flex-1 truncate">{basename(row.filename)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row)}
|
||||
title={isRemovable(row.phase) ? 'Clear' : 'Cancel'}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 hover:bg-white/10 hover:text-zinc-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div className={`h-full ${style.bar}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-zinc-500">
|
||||
<span>{style.label}</span>
|
||||
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
|
||||
{speed && <span>· {speed}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RowList>
|
||||
)}
|
||||
</SubCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, Search, FolderOpen, CircleCheck, CircleSlash, Clock, Loader2 } from 'lucide-react';
|
||||
import type { SlskdBrowseDirectory, SlskdUserInfo, SlskdUserStatus } from './shared';
|
||||
|
||||
// Users panel — look up a peer: their presence (online/away/offline), profile info (description, upload
|
||||
// slots, queue), and optionally browse their shared folders. GET /users/{u}/status + /info fetch the
|
||||
// header; GET /users/{u}/browse pulls the share tree (a flat directory list). Basic version: a lookup
|
||||
// box, a profile card, and a lazily-loaded, collapsed folder list.
|
||||
|
||||
type Loaded = {
|
||||
username: string;
|
||||
status: SlskdUserStatus | null;
|
||||
info: SlskdUserInfo | null;
|
||||
};
|
||||
|
||||
const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof CircleCheck } => {
|
||||
switch ((p ?? '').toLowerCase()) {
|
||||
case 'online':
|
||||
return { label: 'Online', dot: 'text-green-500', icon: CircleCheck };
|
||||
case 'away':
|
||||
return { label: 'Away', dot: 'text-amber-500', icon: Clock };
|
||||
default:
|
||||
return { label: 'Offline', dot: 'text-zinc-500', icon: CircleSlash };
|
||||
}
|
||||
};
|
||||
|
||||
export const SoulseekUsers = () => {
|
||||
const client = useClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [peer, setPeer] = useState<Loaded | null>(null);
|
||||
const [dirs, setDirs] = useState<SlskdBrowseDirectory[] | null>(null);
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
|
||||
const lookup = async () => {
|
||||
const username = query.trim();
|
||||
if (!username || loading) return;
|
||||
setLoading(true);
|
||||
setPeer(null);
|
||||
setDirs(null);
|
||||
const [status, info] = await Promise.allSettled([
|
||||
client.get<SlskdUserStatus>(`/slskd/api/v0/users/${encodeURIComponent(username)}/status`),
|
||||
client.get<SlskdUserInfo>(`/slskd/api/v0/users/${encodeURIComponent(username)}/info`),
|
||||
]);
|
||||
if (status.status === 'rejected' && info.status === 'rejected') {
|
||||
toast.error(`Couldn't reach ${username} — they may be offline.`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setPeer({
|
||||
username,
|
||||
status: status.status === 'fulfilled' ? status.value : null,
|
||||
info: info.status === 'fulfilled' ? info.value : null,
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const browse = async () => {
|
||||
if (!peer || browsing) return;
|
||||
setBrowsing(true);
|
||||
try {
|
||||
const tree = await client.get<SlskdBrowseDirectory[]>(`/slskd/api/v0/users/${encodeURIComponent(peer.username)}/browse`);
|
||||
setDirs([...tree].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} catch (err) {
|
||||
toast.error(`Browse failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setBrowsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pres = presenceStyle(peer?.status?.presence);
|
||||
const PresIcon = pres.icon;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b p-3">
|
||||
<Users className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
lookup();
|
||||
}}
|
||||
className="flex flex-1 items-center gap-2"
|
||||
>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
placeholder="Look up a username…"
|
||||
className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!query.trim() || loading}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
Look up
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{!peer ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<Users className="h-8 w-8" />
|
||||
<p className="text-sm">Enter a Soulseek username to see their profile and shares.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
||||
{/* Profile */}
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-white/5 text-lg font-semibold text-zinc-300">
|
||||
{peer.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-zinc-100">{peer.username}</div>
|
||||
<div className={`flex items-center gap-1.5 text-xs ${pres.dot}`}>
|
||||
<PresIcon className="h-3.5 w-3.5" />
|
||||
{pres.label}
|
||||
{peer.status?.isPrivileged && <span className="text-amber-400">· privileged</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{peer.info && (
|
||||
<>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<Stat label="Upload slots" value={peer.info.uploadSlots ?? 0} />
|
||||
<Stat label="Queue" value={peer.info.queueLength ?? 0} />
|
||||
<Stat
|
||||
label="Free slot"
|
||||
value={peer.info.hasFreeUploadSlot ? 'Yes' : 'No'}
|
||||
accent={peer.info.hasFreeUploadSlot ? 'text-green-400' : 'text-zinc-400'}
|
||||
/>
|
||||
</div>
|
||||
{peer.info.description && (
|
||||
<p className="mt-3 whitespace-pre-wrap border-t border-white/5 pt-3 text-sm text-zinc-300">
|
||||
{peer.info.description}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shares */}
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5">
|
||||
<FolderOpen className="h-4 w-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-100">Shared folders</span>
|
||||
{dirs && <span className="text-xs text-zinc-500">· {dirs.length}</span>}
|
||||
{!dirs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={browse}
|
||||
disabled={browsing}
|
||||
className="ml-auto flex items-center gap-1.5 text-xs text-zinc-400 transition hover:text-zinc-100 disabled:opacity-40"
|
||||
>
|
||||
{browsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderOpen className="h-3.5 w-3.5" />}
|
||||
{browsing ? 'Browsing…' : 'Browse shares'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{dirs &&
|
||||
(dirs.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-zinc-500">No shared folders.</p>
|
||||
) : (
|
||||
<div className="max-h-96 divide-y divide-white/5 overflow-y-auto">
|
||||
{dirs.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-200" title={d.name}>
|
||||
{d.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
||||
{d.fileCount} file{d.fileCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type StatProps = { label: string; value: string | number; accent?: string };
|
||||
|
||||
const Stat = ({ label, value, accent = 'text-zinc-100' }: StatProps) => (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2">
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
<span className={`text-lg font-semibold tabular-nums ${accent}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LayoutGrid, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { soulseekZoomKey, SOULSEEK_ZOOM_MIN, SOULSEEK_ZOOM_MAX, SOULSEEK_ZOOM_STEP } from './shared';
|
||||
|
||||
// Panel header for the right (soulseek-view) panel — icon + title plus +/- zoom controls. The zoom
|
||||
// factor is persisted per-panel via useDashboardState (same store as the layout config) so it survives
|
||||
// reloads; SoulseekView reads the same key to scale its content. This is the Workspace panel-header API:
|
||||
// a `header` component (registered alongside `component`) rendered by PanelSlot with { panelId }.
|
||||
|
||||
const clamp = (z: number) => Math.min(SOULSEEK_ZOOM_MAX, Math.max(SOULSEEK_ZOOM_MIN, Math.round(z * 10) / 10));
|
||||
|
||||
type SoulseekViewHeaderProps = { panelId: string };
|
||||
|
||||
export const SoulseekViewHeader = ({ panelId }: SoulseekViewHeaderProps) => {
|
||||
const { value: zoom, setValue: setZoom } = useDashboardState<number>(soulseekZoomKey(panelId), 1);
|
||||
const level = zoom ?? 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayoutGrid className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">Soulseek</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((z) => clamp((z ?? 1) - SOULSEEK_ZOOM_STEP))}
|
||||
disabled={level <= SOULSEEK_ZOOM_MIN}
|
||||
title="Zoom out"
|
||||
className="cursor-pointer rounded p-1 transition-colors hover:bg-black/10 disabled:cursor-default disabled:opacity-30"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom(1)}
|
||||
title="Reset zoom"
|
||||
className="w-9 cursor-pointer rounded px-1 text-center text-[11px] tabular-nums text-black/60 transition-colors hover:bg-black/10 hover:text-black"
|
||||
>
|
||||
{Math.round(level * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((z) => clamp((z ?? 1) + SOULSEEK_ZOOM_STEP))}
|
||||
disabled={level >= SOULSEEK_ZOOM_MAX}
|
||||
title="Zoom in"
|
||||
className="cursor-pointer rounded p-1 transition-colors hover:bg-black/10 disabled:cursor-default disabled:opacity-30"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user