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:
2026-07-29 21:27:25 +00:00
co-authored by Claude Opus 4.8
parent e25143cc9c
commit 5ea06e2d8c
8 changed files with 1320 additions and 14 deletions
@@ -60,12 +60,12 @@ export const SearchView = () => {
<h1 className="mb-3 text-lg font-semibold">Search</h1> <h1 className="mb-3 text-lg font-semibold">Search</h1>
<form onSubmit={submit} className="flex gap-2"> <form onSubmit={submit} className="flex gap-2">
<div className="relative flex-1"> <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 <input
value={query} value={query}
onChange={(ev) => setQuery(ev.target.value)} onChange={(ev) => setQuery(ev.target.value)}
placeholder="Search artists, albums, tracks…" 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> </div>
<button <button
@@ -81,16 +81,16 @@ export const SearchView = () => {
{/* History */} {/* History */}
<div className="flex shrink-0 items-center justify-between px-6 pb-2 pt-4"> <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" /> <History className="h-3.5 w-3.5" />
Recent searches Recent searches
{history && <span className="text-muted-foreground/70">· {history.length}</span>} {history && <span className="text-zinc-600">· {history.length}</span>}
</div> </div>
<button <button
type="button" type="button"
onClick={load} onClick={load}
title="Refresh" 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" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
@@ -99,18 +99,18 @@ export const SearchView = () => {
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4"> <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 && <p className="px-2 py-3 text-sm text-red-500">Could not load history: {error}</p>}
{!error && history === null && ( {!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 <Loader2 className="h-4 w-4 animate-spin" /> Loading
</div> </div>
)} )}
{!error && history?.length === 0 && ( {!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" /> <History className="h-8 w-8 opacity-40" />
No searches yet try one above. No searches yet try one above.
</div> </div>
)} )}
{history && history.length > 0 && ( {history && history.length > 0 && (
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-2">
{history.map((s) => ( {history.map((s) => (
<li key={s.id}> <li key={s.id}>
<div <div
@@ -123,14 +123,14 @@ export const SearchView = () => {
setSelected(s); 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" />} {s.isComplete ? <Search className="h-4 w-4" /> : <Loader2 className="h-4 w-4 animate-spin" />}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{s.searchText}</div> <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-muted-foreground"> <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>{formatWhen(s.startedAt)}</span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Users className="h-3 w-3" /> <Users className="h-3 w-3" />
@@ -149,11 +149,11 @@ export const SearchView = () => {
remove(s.id); remove(s.id);
}} }}
title="Remove" 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" /> <X className="h-4 w-4" />
</button> </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> </div>
</li> </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>
</>
);
};