Files
soulseek/web/SoulseekUsers.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

236 lines
11 KiB
TypeScript

import { toneText } from '@/components/Data/tone';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react';
import { useSoulseekFavorites } from './useSoulseekFavorites';
import { useSoulseekUser } from './useSoulseekUser';
import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse';
import { SharesBrowser, BrowseStateChip } from './SharesBrowser';
import { USER_PARAM } from './shared';
// Users panel — look up a peer: their presence (online/away/offline) and profile info (description,
// upload slots, queue), both cheap live calls (GET /users/{u}/status + /info). Their shared folders come
// from Officer's own cache instead, via <SharesBrowser> — see that file for why it isn't browsed live.
//
// This is also where the workspace's peer actions land: the username dropdown in search results and
// downloads links here with `?user=<name>`, which is the whole mechanism — a peer profile is now
// linkable and survives a reload.
// 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.
const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof CircleCheck } => {
switch ((p ?? '').toLowerCase()) {
case 'online':
return { label: 'Online', dot: toneText.success, icon: CircleCheck };
case 'away':
return { label: 'Away', dot: toneText.warning, icon: Clock };
default:
return { label: 'Offline', dot: toneText.neutral, icon: CircleSlash };
}
};
export const SoulseekUsers = () => {
const [params, setParams] = useSearchParams();
const selected = params.get(USER_PARAM)?.trim() ?? '';
const [query, setQuery] = useState(selected);
const { favorites, isFavorite, toggle } = useSoulseekFavorites();
const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots();
// Selecting a peer is now just naming one — the query owns the fetching, and its cache owns the answer.
const user = useSoulseekUser(selected);
const peer = user.data ?? null;
// Navigating, not setting state: the peer is `?user=`. Shares are still NOT fetched automatically —
// that's a multi-minute server-side job, so it stays an explicit click in the shares card.
const select = (name: string) => {
const username = name.trim();
if (!username) return;
setParams((prev) => {
const next = new URLSearchParams(prev);
next.set(USER_PARAM, username);
return next;
});
};
// Arriving by link (or Back) should show the name in the box that produced it.
useEffect(() => {
if (selected) setQuery(selected);
}, [selected]);
useEffect(() => {
if (user.error) toast.error(user.error.message);
}, [user.error]);
const pres = presenceStyle(peer?.status?.presence);
const PresIcon = pres.icon;
// Only a peer we have nothing cached for is worth showing a pending state for.
const pending = !!selected && user.isPending;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center gap-2 border-b p-3">
<Users className="h-4 w-4 shrink-0 text-muted-foreground" />
<form
onSubmit={(ev) => {
ev.preventDefault();
select(query);
}}
className="flex flex-1 items-center gap-2"
>
<input
value={query}
onChange={(ev) => 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"
/>
<button
type="submit"
disabled={!query.trim() || user.isFetching}
className="flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
>
{user.isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Look up
</button>
</form>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<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-border bg-card">
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Star className="h-4 w-4 fill-warning text-warning" />
<span className="text-sm font-medium text-foreground">Favorites</span>
{favorites.length > 0 && <span className="text-xs text-muted-foreground">· {favorites.length}</span>}
</div>
{favorites.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
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-border/50 overflow-y-auto">
{favorites.map((u) => {
const current = selected === 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-muted/20'
}`}
>
<button
type="button"
onClick={() => select(u)}
title={`Look up ${u}`}
className={`min-w-0 flex-1 truncate text-left transition ${
current ? 'font-medium text-primary' : 'text-foreground'
}`}
>
{u}
</button>
<BrowseStateChip snapshot={snapshotOf(u)} />
{!snapshotOf(u) && (
<button
type="button"
onClick={() => fetchShares(u)}
title={`Fetch ${u}'s shares in the background`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<Download className="h-3.5 w-3.5" />
</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-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
)}
</div>
{!selected ? (
<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. Keyed off the selected name, not the loaded peer, so a lookup already in cache
paints instantly and a fresh one fills in around a card that's already on screen. */}
<div className="rounded-xl border border-border bg-card 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-muted/40 text-lg font-semibold text-foreground">
{selected.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{selected}</div>
{pending ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Checking…
</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-info">· privileged</span>}
</div>
)}
</div>
<button
type="button"
onClick={() => toggle(selected)}
title={isFavorite(selected) ? 'Remove from favorites' : 'Add to favorites'}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<Star className={`h-4 w-4 ${isFavorite(selected) ? 'fill-warning text-warning' : ''}`} />
</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 ? toneText.success : toneText.neutral}
/>
</div>
{peer.info.description && (
<p className="mt-3 whitespace-pre-wrap border-t border-border/50 pt-3 text-sm text-foreground">
{peer.info.description}
</p>
)}
</>
)}
</div>
<SharesBrowser username={selected} />
</>
)}
</div>
</div>
</div>
);
};
type StatProps = { label: string; value: string | number; accent?: string };
const Stat = ({ label, value, accent = 'text-foreground' }: StatProps) => (
<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={`text-lg font-semibold tabular-nums ${accent}`}>{value}</span>
</div>
);