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:
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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user