diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx index c045de0e..a2c44c30 100644 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx @@ -4,15 +4,15 @@ import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /soulseek uses the Workspace/Panel system (like /chat and /music): two panels — the search browser -// (soulseek-search) and the download queue (soulseek-transfers) — coordinating via the 'soulseek:refresh' -// panel channel. Both talk to slskd through the /api/slskd auth proxy. +// /soulseek uses the Workspace/Panel system (like /chat and /music): a section nav (soulseek-nav) on +// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section' +// channel. Both talk to slskd through the /api/slskd auth proxy. -const ALLOWED_APP_TYPES = new Set(['soulseek-search', 'soulseek-transfers', null]); +const ALLOWED_APP_TYPES = new Set(['soulseek-nav', 'soulseek-view', null]); function normalizeLayout(node: LayoutNode): LayoutNode { if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'soulseek-search' }; + return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'soulseek-view' }; } const children = node.children.map((c) => { const fixed = normalizeLayout(c.node); @@ -23,7 +23,9 @@ function normalizeLayout(node: LayoutNode): LayoutNode { } export const SoulseekScreen = () => { - const rawWorkspace = useDashboardState('screens/soulseek', defaultLayout); + // v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted + // layout doesn't resurrect as two mismatched panels. + const rawWorkspace = useDashboardState('screens/soulseek-v2', defaultLayout); const workspace = useMemo(() => { const fixed = normalizeLayout(rawWorkspace.value); diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts index b82856a7..e5f81710 100644 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts @@ -5,7 +5,7 @@ export const defaultLayout: LayoutNode = { id: 'soulseek-root', direction: 'horizontal', children: [ - { node: { type: 'panel', id: 'soulseek-search', appType: 'soulseek-search' }, size: 55 }, - { node: { type: 'panel', id: 'soulseek-transfers', appType: 'soulseek-transfers' }, size: 45 }, + { node: { type: 'panel', id: 'soulseek-nav', appType: 'soulseek-nav' }, size: 22 }, + { node: { type: 'panel', id: 'soulseek-view', appType: 'soulseek-view' }, size: 78 }, ], }; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx new file mode 100644 index 00000000..75cd1de0 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx @@ -0,0 +1,176 @@ +import { useState, useEffect } from 'react'; +import { useClient } from 'hooks/useClient'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { toast } from 'sonner'; +import { ArrowLeft, Download, Lock, Loader2 } from 'lucide-react'; +import { + SLSKD_REFRESH_CHANNEL, + basename, + extOf, + formatSize, + type ResultRow, + type SlskdResponse, + type SlskdSearchSummary, +} from './shared'; + +// The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses), +// never re-runs the search. Responses can be huge, so we flatten, rank by availability, and cap the +// render. Results are memoised per search id for the session so re-opening is instant (a proper cache +// is a later step). + +const MAX_ROWS = 300; +const resultsCache = new Map(); + +const rowKey = (row: ResultRow) => `${row.username}::${row.filename}`; + +const flatten = (responses: SlskdResponse[]): ResultRow[] => { + const flat: ResultRow[] = []; + for (const resp of responses) { + for (const file of resp.files ?? []) { + flat.push({ + username: resp.username, + filename: file.filename, + size: file.size, + extension: extOf(file), + isLocked: !!file.isLocked, + bitRate: file.bitRate, + hasFreeUploadSlot: resp.hasFreeUploadSlot, + queueLength: resp.queueLength, + uploadSpeed: resp.uploadSpeed, + }); + } + } + // Rank the most-downloadable first: free upload slot, then unlocked, then faster peer, then size. + flat.sort( + (a, b) => + Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) || + Number(a.isLocked) - Number(b.isLocked) || + b.uploadSpeed - a.uploadSpeed || + b.size - a.size, + ); + return flat; +}; + +type SearchResultsProps = { search: SlskdSearchSummary; onBack: () => void }; + +export const SearchResults = ({ search, onBack }: SearchResultsProps) => { + const client = useClient(); + const [, bumpRefresh] = usePanelChannel(SLSKD_REFRESH_CHANNEL, 0); + const [rows, setRows] = useState(() => resultsCache.get(search.id) ?? null); + const [error, setError] = useState(null); + const [queued, setQueued] = useState>(new Set()); + + useEffect(() => { + const cached = resultsCache.get(search.id); + if (cached) { + setRows(cached); + return; + } + let cancelled = false; + setRows(null); + setError(null); + client + .get(`/slskd/api/v0/searches/${search.id}/responses`) + .then((responses) => { + if (cancelled) return; + const flat = flatten(responses); + resultsCache.set(search.id, flat); + setRows(flat); + }) + .catch((err) => !cancelled && setError(err instanceof Error ? err.message : String(err))); + return () => { + cancelled = true; + }; + // useClient() returns a fresh object each render, so depend only on the search id. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search.id]); + + const download = async (row: ResultRow) => { + const key = rowKey(row); + try { + await client.post(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}`, [ + { filename: row.filename, size: row.size }, + ]); + setQueued((prev) => new Set(prev).add(key)); + bumpRefresh(Date.now()); + toast.success(`Queued ${basename(row.filename)}`); + } catch (err) { + toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + const shown = rows ? rows.slice(0, MAX_ROWS) : []; + + return ( +
+
+ +
+
{search.searchText}
+
+ {rows === null && !error + ? 'Loading results…' + : `${(rows?.length ?? 0).toLocaleString()} files from ${search.responseCount.toLocaleString()} users${ + rows && rows.length > shown.length ? ` — showing top ${shown.length}` : '' + }`} +
+
+
+ +
+ {error &&

Could not load results: {error}

} + {!error && rows === null && ( +
+ Loading results… +
+ )} + {!error && rows && rows.length === 0 &&

No results stored.

} + {shown.length > 0 && ( +
+ {shown.map((row) => { + const key = rowKey(row); + const isQueued = queued.has(key); + return ( +
+
+
+ {row.isLocked && } + {basename(row.filename)} +
+
+ {row.username} + · {formatSize(row.size)} + {row.extension && · {row.extension}} + {row.bitRate ? · {row.bitRate} kbps : null} + {row.hasFreeUploadSlot ? ( + · free slot + ) : ( + · queue {row.queueLength} + )} +
+
+ +
+ ); + })} +
+ )} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx new file mode 100644 index 00000000..a4b6a3c8 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx @@ -0,0 +1,165 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useClient } from 'hooks/useClient'; +import { toast } from 'sonner'; +import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight } from 'lucide-react'; +import { SearchResults } from './SearchResults'; +import { formatWhen, type SlskdSearchSummary } from './shared'; + +// The 'search' section — a search input plus the history of past searches (GET /searches). Submitting +// creates a new search on slskd and refreshes the list. (Loading a search's results is a later step.) + +export const SearchView = () => { + const client = useClient(); + const [query, setQuery] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [history, setHistory] = useState(null); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); + + const load = useCallback(() => { + setError(null); + client + .get('/slskd/api/v0/searches') + .then((list) => setHistory([...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt)))) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + // useClient() is a fresh object each render — keep this callback stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + load(); + }, [load]); + + const submit = async (ev: React.FormEvent) => { + ev.preventDefault(); + const text = query.trim(); + if (!text || submitting) return; + setSubmitting(true); + try { + await client.post('/slskd/api/v0/searches', { searchText: text }); + setQuery(''); + load(); + } catch (err) { + toast.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setSubmitting(false); + } + }; + + const remove = async (id: string) => { + setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev); + client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load()); + }; + + if (selected) return setSelected(null)} />; + + return ( +
+ {/* Header + search field */} +
+

Search

+
+
+ + setQuery(ev.target.value)} + placeholder="Search artists, albums, tracks…" + className="h-11 w-full rounded-xl border bg-background pl-10 pr-3 text-sm shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20" + /> +
+ +
+
+ + {/* History */} +
+
+ + Recent searches + {history && · {history.length}} +
+ +
+ +
+ {error &&

Could not load history: {error}

} + {!error && history === null && ( +
+ Loading… +
+ )} + {!error && history?.length === 0 && ( +
+ + No searches yet — try one above. +
+ )} + {history && history.length > 0 && ( +
    + {history.map((s) => ( +
  • +
    setSelected(s)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + setSelected(s); + } + }} + className="group flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-muted/60" + > +
    + {s.isComplete ? : } +
    +
    +
    {s.searchText}
    +
    + {formatWhen(s.startedAt)} + + + {s.responseCount.toLocaleString()} + + + + {s.fileCount.toLocaleString()} + +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx new file mode 100644 index 00000000..36b64a1d --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx @@ -0,0 +1,61 @@ +import type { LucideIcon } from 'lucide-react'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { LayoutGrid, Search, ArrowDownToLine, ArrowUpFromLine, Hash, MessageCircle, Users, Server } from 'lucide-react'; +import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared'; + +// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes +// the active section to the 'soulseek:section' channel; SoulseekView (right) renders the matching UI. + +const ICONS: Record = { + dashboard: LayoutGrid, + search: Search, + downloads: ArrowDownToLine, + uploads: ArrowUpFromLine, + rooms: Hash, + chat: MessageCircle, + users: Users, + system: Server, +}; + +export const SoulseekNav = () => { + const [section, setSection] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'search'); + + return ( +
+
+
+ +
+
+
Soulseek
+
slskd client
+
+
+ + +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSearch.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSearch.tsx deleted file mode 100644 index 46e2d83c..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSearch.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useState } from 'react'; -import { useClient } from 'hooks/useClient'; -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { toast } from 'sonner'; -import { Search, Download, Lock, Loader2 } from 'lucide-react'; -import { - SLSKD_REFRESH_CHANNEL, - basename, - extOf, - formatSize, - type ResultRow, - type SlskdResponse, - type SlskdSearch, -} from './shared'; - -// Left panel of the /soulseek workspace — search slskd and enqueue downloads. A search is a create → -// poll-until-complete → fetch-responses cycle (slskd resolves in ~1-2s). Responses arrive grouped by -// peer and can be huge (hundreds of users, tens of thousands of files), so we flatten, rank by -// availability, and cap the render. Queuing a download bumps the refresh channel so the transfers -// panel (right) reflects it immediately. - -const MAX_ROWS = 200; -const POLL_MS = 600; -const POLL_TIMEOUT_MS = 20_000; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -const rowKey = (row: ResultRow) => `${row.username}::${row.filename}`; - -export const SoulseekSearch = () => { - const client = useClient(); - const [, bumpRefresh] = usePanelChannel(SLSKD_REFRESH_CHANNEL, 0); - - const [query, setQuery] = useState(''); - const [searching, setSearching] = useState(false); - const [error, setError] = useState(null); - const [rows, setRows] = useState([]); - const [meta, setMeta] = useState<{ total: number; users: number } | null>(null); - const [queued, setQueued] = useState>(new Set()); - - const runSearch = async (ev: React.FormEvent) => { - ev.preventDefault(); - const text = query.trim(); - if (!text || searching) return; - - setSearching(true); - setError(null); - setRows([]); - setMeta(null); - setQueued(new Set()); - - try { - const created = await client.post('/slskd/api/v0/searches', { searchText: text }); - const id = created.id; - - let state = created; - const deadline = Date.now() + POLL_TIMEOUT_MS; - while (!state.isComplete && Date.now() < deadline) { - await sleep(POLL_MS); - state = await client.get(`/slskd/api/v0/searches/${id}`); - } - - const responses = await client.get(`/slskd/api/v0/searches/${id}/responses`); - - const flat: ResultRow[] = []; - for (const resp of responses) { - for (const file of resp.files ?? []) { - flat.push({ - username: resp.username, - filename: file.filename, - size: file.size, - extension: extOf(file), - isLocked: !!file.isLocked, - bitRate: file.bitRate, - hasFreeUploadSlot: resp.hasFreeUploadSlot, - queueLength: resp.queueLength, - uploadSpeed: resp.uploadSpeed, - }); - } - } - - // Rank the most-downloadable first: free upload slot, then unlocked, then faster peer, then size. - flat.sort( - (a, b) => - Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) || - Number(a.isLocked) - Number(b.isLocked) || - b.uploadSpeed - a.uploadSpeed || - b.size - a.size, - ); - - setMeta({ total: flat.length, users: responses.length }); - setRows(flat.slice(0, MAX_ROWS)); - - // slskd retains searches server-side; drop this one now that we have its responses. - client.delete(`/slskd/api/v0/searches/${id}`).catch(() => {}); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSearching(false); - } - }; - - const download = async (row: ResultRow) => { - const key = rowKey(row); - try { - await client.post(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}`, [ - { filename: row.filename, size: row.size }, - ]); - setQueued((prev) => new Set(prev).add(key)); - bumpRefresh(Date.now()); - toast.success(`Queued ${basename(row.filename)}`); - } catch (err) { - toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`); - } - }; - - return ( -
-
-
-
- -
-
-

Soulseek

-

Search the Soulseek network

-
-
- -
-
- - setQuery(ev.target.value)} - placeholder="Artists, albums, tracks…" - className="w-full rounded-md border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:ring-2 focus:ring-ring" - /> -
- -
- - {error &&

Search failed: {error}

} - {meta && ( -

- {meta.total.toLocaleString()} files from {meta.users.toLocaleString()} users - {meta.total > rows.length && ` — showing the top ${rows.length}`} -

- )} -
- -
- {rows.length > 0 && ( -
- {rows.map((row) => { - const key = rowKey(row); - const isQueued = queued.has(key); - return ( -
-
-
- {row.isLocked && } - {basename(row.filename)} -
-
- {row.username} - · {formatSize(row.size)} - {row.extension && · {row.extension}} - {row.bitRate ? · {row.bitRate} kbps : null} - {row.hasFreeUploadSlot ? ( - · free slot - ) : ( - · queue {row.queueLength} - )} -
-
- -
- ); - })} -
- )} - - {!searching && !error && meta && rows.length === 0 && ( -

No results.

- )} - {!meta && !searching && !error && ( -

Search for something to get started.

- )} -
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx index 209d337e..27eed44c 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx @@ -62,7 +62,9 @@ export const SoulseekTransfers = () => { .then((data) => setApp(data)) .catch((err) => setStatusError(err instanceof Error ? err.message : String(err))) .finally(() => setStatusLoading(false)); - }, [client]); + // useClient() is a fresh object each render — keep this callback stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { loadStatus(); @@ -82,7 +84,9 @@ export const SoulseekTransfers = () => { cancelled = true; clearInterval(timer); }; - }, [client, refresh]); + // useClient() is a fresh object each render — poll on the refresh nonce only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refresh]); // For an active transfer this cancels it; for a finished/failed one it clears it from the queue. const remove = async (row: Row) => { diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx new file mode 100644 index 00000000..beba6e6a --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx @@ -0,0 +1,36 @@ +import { Construction } from 'lucide-react'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared'; +import { SearchView } from './SearchView'; +import { SoulseekTransfers } from './SoulseekTransfers'; + +// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. Only Search +// (history + input) and Downloads (transfers) are built so far; the rest are placeholders. + +const Placeholder = ({ id }: { id: SoulseekSectionId }) => { + const label = SOULSEEK_SECTIONS.find((s) => s.id === id)?.label ?? id; + return ( +
+
+ +
+
+
{label}
+
Coming soon
+
+
+ ); +}; + +export const SoulseekView = () => { + const [section] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'search'); + + switch (section) { + case 'search': + return ; + case 'downloads': + return ; + default: + return ; + } +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/index.ts b/src/workspaces/officerdev/src/apps/Soulseek/index.ts index f45a7ea3..147d607d 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/index.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/index.ts @@ -1,11 +1,11 @@ import type { AppRegistryMeta } from '../../AppRegistry'; -import { Search, ArrowDownToLine } from 'lucide-react'; -import { SoulseekSearch } from './SoulseekSearch'; -import { SoulseekTransfers } from './SoulseekTransfers'; +import { PanelLeft, LayoutGrid } from 'lucide-react'; +import { SoulseekNav } from './SoulseekNav'; +import { SoulseekView } from './SoulseekView'; -export { SoulseekSearch, SoulseekTransfers }; +export { SoulseekNav, SoulseekView }; export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'soulseek-search', name: 'Search', icon: Search, component: SoulseekSearch, availableOnPanel: false }, - { key: 'soulseek-transfers', name: 'Transfers', icon: ArrowDownToLine, component: SoulseekTransfers, availableOnPanel: false }, + { key: 'soulseek-nav', name: 'Soulseek', icon: PanelLeft, component: SoulseekNav, availableOnPanel: false }, + { key: 'soulseek-view', name: 'Soulseek', icon: LayoutGrid, component: SoulseekView, availableOnPanel: false }, ]; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index 4091c83e..fc48cf87 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -65,8 +65,59 @@ export type SlskdTransfer = { export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] }; export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] }; +// 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. +export const SOULSEEK_SECTION_CHANNEL = 'soulseek:section'; +export type SoulseekSectionId = + | 'dashboard' + | 'search' + | 'downloads' + | 'uploads' + | 'rooms' + | 'chat' + | 'users' + | 'system'; +export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [ + { id: 'dashboard', label: 'Dashboard' }, + { id: 'search', label: 'Search' }, + { id: 'downloads', label: 'Downloads' }, + { id: 'uploads', label: 'Uploads' }, + { id: 'rooms', label: 'Rooms' }, + { id: 'chat', label: 'Chat' }, + { id: 'users', label: 'Users' }, + { id: 'system', label: 'System' }, +]; + +// A past search, as listed by GET /searches (no responses inlined). +export type SlskdSearchSummary = { + id: string; + searchText: string; + startedAt: string; + endedAt?: string; + fileCount: number; + lockedFileCount: number; + responseCount: number; + isComplete: boolean; + state: string; +}; + export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path; +// Compact "time ago" for search-history rows. +export const formatWhen = (iso?: string): string => { + if (!iso) return ''; + const t = new Date(iso).getTime(); + if (!Number.isFinite(t)) return ''; + const s = Math.round((Date.now() - t) / 1000); + if (s < 60) return 'just now'; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return d < 7 ? `${d}d ago` : new Date(iso).toLocaleDateString(); +}; + export const formatSize = (bytes: number) => { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB'];