soulseek: peer menu with browse and favorites

slskd has no favorites or buddy-list concept — 0.26.0's UsersController exposes
only endpoint/browse/directory/info/status — so Officer owns that data itself:
a soulseek_favorites table served by the slskd sidecar under a /_officer/*
namespace, which can never collide with slskd's /api/v0/*. The main server
gains exactly one line, injecting X-Officer-User on the proxy hop, so it stays
a thin auth proxy and grows no Soulseek logic. The route is handled before the
upstream check, so favorites keep working with slskd down.

Usernames in search results and downloads become a dropdown (browse shares,
toggle favorite). Browsing publishes to a nonce-stamped, consumed-once channel
so the Users section looks the peer up without re-running the expensive browse
on every remount, and favorites get their own section at the top of that panel,
which doubles as its landing content. CardHeader had to split its toggle row to
host the dropdown, since a trigger can't live inside the collapse button.

The schema file is deliberately self-contained so it can move wholesale into
the sidecar directory when sidecars start owning their own schema. Its DDL was
applied by hand, matching drizzle's constraint naming, rather than running a
whole-schema push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 23:36:37 +00:00
co-authored by Claude Opus 4.8
parent f777ed4197
commit b00608f3b3
14 changed files with 676 additions and 256 deletions
+1
View File
@@ -102,6 +102,7 @@ export type {
PlaylistSummary,
Playlist,
} from './queries/music';
export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries/soulseek';
export {
getVaultTokens,
setVaultTokens,
@@ -0,0 +1,25 @@
import { eq, and, asc } from 'drizzle-orm';
import { db } from '../db';
import { soulseekFavorites } from '../schema';
/** A user's favourited Soulseek peers, alphabetical (the order the UI lists them in). */
export async function getSoulseekFavorites(userId: number): Promise<string[]> {
const rows = await db
.select({ username: soulseekFavorites.username })
.from(soulseekFavorites)
.where(eq(soulseekFavorites.userId, userId))
.orderBy(asc(soulseekFavorites.username));
return rows.map((r) => r.username);
}
/** Add a favourite (idempotent — a repeat add is a no-op via the unique constraint). */
export async function addSoulseekFavorite(userId: number, username: string): Promise<void> {
await db.insert(soulseekFavorites).values({ userId, username }).onConflictDoNothing();
}
/** Remove a favourite (no-op if it wasn't set). */
export async function removeSoulseekFavorite(userId: number, username: string): Promise<void> {
await db
.delete(soulseekFavorites)
.where(and(eq(soulseekFavorites.userId, userId), eq(soulseekFavorites.username, username)));
}
@@ -7,4 +7,5 @@ export * from './email';
export * from './pipeline-jobs';
export * from './chat-events';
export * from './music';
export * from './soulseek';
export * from './vault';
@@ -0,0 +1,24 @@
import { pgTable, serial, integer, text, timestamp, unique } from 'drizzle-orm/pg-core';
import { users } from './auth';
// Soulseek state that Officer owns because slskd has none. slskd exposes no favourites/buddy-list API
// (verified against 0.26.0's UsersController: only endpoint/browse/directory/info/status), so the peers
// the owner marks live here instead of in a forked daemon.
//
// Every table here is `soulseek_`-prefixed, and this file deliberately holds nothing else: when sidecars
// start owning their own schema, it moves wholesale into src/servers/sidecar/slskd/ with no untangling.
// Only the officer-slskd sidecar reads or writes these tables.
export const soulseekFavorites = pgTable(
'soulseek_favorites',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
username: text('username').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// Also the lookup index — (user_id, username) covers "all of a user's favourites" by leftmost prefix.
(t) => [unique('uq_soulseek_favorites_user_username').on(t.userId, t.username)],
);
+7 -3
View File
@@ -6,9 +6,10 @@ import { getSlskdServerUrl } from './sidecar-server';
// the subpath + query + body to the officer-slskd sidecar, which OWNS the slskd contract and injects the
// slskd API key. The platform holds no slskd credentials.
//
// This is a catch-all with no routes of its own: the full /api/slskd/* contract (slskd's own API, e.g.
// /api/v0/searches, /api/v0/transfers) is documented at the top of the sidecar's fetch handler —
// src/servers/sidecar/slskd/index.ts.
// This is a catch-all with no routes of its own: the full /api/slskd/* contract is documented at the top
// of the sidecar's fetch handler (src/servers/sidecar/slskd/index.ts). That contract covers both slskd's
// own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned routes (/_officer/*,
// features slskd has no concept of). Both are opaque from here — this file never grows Soulseek logic.
export const slskdRouter = createRouter();
@@ -28,6 +29,9 @@ slskdRouter.all('/*', async (ctx) => {
if (contentType) headers['Content-Type'] = contentType;
const range = ctx.req.header('range');
if (range) headers['Range'] = range;
// Forward the authenticated user id so the sidecar can serve its own Officer-owned routes (/_officer/*,
// e.g. favourite peers) against Postgres. The sidecar binds loopback only, so this header is trusted.
headers['X-Officer-User'] = String(ctx.get('user').id);
const hasBody = method !== 'GET' && method !== 'HEAD';
+17
View File
@@ -1,6 +1,7 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream';
import { handleOfficerRoute } from './officer';
// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that
// registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd
@@ -14,6 +15,10 @@ import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream';
// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim.
// `GET /_health` is ours (probes slskd's /health), not part of the slskd contract. The server listens on
// a random loopback port, reported to the API on connect so it can route here.
//
// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept
// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them
// here rather than in the platform API is what lets the main server stay a pure proxy forever.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
//
// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at
@@ -55,6 +60,18 @@ const server = Bun.serve({
}
}
// Officer-owned routes — answered locally, never proxied (so they work even with slskd down).
if (url.pathname.startsWith('/_officer/')) {
try {
const res = await handleOfficerRoute(req, url);
if (res) return res;
return new Response('not found', { status: 404 });
} catch (err) {
console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
if (!base) return new Response('slskd upstream not configured', { status: 503 });
const target = `${base}${url.pathname}${url.search}`;
+59
View File
@@ -0,0 +1,59 @@
import { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from 'officerdb';
// Officer-owned Soulseek routes — everything slskd itself has no concept of. These are served HERE, by
// the sidecar, not forwarded upstream: the platform API stays a pure auth-and-forward proxy forever, and
// slskd stays unforked. They live under the `/_officer/` prefix, mirroring `/_health`, so they can never
// collide with slskd's own surface (all of which is under `/api/v0/`).
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// CONTRACT (reached from the browser as /api/slskd/_officer/…)
// GET /_officer/favorites → string[] favourited peer usernames, alphabetical
// POST /_officer/favorites { username } → { ok: true } add (idempotent)
// DELETE /_officer/favorites?username=<u> → { ok: true } remove (no-op if absent)
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// The authenticated user id arrives in X-Officer-User, injected by the platform proxy after auth. We
// bind loopback only, so it's trusted — same arrangement as the music sidecar's per-user state routes.
const userIdOf = (req: Request): number | null => {
const n = Number(req.headers.get('x-officer-user'));
return Number.isInteger(n) && n > 0 ? n : null;
};
// Soulseek usernames are opaque to us and may contain spaces, so the only shapes we reject are ones
// no nick can have: empty, absurdly long, or containing control characters.
const USERNAME_MAX = 255;
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
const cleanUsername = (v: unknown): string | null => {
if (typeof v !== 'string') return null;
const u = v.trim();
if (!u || u.length > USERNAME_MAX) return null;
return CONTROL_CHARS.test(u) ? null : u;
};
/** Handles a `/_officer/*` request, or returns null if the path isn't one of ours. */
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
const path = url.pathname;
if (path !== '/_officer/favorites') return null;
const userId = userIdOf(req);
if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
if (req.method === 'GET') return Response.json(await getSoulseekFavorites(userId));
if (req.method === 'POST') {
const body = (await req.json().catch(() => null)) as { username?: unknown } | null;
const username = cleanUsername(body?.username);
if (!username) return Response.json({ error: 'username required' }, { status: 400 });
await addSoulseekFavorite(userId, username);
return Response.json({ ok: true });
}
if (req.method === 'DELETE') {
const username = cleanUsername(url.searchParams.get('username'));
if (!username) return Response.json({ error: 'username required' }, { status: 400 });
await removeSoulseekFavorite(userId, username);
return Response.json({ ok: true });
}
return new Response('method not allowed', { status: 405 });
}
@@ -9,18 +9,52 @@ export const Card = ({ children }: { children: ReactNode }) => (
<div className="overflow-hidden rounded-xl border border-white/10 bg-zinc-950 shadow-sm">{children}</div>
);
type CardHeaderProps = { open: boolean; onToggle: () => void; title: ReactNode; meta?: ReactNode };
export const CardHeader = ({ open, onToggle, title, meta }: CardHeaderProps) => (
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-white/5"
>
// Supply exactly one of `title` (inert text — the whole row becomes the collapse toggle) or `titleMenu`
// (an interactive node such as the peer dropdown). A trigger can't live inside the toggle button, so with
// `titleMenu` the row splits: the chevron and the empty space after the node toggle, the node itself doesn't.
type CardHeaderProps = {
open: boolean;
onToggle: () => void;
title?: ReactNode;
meta?: ReactNode;
titleMenu?: ReactNode;
};
export const CardHeader = ({ open, onToggle, title, meta, titleMenu }: CardHeaderProps) => {
const chevron = (
<ChevronRight className={`h-4 w-4 shrink-0 text-zinc-500 transition-transform ${open ? 'rotate-90' : ''}`} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-zinc-100">{title}</span>
{meta && <div className="flex shrink-0 items-center gap-2 text-xs text-zinc-400">{meta}</div>}
</button>
);
);
const metaBlock = meta && <div className="flex shrink-0 items-center gap-2 text-xs text-zinc-400">{meta}</div>;
if (!titleMenu)
return (
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-white/5"
>
{chevron}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-zinc-100">{title}</span>
{metaBlock}
</button>
);
return (
<div className="flex w-full items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-white/5">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-label={open ? 'Collapse' : 'Expand'}
className="flex shrink-0 items-center"
>
{chevron}
</button>
{titleMenu}
<button type="button" onClick={onToggle} tabIndex={-1} aria-hidden className="h-6 min-w-0 flex-1" />
{metaBlock}
</div>
);
};
export const CardBody = ({ children }: { children: ReactNode }) => (
<div className="flex flex-col gap-1.5 border-t border-white/10 bg-black/30 p-2">{children}</div>
@@ -45,9 +79,7 @@ export const SubCardHeader = ({ icon, label, title, meta, action, open, onToggle
const inner = (
<>
{onToggle && (
<ChevronRight
className={`h-3 w-3 shrink-0 text-zinc-600 transition-transform ${open ? 'rotate-90' : ''}`}
/>
<ChevronRight className={`h-3 w-3 shrink-0 text-zinc-600 transition-transform ${open ? 'rotate-90' : ''}`} />
)}
{icon}
<span className="min-w-0 flex-1 truncate text-xs text-zinc-400" title={title}>
@@ -4,6 +4,7 @@ import { usePanelChannel } from 'hooks/usePanelChannel';
import { toast } from 'sonner';
import { ArrowLeft, Download, Lock, Loader2, Folder, Zap, Users, Search, X, RefreshCw } from 'lucide-react';
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards';
import { UserMenu } from './UserMenu';
import {
SLSKD_REFRESH_CHANNEL,
basename,
@@ -58,7 +59,10 @@ const filtersActive = (f: Filters) =>
const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => {
const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean);
const pos = terms.filter((t) => !t.startsWith('-'));
const neg = terms.filter((t) => t.startsWith('-')).map((t) => t.slice(1)).filter(Boolean);
const neg = terms
.filter((t) => t.startsWith('-'))
.map((t) => t.slice(1))
.filter(Boolean);
const fileOk = (file: ResultFile) => {
if (f.hideLocked && file.isLocked) return false;
if (f.exts.size && !f.exts.has(file.extension)) return false;
@@ -373,11 +377,17 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) =
);
return (
<Card>
<CardHeader open={open} onToggle={onToggle} title={user.username} meta={meta} />
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
{open && (
<CardBody>
{user.folders.map((folder) => (
<FolderBlock key={folder.path} username={user.username} folder={folder} queued={queued} onDownload={onDownload} />
<FolderBlock
key={folder.path}
username={user.username}
folder={folder}
queued={queued}
onDownload={onDownload}
/>
))}
{allFiles.length > 1 && (
<button
@@ -405,68 +415,68 @@ const FolderBlock = ({ username, folder, queued, onDownload }: FolderBlockProps)
const [open, setOpen] = useState(true);
const unlocked = folder.files.filter((f) => !f.isLocked).length;
return (
<SubCard>
<SubCardHeader
open={open}
onToggle={() => setOpen((v) => !v)}
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
label={folder.label}
title={folder.path}
meta={`${folder.files.length} · ${formatSize(folder.size)}`}
action={
folder.files.length > 1 ? (
<button
type="button"
onClick={() => onDownload(username, folder.files)}
title="Download folder"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100"
>
<Download className="h-3.5 w-3.5" />
</button>
) : null
}
/>
{open && (
<>
<RowList>
{folder.files.map((file) => {
const isQueued = queued.has(fileKey(username, file.filename));
const duration = formatDuration(file.length);
return (
<div key={file.filename} className="flex items-center gap-3 px-3 py-1.5 pl-8 text-sm text-zinc-200">
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate">
{file.isLocked && <Lock className="h-3 w-3 shrink-0 text-zinc-500" />}
<span className="truncate">{file.name}</span>
</span>
<div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-zinc-500">
{file.bitRate ? <span>{file.bitRate} kbps</span> : null}
{duration && <span>{duration}</span>}
<span className="w-16 text-right">{formatSize(file.size)}</span>
</div>
<SubCard>
<SubCardHeader
open={open}
onToggle={() => setOpen((v) => !v)}
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
label={folder.label}
title={folder.path}
meta={`${folder.files.length} · ${formatSize(folder.size)}`}
action={
folder.files.length > 1 ? (
<button
type="button"
onClick={() => onDownload(username, [file])}
disabled={file.isLocked || isQueued}
title={isQueued ? 'Queued' : 'Download'}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40"
onClick={() => onDownload(username, folder.files)}
title="Download folder"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100"
>
{isQueued ? <Loader2 className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}
<Download className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</RowList>
{unlocked > 1 && (
<button
type="button"
onClick={() => onDownload(username, folder.files)}
className="flex w-full items-center justify-center gap-1.5 border-t border-white/5 py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
>
<Download className="h-3.5 w-3.5" /> Download folder ({unlocked})
</button>
)}
</>
)}
</SubCard>
) : null
}
/>
{open && (
<>
<RowList>
{folder.files.map((file) => {
const isQueued = queued.has(fileKey(username, file.filename));
const duration = formatDuration(file.length);
return (
<div key={file.filename} className="flex items-center gap-3 px-3 py-1.5 pl-8 text-sm text-zinc-200">
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate">
{file.isLocked && <Lock className="h-3 w-3 shrink-0 text-zinc-500" />}
<span className="truncate">{file.name}</span>
</span>
<div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-zinc-500">
{file.bitRate ? <span>{file.bitRate} kbps</span> : null}
{duration && <span>{duration}</span>}
<span className="w-16 text-right">{formatSize(file.size)}</span>
</div>
<button
type="button"
onClick={() => onDownload(username, [file])}
disabled={file.isLocked || isQueued}
title={isQueued ? 'Queued' : 'Download'}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40"
>
{isQueued ? <Loader2 className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}
</button>
</div>
);
})}
</RowList>
{unlocked > 1 && (
<button
type="button"
onClick={() => onDownload(username, folder.files)}
className="flex w-full items-center justify-center gap-1.5 border-t border-white/5 py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
>
<Download className="h-3.5 w-3.5" /> Download folder ({unlocked})
</button>
)}
</>
)}
</SubCard>
);
};
@@ -5,6 +5,7 @@ import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { RefreshCw, X, Radio, Folder, RotateCcw, Ban, Trash2 } from 'lucide-react';
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards';
import { UserMenu } from './UserMenu';
import {
SLSKD_REFRESH_CHANNEL,
basename,
@@ -54,7 +55,8 @@ const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boole
.filter((f) => keep(f.phase));
if (files.length === 0) continue;
files.sort(
(a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)),
(a, b) =>
PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)),
);
dirs.push({
directory: dir.directory,
@@ -71,7 +73,10 @@ const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boole
out.push({ username: user.username, dirs, counts, total });
}
// Active users first, then alphabetical.
out.sort((a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username));
out.sort(
(a, b) =>
Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username),
);
return out;
};
@@ -96,7 +101,8 @@ export const SoulseekTransfers = () => {
// three actions target disjoint sets: retry → failed, cancel → in-flight, remove → done.
const totals = useMemo(() => {
const t: Record<TransferPhase, number> = { downloading: 0, queued: 0, failed: 0, done: 0 };
for (const u of data) for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++;
for (const u of data)
for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++;
return t;
}, [data]);
const inFlight = totals.downloading + totals.queued;
@@ -147,7 +153,10 @@ export const SoulseekTransfers = () => {
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)) })) })),
prev.map((u) => ({
...u,
directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })),
})),
);
// For an active transfer this cancels it; for a finished/failed one it clears it from the queue.
@@ -175,7 +184,9 @@ export const SoulseekTransfers = () => {
};
const clearAllCompleted = async () => {
const finished = completed.flatMap((u) => u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id }))));
const finished = completed.flatMap((u) =>
u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id }))),
);
if (finished.length === 0) return;
await Promise.allSettled(
finished.map((f) =>
@@ -264,10 +275,14 @@ export const SoulseekTransfers = () => {
<span className="truncate text-red-500">slskd unreachable: {statusError}</span>
) : (
<>
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span className="truncate">{server?.state ?? 'unknown'}</span>
{server?.address && <span className="truncate text-muted-foreground">· {server.address}</span>}
{app?.version?.current && <span className="shrink-0 text-muted-foreground">· v{app.version.current}</span>}
{app?.version?.current && (
<span className="shrink-0 text-muted-foreground">· v{app.version.current}</span>
)}
</>
)}
</div>
@@ -283,9 +298,19 @@ export const SoulseekTransfers = () => {
{(active.length > 0 || completed.length > 0) && (
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2">
<BulkButton icon={<RotateCcw className="h-3.5 w-3.5" />} label="Retry errored" count={totals.failed} onClick={retryErrored} />
<BulkButton
icon={<RotateCcw className="h-3.5 w-3.5" />}
label="Retry errored"
count={totals.failed}
onClick={retryErrored}
/>
<BulkButton icon={<Ban className="h-3.5 w-3.5" />} label="Cancel all" count={inFlight} onClick={cancelAll} />
<BulkButton icon={<Trash2 className="h-3.5 w-3.5" />} label="Remove completed" count={totals.done} onClick={clearAllCompleted} />
<BulkButton
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Remove completed"
count={totals.done}
onClick={clearAllCompleted}
/>
</div>
)}
@@ -380,7 +405,7 @@ const UserCard = ({ user, open, onToggle, onRemove, onPosition, onClearCompleted
));
return (
<Card>
<CardHeader open={open} onToggle={onToggle} title={user.username} meta={meta} />
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
{open && (
<CardBody>
{user.dirs.map((dir) => (
@@ -406,64 +431,64 @@ type FolderBlockProps = { dir: DlDir; onRemove: (row: Row) => void; onPosition:
const FolderBlock = ({ dir, onRemove, onPosition }: 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">
{row.phase === 'queued' ? (
<button
type="button"
onClick={() => onPosition(row)}
title={
row.placeInQueue
? `Place in queue: #${row.placeInQueue} · click to refresh`
: 'Queued · click to fetch place in queue'
}
className="inline-flex items-center gap-1 rounded text-amber-400/90 transition hover:text-amber-300"
>
<RefreshCw className="h-3 w-3" />
{row.placeInQueue ? `#${row.placeInQueue} in queue` : 'Queued'}
</button>
) : (
<span>{style.label}</span>
)}
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
{speed && <span>· {speed}</span>}
</div>
</div>
);
})}
</RowList>
)}
</SubCard>
<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">
{row.phase === 'queued' ? (
<button
type="button"
onClick={() => onPosition(row)}
title={
row.placeInQueue
? `Place in queue: #${row.placeInQueue} · click to refresh`
: 'Queued · click to fetch place in queue'
}
className="inline-flex items-center gap-1 rounded text-amber-400/90 transition hover:text-amber-300"
>
<RefreshCw className="h-3 w-3" />
{row.placeInQueue ? `#${row.placeInQueue} in queue` : 'Queued'}
</button>
) : (
<span>{style.label}</span>
)}
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
{speed && <span>· {speed}</span>}
</div>
</div>
);
})}
</RowList>
)}
</SubCard>
);
};
@@ -1,13 +1,25 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { toast } from 'sonner';
import { Users, Search, FolderOpen, CircleCheck, CircleSlash, Clock, Loader2 } from 'lucide-react';
import type { SlskdBrowseDirectory, SlskdUserInfo, SlskdUserStatus } from './shared';
import { Users, Search, FolderOpen, CircleCheck, CircleSlash, Clock, Loader2, Star, X } from 'lucide-react';
import { useSoulseekFavorites } from './useSoulseekFavorites';
import {
SOULSEEK_USER_CHANNEL,
type SlskdBrowseDirectory,
type SlskdUserInfo,
type SlskdUserStatus,
type SoulseekUserRequest,
} 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.
// header; GET /users/{u}/browse pulls the share tree (a flat directory list).
//
// This is also where the workspace's peer actions land: the username dropdown in search results and
// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up and
// auto-browse. Favourites — Officer's own data, since slskd has no such concept — get their own section
// at the top, which also serves as this panel's landing content before any lookup.
type Loaded = {
username: string;
@@ -33,35 +45,15 @@ export const SoulseekUsers = () => {
const [peer, setPeer] = useState<Loaded | null>(null);
const [dirs, setDirs] = useState<SlskdBrowseDirectory[] | null>(null);
const [browsing, setBrowsing] = useState(false);
const [request, setRequest] = usePanelChannel<SoulseekUserRequest | null>(SOULSEEK_USER_CHANNEL, null);
const { favorites, isFavorite, toggle } = useSoulseekFavorites();
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;
const browse = async (username: string) => {
setBrowsing(true);
try {
const tree = await client.get<SlskdBrowseDirectory[]>(`/slskd/api/v0/users/${encodeURIComponent(peer.username)}/browse`);
const tree = await client.get<SlskdBrowseDirectory[]>(
`/slskd/api/v0/users/${encodeURIComponent(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)}`);
@@ -70,6 +62,40 @@ export const SoulseekUsers = () => {
}
};
const lookup = async (name: string, autoBrowse = false) => {
const username = name.trim();
if (!username || loading) return;
setQuery(username);
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`),
]);
setLoading(false);
if (status.status === 'rejected' && info.status === 'rejected') {
toast.error(`Couldn't reach ${username} — they may be offline.`);
return;
}
setPeer({
username,
status: status.status === 'fulfilled' ? status.value : null,
info: info.status === 'fulfilled' ? info.value : null,
});
if (autoBrowse) browse(username);
};
// Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and
// back doesn't re-run the (expensive) browse.
useEffect(() => {
if (!request) return;
setRequest(null);
lookup(request.username, request.browse);
// lookup is recreated each render; the effect deliberately runs only when a request arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request]);
const pres = presenceStyle(peer?.status?.presence);
const PresIcon = pres.icon;
@@ -80,7 +106,7 @@ export const SoulseekUsers = () => {
<form
onSubmit={(ev) => {
ev.preventDefault();
lookup();
lookup(query);
}}
className="flex flex-1 items-center gap-2"
>
@@ -102,88 +128,151 @@ export const SoulseekUsers = () => {
</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 className="mx-auto flex max-w-2xl flex-col gap-4">
{/* Favorites — Officer's own data (slskd has no such concept). Always present, so the section
doubles as the panel's landing content before any lookup. */}
<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">
<Star className="h-4 w-4 fill-amber-400 text-amber-400" />
<span className="text-sm font-medium text-zinc-100">Favorites</span>
{favorites.length > 0 && <span className="text-xs text-zinc-500">· {favorites.length}</span>}
</div>
{favorites.length === 0 ? (
<p className="px-4 py-3 text-sm text-zinc-500">
No favorites yet add one from a peer's profile below, or from the username menu in search results and
downloads.
</p>
) : (
<div className="max-h-64 divide-y divide-white/5 overflow-y-auto">
{favorites.map((u) => {
const current = peer?.username === u;
return (
<div
key={u}
className={`flex items-center gap-2 px-4 py-2 text-sm transition-colors ${
current ? 'bg-primary/5' : 'hover:bg-white/[0.02]'
}`}
>
<button
type="button"
onClick={() => lookup(u)}
title={`Look up ${u}`}
className={`min-w-0 flex-1 truncate text-left transition ${
current ? 'font-medium text-primary' : 'text-zinc-200 hover:text-zinc-100'
}`}
>
{u}
</button>
<button
type="button"
onClick={() => toggle(u)}
title={`Remove ${u} from favorites`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-500 transition hover:bg-white/10 hover:text-zinc-200"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
)}
</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 && (
{!peer ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 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>
) : (
<>
{/* 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>
<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"
onClick={() => toggle(peer.username)}
title={isFavorite(peer.username) ? 'Remove from favorites' : 'Add to favorites'}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-white/5 hover:text-zinc-200"
>
{browsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderOpen className="h-3.5 w-3.5" />}
{browsing ? 'Browsing…' : 'Browse shares'}
<Star className={`h-4 w-4 ${isFavorite(peer.username) ? 'fill-amber-400 text-amber-400' : ''}`} />
</button>
</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>
{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>
)}
{/* 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(peer.username)}
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>
);
@@ -0,0 +1,68 @@
import { usePanelChannel } from 'hooks/usePanelChannel';
import { toast } from 'sonner';
import { FolderOpen, Star, StarOff } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { useSoulseekFavorites } from './useSoulseekFavorites';
import {
SOULSEEK_SECTION_CHANNEL,
SOULSEEK_USER_CHANNEL,
type SoulseekSectionId,
type SoulseekUserRequest,
} from './shared';
// The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace
// and act on that peer. "Browse user files" hands the username to the Users section over a panel channel
// (which owns the lookup + browse); favouriting goes to the sidecar's Officer-owned favourites route.
type UserMenuProps = { username: string };
export const UserMenu = ({ username }: UserMenuProps) => {
const [, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
const [, setRequest] = usePanelChannel<SoulseekUserRequest | null>(SOULSEEK_USER_CHANNEL, null);
const { isFavorite, toggle } = useSoulseekFavorites();
const favorited = isFavorite(username);
const browse = () => {
setRequest({ username, browse: true, nonce: Date.now() });
setSection('users');
};
const toggleFavorite = () => {
toggle(username);
toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={`Actions for ${username}`}
className="min-w-0 max-w-full truncate rounded text-sm font-medium text-zinc-100 underline decoration-dotted decoration-zinc-600 underline-offset-4 outline-none transition hover:decoration-zinc-300 focus-visible:ring-2 focus-visible:ring-primary/40"
>
{username}
{favorited && <Star className="ml-1.5 inline h-3 w-3 fill-amber-400 align-baseline text-amber-400" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="truncate">{username}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={browse}>
<FolderOpen className="mr-2 h-4 w-4" />
Browse user files
</DropdownMenuItem>
<DropdownMenuItem onSelect={toggleFavorite}>
{favorited ? <StarOff className="mr-2 h-4 w-4" /> : <Star className="mr-2 h-4 w-4" />}
{favorited ? 'Remove from favorites' : 'Add user to favorites'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -180,7 +180,13 @@ export type SlskdServerState = { address?: string; state?: string; isConnected?:
// Chat rooms. GET /rooms/joined/{name} inlines users + messages; GET /rooms/available lists the rest.
export type SlskdRoomUser = { username: string };
export type SlskdRoomMessage = { timestamp: string; username: string; message: string; roomName?: string; self?: boolean };
export type SlskdRoomMessage = {
timestamp: string;
username: string;
message: string;
roomName?: string;
self?: boolean;
};
export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] };
export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
@@ -207,6 +213,12 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
{ id: 'system', label: 'System' },
];
// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users
// section. Nonce-stamped and consumed-once (the Users panel clears it) so revisiting the section doesn't
// re-run an expensive browse, while two requests for the SAME username still each trigger a fresh lookup.
export const SOULSEEK_USER_CHANNEL = 'soulseek:user';
export type SoulseekUserRequest = { username: string; browse: boolean; nonce: number };
// A past search, as listed by GET /searches (no responses inlined).
export type SlskdSearchSummary = {
id: string;
@@ -277,7 +289,13 @@ export const transferPhase = (state: string): TransferPhase => {
const s = state.toLowerCase();
if (s.includes('inprogress') || s.includes('initializing')) return 'downloading';
if (s.includes('succeeded')) return 'done';
if (s.includes('errored') || s.includes('cancelled') || s.includes('rejected') || s.includes('timedout') || s.includes('aborted'))
if (
s.includes('errored') ||
s.includes('cancelled') ||
s.includes('rejected') ||
s.includes('timedout') ||
s.includes('aborted')
)
return 'failed';
return 'queued'; // Requested / Queued / anything not yet resolved
};
@@ -0,0 +1,47 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
const KEY = ['soulseek', 'favorites'] as const;
const EMPTY: string[] = [];
/**
* The owner's favourited Soulseek peers, backed by the officer-slskd sidecar's own `/_officer/favorites`
* route (slskd has no favourites API — Officer owns that data). One shared react-query cache, so every
* star in the workspace reflects the same state; toggling is optimistic and rolls back on failure.
*/
export function useSoulseekFavorites() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const { data } = useQuery({
queryKey: KEY,
queryFn: () => get<string[]>('/slskd/_officer/favorites'),
staleTime: 60_000,
});
const mutation = useMutation({
mutationFn: ({ on, username }: { on: boolean; username: string }) =>
on
? post('/slskd/_officer/favorites', { username })
: del(`/slskd/_officer/favorites?username=${encodeURIComponent(username)}`),
onMutate: async ({ on, username }) => {
await qc.cancelQueries({ queryKey: KEY });
const prev = qc.getQueryData<string[]>(KEY) ?? EMPTY;
const next = on ? [...prev, username].sort((a, b) => a.localeCompare(b)) : prev.filter((u) => u !== username);
qc.setQueryData<string[]>(KEY, next);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(KEY, ctx.prev);
},
});
const favorites = data ?? EMPTY;
const isFavorite = (username: string) => favorites.includes(username);
const toggle = (username: string) => {
if (!username) return;
mutation.mutate({ on: !isFavorite(username), username });
};
return { favorites, isFavorite, toggle };
}