From 520944c6ca9a75bf40cad46bfc201b18ebe8bbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 21:27:07 +0000 Subject: [PATCH] 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 --- public/slskd.png | Bin 1634 -> 3570 bytes .../officerdev/src/apps/Soulseek/Cards.tsx | 95 ++++++++++++ .../officerdev/src/apps/Soulseek/shared.ts | 140 ++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx diff --git a/public/slskd.png b/public/slskd.png index 0b82062ccf6c1807f09a4ef89650ecefffc501cb..980e0078c7af13cba3537bbc6fad6d2f75ff4a7c 100644 GIT binary patch literal 3570 zcmbtX4Nw$i7~Vrk#ViJ6Y)qT&7@ZRKcK7bMyWKntK>_uIlo0%7Z+8zi?rzW9#XBsF zam>;hCvC*ElFBJ`EJH^o9V$-7#>|o&$DbkvO@(xV8~>OfroO#h_J9rry4f4{`}X~w z_j#Z9*_XLZISVr;#7vHXAZS8XX8Ixs(%hB4V;=!Kr_MKT27ktRG7AI<8b49`YM@;o zJPAS3C%N2wG2ga;qyps}@j|YjAl~Sht|)1QbL4ln2Fim{e0JkfGH$PpQ`#O3u(I>ttQ5TNFSn zuEu(bd65^2`9Yx7$NdrjZEZF+M?@|zmzqKl=d1x`D1twI$TTlYiM%(L=bgQs%IT#H>&-eGes&4xW_X|QT%QAMIwi7JE$EsF!Hfu&oU6x4 z13@Mei8w}Lm>g>38O~7_DKuG6;)Z^qz%vXbQV$JgXwt!ZT@)yob5TVs>TwroVO2&l zjd${1kQlHtgjm>YWR_bHDL2h#rCYV2W*x^dq{Zkkm{{C`(5#U{2!oM8EOxU2u}~ID z?=aa}hE~s~^K_}yS?Rp&a)ze?MmVbx!|W7evLFVB*^aOlqY0t(6u?ae8wN9ObYOTd zn`|!!UNp+t7gb`#07TYkbeN1rGeXhHX2f7O;)tEHQwWP=IF`&}goSWOZVnlQ%;W^n zx-#YcSj?_g?wlMP@&rlIlGm+TT52-OXq9a)f*wQ4tep21vjFq~&6l$ayhHR+UN*G| zX!lTap@WeZs5K9!KD1WKoFY;0XU36GS7f1!DR&VICMT+uib16jBR2&d*ssvDKI5o2 z9LzZBR-LY3q0VR44WPYX`nK+kvqR9B#;o+zT>r0sUOeV{3Xk3RVs=g7iGxXSdqq#x z#nj1#npduGBCa!IW3QDS{^W9E;WItk{-&k~ryqw}YJ2n=XeD{s$ca~Px%xIZC1{yt8x?)A>g`YK0>KN%#*UFZ>4u{A` z{KWg?TK?I$r!leq+sbLL&QriZSpUdNqx;ZRxeTNqn6&zuJ9_tLTT;HLIZ!$6b4V9r z^gssTJbg)n3M!(HZ>@{ZpF6dDDYXKbpRZZoHWUtkQzQwqYj##_Kb*As#+{pOcRsw| zE!?i_d9*pp^YO475Jpd;44PnL(emzl=gze5Y%D2gsNOPuL~W2osCeEfb#>ITN-PoD zPt@%gv3x-BiXCzFvu=ESd%Uxt;l%M7Qq`9`eyMNX-_hE!z4;B9XIT8m_6R@k+nmCJ zrgN(brt1crm6Flx_%B;-zLs5HwW19QURl-NYwJNhJkU2v&o0Dgv{$FpY)Sd) zhi{fv`l~x{bsoIm+1Prk`)UM_%Lb0ju%F}d^QIiF>I#ru#zd&`{GkmaXc5RFC#rb& z_}1=ErQ*BZgU(+#v>|?=E5R!p;jJB{;z^)l{@%u0ZD&tB7AyxQKlXa>C>T13Iz58Z zuYP|ira330Di2zjdMYkYarbchAOi;9v3S2~Xt>#SHYT_W44Nq5#<1E$Rv)xNa^w6r z!PKgGo9sFq)%kKkL(CC*Pz}d+;ieu*n{srS#=Un>cH;63P$x90cRCKDvno*pRRa^9 z)1h_q6qsSPPlW|(15u`lHQPXa4Zllbh3k_`RLG~AhcxP2PB_s9S5;9vKfV}0$pGu81=OLmo{l0{IY8jd*vO-&0tNadm(`?mwuIH%~_a! JV0Pj9e*skwAYT9g literal 1634 zcmV-o2A%ndP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00006VoOIv0RI600RN!9r;`8x z1?x#fK~!ko?bv&emsK6d@z?pmN(>U3Ogd@Mq{Tl?BuUPsgHnoPiU<+Hh$tdsUMj;% z88TM_GfHO^%~>tO1k`oFQ8YEE1@lsh;@Cwg#%f~3QlViCWp{t)+aLSz+dHrq*qq@r zv$K1?=j?OdpL6{_4+nHWJyPnko+FZ*eNx#cgrk%Vkqs6?ct^HX+27Rrlk3x#zBh1F z4D?$u)@5=tY3cf6cE4xZ|p)jCD&FgqncwDwda+YvU z4|>{!vMML_j)CQII2OpxM^z=BtQBsx#Y3u7V(Tx_QS2{xI{tVQ{G2XINIKEBI zdc&P(Y>N?ZsYVxRIZK`EIpGr3>BZeODs>C~!MH#_8>qm)3&Kv!>=gc%7$97_$LA-D zeb|Y6f~Tu9P#q=5TBPjC%6KN<$0$rt@^$BSuxy%pn<{%lSSM_eye$l)Vk+Zu^V02m zi?sK9;E5Pyr)9DU#TtDLQ}y?%9p1p*rlmcPZcE)32Yb&`vXR9NJj+$-=UsWk-Yuck zF+_v4m{3I4N3Q#(h5KxDd6(d;qom$)j4UEocNKo`4AjOz^&ZA?MP!LLjWa2| zttP4@huCPg>Xf_G^P#T6co2T-J84l@;rDD{eN;JEEyjT4E-#to;?w{J8R!-BJA{wu ze0kmyr{H@<=|wwytvm4SX%oNY2FaNMEcHU0k)EEmw`#vUKCRI#*>hOl;WKZKBQ;o7 z4Bj@`WYy`t?i%QPlztWCimN{f&tj}ErJWtNy*Ex4u9J`!4BZcWqi67) z8z}oOjsW3OXQVgUjpR`CWLFnS+l#UZrl-xF_EZyJQFcX9`n4k}d!R>Zzl2uDFv(wp z0kRcNOOspGKNv>~_X?wnyk)hWCYq7{yBqjX!jrN=vd#MWtV`0rd&5BcQ>dS-FaTxQ zyjFFq;%vzVj4mP%8EpJ+;qQu%;$aNJPE6@7`1S_YM-|Q~O7}ZGZEOKQM_7TuMP#|5 zCU?5A;m$Z**=iiADJ7oi^7L$P!M8WC-8dWuk|mY+qj8hu#un}G^oQMKoV(yl)?h>t znd`E2Z*RiWb`zJJr0JC3YOCLX$KpoWH(Rt?Vxo&pN>Pa{jSniBCi|l77}-I>VRCU0 zu+Uf24|^NFy@BS@ddZYj_Czd@&1q4;$`G@q0zM+S-+96!;qVT72=iT>uH6^#v^|B^ z#tX7hvL#rf>>gPvvd^dNFwT_Jl>663e{Z=}_Ho%WvVRKGWhWQ2*eJWqh3Src1>eyO z>^e<8BTQ{!@Xs2IufW5bKBmE3=ca9H;~?3OaB@+*LzwEqv}#|%cVysS6$8!Mu4Iyl z=|8PnPDz`6Mr%-sbO?_ZytK)Q+YU4v5h}$H;!?8v5 z35`bWFL>IYLa!;eiu~B=X=SU(%J>v!3bWo=^VPJ#Dd}a^QId=iUch!?qp;pVRys3n>r3!vYFf2-t~I0nNs<^SL6YoM+h z2qzYQ|H9bxYF`6&-2k6Qy|Vdz4bxo%BZNDQ?VF3JHw+xCzZ?1*r2Bd%-gg_fwd&%K gzD78p1L{Nl57{ed-f}^y-2eap07*qoM6N<$f<|s9d;kCd 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();