diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx index a4b6a3c8..2e02e7ae 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx @@ -60,12 +60,12 @@ export const SearchView = () => {

Search

- + 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" />
@@ -99,18 +99,18 @@ export const SearchView = () => {
{error &&

Could not load history: {error}

} {!error && history === null && ( -
+
Loading…
)} {!error && history?.length === 0 && ( -
+
No searches yet — try one above.
)} {history && history.length > 0 && ( -
    +
      {history.map((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" > -
      +
      {s.isComplete ? : }
      -
      {s.searchText}
      -
      +
      {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 */} +
      + { + ev.preventDefault(); + openPeer(peerName); + }} + className="flex items-center gap-1.5" + > + 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" + /> + + +
      +
      + + {/* 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)} +
      +
      +
      + ); + })} +
      + )} +
      + +
      { + ev.preventDefault(); + send(); + }} + className="flex shrink-0 items-center gap-2 border-t p-3" + > + 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" + /> + +
      + + )} +
      +
      + ); +}; 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 */} +
      +
      { + ev.preventDefault(); + join(joinName); + }} + className="flex items-center gap-1.5" + > + 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" + /> + + {notJoined.map((r) => ( + + ))} + + +
      +
      +
      + + {/* 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} +
      + ))} +
      + )} +
      + +
      { + ev.preventDefault(); + send(); + }} + className="flex shrink-0 items-center gap-2 border-t p-3" + > + 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" + /> + +
      + + )} +
      +
      + ); +}; 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 +
      +
      + + +
      + {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 ( +
      +
      + +
      { + ev.preventDefault(); + lookup(); + }} + className="flex flex-1 items-center gap-2" + > + 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" + /> + +
      +
      + +
      + {!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 +
      + + + +
      + + ); +};