+
{s.searchText}
+
{formatWhen(s.startedAt)}
@@ -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"
>
-
+
))}
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx
new file mode 100644
index 00000000..692e9568
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx
@@ -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
([]);
+ const [selected, setSelected] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [draft, setDraft] = useState('');
+ const [peerName, setPeerName] = useState('');
+ const scrollRef = useRef(null);
+
+ const loadConversations = useCallback(async () => {
+ try {
+ const list = await client.get('/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(`/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 (
+
+ {/* Peer rail */}
+
+
+ Conversations
+
+
+
+ {conversations.length === 0 && (
+
No conversations yet.
+ )}
+ {conversations.map((c) => {
+ const active = c.username === selected;
+ const unread = c.unAcknowledgedMessageCount ?? 0;
+ return (
+
+
+ {unread > 0 && !active && (
+
+ {unread}
+
+ )}
+
+
+ );
+ })}
+
+
+ {/* Start a conversation */}
+
+
+
+ {/* Transcript + composer */}
+
+ {!selected ? (
+
+
+
Pick a conversation, or message a user to start one.
+
+ ) : (
+ <>
+
+
+ {selected}
+
+
+
+ {messages.length === 0 ? (
+
No messages yet. Say hello.
+ ) : (
+
+ {messages.map((m, i) => {
+ const mine = isMine(m);
+ return (
+
+
+
{m.message}
+
+ {formatClock(m.timestamp)}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekDashboard.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekDashboard.tsx
new file mode 100644
index 00000000..304d6ea8
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekDashboard.tsx
@@ -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;
+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(SOULSEEK_SECTION_CHANNEL, 'dashboard');
+ const [app, setApp] = useState(null);
+ const [downloads, setDownloads] = useState(emptyTally);
+ const [uploads, setUploads] = useState(emptyTally);
+ const [searches, setSearches] = useState([]);
+
+ useEffect(() => {
+ let cancelled = false;
+ const tick = async () => {
+ const [a, d, u, s] = await Promise.allSettled([
+ client.get('/slskd/api/v0/application'),
+ client.get('/slskd/api/v0/transfers/downloads'),
+ client.get('/slskd/api/v0/transfers/uploads'),
+ client.get('/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 (
+
+
+ {/* Connection */}
+
+
+
+
+
{server?.state ?? 'Unknown'}
+ {server?.address &&
{server.address}
}
+
+ {app?.version?.current && (
+
+ v{app.version.current}
+ {app.version.isUpdateAvailable ? ' · update available' : ''}
+
+ )}
+
+
+ {/* Downloads */}
+
} onClick={() => setSection('downloads')}>
+
+
+
+
+
+
+ {/* Uploads */}
+
} onClick={() => setSection('uploads')}>
+
+
+
+
+
+
+ {/* Recent searches */}
+
+
+
+ Recent searches
+
+
+ {recent.length === 0 ? (
+
No searches yet.
+ ) : (
+
+ {recent.map((s) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+type SectionProps = { title: string; icon: React.ReactNode; onClick: () => void; children: React.ReactNode };
+
+const Section = ({ title, icon, onClick, children }: SectionProps) => (
+
+
+
{children}
+
+);
+
+type TileProps = { label: string; value: number; dot: string };
+
+const Tile = ({ label, value, dot }: TileProps) => (
+
+
+
+ {label}
+
+
{value}
+
+);
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx
new file mode 100644
index 00000000..40c36285
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx
@@ -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([]);
+ const [available, setAvailable] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [room, setRoom] = useState(null);
+ const [draft, setDraft] = useState('');
+ const [joinName, setJoinName] = useState('');
+ const [busy, setBusy] = useState(false);
+ const scrollRef = useRef(null);
+
+ const loadJoined = useCallback(async () => {
+ try {
+ const names = await client.get('/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('/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(`/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 (
+
+ {/* Room rail */}
+
+
+ Joined
+
+
+
+ {joined.length === 0 &&
No rooms joined yet.
}
+ {joined.map((name) => {
+ const active = name === selected;
+ return (
+
+
+
+
+ );
+ })}
+
+
+ {/* Join a room */}
+
+
+
+ {/* Transcript + composer */}
+
+ {!selected ? (
+
+
+
Join a room to start chatting.
+
+ ) : (
+ <>
+
+
+ {selected}
+
+ {room?.users?.length ?? 0}
+
+
+
+
+ {messages.length === 0 ? (
+
No messages yet.
+ ) : (
+
+ {messages.map((m, i) => (
+
+ {formatClock(m.timestamp)}
+ {m.username}
+ {m.message}
+
+ ))}
+
+ )}
+
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSystem.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSystem.tsx
new file mode 100644
index 00000000..b3f6dc5b
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSystem.tsx
@@ -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(null);
+ const [busy, setBusy] = useState(false);
+
+ const refresh = useCallback(async () => {
+ try {
+ setApp(await client.get('/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 (
+
+
+
+
+ System
+
+
+
+ {/* Connection */}
+
+
+
+
+
+
{server?.state ?? 'Unknown'}
+ {server?.address &&
{server.address}
}
+
+ {server?.username &&
{server.username}}
+
+
+
+
+
+
+
+ {/* Version */}
+
+
+
+
+
+
+ {version?.isUpdateAvailable ? (
+
+ Update available
+
+ ) : (
+
Up to date
+ )}
+
+
+
+
+
+ );
+};
+
+type FieldProps = { label: string; value: string };
+
+const Field = ({ label, value }: FieldProps) => (
+
+ {label}
+ {value}
+
+);
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUploads.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUploads.tsx
new file mode 100644
index 00000000..cb1d37a6
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUploads.tsx
@@ -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; total: number };
+
+const PHASE_ORDER: Record = { downloading: 0, queued: 1, failed: 2, done: 3 };
+const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done'];
+
+const phaseStyle: Record = {
+ 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 = { 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([]);
+ const [loaded, setLoaded] = useState(false);
+ const [collapsed, setCollapsed] = useState>(new Set());
+
+ const users = useMemo(() => group(data), [data]);
+
+ useEffect(() => {
+ let cancelled = false;
+ const tick = () =>
+ client
+ .get('/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) =>
+ 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 (
+
+
+
+
Uploads
+
· {users.length} peer{users.length === 1 ? '' : 's'}
+ {!loaded &&
}
+
+
+
+ {users.length === 0 ? (
+
+ {loaded ? 'No uploads. When a peer downloads a shared file it shows up here.' : 'Loading uploads…'}
+
+ ) : (
+
+ {users.map((user) => (
+ toggle(user.username)}
+ onRemove={remove}
+ />
+ ))}
+
+ )}
+
+
+ );
+};
+
+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) => (
+
+
+ {user.counts[p]}
+
+ ));
+ return (
+
+
+ {open && (
+
+ {user.dirs.map((dir) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+type FolderBlockProps = { dir: UpDir; onRemove: (row: Row) => void };
+
+const FolderBlock = ({ dir, onRemove }: FolderBlockProps) => {
+ const [open, setOpen] = useState(true);
+ return (
+
+ setOpen((v) => !v)}
+ icon={}
+ label={dir.label}
+ title={dir.directory}
+ meta={`${dir.files.length} · ${formatSize(dir.size)}`}
+ />
+ {open && (
+
+ {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 (
+
+
+
+ {basename(row.filename)}
+
+
+
+
+ {style.label}
+ {row.size > 0 && · {formatSize(row.size)}}
+ {speed && · {speed}}
+
+
+ );
+ })}
+
+ )}
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx
new file mode 100644
index 00000000..355000e6
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx
@@ -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(null);
+ const [dirs, setDirs] = useState(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(`/slskd/api/v0/users/${encodeURIComponent(username)}/status`),
+ client.get(`/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(`/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 (
+
+
+
+
+
+
+
+ {!peer ? (
+
+
+
Enter a Soulseek username to see their profile and shares.
+
+ ) : (
+
+ {/* Profile */}
+
+
+
+ {peer.username.charAt(0).toUpperCase()}
+
+
+
{peer.username}
+
+
+ {pres.label}
+ {peer.status?.isPrivileged &&
· privileged}
+
+
+
+
+ {peer.info && (
+ <>
+
+
+
+
+
+ {peer.info.description && (
+
+ {peer.info.description}
+
+ )}
+ >
+ )}
+
+
+ {/* Shares */}
+
+
+
+ Shared folders
+ {dirs && · {dirs.length}}
+ {!dirs && (
+
+ )}
+
+ {dirs &&
+ (dirs.length === 0 ? (
+
No shared folders.
+ ) : (
+
+ {dirs.map((d) => (
+
+
+
+ {d.name}
+
+
+ {d.fileCount} file{d.fileCount === 1 ? '' : 's'}
+
+
+ ))}
+
+ ))}
+
+
+ )}
+
+
+ );
+};
+
+type StatProps = { label: string; value: string | number; accent?: string };
+
+const Stat = ({ label, value, accent = 'text-zinc-100' }: StatProps) => (
+
+ {label}
+ {value}
+
+);
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx
new file mode 100644
index 00000000..7b4b0985
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx
@@ -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(soulseekZoomKey(panelId), 1);
+ const level = zoom ?? 1;
+
+ return (
+ <>
+
+ Soulseek
+
+
+
+
+
+ >
+ );
+};