refactor(website): split the hero terminal and the page components

HeroTerminal was a single 367-line component doing three separate jobs:
simulating the transfer queue, holding the per-search result sets, and
rendering. It is now useTransfers, useResults and useHeroDemo behind a
presentational component. The dead shellRef went with it.

The home, docs and install pages each returned one long JSX block; every
section is now a named component, so the page body reads as an outline.

The prerendered HTML is byte-identical for all five pages, and the extracted
transfer reducer matches the original over 200 ticks.
This commit is contained in:
Michel de Graaf
2026-07-26 15:40:58 +02:00
parent 093f32b360
commit 2e9cd6b860
7 changed files with 1128 additions and 1028 deletions
+20 -344
View File
@@ -1,328 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import { MacWindow, ShortcutBar, StatusLine } from './chrome'
import { SHARE } from './data'
import { MacWindow, ShortcutBar, StatusLine, type StatusCounts } from './chrome'
import {
RESULTS,
SEARCHES,
SHARE,
TRANSFERS,
type ResultRow,
type SearchRow,
type TransferRow,
} from './data'
import { InfoPane, ResultsPane, SearchesPane, TransfersPane } from './panes' import { InfoPane, ResultsPane, SearchesPane, TransfersPane } from './panes'
import { useFitScale } from './use-fit-scale' import { useFitScale } from './use-fit-scale'
import { useHeroDemo } from './use-hero-demo'
const DESIGN_WIDTH = 1360 const DESIGN_WIDTH = 1360
const DESIGN_HEIGHT = 863 const DESIGN_HEIGHT = 863
const TICK_MS = 480
const MAX_CONCURRENT = 2
const RESTING_ROW = 5
type Phase = 'searching' | 'results' | 'interactive'
const isBrowser = typeof window !== 'undefined'
const matches = (query: string): boolean =>
isBrowser && window.matchMedia(query).matches
const prefersReducedMotion = (): boolean => matches('(prefers-reduced-motion: reduce)')
const sizeMb = (size: string): number => {
const parsed = Number.parseFloat(size)
return Number.isNaN(parsed) || parsed === 0 ? 12 : parsed
}
const toTransfer = (row: ResultRow): TransferRow => ({
id: `t:${row.name}`,
status: 'queued',
name: row.name,
user: row.user,
progress: 'Queued',
speed: '-',
pct: 0,
sizeMb: sizeMb(row.size),
rateMbps: 1.4 + (row.name.length % 7) * 0.4,
})
export const HeroTerminal = () => { export const HeroTerminal = () => {
const [searches, setSearches] = useState<readonly SearchRow[]>(SEARCHES) const demo = useHeroDemo()
const [results, setResults] =
useState<Readonly<Record<string, readonly ResultRow[]>>>(RESULTS)
const [activeId, setActiveId] = useState('s1')
const [transfers, setTransfers] = useState<readonly TransferRow[]>(TRANSFERS)
const [focus, setFocus] = useState(2)
const [phase, setPhase] = useState<Phase>('searching')
const [selected, setSelected] = useState(0)
const shellRef = useRef<HTMLDivElement>(null)
const resultsRef = useRef(results)
useEffect(() => {
resultsRef.current = results
}, [results])
const rows = useMemo(() => results[activeId] ?? [], [results, activeId])
const activeSearch = searches.find((search) => search.id === activeId)
const counts = useMemo<StatusCounts>(() => {
const tally: StatusCounts = { active: 0, done: 0, fail: 0, queued: 0 }
for (const transfer of transfers) tally[transfer.status] += 1
return tally
}, [transfers])
const pct = useMemo(() => {
const live = transfers.filter(
(t) => t.pct !== undefined && (t.status === 'active' || t.status === 'done'),
)
if (live.length === 0) return 0
return Math.round(live.reduce((sum, t) => sum + (t.pct ?? 0), 0) / live.length)
}, [transfers])
const hasWork = useMemo(
() => transfers.some((t) => t.pct !== undefined && t.status !== 'done'),
[transfers],
)
useEffect(() => {
if (!hasWork || prefersReducedMotion()) return
const timer = setInterval(() => {
setTransfers((list) => {
let live = list.filter((t) => t.status === 'active' && t.pct !== undefined).length
const next = list.map((t) => {
if (t.pct === undefined) return t
const total = t.sizeMb ?? 12
const rate = t.rateMbps ?? 2
if (t.status === 'queued' && live < MAX_CONCURRENT) {
live += 1
return {
...t,
status: 'active' as const,
speed: `${rate.toFixed(2)} MB/s`,
progress: `0.0/${total.toFixed(1)} MB (0%)`,
}
}
if (t.status !== 'active') return t
const step = 4 + ((t.name.length + Math.round(t.pct)) % 9)
const pct = Math.min(100, t.pct + step)
const finished = pct >= 100
return {
...t,
status: finished ? ('done' as const) : ('active' as const),
pct,
speed: finished ? '-' : `${rate.toFixed(2)} MB/s`,
progress: finished
? 'Complete'
: `${((total * pct) / 100).toFixed(1)}/${total.toFixed(1)} MB (${String(Math.round(pct))}%)`,
}
})
const moved = next.some((t, i) => t !== list[i])
return moved ? next : list
})
}, TICK_MS)
return () => {
clearInterval(timer)
}
}, [hasWork])
const queueFrom = useCallback((searchId: string, indexes: readonly number[]) => {
if (indexes.length === 0) return
const current = resultsRef.current[searchId] ?? []
const picked = indexes
.map((i) => current[i])
.filter((row): row is ResultRow => row !== undefined && row.queued !== true)
if (picked.length === 0) return
const wanted = new Set(picked.map((row) => row.name))
setResults((prev) => ({
...prev,
[searchId]: (prev[searchId] ?? []).map((row) =>
wanted.has(row.name) ? { ...row, queued: true, marked: false } : row,
),
}))
setTransfers((list) => {
const seen = new Set(list.map((transfer) => transfer.id))
const additions = picked.map(toTransfer).filter((t) => !seen.has(t.id))
return additions.length === 0 ? list : [...additions, ...list]
})
}, [])
const queueIndexes = useCallback(
(indexes: readonly number[]) => {
queueFrom(activeId, indexes)
},
[queueFrom, activeId],
)
const toggleMark = useCallback(
(index: number) => {
setResults((prev) => ({
...prev,
[activeId]: (prev[activeId] ?? []).map((row, i) =>
i === index && row.queued !== true ? { ...row, marked: row.marked !== true } : row,
),
}))
},
[activeId],
)
const markAll = useCallback(
(on: boolean) => {
setResults((prev) => ({
...prev,
[activeId]: (prev[activeId] ?? []).map((row) =>
row.queued === true ? row : { ...row, marked: on },
),
}))
},
[activeId],
)
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLInputElement) return
const list = results[activeId] ?? []
switch (event.key) {
case 'ArrowDown':
case 'j':
event.preventDefault()
setFocus(2)
setSelected((s) => Math.min(list.length - 1, s + 1))
return
case 'ArrowUp':
case 'k':
event.preventDefault()
setFocus(2)
setSelected((s) => Math.max(0, s - 1))
return
case ' ':
event.preventDefault()
setFocus(2)
toggleMark(selected)
return
case 'Enter': {
event.preventDefault()
const marked = list.flatMap((row, i) =>
row.marked === true && row.queued !== true ? [i] : [],
)
queueIndexes(marked.length > 0 ? marked : [selected])
setFocus(3)
return
}
case 'a':
event.preventDefault()
markAll(true)
return
case 'A':
event.preventDefault()
markAll(false)
return
case '1':
case '2':
case '3':
event.preventDefault()
setFocus(Number(event.key))
return
default:
return
}
}
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = []
const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
const cancel = () => {
for (const timer of timers) clearTimeout(timer)
}
if (prefersReducedMotion()) {
at(() => {
setSelected(RESTING_ROW)
setPhase('interactive')
}, 0)
return cancel
}
const mark = (i: number) => {
setResults((prev) => ({
...prev,
s1: (prev['s1'] ?? []).map((row, ix) => (ix === i ? { ...row, marked: true } : row)),
}))
}
at(() => {
setPhase('results')
}, 1050)
at(() => {
setSelected(3)
}, 1550)
at(() => {
setSelected(7)
}, 2050)
at(() => {
setSelected(10)
}, 2550)
at(() => {
mark(10)
}, 2850)
at(() => {
queueFrom('s1', [10])
}, 3050)
at(() => {
setSelected(3)
}, 3950)
at(() => {
mark(3)
}, 4150)
at(() => {
queueFrom('s1', [3])
}, 4350)
at(() => {
setSelected(RESTING_ROW)
setPhase('interactive')
}, 5050)
return cancel
}, [queueFrom])
const submitSearch = (query: string) => {
const id = `s${String(searches.length + 1)}`
setSearches((prev) => [
...prev,
{ id, query, results: 40 + Math.floor(Math.random() * 900), status: 'Done' },
])
setResults((prev) => ({
...prev,
[id]: Array.from({ length: 6 }, (_, i) => ({
name: `shared\\results\\${query.replace(/[^a-z0-9]+/gi, '_')}\\0${String(i + 1)} - track.flac`,
size: `${(10 + i * 7).toFixed(1)} MB`,
user: ['loopdigger', 'tapehiss', 'nectoUA'][i % 3] ?? 'loopdigger',
bitrate: `${String(500 + i * 60)} kbps`,
speed: '2.20 MB/s',
slots: i % 2,
})),
}))
setActiveId(id)
setSelected(0)
setFocus(2)
setPhase('interactive')
}
const { wrapRef, innerRef, scale, height, ready } = useFitScale(DESIGN_WIDTH) const { wrapRef, innerRef, scale, height, ready } = useFitScale(DESIGN_WIDTH)
const shownSearches = searches.map((search, i) =>
i === 0 && phase === 'searching' ? { ...search, status: '⋯', results: '—' } : search,
)
const shownRows = phase === 'searching' ? [] : rows
return ( return (
<div <div
ref={wrapRef} ref={wrapRef}
@@ -341,48 +29,36 @@ export const HeroTerminal = () => {
> >
<MacWindow> <MacWindow>
<div <div
ref={shellRef}
tabIndex={0} tabIndex={0}
role="application" role="application"
aria-label="Interactive soulseek-rs terminal demo" aria-label="Interactive soulseek-rs terminal demo"
onKeyDown={onKeyDown} onKeyDown={demo.onKeyDown}
className="tui-content p-2.5 outline-none" className="tui-content p-2.5 outline-none"
> >
<StatusLine counts={counts} pct={pct} /> <StatusLine counts={demo.counts} pct={demo.pct} />
<div className="mt-3 grid gap-2.5" style={{ gridTemplateColumns: '300px 1fr' }}> <div className="mt-3 grid gap-2.5" style={{ gridTemplateColumns: '300px 1fr' }}>
<div className="min-h-[498px]"> <div className="min-h-[498px]">
<SearchesPane <SearchesPane
searches={shownSearches} searches={demo.searches}
activeId={activeId} activeId={demo.activeId}
active={focus === 1} active={demo.focus === 1}
onPick={(id) => { onPick={demo.pickSearch}
setActiveId(id) onSubmit={demo.submitSearch}
setSelected(0)
setFocus(1)
setPhase('interactive')
}}
onSubmit={submitSearch}
/> />
</div> </div>
<div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}> <div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}>
<div className="relative min-h-[250px] overflow-hidden"> <div className="relative min-h-[250px] overflow-hidden">
<ResultsPane <ResultsPane
query={activeSearch?.query ?? ''} query={demo.query}
rows={shownRows} rows={demo.rows}
selected={selected} selected={demo.selected}
active={focus === 2} active={demo.focus === 2}
onSelect={(i) => { onSelect={demo.selectRow}
setSelected(i) onQueue={demo.queueRow}
setFocus(2)
}}
onQueue={(i) => {
queueIndexes([i])
setFocus(3)
}}
/> />
{phase === 'searching' && ( {demo.searching && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]">
searching the network searching the network
<span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" /> <span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
@@ -395,10 +71,10 @@ export const HeroTerminal = () => {
style={{ gridTemplateColumns: '1fr 340px' }} style={{ gridTemplateColumns: '1fr 340px' }}
> >
<div className="min-h-0 min-w-0 overflow-hidden"> <div className="min-h-0 min-w-0 overflow-hidden">
<TransfersPane rows={transfers.slice(0, 7)} active={focus === 3} /> <TransfersPane rows={demo.transfers.slice(0, 7)} active={demo.focus === 3} />
</div> </div>
<div className="min-h-0 min-w-0 overflow-hidden"> <div className="min-h-0 min-w-0 overflow-hidden">
<InfoPane row={phase === 'searching' ? undefined : rows[selected]} /> <InfoPane row={demo.infoRow} />
</div> </div>
</div> </div>
</div> </div>
+215
View File
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react'
import { SEARCHES, type ResultRow, type SearchRow } from './data'
import { useResults } from './use-results'
import { useTransfers } from './use-transfers'
const RESTING_ROW = 5
type Phase = 'searching' | 'results' | 'interactive'
const isBrowser = typeof window !== 'undefined'
const prefersReducedMotion = (): boolean =>
isBrowser && window.matchMedia('(prefers-reduced-motion: reduce)').matches
const fakeResults = (query: string): readonly ResultRow[] =>
Array.from({ length: 6 }, (_, i) => ({
name: `shared\\results\\${query.replace(/[^a-z0-9]+/gi, '_')}\\0${String(i + 1)} - track.flac`,
size: `${(10 + i * 7).toFixed(1)} MB`,
user: ['loopdigger', 'tapehiss', 'nectoUA'][i % 3] ?? 'loopdigger',
bitrate: `${String(500 + i * 60)} kbps`,
speed: '2.20 MB/s',
slots: i % 2,
}))
interface Keys {
rows: readonly ResultRow[]
selected: number
moveRow: (delta: number) => void
focusPane: (pane: number) => void
toggleMark: (index: number) => void
markAll: (on: boolean) => void
queueIndexes: (indexes: readonly number[]) => void
}
const handleKey = (k: Keys, event: KeyboardEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLInputElement) return
switch (event.key) {
case 'ArrowDown':
case 'j':
event.preventDefault()
k.moveRow(1)
return
case 'ArrowUp':
case 'k':
event.preventDefault()
k.moveRow(-1)
return
case ' ':
event.preventDefault()
k.focusPane(2)
k.toggleMark(k.selected)
return
case 'Enter': {
event.preventDefault()
const marked = k.rows.flatMap((row, i) =>
row.marked === true && row.queued !== true ? [i] : [],
)
k.queueIndexes(marked.length > 0 ? marked : [k.selected])
k.focusPane(3)
return
}
case 'a':
event.preventDefault()
k.markAll(true)
return
case 'A':
event.preventDefault()
k.markAll(false)
return
case '1':
case '2':
case '3':
event.preventDefault()
k.focusPane(Number(event.key))
return
default:
return
}
}
/** Plays the scripted intro once: the search resolves, rows walk, two downloads queue. */
const useIntroTimeline = (
animate: boolean,
rest: () => void,
setPhase: (phase: Phase) => void,
setSelected: (row: number) => void,
mark: (row: number) => void,
queueFrom: (searchId: string, indexes: readonly number[]) => void,
) => {
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = []
const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
const cancel = () => {
for (const timer of timers) clearTimeout(timer)
}
if (!animate) {
at(rest, 0)
return cancel
}
at(() => {
setPhase('results')
}, 1050)
at(() => {
setSelected(3)
}, 1550)
at(() => {
setSelected(7)
}, 2050)
at(() => {
setSelected(10)
}, 2550)
at(() => {
mark(10)
}, 2850)
at(() => {
queueFrom('s1', [10])
}, 3050)
at(() => {
setSelected(3)
}, 3950)
at(() => {
mark(3)
}, 4150)
at(() => {
queueFrom('s1', [3])
}, 4350)
at(rest, 5050)
return cancel
}, [animate, rest, setPhase, setSelected, mark, queueFrom])
}
export const useHeroDemo = () => {
const [searches, setSearches] = useState<readonly SearchRow[]>(SEARCHES)
const [focus, setFocus] = useState(2)
const [phase, setPhase] = useState<Phase>('searching')
const animate = !prefersReducedMotion()
const { transfers, counts, pct, queue } = useTransfers(animate)
const {
rows,
activeId,
selected,
setSelected,
open,
mark,
queueFrom,
queueIndexes,
toggleMark,
markAll,
} = useResults(queue)
const rest = useCallback(() => {
setSelected(RESTING_ROW)
setPhase('interactive')
}, [setSelected])
useIntroTimeline(animate, rest, setPhase, setSelected, mark, queueFrom)
const searching = phase === 'searching'
const moveRow = (delta: number) => {
setFocus(2)
setSelected((s) => Math.min(rows.length - 1, Math.max(0, s + delta)))
}
const submitSearch = (query: string) => {
const id = `s${String(searches.length + 1)}`
const results = 40 + Math.floor(Math.random() * 900)
setSearches((prev) => [...prev, { id, query, results, status: 'Done' }])
open(id, fakeResults(query))
setFocus(2)
setPhase('interactive')
}
return {
searches: searches.map((search, i) =>
i === 0 && searching ? { ...search, status: '⋯', results: '—' } : search,
),
rows: searching ? [] : rows,
infoRow: searching ? undefined : rows[selected],
query: searches.find((search) => search.id === activeId)?.query ?? '',
activeId,
transfers,
counts,
pct,
focus,
selected,
searching,
submitSearch,
pickSearch: (id: string) => {
open(id)
setFocus(1)
setPhase('interactive')
},
selectRow: (i: number) => {
setSelected(i)
setFocus(2)
},
queueRow: (i: number) => {
queueIndexes([i])
setFocus(3)
},
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
handleKey(
{ rows, selected, moveRow, focusPane: setFocus, queueIndexes, toggleMark, markAll },
event,
)
},
}
}
+77
View File
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { RESULTS, type ResultRow } from './data'
type Results = Readonly<Record<string, readonly ResultRow[]>>
/** Result sets per search, which row is selected, and what is marked or already queued. */
export const useResults = (queue: (rows: readonly ResultRow[]) => void) => {
const [results, setResults] = useState<Results>(RESULTS)
const [activeId, setActiveId] = useState('s1')
const [selected, setSelected] = useState(0)
const resultsRef = useRef(results)
useEffect(() => {
resultsRef.current = results
}, [results])
const rows = useMemo(() => results[activeId] ?? [], [results, activeId])
const patch = useCallback(
(searchId: string, edit: (row: ResultRow, index: number) => ResultRow) => {
setResults((prev) => ({ ...prev, [searchId]: (prev[searchId] ?? []).map(edit) }))
},
[],
)
const queueFrom = useCallback(
(searchId: string, indexes: readonly number[]) => {
const current = resultsRef.current[searchId] ?? []
const picked = indexes
.map((i) => current[i])
.filter((row): row is ResultRow => row !== undefined && row.queued !== true)
if (picked.length === 0) return
const wanted = new Set(picked.map((row) => row.name))
patch(searchId, (row) =>
wanted.has(row.name) ? { ...row, queued: true, marked: false } : row,
)
queue(picked)
},
[patch, queue],
)
const mark = useCallback(
(row: number) => {
patch('s1', (r, i) => (i === row ? { ...r, marked: true } : r))
},
[patch],
)
const open = useCallback((id: string, next?: readonly ResultRow[]) => {
if (next) setResults((prev) => ({ ...prev, [id]: next }))
setActiveId(id)
setSelected(0)
}, [])
return {
rows,
activeId,
selected,
setSelected,
open,
mark,
queueFrom,
queueIndexes: (indexes: readonly number[]) => {
queueFrom(activeId, indexes)
},
toggleMark: (index: number) => {
patch(activeId, (row, i) =>
i === index && row.queued !== true ? { ...row, marked: row.marked !== true } : row,
)
},
markAll: (on: boolean) => {
patch(activeId, (row) => (row.queued === true ? row : { ...row, marked: on }))
},
}
}
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { StatusCounts } from './chrome'
import { TRANSFERS, type ResultRow, type TransferRow } from './data'
const TICK_MS = 480
const MAX_CONCURRENT = 2
const sizeMb = (size: string): number => {
const parsed = Number.parseFloat(size)
return Number.isNaN(parsed) || parsed === 0 ? 12 : parsed
}
const toTransfer = (row: ResultRow): TransferRow => ({
id: `t:${row.name}`,
status: 'queued',
name: row.name,
user: row.user,
progress: 'Queued',
speed: '-',
pct: 0,
sizeMb: sizeMb(row.size),
rateMbps: 1.4 + (row.name.length % 7) * 0.4,
})
const begin = (t: TransferRow, total: number, rate: number): TransferRow => ({
...t,
status: 'active',
speed: `${rate.toFixed(2)} MB/s`,
progress: `0.0/${total.toFixed(1)} MB (0%)`,
})
const advance = (t: TransferRow, total: number, rate: number): TransferRow => {
const from = t.pct ?? 0
const pct = Math.min(100, from + 4 + ((t.name.length + Math.round(from)) % 9))
const finished = pct >= 100
return {
...t,
status: finished ? 'done' : 'active',
pct,
speed: finished ? '-' : `${rate.toFixed(2)} MB/s`,
progress: finished
? 'Complete'
: `${((total * pct) / 100).toFixed(1)}/${total.toFixed(1)} MB (${String(Math.round(pct))}%)`,
}
}
const tick = (list: readonly TransferRow[]): readonly TransferRow[] => {
let live = list.filter((t) => t.status === 'active' && t.pct !== undefined).length
const next = list.map((t) => {
if (t.pct === undefined) return t
const total = t.sizeMb ?? 12
const rate = t.rateMbps ?? 2
if (t.status === 'queued' && live < MAX_CONCURRENT) {
live += 1
return begin(t, total, rate)
}
return t.status === 'active' ? advance(t, total, rate) : t
})
return next.some((t, i) => t !== list[i]) ? next : list
}
export const useTransfers = (animate: boolean) => {
const [transfers, setTransfers] = useState<readonly TransferRow[]>(TRANSFERS)
const hasWork = useMemo(
() => transfers.some((t) => t.pct !== undefined && t.status !== 'done'),
[transfers],
)
useEffect(() => {
if (!hasWork || !animate) return
const timer = setInterval(() => {
setTransfers(tick)
}, TICK_MS)
return () => {
clearInterval(timer)
}
}, [hasWork, animate])
const counts = useMemo<StatusCounts>(() => {
const tally: StatusCounts = { active: 0, done: 0, fail: 0, queued: 0 }
for (const transfer of transfers) tally[transfer.status] += 1
return tally
}, [transfers])
const pct = useMemo(() => {
const live = transfers.filter(
(t) => t.pct !== undefined && (t.status === 'active' || t.status === 'done'),
)
if (live.length === 0) return 0
return Math.round(live.reduce((sum, t) => sum + (t.pct ?? 0), 0) / live.length)
}, [transfers])
const queue = useCallback((rows: readonly ResultRow[]) => {
setTransfers((list) => {
const seen = new Set(list.map((transfer) => transfer.id))
const additions = rows.map(toTransfer).filter((t) => !seen.has(t.id))
return additions.length === 0 ? list : [...additions, ...list]
})
}, [])
return { transfers, counts, pct, queue }
}
+50 -43
View File
@@ -71,17 +71,7 @@ const KeyRow = ({ combo, description }: { combo: string; description: string })
</div> </div>
) )
export const Docs = () => { const FirstRun = () => (
return (
<>
<PageHead
eyebrow="docs · quick start"
title="Install it, run it, drive it from the keyboard."
>
This is the short version. The full reference (every flag, every message) lives in
the README and on docs.rs.
</PageHead>
<Section> <Section>
<SectionHead eyebrow="1 · first run" title="Two commands to a running client."> <SectionHead eyebrow="1 · first run" title="Two commands to a running client.">
Homebrew puts the binary on your PATH. With a Rust toolchain,{' '} Homebrew puts the binary on your PATH. With a Rust toolchain,{' '}
@@ -97,9 +87,8 @@ export const Docs = () => {
/> />
<Prose> <Prose>
<p> <p>
On first run you get a login screen: sign in with an existing Soulseek On first run you get a login screen: sign in with an existing Soulseek account or
account or register a new one. Your password goes to the OS keychain, not a register a new one. Your password goes to the OS keychain, not a plain-text file.
plain-text file.
</p> </p>
<p> <p>
Downloads land in a conventional folder, and TUI state (searches, downloads, Downloads land in a conventional folder, and TUI state (searches, downloads,
@@ -108,7 +97,9 @@ export const Docs = () => {
</Prose> </Prose>
</Cols> </Cols>
</Section> </Section>
)
const Layout = () => (
<Section> <Section>
<SectionHead eyebrow="2 · the layout" title="Four panes."> <SectionHead eyebrow="2 · the layout" title="Four panes.">
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
@@ -122,7 +113,9 @@ export const Docs = () => {
))} ))}
</div> </div>
</Section> </Section>
)
const Keys = () => (
<Section> <Section>
<SectionHead eyebrow="3 · keys" title="It's all keyboard."> <SectionHead eyebrow="3 · keys" title="It's all keyboard.">
The bottom bar always shows the keys for where you are. The essentials: The bottom bar always shows the keys for where you are. The essentials:
@@ -148,12 +141,11 @@ export const Docs = () => {
</div> </div>
</Cols> </Cols>
</Section> </Section>
)
const FromTheShell = () => (
<Section> <Section>
<SectionHead <SectionHead eyebrow="4 · from the shell" title="Every command also runs as a one-off.">
eyebrow="4 · from the shell"
title="Every command also runs as a one-off."
>
Skip the TUI when you&rsquo;re scripting or mid-task. stdout is data, stderr is Skip the TUI when you&rsquo;re scripting or mid-task. stdout is data, stderr is
progress, and the exit code is the verdict. progress, and the exit code is the verdict.
</SectionHead> </SectionHead>
@@ -177,26 +169,25 @@ export const Docs = () => {
/> />
<Prose> <Prose>
<p> <p>
The command groups: <Code>search</Code>, <Code>get</Code>,{' '} The command groups: <Code>search</Code>, <Code>get</Code>, <Code>download</Code>,{' '}
<Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '} <Code>browse</Code>, <Code>room</Code>, <Code>message</Code>, <Code>serve</Code>,{' '}
<Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '} <Code>user</Code>, <Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
<Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '} <Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records are
<Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
are tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
</p> </p>
<p> <p>
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code>{' '} No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code> into{' '}
into <Code>download --stdin</Code>. Every ending has its own exit code: 4 found <Code>download --stdin</Code>. Every ending has its own exit code: 4 found
nothing, 3 could not reach the server, 6 the transfer died. nothing, 3 could not reach the server, 6 the transfer died.
</p> </p>
<p> <p>
<Code>completions install</Code> gives bash, zsh and fish tab completion for <Code>completions install</Code> gives bash, zsh and fish tab completion for all
all of it, generated from the same definition the binary parses with.{' '} of it, generated from the same definition the binary parses with.{' '}
<Code>completions uninstall</Code> takes it back out. <Code>completions uninstall</Code> takes it back out.
</p> </p>
<p> <p>
<Code>portmap</Code> tests whether your router will let peers connect back, <Code>portmap</Code> tests whether your router will let peers connect back, worth
worth running once.{' '} running once.{' '}
<Link to="/install" className="text-link hover:text-link-hover"> <Link to="/install" className="text-link hover:text-link-hover">
More on being reachable More on being reachable
</Link> </Link>
@@ -206,16 +197,15 @@ export const Docs = () => {
<div className="mt-6"> <div className="mt-6">
<Callout title="give it to your agent"> <Callout title="give it to your agent">
<p> <p>
<Code>soulseek-rs skills install</Code> writes a <Code>SKILL.md</Code> into <Code>soulseek-rs skills install</Code> writes a <Code>SKILL.md</Code> into every
every coding agent on the machine: Claude Code and opencode today,{' '} coding agent on the machine: Claude Code and opencode today, <Code>--dir</Code>{' '}
<Code>--dir</Code> for anything else. After it lands you describe the for anything else. After it lands you describe the outcome instead of the
outcome instead of the pipeline: &ldquo;mirror that peer&rsquo;s FLAC pipeline: &ldquo;mirror that peer&rsquo;s FLAC folder&rdquo;, &ldquo;watch this
folder&rdquo;, &ldquo;watch this room for an hour and save anything room for an hour and save anything mentioning the label&rdquo;.
mentioning the label&rdquo;.
</p> </p>
<p> <p>
It carries what <Code>--help</Code> cannot: the JSON keys of every record, It carries what <Code>--help</Code> cannot: the JSON keys of every record, and
and what each exit code means. Rerun it after an upgrade;{' '} what each exit code means. Rerun it after an upgrade;{' '}
<Code>skills uninstall</Code> takes it back out. <Code>skills uninstall</Code> takes it back out.
</p> </p>
</Callout> </Callout>
@@ -223,21 +213,23 @@ export const Docs = () => {
<div className="mt-4"> <div className="mt-4">
<Callout tone="warn" title="the one limit worth knowing up front"> <Callout tone="warn" title="the one limit worth knowing up front">
<p> <p>
If both you and a peer are behind routers with no forwarded port, browsing If both you and a peer are behind routers with no forwarded port, browsing that
that peer can&rsquo;t work, that&rsquo;s a fundamental Soulseek/peer-to-peer peer can&rsquo;t work, that&rsquo;s a fundamental Soulseek/peer-to-peer
limitation, not a bug. limitation, not a bug.
</p> </p>
</Callout> </Callout>
</div> </div>
</Section> </Section>
)
const TheRest = () => (
<Section band> <Section band>
<Panel title="the rest" className="bg-raised [&>span]:bg-panel"> <Panel title="the rest" className="bg-raised [&>span]:bg-panel">
<Cols center className="gap-6"> <Cols center className="gap-6">
<Prose> <Prose>
<p> <p>
The README on GitHub and the library API reference on docs.rs stay current The README on GitHub and the library API reference on docs.rs stay current with
with the code. the code.
</p> </p>
</Prose> </Prose>
<div className="flex flex-wrap gap-2 sm:gap-3"> <div className="flex flex-wrap gap-2 sm:gap-3">
@@ -250,6 +242,21 @@ export const Docs = () => {
</Cols> </Cols>
</Panel> </Panel>
</Section> </Section>
)
export const Docs = () => (
<>
<PageHead
eyebrow="docs · quick start"
title="Install it, run it, drive it from the keyboard."
>
This is the short version. The full reference (every flag, every message) lives in the
README and on docs.rs.
</PageHead>
<FirstRun />
<Layout />
<Keys />
<FromTheShell />
<TheRest />
</> </>
) )
}
+44 -36
View File
@@ -117,9 +117,7 @@ const FEATURES = [
}, },
] as const ] as const
export const Home = () => { const Hero = () => (
return (
<>
<section className="pt-5 pb-8 sm:pt-7 sm:pb-10"> <section className="pt-5 pb-8 sm:pt-7 sm:pb-10">
<Wrap> <Wrap>
<div className="flex flex-col gap-[22px]"> <div className="flex flex-col gap-[22px]">
@@ -127,13 +125,12 @@ export const Home = () => {
<div className="flex items-center gap-3.5 sm:gap-6 md:items-start md:gap-11"> <div className="flex items-center gap-3.5 sm:gap-6 md:items-start md:gap-11">
<Logo size={172} className="max-md:!h-32 md:mt-2" /> <Logo size={172} className="max-md:!h-32 md:mt-2" />
<h1 className="text-[29px] leading-[37px] font-bold tracking-[-0.01em] text-balance sm:text-[34px] sm:leading-[42px] md:text-display md:leading-[var(--text-display--line-height)]"> <h1 className="text-[29px] leading-[37px] font-bold tracking-[-0.01em] text-balance sm:text-[34px] sm:leading-[42px] md:text-display md:leading-[var(--text-display--line-height)]">
A soulseek client for the terminal. Built for agents and people who live A soulseek client for the terminal. Built for agents and people who live there.
there.
</h1> </h1>
</div> </div>
<p className="text-lg leading-[29px] text-pretty text-secondary max-md:hidden"> <p className="text-lg leading-[29px] text-pretty text-secondary max-md:hidden">
Search the network, share your files, browse someone&rsquo;s collection, join Search the network, share your files, browse someone&rsquo;s collection, join a
a room. It runs over ssh on the machine where your music already lives. room. It runs over ssh on the machine where your music already lives.
</p> </p>
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-3 sm:gap-y-4"> <div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-3 sm:gap-y-4">
<div className="flex flex-wrap items-center gap-2 max-md:w-full max-md:justify-center sm:gap-3"> <div className="flex flex-wrap items-center gap-2 max-md:w-full max-md:justify-center sm:gap-3">
@@ -152,7 +149,9 @@ export const Home = () => {
</div> </div>
</Wrap> </Wrap>
</section> </section>
)
const RunsOn = () => (
<section className="border-t border-hairline py-6"> <section className="border-t border-hairline py-6">
<Wrap> <Wrap>
<div className="flex flex-wrap items-center gap-3.5 sm:gap-8"> <div className="flex flex-wrap items-center gap-3.5 sm:gap-8">
@@ -164,9 +163,7 @@ export const Home = () => {
className={cn( className={cn(
'flex w-auto border-hairline px-[18px] first:border-l-0 first:pl-0 not-first:border-l', 'flex w-auto border-hairline px-[18px] first:border-l-0 first:pl-0 not-first:border-l',
'min-[520px]:w-full min-[520px]:border-l-0 min-[520px]:px-0', 'min-[520px]:w-full min-[520px]:border-l-0 min-[520px]:px-0',
i === 0 i === 0 ? 'sm:w-auto sm:pr-6.5' : 'sm:w-auto sm:border-l sm:px-6.5',
? 'sm:w-auto sm:pr-6.5'
: 'sm:w-auto sm:border-l sm:px-6.5',
)} )}
> >
<Link <Link
@@ -187,33 +184,32 @@ export const Home = () => {
</div> </div>
</Wrap> </Wrap>
</section> </section>
)
const About = () => (
<Section id="about"> <Section id="about">
<SectionHead <SectionHead eyebrow="what it is" title="A client for a network that runs on people.">
eyebrow="what it is"
title="A client for a network that runs on people."
>
No charts, no recommendations. You search, you find the person who has it, you No charts, no recommendations. You search, you find the person who has it, you
download it from them. download it from them.
</SectionHead> </SectionHead>
<Cols> <Cols>
<Prose> <Prose>
<p> <p>
Soulseek is a peer-to-peer network from the early 2000s. People still use it Soulseek is a peer-to-peer network from the early 2000s. People still use it to
to share niche music by hand: the album that exists in one person&rsquo;s share niche music by hand: the album that exists in one person&rsquo;s folder and
folder and nowhere else. nowhere else.
</p> </p>
<p> <p>
soulseek-rs speaks that protocol. It&rsquo;s a third-party client, not the soulseek-rs speaks that protocol. It&rsquo;s a third-party client, not the
official app. It shares, browses, and does rooms and private messages, official app. It shares, browses, and does rooms and private messages, because a
because a client that only downloads takes without giving anything back. client that only downloads takes without giving anything back.
</p> </p>
</Prose> </Prose>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Callout title="build your own"> <Callout title="build your own">
<p> <p>
Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent on
on the protocol.{' '} the protocol.{' '}
<Link to="/install" className="text-link hover:text-link-hover"> <Link to="/install" className="text-link hover:text-link-hover">
Use the library Use the library
</Link> </Link>
@@ -222,9 +218,8 @@ export const Home = () => {
<Callout title="agents and automation"> <Callout title="agents and automation">
<p> <p>
Every command runs as a one-off from the shell. Point cron, a script, or an Every command runs as a one-off from the shell. Point cron, a script, or an
agent at them and read JSON records back off stdout. No daemon, no API to agent at them and read JSON records back off stdout. No daemon, no API to stand
stand up. <Code>skills install</Code> teaches the agent the surface for up. <Code>skills install</Code> teaches the agent the surface for you.{' '}
you.{' '}
<Link to="/docs" className="text-link hover:text-link-hover"> <Link to="/docs" className="text-link hover:text-link-hover">
See the commands See the commands
</Link> </Link>
@@ -233,7 +228,9 @@ export const Home = () => {
</div> </div>
</Cols> </Cols>
</Section> </Section>
)
const Features = () => (
<Section id="features"> <Section id="features">
<SectionHead eyebrow="features" title="What it does."> <SectionHead eyebrow="features" title="What it does.">
Everything happens in the TUI, and every command also runs as a one-off from the Everything happens in the TUI, and every command also runs as a one-off from the
@@ -254,11 +251,12 @@ export const Home = () => {
))} ))}
</div> </div>
</Section> </Section>
)
const Agents = () => (
<Section id="agents"> <Section id="agents">
<SectionHead eyebrow="agent native" title="Ask for the record. Skip the pipeline."> <SectionHead eyebrow="agent native" title="Ask for the record. Skip the pipeline.">
An agent can run any CLI. <Code>skills install</Code> teaches it to run this one An agent can run any CLI. <Code>skills install</Code> teaches it to run this one well.
well.
</SectionHead> </SectionHead>
<img <img
src={`${import.meta.env.BASE_URL}agent-demo.svg`} src={`${import.meta.env.BASE_URL}agent-demo.svg`}
@@ -267,26 +265,27 @@ export const Home = () => {
/> />
<Prose className="mt-11 max-w-[660px]"> <Prose className="mt-11 max-w-[660px]">
<p> <p>
<Code>--help</Code> lists flags. It cannot say which JSON keys a record <Code>--help</Code> lists flags. It cannot say which JSON keys a record carries, or
carries, or that exit 4 means widen the query rather than retry it. An agent that exit 4 means widen the query rather than retry it. An agent without that
without that guesses, and guessing against a stranger&rsquo;s file server guesses, and guessing against a stranger&rsquo;s file server hangs the job.
hangs the job.
</p> </p>
<p> <p>
<Code>skills install</Code> fills that in. The requests stop being pipelines <Code>skills install</Code> fills that in. The requests stop being pipelines you
you write: mirror a peer&rsquo;s FLAC folder overnight, or check whether a write: mirror a peer&rsquo;s FLAC folder overnight, or check whether a user is worth
user is worth queueing behind before a 2 GB transfer. queueing behind before a 2 GB transfer.
</p> </p>
<p> <p>
It stays a CLI: no daemon, no MCP server, no API key. Upgrading the binary It stays a CLI: no daemon, no MCP server, no API key. Upgrading the binary upgrades
upgrades the skill; rerun <Code>skills install</Code> to take it.{' '} the skill; rerun <Code>skills install</Code> to take it.{' '}
<Link to="/docs" className="text-link hover:text-link-hover"> <Link to="/docs" className="text-link hover:text-link-hover">
See the commands See the commands
</Link> </Link>
</p> </p>
</Prose> </Prose>
</Section> </Section>
)
const GetStarted = () => (
<Section band> <Section band>
<Cols center> <Cols center>
<div className="flex flex-col gap-3.5"> <div className="flex flex-col gap-3.5">
@@ -313,6 +312,15 @@ export const Home = () => {
/> />
</Cols> </Cols>
</Section> </Section>
)
export const Home = () => (
<>
<Hero />
<RunsOn />
<About />
<Features />
<Agents />
<GetStarted />
</> </>
) )
}
+21 -13
View File
@@ -250,14 +250,7 @@ const Step = ({ n, title, children }: StepProps) => (
</div> </div>
) )
export const Install = () => { const Steps = () => (
return (
<>
<PageHead eyebrow="install" title="Three ways in.">
One command with Homebrew or cargo. Build from source if you&rsquo;d rather. Or
depend on the library and write your own client.
</PageHead>
<Section> <Section>
<div className="flex flex-col gap-9"> <div className="flex flex-col gap-9">
<Step n={1} title="Install the client"> <Step n={1} title="Install the client">
@@ -376,7 +369,8 @@ export const Install = () => {
</Step> </Step>
</div> </div>
</Section> </Section>
)
const Reachable = () => (
<Section> <Section>
<SectionHead eyebrow="being reachable" title="Let peers connect back."> <SectionHead eyebrow="being reachable" title="Let peers connect back.">
Browsing and downloading are peer-to-peer, so at least one side has to accept an Browsing and downloading are peer-to-peer, so at least one side has to accept an
@@ -418,7 +412,8 @@ export const Install = () => {
</Callout> </Callout>
</Cols> </Cols>
</Section> </Section>
)
const Platforms = () => (
<Section id="platforms"> <Section id="platforms">
<SectionHead eyebrow="platform notes" title="Where things live."> <SectionHead eyebrow="platform notes" title="Where things live.">
Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '} Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '}
@@ -481,7 +476,8 @@ export const Install = () => {
</FeatureCard> </FeatureCard>
</Cols> </Cols>
</Section> </Section>
)
const Config = () => (
<Section id="config"> <Section id="config">
<SectionHead eyebrow="config.toml" title="Nine settings, all optional."> <SectionHead eyebrow="config.toml" title="Nine settings, all optional.">
Every one has a working default, so an empty file, or no file at all, is valid.{' '} Every one has a working default, so an empty file, or no file at all, is valid.{' '}
@@ -522,7 +518,8 @@ export const Install = () => {
</div> </div>
</Cols> </Cols>
</Section> </Section>
)
const Uninstall = () => (
<Section band> <Section band>
<SectionHead eyebrow="uninstall" title="Removing it completely."> <SectionHead eyebrow="uninstall" title="Removing it completely.">
Three things exist on disk: the binary, the config, and the state &mdash; plus Three things exist on disk: the binary, the config, and the state &mdash; plus
@@ -570,6 +567,17 @@ export const Install = () => {
</Callout> </Callout>
</div> </div>
</Section> </Section>
)
export const Install = () => (
<>
<PageHead eyebrow="install" title="Three ways in.">
One command with Homebrew or cargo. Build from source if you&rsquo;d rather. Or depend
on the library and write your own client.
</PageHead>
<Steps />
<Reachable />
<Platforms />
<Config />
<Uninstall />
</> </>
) )
}