From 2e9cd6b860180317e54045570c4ea3c50543023d Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Sun, 26 Jul 2026 15:40:09 +0200 Subject: [PATCH] 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. --- website/src/components/tui/hero-terminal.tsx | 364 +---------- website/src/components/tui/use-hero-demo.ts | 215 +++++++ website/src/components/tui/use-results.ts | 77 +++ website/src/components/tui/use-transfers.ts | 109 ++++ website/src/pages/docs.tsx | 359 +++++------ website/src/pages/home.tsx | 402 ++++++------ website/src/pages/install.tsx | 630 ++++++++++--------- 7 files changed, 1128 insertions(+), 1028 deletions(-) create mode 100644 website/src/components/tui/use-hero-demo.ts create mode 100644 website/src/components/tui/use-results.ts create mode 100644 website/src/components/tui/use-transfers.ts diff --git a/website/src/components/tui/hero-terminal.tsx b/website/src/components/tui/hero-terminal.tsx index 50ce0a1..5806214 100644 --- a/website/src/components/tui/hero-terminal.tsx +++ b/website/src/components/tui/hero-terminal.tsx @@ -1,328 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' - -import { MacWindow, ShortcutBar, StatusLine, type StatusCounts } from './chrome' -import { - RESULTS, - SEARCHES, - SHARE, - TRANSFERS, - type ResultRow, - type SearchRow, - type TransferRow, -} from './data' +import { MacWindow, ShortcutBar, StatusLine } from './chrome' +import { SHARE } from './data' import { InfoPane, ResultsPane, SearchesPane, TransfersPane } from './panes' import { useFitScale } from './use-fit-scale' +import { useHeroDemo } from './use-hero-demo' const DESIGN_WIDTH = 1360 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 = () => { - const [searches, setSearches] = useState(SEARCHES) - const [results, setResults] = - useState>>(RESULTS) - const [activeId, setActiveId] = useState('s1') - const [transfers, setTransfers] = useState(TRANSFERS) - const [focus, setFocus] = useState(2) - - const [phase, setPhase] = useState('searching') - const [selected, setSelected] = useState(0) - - const shellRef = useRef(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(() => { - 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) => { - 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[] = [] - 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 demo = useHeroDemo() 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 (
{ >
- +
{ - setActiveId(id) - setSelected(0) - setFocus(1) - setPhase('interactive') - }} - onSubmit={submitSearch} + searches={demo.searches} + activeId={demo.activeId} + active={demo.focus === 1} + onPick={demo.pickSearch} + onSubmit={demo.submitSearch} />
{ - setSelected(i) - setFocus(2) - }} - onQueue={(i) => { - queueIndexes([i]) - setFocus(3) - }} + query={demo.query} + rows={demo.rows} + selected={demo.selected} + active={demo.focus === 2} + onSelect={demo.selectRow} + onQueue={demo.queueRow} /> - {phase === 'searching' && ( + {demo.searching && (
searching the network @@ -395,10 +71,10 @@ export const HeroTerminal = () => { style={{ gridTemplateColumns: '1fr 340px' }} >
- +
- +
diff --git a/website/src/components/tui/use-hero-demo.ts b/website/src/components/tui/use-hero-demo.ts new file mode 100644 index 0000000..07cbeaf --- /dev/null +++ b/website/src/components/tui/use-hero-demo.ts @@ -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) => { + 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[] = [] + 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(SEARCHES) + const [focus, setFocus] = useState(2) + const [phase, setPhase] = useState('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) => { + handleKey( + { rows, selected, moveRow, focusPane: setFocus, queueIndexes, toggleMark, markAll }, + event, + ) + }, + } +} diff --git a/website/src/components/tui/use-results.ts b/website/src/components/tui/use-results.ts new file mode 100644 index 0000000..38c2c97 --- /dev/null +++ b/website/src/components/tui/use-results.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { RESULTS, type ResultRow } from './data' + +type Results = Readonly> + +/** 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) + 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 })) + }, + } +} diff --git a/website/src/components/tui/use-transfers.ts b/website/src/components/tui/use-transfers.ts new file mode 100644 index 0000000..0fe556e --- /dev/null +++ b/website/src/components/tui/use-transfers.ts @@ -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(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(() => { + 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 } +} diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx index c361919..3d31b91 100644 --- a/website/src/pages/docs.tsx +++ b/website/src/pages/docs.tsx @@ -71,185 +71,192 @@ const KeyRow = ({ combo, description }: { combo: string; description: string })
) -export const Docs = () => { - return ( - <> - - This is the short version. The full reference (every flag, every message) lives in - the README and on docs.rs. - +const FirstRun = () => ( +
+ + Homebrew puts the binary on your PATH. With a Rust toolchain,{' '} + cargo install soulseek-rs does the same. + + + + +

+ On first run you get a login screen: sign in with an existing Soulseek account or + register a new one. Your password goes to the OS keychain, not a plain-text file. +

+

+ Downloads land in a conventional folder, and TUI state (searches, downloads, + rooms) is restored across restarts. +

+
+
+
+) -
- - Homebrew puts the binary on your PATH. With a Rust toolchain,{' '} - cargo install soulseek-rs does the same. - - - - -

- On first run you get a login screen: sign in with an existing Soulseek - account or register a new one. Your password goes to the OS keychain, not a - plain-text file. -

-

- Downloads land in a conventional folder, and TUI state (searches, downloads, - rooms) is restored across restarts. -

-
-
-
+const Layout = () => ( +
+ + The whole client is these four boxes. Press 1–3 to focus + one; the focused pane’s legend turns green. + +
+ {PANES.map((pane) => ( + +

{pane.body}

+
+ ))} +
+
+) -
- - The whole client is these four boxes. Press 1–3 to focus - one; the focused pane’s legend turns green. - -
- {PANES.map((pane) => ( - -

{pane.body}

-
+const Keys = () => ( +
+ + The bottom bar always shows the keys for where you are. The essentials: + + +
+ anywhere +
+ {GLOBAL_KEYS.map(([combo, description]) => ( + ))}
-
- -
- - The bottom bar always shows the keys for where you are. The essentials: - - -
- anywhere -
- {GLOBAL_KEYS.map(([combo, description]) => ( - - ))} -
-
-
- - in the chat-rooms popup (press c) - -
- {ROOM_KEYS.map(([combo, description]) => ( - - ))} -
-
-
-
- -
- - Skip the TUI when you’re scripting or mid-task. stdout is data, stderr is - progress, and the exit code is the verdict. - - - --json' }, - { t: 'cmd', text: 'soulseek-rs room list' }, - { t: 'cm', text: '# public rooms, busiest first' }, - { t: 'cmd', text: 'soulseek-rs room say "hello room"' }, - { t: 'cmd', text: 'soulseek-rs message send "hello there"' }, - { t: 'cmd', text: 'soulseek-rs serve --follow' }, - { t: 'cm', text: '# stay online sharing; every upload as it happens' }, - { t: 'cmd', text: 'soulseek-rs skills install' }, - { t: 'cm', text: '# hand all of the above to your coding agent' }, - ]} - /> - -

- The command groups: search, get,{' '} - download, browse, room,{' '} - message, serve, user,{' '} - whoami, shares, config,{' '} - portmap, skills, completions. Records - are tab-separated fields, one per line, or NDJSON with --json. -

-

- No TTY, no daemon. search --json pipes through jq{' '} - into download --stdin. Every ending has its own exit code: 4 found - nothing, 3 could not reach the server, 6 the transfer died. -

-

- completions install gives bash, zsh and fish tab completion for - all of it, generated from the same definition the binary parses with.{' '} - completions uninstall takes it back out. -

-

- portmap tests whether your router will let peers connect back, - worth running once.{' '} - - More on being reachable - -

-
-
-
- -

- soulseek-rs skills install writes a SKILL.md into - every coding agent on the machine: Claude Code and opencode today,{' '} - --dir for anything else. After it lands you describe the - outcome instead of the pipeline: “mirror that peer’s FLAC - folder”, “watch this room for an hour and save anything - mentioning the label”. -

-

- It carries what --help cannot: the JSON keys of every record, - and what each exit code means. Rerun it after an upgrade;{' '} - skills uninstall takes it back out. -

-
+
+
+ + in the chat-rooms popup (press c) + +
+ {ROOM_KEYS.map(([combo, description]) => ( + + ))}
-
- -

- If both you and a peer are behind routers with no forwarded port, browsing - that peer can’t work, that’s a fundamental Soulseek/peer-to-peer - limitation, not a bug. -

-
-
-
+
+ +
+) -
- - - -

- The README on GitHub and the library API reference on docs.rs stay current - with the code. -

-
-
- - - -
-
-
-
- - ) -} +const FromTheShell = () => ( +
+ + Skip the TUI when you’re scripting or mid-task. stdout is data, stderr is + progress, and the exit code is the verdict. + + + --json' }, + { t: 'cmd', text: 'soulseek-rs room list' }, + { t: 'cm', text: '# public rooms, busiest first' }, + { t: 'cmd', text: 'soulseek-rs room say "hello room"' }, + { t: 'cmd', text: 'soulseek-rs message send "hello there"' }, + { t: 'cmd', text: 'soulseek-rs serve --follow' }, + { t: 'cm', text: '# stay online sharing; every upload as it happens' }, + { t: 'cmd', text: 'soulseek-rs skills install' }, + { t: 'cm', text: '# hand all of the above to your coding agent' }, + ]} + /> + +

+ The command groups: search, get, download,{' '} + browse, room, message, serve,{' '} + user, whoami, shares, config,{' '} + portmap, skills, completions. Records are + tab-separated fields, one per line, or NDJSON with --json. +

+

+ No TTY, no daemon. search --json pipes through jq into{' '} + download --stdin. Every ending has its own exit code: 4 found + nothing, 3 could not reach the server, 6 the transfer died. +

+

+ completions install gives bash, zsh and fish tab completion for all + of it, generated from the same definition the binary parses with.{' '} + completions uninstall takes it back out. +

+

+ portmap tests whether your router will let peers connect back, worth + running once.{' '} + + More on being reachable + +

+
+
+
+ +

+ soulseek-rs skills install writes a SKILL.md into every + coding agent on the machine: Claude Code and opencode today, --dir{' '} + for anything else. After it lands you describe the outcome instead of the + pipeline: “mirror that peer’s FLAC folder”, “watch this + room for an hour and save anything mentioning the label”. +

+

+ It carries what --help cannot: the JSON keys of every record, and + what each exit code means. Rerun it after an upgrade;{' '} + skills uninstall takes it back out. +

+
+
+
+ +

+ If both you and a peer are behind routers with no forwarded port, browsing that + peer can’t work, that’s a fundamental Soulseek/peer-to-peer + limitation, not a bug. +

+
+
+
+) + +const TheRest = () => ( +
+ + + +

+ The README on GitHub and the library API reference on docs.rs stay current with + the code. +

+
+
+ + + +
+
+
+
+) + +export const Docs = () => ( + <> + + This is the short version. The full reference (every flag, every message) lives in the + README and on docs.rs. + + + + + + + +) diff --git a/website/src/pages/home.tsx b/website/src/pages/home.tsx index 595eef3..f13b259 100644 --- a/website/src/pages/home.tsx +++ b/website/src/pages/home.tsx @@ -117,202 +117,210 @@ const FEATURES = [ }, ] as const -export const Home = () => { - return ( - <> -
- -
- Soulseek client · terminal · Rust -
- -

- A soulseek client for the terminal. Built for agents and people who live - there. -

-
-

- Search the network, share your files, browse someone’s collection, join - a room. It runs over ssh on the machine where your music already lives. -

-
-
- - - -
- -
-
- -
- -
-
-
- -
- -
- Runs on -
    - {PLATFORMS.map((platform, i) => ( -
  • - - - - - {platform.name} - - {platform.note} - - -
  • - ))} -
-
-
-
- -
- - No charts, no recommendations. You search, you find the person who has it, you - download it from them. - - - -

- Soulseek is a peer-to-peer network from the early 2000s. People still use it - to share niche music by hand: the album that exists in one person’s - folder and nowhere else. -

-

- soulseek-rs speaks that protocol. It’s a third-party client, not the - official app. It shares, browses, and does rooms and private messages, - because a client that only downloads takes without giving anything back. -

-
-
- -

- Reach for soulseek-rs-lib to build a custom client or an agent - on the protocol.{' '} - - Use the library - -

-
- -

- 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 - stand up. skills install teaches the agent the surface for - you.{' '} - - See the commands - -

-
-
-
-
- -
- - Everything happens in the TUI, and every command also runs as a one-off from the - shell. - -
- {FEATURES.map((feature, i) => ( - - {feature.body} - - ))} +const Hero = () => ( +
+ +
+ Soulseek client · terminal · Rust +
+ +

+ A soulseek client for the terminal. Built for agents and people who live there. +

-
- -
- - An agent can run any CLI. skills install teaches it to run this one - well. - - an agent loading the soulseek-rs skill and downloading tracks - -

- --help lists flags. It cannot say which JSON keys a record - carries, or that exit 4 means widen the query rather than retry it. An agent - without that guesses, and guessing against a stranger’s file server - hangs the job. -

-

- skills install fills that in. The requests stop being pipelines - you write: mirror a peer’s FLAC folder overnight, or check whether a - user is worth queueing behind before a 2 GB transfer. -

-

- It stays a CLI: no daemon, no MCP server, no API key. Upgrading the binary - upgrades the skill; rerun skills install to take it.{' '} - - See the commands - -

-
-
- -
- -
- get started -

- Install it, then press s to search. -

-

- One command on macOS or Linux, then the TUI opens. No Rust toolchain needed. -

-
- - -
+

+ Search the network, share your files, browse someone’s collection, join a + room. It runs over ssh on the machine where your music already lives. +

+
+
+ + +
- - -
- - ) -} + +
+
+ +
+ +
+ + +) + +const RunsOn = () => ( +
+ +
+ Runs on +
    + {PLATFORMS.map((platform, i) => ( +
  • + + + + + {platform.name} + + {platform.note} + + +
  • + ))} +
+
+
+
+) + +const About = () => ( +
+ + No charts, no recommendations. You search, you find the person who has it, you + download it from them. + + + +

+ Soulseek is a peer-to-peer network from the early 2000s. People still use it to + share niche music by hand: the album that exists in one person’s folder and + nowhere else. +

+

+ soulseek-rs speaks that protocol. It’s a third-party client, not the + official app. It shares, browses, and does rooms and private messages, because a + client that only downloads takes without giving anything back. +

+
+
+ +

+ Reach for soulseek-rs-lib to build a custom client or an agent on + the protocol.{' '} + + Use the library + +

+
+ +

+ 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 stand + up. skills install teaches the agent the surface for you.{' '} + + See the commands + +

+
+
+
+
+) + +const Features = () => ( +
+ + Everything happens in the TUI, and every command also runs as a one-off from the + shell. + +
+ {FEATURES.map((feature, i) => ( + + {feature.body} + + ))} +
+
+) + +const Agents = () => ( +
+ + An agent can run any CLI. skills install teaches it to run this one well. + + an agent loading the soulseek-rs skill and downloading tracks + +

+ --help lists flags. It cannot say which JSON keys a record carries, or + that exit 4 means widen the query rather than retry it. An agent without that + guesses, and guessing against a stranger’s file server hangs the job. +

+

+ skills install fills that in. The requests stop being pipelines you + write: mirror a peer’s FLAC folder overnight, or check whether a user is worth + queueing behind before a 2 GB transfer. +

+

+ It stays a CLI: no daemon, no MCP server, no API key. Upgrading the binary upgrades + the skill; rerun skills install to take it.{' '} + + See the commands + +

+
+
+) + +const GetStarted = () => ( +
+ +
+ get started +

+ Install it, then press s to search. +

+

+ One command on macOS or Linux, then the TUI opens. No Rust toolchain needed. +

+
+ + +
+
+ +
+
+) + +export const Home = () => ( + <> + + + + + + + +) diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index 93a28b1..3724de7 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -250,326 +250,334 @@ const Step = ({ n, title, children }: StepProps) => (
) -export const Install = () => { - return ( - <> - - One command with Homebrew or cargo. Build from source if you’d rather. Or - depend on the library and write your own client. - - -
-
- -

- One command per platform. Cargo needs a Rust toolchain from{' '} - - rustup.rs - - ; the other routes don’t. -

-
- {INSTALLS.map((route) => ( -
-
- -

- {route.os} -

-
- - {route.best} - - -

- {route.alt} -

-
- ))} -
-

- Every route puts the same binary on your PATH. Run soulseek-rs in - a terminal and the TUI opens, in a script or a pipe it wants a subcommand - instead. -

-

- soulseek-rs completions install then adds tab completion to - whichever of bash, zsh and fish this machine uses — generated from the - same definition the binary parses with, so it never drifts from{' '} - --help. Open a new shell to pick it up. -

-
- -
-
- - -

- Clone the workspace and build a release binary. -

- -

- You’ll find the binary at target/release/soulseek-rs. - Prebuilt archives for tagged releases are on the{' '} - - releases page - - . -

-
- - -

- Write your own client or bot on soulseek-rs-lib: the protocol - implementation, separate from the TUI. -

- - -

- Full API reference on{' '} - - docs.rs - - . The lib API changes between majors, so check the{' '} - - changelog - {' '} - when you upgrade. -

-
-
-
- -
- - Browsing and downloading are peer-to-peer, so at least one side has to accept an - incoming connection. serve refuses to start with the listener off. - - - -

- With the listener on (the default), soulseek-rs tries to open its listen port - on your router via{' '} - UPnP-IGD and{' '} - NAT-PMP. It’s best-effort: - if your router has those disabled it’s a no-op, and you forward the port - yourself. -

-

- The port is your --listener-port (env{' '} - SOULSEEK_LISTENER_PORT, default 2234); soulseek-rs - renews it while it runs and removes it on exit. Pass{' '} - --no-listener to turn it off. Check your own network without - launching the client: -

-
- -
-
- -

- If both you and a peer are behind routers with no forwarded port, browsing - that peer can’t work, that’s a fundamental Soulseek/peer-to-peer - limitation, not a bug. Forward --listener-port, or let{' '} - portmap try UPnP. -

-
-
-
- -
- - Sensible defaults per OS, all overridable. Set SOULSEEK_CONFIG_DIR or{' '} - SOULSEEK_STATE_DIR to relocate either one, which also makes a - portable install possible. - +const Steps = () => ( +
+
+ +

+ One command per platform. Cargo needs a Rust toolchain from{' '} + + rustup.rs + + ; the other routes don’t. +

- {PLATFORMS.map((platform) => ( + {INSTALLS.map((route) => (
-

- {platform.name} -

-
- {( - [ - ['Password', platform.credentialStore, false], - ['Config', platform.config, true], - ['State', platform.state, true], - ['Downloads', platform.downloads, true], - ] as const - ).map(([label, value, mono]) => ( -
-
- {label} -
-
- {mono ? ( - {value} - ) : ( - value - )} -
-
- ))} -
-

- {platform.note} +

+ +

+ {route.os} +

+
+ + {route.best} + + +

+ {route.alt}

))}
+

+ Every route puts the same binary on your PATH. Run soulseek-rs in + a terminal and the TUI opens, in a script or a pipe it wants a subcommand + instead. +

+

+ soulseek-rs completions install then adds tab completion to + whichever of bash, zsh and fish this machine uses — generated from the + same definition the binary parses with, so it never drifts from{' '} + --help. Open a new shell to pick it up. +

+
+ +
+
- - - Your password goes to the OS keychain, never a plain-text file. Point{' '} - password_cmd at your own password manager and it is never stored - at all. For CI and containers,{' '} - --password-stdin reads it off a pipe so it never reaches{' '} - ps or the environment. - - - config.toml is layered: flags beat environment variables, which - beat the file, which beats the defaults. State (searches, downloads, rooms) is - versioned JSON and restores on restart; soulseek-rs sets anything unreadable - aside as .bak rather than overwriting it. - - -
+ +

+ Clone the workspace and build a release binary. +

+ +

+ You’ll find the binary at target/release/soulseek-rs. + Prebuilt archives for tagged releases are on the{' '} + + releases page + + . +

+
-
- - Every one has a working default, so an empty file, or no file at all, is valid.{' '} - config list prints the effective value of each. - - -
- {SETTINGS.map((setting) => ( -
-
- - {setting.key} - - - {setting.fallback} - -
-

- {setting.body} -

+ +

+ Write your own client or bot on soulseek-rs-lib: the protocol + implementation, separate from the TUI. +

+ + +

+ Full API reference on{' '} + + docs.rs + + . The lib API changes between majors, so check the{' '} + + changelog + {' '} + when you upgrade. +

+
+
+
+) +const Reachable = () => ( +
+ + Browsing and downloading are peer-to-peer, so at least one side has to accept an + incoming connection. serve refuses to start with the listener off. + + + +

+ With the listener on (the default), soulseek-rs tries to open its listen port + on your router via{' '} + UPnP-IGD and{' '} + NAT-PMP. It’s best-effort: + if your router has those disabled it’s a no-op, and you forward the port + yourself. +

+

+ The port is your --listener-port (env{' '} + SOULSEEK_LISTENER_PORT, default 2234); soulseek-rs + renews it while it runs and removes it on exit. Pass{' '} + --no-listener to turn it off. Check your own network without + launching the client: +

+
+ +
+
+ +

+ If both you and a peer are behind routers with no forwarded port, browsing + that peer can’t work, that’s a fundamental Soulseek/peer-to-peer + limitation, not a bug. Forward --listener-port, or let{' '} + portmap try UPnP. +

+
+
+
+) +const Platforms = () => ( +
+ + Sensible defaults per OS, all overridable. Set SOULSEEK_CONFIG_DIR or{' '} + SOULSEEK_STATE_DIR to relocate either one, which also makes a + portable install possible. + +
+ {PLATFORMS.map((platform) => ( +
+

+ {platform.name} +

+
+ {( + [ + ['Password', platform.credentialStore, false], + ['Config', platform.config, true], + ['State', platform.state, true], + ['Downloads', platform.downloads, true], + ] as const + ).map(([label, value, mono]) => ( +
+
+ {label} +
+
+ {mono ? ( + {value} + ) : ( + value + )} +
))} -
-
- - -

- config path prints the file in use, config get{' '} - reads one setting and config set writes one. It rejects - unknown keys, so a typo fails instead of doing nothing.{' '} - --no-config ignores the file entirely and{' '} - --config FILE points at another one. -

-
-
- -
- -
- - Three things exist on disk: the binary, the config, and the state — plus - the completion scripts and the agent skill, if you asked for those. Nothing else, - unless you point --log-file somewhere. No telemetry to opt out of. - - - - Windows Credentials' }, - ]} - /> - -
- -

- Your downloads and your shared directories are yours: they stay where they - are. On macOS, delete the keychain entry for soulseek-rs in - Keychain Access if you saved a password. -

-
+ +

+ {platform.note} +

-
- - ) -} + ))} +
+ + + + Your password goes to the OS keychain, never a plain-text file. Point{' '} + password_cmd at your own password manager and it is never stored + at all. For CI and containers,{' '} + --password-stdin reads it off a pipe so it never reaches{' '} + ps or the environment. + + + config.toml is layered: flags beat environment variables, which + beat the file, which beats the defaults. State (searches, downloads, rooms) is + versioned JSON and restores on restart; soulseek-rs sets anything unreadable + aside as .bak rather than overwriting it. + + + +) +const Config = () => ( +
+ + Every one has a working default, so an empty file, or no file at all, is valid.{' '} + config list prints the effective value of each. + + +
+ {SETTINGS.map((setting) => ( +
+
+ + {setting.key} + + + {setting.fallback} + +
+

+ {setting.body} +

+
+ ))} +
+
+ + +

+ config path prints the file in use, config get{' '} + reads one setting and config set writes one. It rejects + unknown keys, so a typo fails instead of doing nothing.{' '} + --no-config ignores the file entirely and{' '} + --config FILE points at another one. +

+
+
+
+
+) +const Uninstall = () => ( +
+ + Three things exist on disk: the binary, the config, and the state — plus + the completion scripts and the agent skill, if you asked for those. Nothing else, + unless you point --log-file somewhere. No telemetry to opt out of. + + + + Windows Credentials' }, + ]} + /> + +
+ +

+ Your downloads and your shared directories are yours: they stay where they + are. On macOS, delete the keychain entry for soulseek-rs in + Keychain Access if you saved a password. +

+
+
+
+) +export const Install = () => ( + <> + + One command with Homebrew or cargo. Build from source if you’d rather. Or depend + on the library and write your own client. + + + + + + + +)