Files
soulseek/web/SoulseekSystem.tsx
officer c11f45c033 soulseek, extracted from the platform as a plugin
Seventh plugin out, and the first that was already dead. Soulseek had been
switched off since 2026-08-13: hono.ts had the router import and its mount
commented out, so createSidecarProxy never ran, registerProxiedPrefix was never
called, the slskd:server announcement fired into a void, and officer-slskd was
not in the ecosystem at all. The permission entry had been reduced to api: [],
which is why assertPermissionTotality was satisfied and the server booted
clean. The UI rendered perfectly and every request 404'd.

So this is a resurrection as well as a move.

The mount is now /api/soulseek, derived via mountPrefix from the appName — all
47 frontend call sites repointed from /slskd/. The credential key in
service_connections stays 'slskd': it names the daemon, not the route. Those
two strings differ on purpose and conflating them reads the wrong row.

The connection stays in core service_connections, as transmission does, so
'slskd' remains in a platform union this plugin cannot extend. Recorded in
PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and
the two halves of the cached browse tree, which slskd has no concept of. Those
three tables had been dropped from the drizzle barrel, so bun db:push would
have deleted them on any server holding data.

313 palette classes converted to the semantic tone set, including a shared
PHASE_TONE the three transfer views previously each copied.
2026-08-19 02:55:02 +00:00

146 lines
5.8 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
import { Server, Radio, Plug, PlugZap, RefreshCw, Package, Loader2 } from 'lucide-react';
import type { SlskdApplication, SlskdServerState } from './shared';
// System panel — connection health and controls for the slskd daemon. GET /application carries the
// server block (state/address/username) and version info; PUT /server connects to Soulseek, DELETE
// /server disconnects. Basic version: a connection card with connect/disconnect, and a version card.
const POLL_MS = 3000;
export const SoulseekSystem = () => {
const client = useClient();
const [app, setApp] = useState<SlskdApplication | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
setApp(await client.get<SlskdApplication>('/soulseek/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('/soulseek/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('/soulseek/api/v0/server');
toast.success('Disconnected from Soulseek.');
await refresh();
} catch (err) {
toast.error(`Disconnect failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setBusy(false);
}
};
const version = app?.version;
return (
<div className="h-full overflow-y-auto p-4">
<div className="mx-auto flex max-w-2xl flex-col gap-4">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">System</span>
<button
type="button"
onClick={refresh}
title="Refresh"
className="ml-auto rounded p-1 text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
{/* Connection */}
<div className="rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Radio className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-success' : 'bg-destructive'}`} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{server?.state ?? 'Unknown'}</div>
{server?.address && <div className="truncate text-xs text-muted-foreground">{server.address}</div>}
</div>
{server?.username && <span className="shrink-0 text-xs text-muted-foreground">{server.username}</span>}
</div>
<div className="flex items-center gap-2 p-3">
<button
type="button"
onClick={connect}
disabled={busy || connected}
className="flex items-center gap-1.5 rounded-lg border border-border bg-muted/40 px-3 py-1.5 text-sm text-foreground transition hover:bg-muted disabled:opacity-40"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PlugZap className="h-3.5 w-3.5" />}
Connect
</button>
<button
type="button"
onClick={disconnect}
disabled={busy || !connected}
className="flex items-center gap-1.5 rounded-lg border border-border bg-muted/40 px-3 py-1.5 text-sm text-foreground transition hover:bg-muted disabled:opacity-40"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plug className="h-3.5 w-3.5" />}
Disconnect
</button>
</div>
</div>
{/* Version */}
<div className="rounded-xl border border-border bg-card">
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">Version</span>
</div>
<div className="grid grid-cols-2 gap-2 p-3">
<Field label="Current" value={version?.current ?? '—'} />
<Field label="Latest" value={version?.latest ?? '—'} />
<div className="col-span-2">
{version?.isUpdateAvailable ? (
<span className="inline-flex items-center gap-1.5 rounded-md bg-warning/15 px-2 py-1 text-xs text-warning">
<Package className="h-3.5 w-3.5" /> Update available
</span>
) : (
<span className="text-xs text-muted-foreground">Up to date</span>
)}
</div>
</div>
</div>
</div>
</div>
);
};
type FieldProps = { label: string; value: string };
const Field = ({ label, value }: FieldProps) => (
<div className="flex flex-col gap-1 rounded-lg border border-border/50 bg-muted/20 px-3 py-2">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="truncate text-sm font-medium tabular-nums text-foreground">{value}</span>
</div>
);