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.
128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { ChevronRight } from 'lucide-react';
|
|
|
|
// Shared visual language for the /soulseek grouped views (search results + downloads). Each top-level
|
|
// grouping (a peer/user) is an almost-black Card; each folder within it is a lifted SubCard. Kept here
|
|
// so search and downloads stay pixel-identical — change the look in one place.
|
|
|
|
export const Card = ({ children }: { children: ReactNode }) => (
|
|
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">{children}</div>
|
|
);
|
|
|
|
// 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-muted-foreground transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
);
|
|
const metaBlock = meta && <div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">{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-muted"
|
|
>
|
|
{chevron}
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{title}</span>
|
|
{metaBlock}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div className="flex w-full items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-muted">
|
|
<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-border bg-muted/30 p-2">{children}</div>
|
|
);
|
|
|
|
export const SubCard = ({ children }: { children: ReactNode }) => (
|
|
<div className="overflow-hidden rounded-lg border border-border/50 bg-muted/20">{children}</div>
|
|
);
|
|
|
|
// A folder header. When `onToggle` is supplied it becomes a collapse control (chevron + clickable
|
|
// label); `action` stays a sibling so it never nests inside the toggle button.
|
|
type SubCardHeaderProps = {
|
|
icon?: ReactNode;
|
|
label: ReactNode;
|
|
title?: string;
|
|
meta?: ReactNode;
|
|
action?: ReactNode;
|
|
open?: boolean;
|
|
onToggle?: () => void;
|
|
};
|
|
export const SubCardHeader = ({ icon, label, title, meta, action, open, onToggle }: SubCardHeaderProps) => {
|
|
const inner = (
|
|
<>
|
|
{onToggle && (
|
|
<ChevronRight className={`h-3 w-3 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
)}
|
|
{icon}
|
|
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground" title={title}>
|
|
{label}
|
|
</span>
|
|
{meta && <span className="shrink-0 text-xs text-muted-foreground">{meta}</span>}
|
|
</>
|
|
);
|
|
return (
|
|
<div className="flex items-center gap-2 bg-muted/30 px-2.5 py-1.5">
|
|
{onToggle ? (
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="flex min-w-0 flex-1 items-center gap-2 text-left transition-colors hover:text-foreground"
|
|
>
|
|
{inner}
|
|
</button>
|
|
) : (
|
|
inner
|
|
)}
|
|
{action}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// File rows inside a SubCard, hairline-divided.
|
|
export const RowList = ({ children }: { children: ReactNode }) => (
|
|
<div className="divide-y divide-border/50">{children}</div>
|
|
);
|
|
|
|
type PillProps = { active: boolean; onClick: () => void; children: ReactNode };
|
|
export const Pill = ({ active, onClick, children }: PillProps) => (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition ${
|
|
active
|
|
? 'border-primary/40 bg-primary/10 text-primary'
|
|
: 'border-border bg-muted/20 text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|