Files
soulseek/web/SoulseekConnection.tsx
pastilhasandClaude Opus 5 a9b83228be wrap the connection form in a <form> so enter submits
the url and api key fields sat in a div, so pressing enter did nothing
and the save button was the only way in. same fix as gitea's connection
screen: submit is now onSubmit with preventDefault, and disconnect is
marked type="button" so it can't submit the form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:41:58 +00:00

177 lines
6.4 KiB
TypeScript

import type { ServiceConnection } from 'officerdev';
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
serviceErrorMessage,
useServiceConnection,
useServiceConnectionActions,
useServiceHealth,
} from 'officerdev';
// Connecting a slskd daemon to Officer, from the app.
//
// Both the setup wizard and the permanent settings page: SoulseekView renders it in place of whatever
// section the nav asked for while nothing is connected, and the Connection section renders it for good.
// One component, so re-pointing at a different daemon later goes through exactly the code path that
// stored the first one.
//
// The API key is write-only — the GET it reads has no field that could carry one back, so the input is
// always blank and an empty input means "keep the stored key".
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** slskd's own default HTTP port. Officer dials it from the server, not from this browser. */
const DEFAULT_URL = 'http://localhost:5030';
const URL_HINT =
"The daemon's base URL. Officer reaches it from the server, not from this browser — so localhost here " +
'means the machine Officer runs on.';
const KEY_HINT =
'An API key from slskd.yml (web.authentication.api_keys). Officer sends it as X-API-Key on every call.';
type SaveInput = Record<string, unknown> & { url: string };
export const SoulseekConnection = () => {
const { data, isLoading } = useServiceConnection('soulseek');
const { data: health } = useServiceHealth('soulseek');
const { save, forget } = useServiceConnectionActions<SaveInput>('soulseek');
const connection = data?.connection ?? null;
const [url, setUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState('');
// Seed from the stored row once it arrives. Keyed on its id so a save (same id) doesn't stomp what the
// owner is still typing, while forgetting and re-adding does reset the form.
useEffect(() => {
setUrl(connection?.url ?? '');
setApiKey('');
}, [connection?.id]);
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
setError('');
try {
await save.mutateAsync({ url: url.trim(), apiKey: apiKey.trim() });
setApiKey('');
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const remove = async () => {
setError('');
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading…
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<header className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-busy/15 text-busy-ink">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">slskd daemon</h2>
<p className={HINT}>
{connection
? 'Where Officer talks to slskd. Saving re-checks the daemon before storing anything.'
: 'Point Officer at your slskd daemon. It needs the URL and one API key.'}
</p>
</div>
</header>
{connection && <StatusRow connection={connection} health={health} />}
<form onSubmit={submit} className="flex flex-col gap-4 rounded-xl border p-4">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">Server URL</span>
<Input
value={url}
onChange={(ev) => setUrl(ev.target.value)}
placeholder={DEFAULT_URL}
autoFocus={!connection}
autoComplete="off"
spellCheck={false}
/>
<span className={HINT}>{URL_HINT}</span>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">API key</span>
<Input
value={apiKey}
onChange={(ev) => setApiKey(ev.target.value)}
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : 'slskd API key'}
type="password"
autoComplete="off"
spellCheck={false}
/>
<span className={HINT}>{KEY_HINT}</span>
</label>
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button
type="submit"
size="sm"
disabled={!url.trim() || (!apiKey.trim() && !connection?.hasSecret) || save.isPending}
>
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{connection ? 'Save' : 'Connect'}
</Button>
{connection && (
<Button type="button" size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Disconnect
</Button>
)}
</div>
</form>
</div>
</div>
);
};
type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined };
const StatusRow = ({ connection, health }: StatusRowProps) => (
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
{health?.ok ? (
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-success" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-warning" />
)}
<div className="min-w-0">
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
<div className="truncate text-muted-foreground">
{connection.url}
{connection.version ? ` · slskd ${connection.version}` : ''}
</div>
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
</div>
</div>
);