soulseek: shared slskd types, helpers, and card primitives

- add types for transfers, private conversations, users, rooms, and server state
- add formatClock / folderLabel / formatDuration helpers and the groupResponses shaper
- move the per-panel zoom key into the persisted screens/ namespace
- extract shared dark-theme Card / SubCard / RowList primitives used by the panels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 21:27:07 +00:00
co-authored by Claude Opus 4.8
parent e5950b6493
commit 520944c6ca
3 changed files with 235 additions and 0 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,95 @@
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-white/10 bg-zinc-950 shadow-sm">{children}</div>
);
type CardHeaderProps = { open: boolean; onToggle: () => void; title: ReactNode; meta?: ReactNode };
export const CardHeader = ({ open, onToggle, title, meta }: CardHeaderProps) => (
<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-white/5"
>
<ChevronRight className={`h-4 w-4 shrink-0 text-zinc-500 transition-transform ${open ? 'rotate-90' : ''}`} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-zinc-100">{title}</span>
{meta && <div className="flex shrink-0 items-center gap-2 text-xs text-zinc-400">{meta}</div>}
</button>
);
export const CardBody = ({ children }: { children: ReactNode }) => (
<div className="flex flex-col gap-1.5 border-t border-white/10 bg-black/30 p-2">{children}</div>
);
export const SubCard = ({ children }: { children: ReactNode }) => (
<div className="overflow-hidden rounded-lg border border-white/5 bg-white/[0.02]">{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-zinc-600 transition-transform ${open ? 'rotate-90' : ''}`}
/>
)}
{icon}
<span className="min-w-0 flex-1 truncate text-xs text-zinc-400" title={title}>
{label}
</span>
{meta && <span className="shrink-0 text-xs text-zinc-500">{meta}</span>}
</>
);
return (
<div className="flex items-center gap-2 bg-white/[0.03] 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-zinc-200"
>
{inner}
</button>
) : (
inner
)}
{action}
</div>
);
};
// File rows inside a SubCard, hairline-divided.
export const RowList = ({ children }: { children: ReactNode }) => (
<div className="divide-y divide-white/5">{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-white/10 bg-white/[0.02] text-zinc-400 hover:bg-white/5 hover:text-zinc-100'
}`}
>
{children}
</button>
);
@@ -5,6 +5,15 @@
// panel refetches immediately instead of waiting for its next poll tick. // panel refetches immediately instead of waiting for its next poll tick.
export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh'; export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh';
// Per-panel content zoom for the soulseek-view (right) panel. The panel header (+/- buttons) writes the
// factor here; SoulseekView reads it and scales its content. Keyed by panelId so each panel instance
// zooms independently. Stored under the `screens/` namespace via useDashboardState — the same persisted
// dashboards store that holds the layout config — so the zoom level survives reloads.
export const soulseekZoomKey = (panelId: string) => `screens/soulseek-zoom/${panelId}`;
export const SOULSEEK_ZOOM_MIN = 0.7;
export const SOULSEEK_ZOOM_MAX = 1.6;
export const SOULSEEK_ZOOM_STEP = 0.1;
export type SlskdApplication = { export type SlskdApplication = {
version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean }; version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean };
server?: { state?: string; address?: string; isConnected?: boolean }; server?: { state?: string; address?: string; isConnected?: boolean };
@@ -49,6 +58,73 @@ export type ResultRow = {
uploadSpeed: number; uploadSpeed: number;
}; };
// slskd presents results grouped by responder (user) → folder → files. We mirror that shape.
export type ResultFile = {
filename: string; // full remote path (slskd needs this verbatim to enqueue)
name: string; // basename for display
size: number;
extension: string;
bitRate?: number;
length?: number;
isLocked: boolean;
};
export type ResultFolder = { path: string; label: string; files: ResultFile[]; size: number };
export type ResultUser = {
username: string;
hasFreeUploadSlot: boolean;
queueLength: number;
uploadSpeed: number;
fileCount: number;
folders: ResultFolder[];
};
// Group a search's responses into slskd's user → folder → file layout, best peers first.
export const groupResponses = (responses: SlskdResponse[]): ResultUser[] => {
const users: ResultUser[] = [];
for (const resp of responses) {
const byFolder = new Map<string, ResultFile[]>();
for (const f of resp.files ?? []) {
const parts = f.filename.split(/[\\/]/);
const name = parts.pop() ?? f.filename;
const path = parts.join('\\') || '\\';
const file: ResultFile = {
filename: f.filename,
name,
size: f.size,
extension: extOf(f),
bitRate: f.bitRate,
length: f.length,
isLocked: !!f.isLocked,
};
const arr = byFolder.get(path);
if (arr) arr.push(file);
else byFolder.set(path, [file]);
}
const folders: ResultFolder[] = [...byFolder.entries()]
.map(([path, files]) => {
files.sort((a, b) => a.name.localeCompare(b.name));
return { path, label: folderLabel(path), files, size: files.reduce((n, f) => n + f.size, 0) };
})
.sort((a, b) => a.path.localeCompare(b.path));
users.push({
username: resp.username,
hasFreeUploadSlot: resp.hasFreeUploadSlot,
queueLength: resp.queueLength,
uploadSpeed: resp.uploadSpeed,
fileCount: resp.fileCount ?? resp.files?.length ?? 0,
folders,
});
}
// Best peer first: free slot, then faster upload, then more files.
users.sort(
(a, b) =>
Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) ||
b.uploadSpeed - a.uploadSpeed ||
b.fileCount - a.fileCount,
);
return users;
};
// A download transfer. GET /transfers/downloads nests these as user → directories → files. // A download transfer. GET /transfers/downloads nests these as user → directories → files.
export type SlskdTransfer = { export type SlskdTransfer = {
id: string; id: string;
@@ -64,6 +140,49 @@ export type SlskdTransfer = {
}; };
export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] }; export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] };
export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] }; export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] };
// Uploads (GET /transfers/uploads) use the same user → directories → files shape as downloads.
export type SlskdTransferUser = SlskdDownloadUser;
// Private conversations. GET /conversations lists them (one per peer); GET /conversations/{user}/messages
// returns that thread. A message's direction ('Out' = sent by us) drives self-alignment in the UI.
export type SlskdPrivateMessage = {
id: number;
timestamp: string;
username: string;
direction: string; // 'In' | 'Out'
message: string;
isAcknowledged?: boolean;
};
export type SlskdConversation = {
username: string;
isActive?: boolean;
unAcknowledgedMessageCount?: number;
hasUnAcknowledgedMessages?: boolean;
messages?: SlskdPrivateMessage[];
};
// A peer, as seen through the Users section. GET /users/{u}/info + /status; /browse returns their shares.
export type SlskdUserInfo = {
description?: string;
hasFreeUploadSlot?: boolean;
hasPicture?: boolean;
queueLength?: number;
uploadSlots?: number;
};
export type SlskdUserStatus = {
isPrivileged?: boolean;
presence?: string; // 'Offline' | 'Away' | 'Online'
};
export type SlskdBrowseDirectory = { name: string; fileCount: number; files?: SlskdFile[] };
// Server connection state (GET /server, and the server block of GET /application).
export type SlskdServerState = { address?: string; state?: string; isConnected?: boolean; username?: string };
// Chat rooms. GET /rooms/joined/{name} inlines users + messages; GET /rooms/available lists the rest.
export type SlskdRoomUser = { username: string };
export type SlskdRoomMessage = { timestamp: string; username: string; message: string; roomName?: string; self?: boolean };
export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] };
export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
// The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads // The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads
// it and renders the matching UI. Mirrors slskd's own top-menu sections. // it and renders the matching UI. Mirrors slskd's own top-menu sections.
@@ -103,6 +222,12 @@ export type SlskdSearchSummary = {
export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path; export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path;
// Compact folder label for grouped views — the last two path segments (slskd paths use backslashes).
export const folderLabel = (path: string) => {
const segs = path.split(/[\\/]/).filter(Boolean);
return segs.slice(-2).join(' / ') || path;
};
// Compact "time ago" for search-history rows. // Compact "time ago" for search-history rows.
export const formatWhen = (iso?: string): string => { export const formatWhen = (iso?: string): string => {
if (!iso) return ''; if (!iso) return '';
@@ -118,6 +243,13 @@ export const formatWhen = (iso?: string): string => {
return d < 7 ? `${d}d ago` : new Date(iso).toLocaleDateString(); return d < 7 ? `${d}d ago` : new Date(iso).toLocaleDateString();
}; };
// Wall-clock HH:MM for chat message timestamps.
export const formatClock = (iso?: string): string => {
if (!iso) return '';
const d = new Date(iso);
return Number.isFinite(d.getTime()) ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
};
export const formatSize = (bytes: number) => { export const formatSize = (bytes: number) => {
if (!bytes) return '0 B'; if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const units = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -127,6 +259,14 @@ export const formatSize = (bytes: number) => {
export const formatSpeed = (bytesPerSec: number) => (bytesPerSec > 0 ? `${formatSize(bytesPerSec)}/s` : ''); export const formatSpeed = (bytesPerSec: number) => (bytesPerSec > 0 ? `${formatSize(bytesPerSec)}/s` : '');
// Track length (seconds) → m:ss, as slskd shows it.
export const formatDuration = (sec?: number): string => {
if (!sec || sec < 0) return '';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
};
export const extOf = (file: SlskdFile) => export const extOf = (file: SlskdFile) =>
(file.extension?.trim() || basename(file.filename).split('.').pop() || '').toLowerCase(); (file.extension?.trim() || basename(file.filename).split('.').pop() || '').toLowerCase();