diff --git a/public/slskd.png b/public/slskd.png index 0b82062c..980e0078 100644 Binary files a/public/slskd.png and b/public/slskd.png differ diff --git a/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx b/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx new file mode 100644 index 00000000..e78708e8 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx @@ -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 }) => ( +
{children}
+); + +type CardHeaderProps = { open: boolean; onToggle: () => void; title: ReactNode; meta?: ReactNode }; +export const CardHeader = ({ open, onToggle, title, meta }: CardHeaderProps) => ( + +); + +export const CardBody = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +export const SubCard = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +// 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 && ( + + )} + {icon} + + {label} + + {meta && {meta}} + + ); + return ( +
+ {onToggle ? ( + + ) : ( + inner + )} + {action} +
+ ); +}; + +// File rows inside a SubCard, hairline-divided. +export const RowList = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +type PillProps = { active: boolean; onClick: () => void; children: ReactNode }; +export const Pill = ({ active, onClick, children }: PillProps) => ( + +); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index fc48cf87..017925df 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -5,6 +5,15 @@ // panel refetches immediately instead of waiting for its next poll tick. 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 = { version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean }; server?: { state?: string; address?: string; isConnected?: boolean }; @@ -49,6 +58,73 @@ export type ResultRow = { 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(); + 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. export type SlskdTransfer = { id: string; @@ -64,6 +140,49 @@ export type SlskdTransfer = { }; export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] }; 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 // 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; +// 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. export const formatWhen = (iso?: string): string => { if (!iso) return ''; @@ -118,6 +243,13 @@ export const formatWhen = (iso?: string): string => { 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) => { if (!bytes) return '0 B'; 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` : ''); +// 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) => (file.extension?.trim() || basename(file.filename).split('.').pop() || '').toLowerCase();