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.
236 lines
11 KiB
TypeScript
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>
|
|
);
|