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:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
+183
-176
@@ -71,185 +71,192 @@ const KeyRow = ({ combo, description }: { combo: string; description: string })
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Docs = () => {
|
const FirstRun = () => (
|
||||||
return (
|
<Section>
|
||||||
<>
|
<SectionHead eyebrow="1 · first run" title="Two commands to a running client.">
|
||||||
<PageHead
|
Homebrew puts the binary on your PATH. With a Rust toolchain,{' '}
|
||||||
eyebrow="docs · quick start"
|
<Code>cargo install soulseek-rs</Code> does the same.
|
||||||
title="Install it, run it, drive it from the keyboard."
|
</SectionHead>
|
||||||
>
|
<Cols start>
|
||||||
This is the short version. The full reference (every flag, every message) lives in
|
<Terminal
|
||||||
the README and on docs.rs.
|
lines={[
|
||||||
</PageHead>
|
{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs' },
|
||||||
|
{ t: 'cm', text: '# the TUI opens; log in or register on first run' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Prose>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Downloads land in a conventional folder, and TUI state (searches, downloads,
|
||||||
|
rooms) is restored across restarts.
|
||||||
|
</p>
|
||||||
|
</Prose>
|
||||||
|
</Cols>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
|
||||||
<Section>
|
const Layout = () => (
|
||||||
<SectionHead eyebrow="1 · first run" title="Two commands to a running client.">
|
<Section>
|
||||||
Homebrew puts the binary on your PATH. With a Rust toolchain,{' '}
|
<SectionHead eyebrow="2 · the layout" title="Four panes.">
|
||||||
<Code>cargo install soulseek-rs</Code> does the same.
|
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
|
||||||
</SectionHead>
|
one; the focused pane’s legend turns green.
|
||||||
<Cols start>
|
</SectionHead>
|
||||||
<Terminal
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
lines={[
|
{PANES.map((pane) => (
|
||||||
{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' },
|
<Panel key={pane.title} num={pane.num} title={pane.title}>
|
||||||
{ t: 'cmd', text: 'soulseek-rs' },
|
<p className="text-[13px] leading-[22px] text-secondary">{pane.body}</p>
|
||||||
{ t: 'cm', text: '# the TUI opens; log in or register on first run' },
|
</Panel>
|
||||||
]}
|
))}
|
||||||
/>
|
</div>
|
||||||
<Prose>
|
</Section>
|
||||||
<p>
|
)
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Downloads land in a conventional folder, and TUI state (searches, downloads,
|
|
||||||
rooms) is restored across restarts.
|
|
||||||
</p>
|
|
||||||
</Prose>
|
|
||||||
</Cols>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section>
|
const Keys = () => (
|
||||||
<SectionHead eyebrow="2 · the layout" title="Four panes.">
|
<Section>
|
||||||
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
|
<SectionHead eyebrow="3 · keys" title="It's all keyboard.">
|
||||||
one; the focused pane’s legend turns green.
|
The bottom bar always shows the keys for where you are. The essentials:
|
||||||
</SectionHead>
|
</SectionHead>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<Cols>
|
||||||
{PANES.map((pane) => (
|
<div>
|
||||||
<Panel key={pane.title} num={pane.num} title={pane.title}>
|
<Eyebrow className="mb-4 block">anywhere</Eyebrow>
|
||||||
<p className="text-[13px] leading-[22px] text-secondary">{pane.body}</p>
|
<div className="flex flex-col">
|
||||||
</Panel>
|
{GLOBAL_KEYS.map(([combo, description]) => (
|
||||||
|
<KeyRow key={combo} combo={combo} description={description} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
|
<div>
|
||||||
<Section>
|
<Eyebrow className="mb-4 block">
|
||||||
<SectionHead eyebrow="3 · keys" title="It's all keyboard.">
|
in the chat-rooms popup <span className="text-muted">(press c)</span>
|
||||||
The bottom bar always shows the keys for where you are. The essentials:
|
</Eyebrow>
|
||||||
</SectionHead>
|
<div className="flex flex-col">
|
||||||
<Cols>
|
{ROOM_KEYS.map(([combo, description]) => (
|
||||||
<div>
|
<KeyRow key={combo} combo={combo} description={description} />
|
||||||
<Eyebrow className="mb-4 block">anywhere</Eyebrow>
|
))}
|
||||||
<div className="flex flex-col">
|
|
||||||
{GLOBAL_KEYS.map(([combo, description]) => (
|
|
||||||
<KeyRow key={combo} combo={combo} description={description} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Eyebrow className="mb-4 block">
|
|
||||||
in the chat-rooms popup <span className="text-muted">(press c)</span>
|
|
||||||
</Eyebrow>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{ROOM_KEYS.map(([combo, description]) => (
|
|
||||||
<KeyRow key={combo} combo={combo} description={description} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Cols>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section>
|
|
||||||
<SectionHead
|
|
||||||
eyebrow="4 · from the shell"
|
|
||||||
title="Every command also runs as a one-off."
|
|
||||||
>
|
|
||||||
Skip the TUI when you’re scripting or mid-task. stdout is data, stderr is
|
|
||||||
progress, and the exit code is the verdict.
|
|
||||||
</SectionHead>
|
|
||||||
<Cols start>
|
|
||||||
<Terminal
|
|
||||||
label="shell"
|
|
||||||
lines={[
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs get "public domain field recordings"' },
|
|
||||||
{ t: 'cm', text: '# search, pick the best, download it' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs search "netlabel 2004" --min-bitrate 320' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs browse <username> --json' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs room list' },
|
|
||||||
{ t: 'cm', text: '# public rooms, busiest first' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs room say <room> "hello room"' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs message send <username> "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' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Prose>
|
|
||||||
<p>
|
|
||||||
The command groups: <Code>search</Code>, <Code>get</Code>,{' '}
|
|
||||||
<Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '}
|
|
||||||
<Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '}
|
|
||||||
<Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
|
|
||||||
<Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records
|
|
||||||
are tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code>{' '}
|
|
||||||
into <Code>download --stdin</Code>. Every ending has its own exit code: 4 found
|
|
||||||
nothing, 3 could not reach the server, 6 the transfer died.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<Code>completions install</Code> gives bash, zsh and fish tab completion for
|
|
||||||
all of it, generated from the same definition the binary parses with.{' '}
|
|
||||||
<Code>completions uninstall</Code> takes it back out.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<Code>portmap</Code> tests whether your router will let peers connect back,
|
|
||||||
worth running once.{' '}
|
|
||||||
<Link to="/install" className="text-link hover:text-link-hover">
|
|
||||||
More on being reachable
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Prose>
|
|
||||||
</Cols>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Callout title="give it to your agent">
|
|
||||||
<p>
|
|
||||||
<Code>soulseek-rs skills install</Code> writes a <Code>SKILL.md</Code> into
|
|
||||||
every coding agent on the machine: Claude Code and opencode today,{' '}
|
|
||||||
<Code>--dir</Code> 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”.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
It carries what <Code>--help</Code> cannot: the JSON keys of every record,
|
|
||||||
and what each exit code means. Rerun it after an upgrade;{' '}
|
|
||||||
<Code>skills uninstall</Code> takes it back out.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
</div>
|
||||||
<Callout tone="warn" title="the one limit worth knowing up front">
|
</Cols>
|
||||||
<p>
|
</Section>
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section band>
|
const FromTheShell = () => (
|
||||||
<Panel title="the rest" className="bg-raised [&>span]:bg-panel">
|
<Section>
|
||||||
<Cols center className="gap-6">
|
<SectionHead eyebrow="4 · from the shell" title="Every command also runs as a one-off.">
|
||||||
<Prose>
|
Skip the TUI when you’re scripting or mid-task. stdout is data, stderr is
|
||||||
<p>
|
progress, and the exit code is the verdict.
|
||||||
The README on GitHub and the library API reference on docs.rs stay current
|
</SectionHead>
|
||||||
with the code.
|
<Cols start>
|
||||||
</p>
|
<Terminal
|
||||||
</Prose>
|
label="shell"
|
||||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
lines={[
|
||||||
<Button href={LINKS.gh} variant="accent">
|
{ t: 'cmd', text: 'soulseek-rs get "public domain field recordings"' },
|
||||||
README
|
{ t: 'cm', text: '# search, pick the best, download it' },
|
||||||
</Button>
|
{ t: 'cmd', text: 'soulseek-rs search "netlabel 2004" --min-bitrate 320' },
|
||||||
<Button href={LINKS.docsrs}>docs.rs</Button>
|
{ t: 'cmd', text: 'soulseek-rs browse <username> --json' },
|
||||||
<Button href={LINKS.changelog}>Changelog</Button>
|
{ t: 'cmd', text: 'soulseek-rs room list' },
|
||||||
</div>
|
{ t: 'cm', text: '# public rooms, busiest first' },
|
||||||
</Cols>
|
{ t: 'cmd', text: 'soulseek-rs room say <room> "hello room"' },
|
||||||
</Panel>
|
{ t: 'cmd', text: 'soulseek-rs message send <username> "hello there"' },
|
||||||
</Section>
|
{ 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' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Prose>
|
||||||
|
<p>
|
||||||
|
The command groups: <Code>search</Code>, <Code>get</Code>, <Code>download</Code>,{' '}
|
||||||
|
<Code>browse</Code>, <Code>room</Code>, <Code>message</Code>, <Code>serve</Code>,{' '}
|
||||||
|
<Code>user</Code>, <Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
|
||||||
|
<Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records are
|
||||||
|
tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code> into{' '}
|
||||||
|
<Code>download --stdin</Code>. Every ending has its own exit code: 4 found
|
||||||
|
nothing, 3 could not reach the server, 6 the transfer died.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Code>completions install</Code> gives bash, zsh and fish tab completion for all
|
||||||
|
of it, generated from the same definition the binary parses with.{' '}
|
||||||
|
<Code>completions uninstall</Code> takes it back out.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Code>portmap</Code> tests whether your router will let peers connect back, worth
|
||||||
|
running once.{' '}
|
||||||
|
<Link to="/install" className="text-link hover:text-link-hover">
|
||||||
|
More on being reachable
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Prose>
|
||||||
|
</Cols>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Callout title="give it to your agent">
|
||||||
|
<p>
|
||||||
|
<Code>soulseek-rs skills install</Code> writes a <Code>SKILL.md</Code> into every
|
||||||
|
coding agent on the machine: Claude Code and opencode today, <Code>--dir</Code>{' '}
|
||||||
|
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”.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It carries what <Code>--help</Code> cannot: the JSON keys of every record, and
|
||||||
|
what each exit code means. Rerun it after an upgrade;{' '}
|
||||||
|
<Code>skills uninstall</Code> takes it back out.
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Callout tone="warn" title="the one limit worth knowing up front">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const TheRest = () => (
|
||||||
|
<Section band>
|
||||||
|
<Panel title="the rest" className="bg-raised [&>span]:bg-panel">
|
||||||
|
<Cols center className="gap-6">
|
||||||
|
<Prose>
|
||||||
|
<p>
|
||||||
|
The README on GitHub and the library API reference on docs.rs stay current with
|
||||||
|
the code.
|
||||||
|
</p>
|
||||||
|
</Prose>
|
||||||
|
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||||
|
<Button href={LINKS.gh} variant="accent">
|
||||||
|
README
|
||||||
|
</Button>
|
||||||
|
<Button href={LINKS.docsrs}>docs.rs</Button>
|
||||||
|
<Button href={LINKS.changelog}>Changelog</Button>
|
||||||
|
</div>
|
||||||
|
</Cols>
|
||||||
|
</Panel>
|
||||||
|
</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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|||||||
+205
-197
@@ -117,202 +117,210 @@ const FEATURES = [
|
|||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const Home = () => {
|
const Hero = () => (
|
||||||
return (
|
<section className="pt-5 pb-8 sm:pt-7 sm:pb-10">
|
||||||
<>
|
<Wrap>
|
||||||
<section className="pt-5 pb-8 sm:pt-7 sm:pb-10">
|
<div className="flex flex-col gap-[22px]">
|
||||||
<Wrap>
|
<Eyebrow className="max-md:hidden">Soulseek client · terminal · Rust</Eyebrow>
|
||||||
<div className="flex flex-col gap-[22px]">
|
<div className="flex items-center gap-3.5 sm:gap-6 md:items-start md:gap-11">
|
||||||
<Eyebrow className="max-md:hidden">Soulseek client · terminal · Rust</Eyebrow>
|
<Logo size={172} className="max-md:!h-32 md:mt-2" />
|
||||||
<div className="flex items-center gap-3.5 sm:gap-6 md:items-start md:gap-11">
|
<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)]">
|
||||||
<Logo size={172} className="max-md:!h-32 md:mt-2" />
|
A soulseek client for the terminal. Built for agents and people who live there.
|
||||||
<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>
|
||||||
A soulseek client for the terminal. Built for agents and people who live
|
|
||||||
there.
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg leading-[29px] text-pretty text-secondary max-md:hidden">
|
|
||||||
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.
|
|
||||||
</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 gap-2 max-md:w-full max-md:justify-center sm:gap-3">
|
|
||||||
<Button href="/install" variant="accent">
|
|
||||||
Install
|
|
||||||
</Button>
|
|
||||||
<Button href="/docs">Read the docs</Button>
|
|
||||||
<Button href={LINKS.gh}>GitHub</Button>
|
|
||||||
</div>
|
|
||||||
<CommandPill text="brew install michel/tap/soulseek-rs" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3.5 overflow-hidden">
|
|
||||||
<HeroTerminal />
|
|
||||||
</div>
|
|
||||||
</Wrap>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="border-t border-hairline py-6">
|
|
||||||
<Wrap>
|
|
||||||
<div className="flex flex-wrap items-center gap-3.5 sm:gap-8">
|
|
||||||
<Eyebrow className="max-[519px]:hidden">Runs on</Eyebrow>
|
|
||||||
<ul className="flex w-full flex-nowrap justify-center min-[520px]:flex-wrap min-[520px]:justify-start sm:w-auto">
|
|
||||||
{PLATFORMS.map((platform, i) => (
|
|
||||||
<li
|
|
||||||
key={platform.name}
|
|
||||||
className={cn(
|
|
||||||
'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',
|
|
||||||
i === 0
|
|
||||||
? 'sm:w-auto sm:pr-6.5'
|
|
||||||
: 'sm:w-auto sm:border-l sm:px-6.5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to={`/install#${platform.anchor}`}
|
|
||||||
className="group flex min-h-11 w-auto items-center justify-center gap-2.5 border-hairline py-1.5 min-[520px]:w-full min-[520px]:justify-start min-[520px]:border-t min-[520px]:py-2.5 min-[520px]:first:border-t-0 sm:w-auto sm:border-t-0 sm:py-0"
|
|
||||||
>
|
|
||||||
<OsIcon name={platform.name} className="max-[519px]:size-[26px]" />
|
|
||||||
<span className="flex flex-col leading-[1.35] max-[519px]:hidden">
|
|
||||||
<b className="text-[13.5px] font-medium text-primary transition-colors group-hover:text-accent-text">
|
|
||||||
{platform.name}
|
|
||||||
</b>
|
|
||||||
<span className="text-[11.5px] text-secondary">{platform.note}</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Wrap>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Section id="about">
|
|
||||||
<SectionHead
|
|
||||||
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
|
|
||||||
download it from them.
|
|
||||||
</SectionHead>
|
|
||||||
<Cols>
|
|
||||||
<Prose>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</Prose>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Callout title="build your own">
|
|
||||||
<p>
|
|
||||||
Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent
|
|
||||||
on the protocol.{' '}
|
|
||||||
<Link to="/install" className="text-link hover:text-link-hover">
|
|
||||||
Use the library
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
<Callout title="agents and automation">
|
|
||||||
<p>
|
|
||||||
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. <Code>skills install</Code> teaches the agent the surface for
|
|
||||||
you.{' '}
|
|
||||||
<Link to="/docs" className="text-link hover:text-link-hover">
|
|
||||||
See the commands
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
</div>
|
|
||||||
</Cols>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section id="features">
|
|
||||||
<SectionHead eyebrow="features" title="What it does.">
|
|
||||||
Everything happens in the TUI, and every command also runs as a one-off from the
|
|
||||||
shell.
|
|
||||||
</SectionHead>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{FEATURES.map((feature, i) => (
|
|
||||||
<FeatureCard
|
|
||||||
key={feature.title}
|
|
||||||
glyph={feature.glyph}
|
|
||||||
color={feature.color}
|
|
||||||
title={feature.title}
|
|
||||||
cmd={feature.cmd}
|
|
||||||
className={i === 0 ? 'lg:col-span-2' : ''}
|
|
||||||
>
|
|
||||||
{feature.body}
|
|
||||||
</FeatureCard>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
<p className="text-lg leading-[29px] text-pretty text-secondary max-md:hidden">
|
||||||
|
Search the network, share your files, browse someone’s collection, join a
|
||||||
<Section id="agents">
|
room. It runs over ssh on the machine where your music already lives.
|
||||||
<SectionHead eyebrow="agent native" title="Ask for the record. Skip the pipeline.">
|
</p>
|
||||||
An agent can run any CLI. <Code>skills install</Code> teaches it to run this one
|
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-3 sm:gap-y-4">
|
||||||
well.
|
<div className="flex flex-wrap items-center gap-2 max-md:w-full max-md:justify-center sm:gap-3">
|
||||||
</SectionHead>
|
<Button href="/install" variant="accent">
|
||||||
<img
|
Install
|
||||||
src={`${import.meta.env.BASE_URL}agent-demo.svg`}
|
</Button>
|
||||||
alt="an agent loading the soulseek-rs skill and downloading tracks"
|
<Button href="/docs">Read the docs</Button>
|
||||||
className="w-full"
|
<Button href={LINKS.gh}>GitHub</Button>
|
||||||
/>
|
|
||||||
<Prose className="mt-11 max-w-[660px]">
|
|
||||||
<p>
|
|
||||||
<Code>--help</Code> 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.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<Code>skills install</Code> 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.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
It stays a CLI: no daemon, no MCP server, no API key. Upgrading the binary
|
|
||||||
upgrades the skill; rerun <Code>skills install</Code> to take it.{' '}
|
|
||||||
<Link to="/docs" className="text-link hover:text-link-hover">
|
|
||||||
See the commands
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Prose>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section band>
|
|
||||||
<Cols center>
|
|
||||||
<div className="flex flex-col gap-3.5">
|
|
||||||
<Eyebrow>get started</Eyebrow>
|
|
||||||
<h2 className="text-[26px] leading-[34px] font-medium text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
|
||||||
Install it, then press <span className="text-accent-text">s</span> to search.
|
|
||||||
</h2>
|
|
||||||
<p className="text-secondary">
|
|
||||||
One command on macOS or Linux, then the TUI opens. No Rust toolchain needed.
|
|
||||||
</p>
|
|
||||||
<div className="mt-1.5 flex flex-wrap gap-2 sm:gap-3">
|
|
||||||
<Button href="/docs" variant="accent">
|
|
||||||
Read the docs
|
|
||||||
</Button>
|
|
||||||
<Button href="/install">Install options</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Terminal
|
<CommandPill text="brew install michel/tap/soulseek-rs" />
|
||||||
label="quick start"
|
</div>
|
||||||
lines={[
|
</div>
|
||||||
{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs' },
|
<div className="mt-3.5 overflow-hidden">
|
||||||
]}
|
<HeroTerminal />
|
||||||
/>
|
</div>
|
||||||
</Cols>
|
</Wrap>
|
||||||
</Section>
|
</section>
|
||||||
</>
|
)
|
||||||
)
|
|
||||||
}
|
const RunsOn = () => (
|
||||||
|
<section className="border-t border-hairline py-6">
|
||||||
|
<Wrap>
|
||||||
|
<div className="flex flex-wrap items-center gap-3.5 sm:gap-8">
|
||||||
|
<Eyebrow className="max-[519px]:hidden">Runs on</Eyebrow>
|
||||||
|
<ul className="flex w-full flex-nowrap justify-center min-[520px]:flex-wrap min-[520px]:justify-start sm:w-auto">
|
||||||
|
{PLATFORMS.map((platform, i) => (
|
||||||
|
<li
|
||||||
|
key={platform.name}
|
||||||
|
className={cn(
|
||||||
|
'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',
|
||||||
|
i === 0 ? 'sm:w-auto sm:pr-6.5' : 'sm:w-auto sm:border-l sm:px-6.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={`/install#${platform.anchor}`}
|
||||||
|
className="group flex min-h-11 w-auto items-center justify-center gap-2.5 border-hairline py-1.5 min-[520px]:w-full min-[520px]:justify-start min-[520px]:border-t min-[520px]:py-2.5 min-[520px]:first:border-t-0 sm:w-auto sm:border-t-0 sm:py-0"
|
||||||
|
>
|
||||||
|
<OsIcon name={platform.name} className="max-[519px]:size-[26px]" />
|
||||||
|
<span className="flex flex-col leading-[1.35] max-[519px]:hidden">
|
||||||
|
<b className="text-[13.5px] font-medium text-primary transition-colors group-hover:text-accent-text">
|
||||||
|
{platform.name}
|
||||||
|
</b>
|
||||||
|
<span className="text-[11.5px] text-secondary">{platform.note}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Wrap>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const About = () => (
|
||||||
|
<Section id="about">
|
||||||
|
<SectionHead 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
|
||||||
|
download it from them.
|
||||||
|
</SectionHead>
|
||||||
|
<Cols>
|
||||||
|
<Prose>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</Prose>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Callout title="build your own">
|
||||||
|
<p>
|
||||||
|
Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent on
|
||||||
|
the protocol.{' '}
|
||||||
|
<Link to="/install" className="text-link hover:text-link-hover">
|
||||||
|
Use the library
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
<Callout title="agents and automation">
|
||||||
|
<p>
|
||||||
|
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. <Code>skills install</Code> teaches the agent the surface for you.{' '}
|
||||||
|
<Link to="/docs" className="text-link hover:text-link-hover">
|
||||||
|
See the commands
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
</Cols>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Features = () => (
|
||||||
|
<Section id="features">
|
||||||
|
<SectionHead eyebrow="features" title="What it does.">
|
||||||
|
Everything happens in the TUI, and every command also runs as a one-off from the
|
||||||
|
shell.
|
||||||
|
</SectionHead>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{FEATURES.map((feature, i) => (
|
||||||
|
<FeatureCard
|
||||||
|
key={feature.title}
|
||||||
|
glyph={feature.glyph}
|
||||||
|
color={feature.color}
|
||||||
|
title={feature.title}
|
||||||
|
cmd={feature.cmd}
|
||||||
|
className={i === 0 ? 'lg:col-span-2' : ''}
|
||||||
|
>
|
||||||
|
{feature.body}
|
||||||
|
</FeatureCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Agents = () => (
|
||||||
|
<Section id="agents">
|
||||||
|
<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 well.
|
||||||
|
</SectionHead>
|
||||||
|
<img
|
||||||
|
src={`${import.meta.env.BASE_URL}agent-demo.svg`}
|
||||||
|
alt="an agent loading the soulseek-rs skill and downloading tracks"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<Prose className="mt-11 max-w-[660px]">
|
||||||
|
<p>
|
||||||
|
<Code>--help</Code> 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Code>skills install</Code> 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It stays a CLI: no daemon, no MCP server, no API key. Upgrading the binary upgrades
|
||||||
|
the skill; rerun <Code>skills install</Code> to take it.{' '}
|
||||||
|
<Link to="/docs" className="text-link hover:text-link-hover">
|
||||||
|
See the commands
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Prose>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const GetStarted = () => (
|
||||||
|
<Section band>
|
||||||
|
<Cols center>
|
||||||
|
<div className="flex flex-col gap-3.5">
|
||||||
|
<Eyebrow>get started</Eyebrow>
|
||||||
|
<h2 className="text-[26px] leading-[34px] font-medium text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||||
|
Install it, then press <span className="text-accent-text">s</span> to search.
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary">
|
||||||
|
One command on macOS or Linux, then the TUI opens. No Rust toolchain needed.
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-2 sm:gap-3">
|
||||||
|
<Button href="/docs" variant="accent">
|
||||||
|
Read the docs
|
||||||
|
</Button>
|
||||||
|
<Button href="/install">Install options</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Terminal
|
||||||
|
label="quick start"
|
||||||
|
lines={[
|
||||||
|
{ t: 'cmd', text: 'brew install michel/tap/soulseek-rs' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Cols>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Home = () => (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
<RunsOn />
|
||||||
|
<About />
|
||||||
|
<Features />
|
||||||
|
<Agents />
|
||||||
|
<GetStarted />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|||||||
+319
-311
@@ -250,326 +250,334 @@ const Step = ({ n, title, children }: StepProps) => (
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Install = () => {
|
const Steps = () => (
|
||||||
return (
|
<Section>
|
||||||
<>
|
<div className="flex flex-col gap-9">
|
||||||
<PageHead eyebrow="install" title="Three ways in.">
|
<Step n={1} title="Install the client">
|
||||||
One command with Homebrew or cargo. Build from source if you’d rather. Or
|
<p className="text-secondary">
|
||||||
depend on the library and write your own client.
|
One command per platform. Cargo needs a Rust toolchain from{' '}
|
||||||
</PageHead>
|
<a
|
||||||
|
href={LINKS.rustup}
|
||||||
<Section>
|
target="_blank"
|
||||||
<div className="flex flex-col gap-9">
|
rel="noopener noreferrer"
|
||||||
<Step n={1} title="Install the client">
|
className="text-link hover:text-link-hover"
|
||||||
<p className="text-secondary">
|
>
|
||||||
One command per platform. Cargo needs a Rust toolchain from{' '}
|
rustup.rs
|
||||||
<a
|
</a>
|
||||||
href={LINKS.rustup}
|
; the other routes don’t.
|
||||||
target="_blank"
|
</p>
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-link hover:text-link-hover"
|
|
||||||
>
|
|
||||||
rustup.rs
|
|
||||||
</a>
|
|
||||||
; the other routes don’t.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
||||||
{INSTALLS.map((route) => (
|
|
||||||
<div
|
|
||||||
key={route.id}
|
|
||||||
id={route.id}
|
|
||||||
className="flex scroll-mt-20 flex-col gap-3.5 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<OsIcon name={route.os} />
|
|
||||||
<h3 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
|
|
||||||
{route.os}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary">
|
|
||||||
{route.best}
|
|
||||||
</span>
|
|
||||||
<Terminal lines={route.lines} wrap />
|
|
||||||
<p className="text-[12.5px] leading-5 text-secondary [&_code]:font-mono [&_code]:text-primary">
|
|
||||||
{route.alt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-muted">
|
|
||||||
Every route puts the same binary on your PATH. Run <Code>soulseek-rs</Code> in
|
|
||||||
a terminal and the TUI opens, in a script or a pipe it wants a subcommand
|
|
||||||
instead.
|
|
||||||
</p>
|
|
||||||
<p className="text-[13px] text-muted">
|
|
||||||
<Code>soulseek-rs completions install</Code> 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{' '}
|
|
||||||
<Code>--help</Code>. Open a new shell to pick it up.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
|
||||||
<Button href={LINKS.releases}>Prebuilt binaries</Button>
|
|
||||||
</div>
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step n={2} title="Build from source">
|
|
||||||
<p className="text-secondary">
|
|
||||||
Clone the workspace and build a release binary.
|
|
||||||
</p>
|
|
||||||
<Terminal
|
|
||||||
lines={[
|
|
||||||
{ t: 'cmd', text: 'git clone https://github.com/michel/soulseek-rs.git' },
|
|
||||||
{ t: 'cmd', text: 'cd soulseek-rs' },
|
|
||||||
{ t: 'cmd', text: 'cargo build --release' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<p className="text-[13px] text-muted">
|
|
||||||
You’ll find the binary at <Code>target/release/soulseek-rs</Code>.
|
|
||||||
Prebuilt archives for tagged releases are on the{' '}
|
|
||||||
<a
|
|
||||||
href={LINKS.releases}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-link hover:text-link-hover"
|
|
||||||
>
|
|
||||||
releases page
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step n={3} title="Build on the library">
|
|
||||||
<p className="text-secondary">
|
|
||||||
Write your own client or bot on <Code>soulseek-rs-lib</Code>: the protocol
|
|
||||||
implementation, separate from the TUI.
|
|
||||||
</p>
|
|
||||||
<Terminal
|
|
||||||
label="Cargo.toml"
|
|
||||||
lines={[
|
|
||||||
{ t: 'cm', text: '[dependencies]' },
|
|
||||||
{ t: 'code', text: 'soulseek-rs-lib = "8"' },
|
|
||||||
]}
|
|
||||||
copy={'[dependencies]\nsoulseek-rs-lib = "8"'}
|
|
||||||
/>
|
|
||||||
<Terminal label="src/main.rs" lines={LIB_LINES} copy={LIB_SRC} />
|
|
||||||
<p className="text-[13px] text-muted">
|
|
||||||
Full API reference on{' '}
|
|
||||||
<a
|
|
||||||
href={LINKS.docsrs}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-link hover:text-link-hover"
|
|
||||||
>
|
|
||||||
docs.rs
|
|
||||||
</a>
|
|
||||||
. The lib API changes between majors, so check the{' '}
|
|
||||||
<a
|
|
||||||
href={LINKS.changelog}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-link hover:text-link-hover"
|
|
||||||
>
|
|
||||||
changelog
|
|
||||||
</a>{' '}
|
|
||||||
when you upgrade.
|
|
||||||
</p>
|
|
||||||
</Step>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section>
|
|
||||||
<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
|
|
||||||
incoming connection. <Code>serve</Code> refuses to start with the listener off.
|
|
||||||
</SectionHead>
|
|
||||||
<Cols start>
|
|
||||||
<Prose>
|
|
||||||
<p>
|
|
||||||
With the listener on (the default), soulseek-rs tries to open its listen port
|
|
||||||
on your router via{' '}
|
|
||||||
<b className="font-medium text-primary">UPnP-IGD</b> and{' '}
|
|
||||||
<b className="font-medium text-primary">NAT-PMP</b>. It’s best-effort:
|
|
||||||
if your router has those disabled it’s a no-op, and you forward the port
|
|
||||||
yourself.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The port is your <Code>--listener-port</Code> (env{' '}
|
|
||||||
<Code>SOULSEEK_LISTENER_PORT</Code>, default <Code>2234</Code>); soulseek-rs
|
|
||||||
renews it while it runs and removes it on exit. Pass{' '}
|
|
||||||
<Code>--no-listener</Code> to turn it off. Check your own network without
|
|
||||||
launching the client:
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Terminal
|
|
||||||
lines={[
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs portmap' },
|
|
||||||
{ t: 'cm', text: "# exits 0 when it works, 4 when it doesn't" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Prose>
|
|
||||||
<Callout tone="warn" title="honest about limits">
|
|
||||||
<p>
|
|
||||||
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 <Code>--listener-port</Code>, or let{' '}
|
|
||||||
<Code>portmap</Code> try UPnP.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
</Cols>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section id="platforms">
|
|
||||||
<SectionHead eyebrow="platform notes" title="Where things live.">
|
|
||||||
Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '}
|
|
||||||
<Code>SOULSEEK_STATE_DIR</Code> to relocate either one, which also makes a
|
|
||||||
portable install possible.
|
|
||||||
</SectionHead>
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
{PLATFORMS.map((platform) => (
|
{INSTALLS.map((route) => (
|
||||||
<div
|
<div
|
||||||
key={platform.id}
|
key={route.id}
|
||||||
id={`${platform.id}-paths`}
|
id={route.id}
|
||||||
className="scroll-mt-20 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
className="flex scroll-mt-20 flex-col gap-3.5 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
||||||
>
|
>
|
||||||
<h3 className="mb-4 text-heading leading-[var(--text-heading--line-height)] font-medium">
|
<div className="flex items-center gap-2.5">
|
||||||
{platform.name}
|
<OsIcon name={route.os} />
|
||||||
</h3>
|
<h3 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
|
||||||
<dl className="grid grid-cols-1 gap-x-3.5 gap-y-0.5 sm:grid-cols-[auto_1fr] sm:gap-y-2 lg:grid-cols-1 lg:gap-y-0.5">
|
{route.os}
|
||||||
{(
|
</h3>
|
||||||
[
|
</div>
|
||||||
['Password', platform.credentialStore, false],
|
<span className="text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary">
|
||||||
['Config', platform.config, true],
|
{route.best}
|
||||||
['State', platform.state, true],
|
</span>
|
||||||
['Downloads', platform.downloads, true],
|
<Terminal lines={route.lines} wrap />
|
||||||
] as const
|
<p className="text-[12.5px] leading-5 text-secondary [&_code]:font-mono [&_code]:text-primary">
|
||||||
).map(([label, value, mono]) => (
|
{route.alt}
|
||||||
<div key={label} className="contents">
|
|
||||||
<dt className="pt-2 text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary sm:pt-0.5 lg:pt-2">
|
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
<dd className="min-w-0 text-[12.5px] leading-[19px] text-primary">
|
|
||||||
{mono ? (
|
|
||||||
<code className="font-mono text-xs break-all">{value}</code>
|
|
||||||
) : (
|
|
||||||
value
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<p className="mt-4 border-t border-hairline pt-3.5 text-[12.5px] leading-5 text-secondary">
|
|
||||||
{platform.note}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[13px] text-muted">
|
||||||
|
Every route puts the same binary on your PATH. Run <Code>soulseek-rs</Code> in
|
||||||
|
a terminal and the TUI opens, in a script or a pipe it wants a subcommand
|
||||||
|
instead.
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-muted">
|
||||||
|
<Code>soulseek-rs completions install</Code> 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{' '}
|
||||||
|
<Code>--help</Code>. Open a new shell to pick it up.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||||
|
<Button href={LINKS.releases}>Prebuilt binaries</Button>
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
|
||||||
<Cols start className="mt-7">
|
<Step n={2} title="Build from source">
|
||||||
<FeatureCard title="Secrets">
|
<p className="text-secondary">
|
||||||
Your password goes to the OS keychain, never a plain-text file. Point{' '}
|
Clone the workspace and build a release binary.
|
||||||
<code>password_cmd</code> at your own password manager and it is never stored
|
</p>
|
||||||
at all. For CI and containers,{' '}
|
<Terminal
|
||||||
<code>--password-stdin</code> reads it off a pipe so it never reaches{' '}
|
lines={[
|
||||||
<code>ps</code> or the environment.
|
{ t: 'cmd', text: 'git clone https://github.com/michel/soulseek-rs.git' },
|
||||||
</FeatureCard>
|
{ t: 'cmd', text: 'cd soulseek-rs' },
|
||||||
<FeatureCard title="Settings and state">
|
{ t: 'cmd', text: 'cargo build --release' },
|
||||||
<code>config.toml</code> 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
|
<p className="text-[13px] text-muted">
|
||||||
aside as <code>.bak</code> rather than overwriting it.
|
You’ll find the binary at <Code>target/release/soulseek-rs</Code>.
|
||||||
</FeatureCard>
|
Prebuilt archives for tagged releases are on the{' '}
|
||||||
</Cols>
|
<a
|
||||||
</Section>
|
href={LINKS.releases}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-link hover:text-link-hover"
|
||||||
|
>
|
||||||
|
releases page
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</Step>
|
||||||
|
|
||||||
<Section id="config">
|
<Step n={3} title="Build on the library">
|
||||||
<SectionHead eyebrow="config.toml" title="Nine settings, all optional.">
|
<p className="text-secondary">
|
||||||
Every one has a working default, so an empty file, or no file at all, is valid.{' '}
|
Write your own client or bot on <Code>soulseek-rs-lib</Code>: the protocol
|
||||||
<Code>config list</Code> prints the effective value of each.
|
implementation, separate from the TUI.
|
||||||
</SectionHead>
|
</p>
|
||||||
<Cols start>
|
<Terminal
|
||||||
<div className="flex flex-col">
|
label="Cargo.toml"
|
||||||
{SETTINGS.map((setting) => (
|
lines={[
|
||||||
<div
|
{ t: 'cm', text: '[dependencies]' },
|
||||||
key={setting.key}
|
{ t: 'code', text: 'soulseek-rs-lib = "8"' },
|
||||||
className="grid grid-cols-1 gap-x-4 border-t border-hairline py-3.5 first:border-t-0 first:pt-0 sm:grid-cols-[minmax(0,15rem)_1fr]"
|
]}
|
||||||
>
|
copy={'[dependencies]\nsoulseek-rs-lib = "8"'}
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
/>
|
||||||
<code className="font-mono text-[12.5px] break-all text-primary">
|
<Terminal label="src/main.rs" lines={LIB_LINES} copy={LIB_SRC} />
|
||||||
{setting.key}
|
<p className="text-[13px] text-muted">
|
||||||
</code>
|
Full API reference on{' '}
|
||||||
<span className="text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary">
|
<a
|
||||||
{setting.fallback}
|
href={LINKS.docsrs}
|
||||||
</span>
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
<p className="mt-1.5 text-[13px] leading-[21px] text-pretty text-secondary sm:mt-0 [&_code]:font-mono [&_code]:text-primary">
|
className="text-link hover:text-link-hover"
|
||||||
{setting.body}
|
>
|
||||||
</p>
|
docs.rs
|
||||||
|
</a>
|
||||||
|
. The lib API changes between majors, so check the{' '}
|
||||||
|
<a
|
||||||
|
href={LINKS.changelog}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-link hover:text-link-hover"
|
||||||
|
>
|
||||||
|
changelog
|
||||||
|
</a>{' '}
|
||||||
|
when you upgrade.
|
||||||
|
</p>
|
||||||
|
</Step>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
const Reachable = () => (
|
||||||
|
<Section>
|
||||||
|
<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
|
||||||
|
incoming connection. <Code>serve</Code> refuses to start with the listener off.
|
||||||
|
</SectionHead>
|
||||||
|
<Cols start>
|
||||||
|
<Prose>
|
||||||
|
<p>
|
||||||
|
With the listener on (the default), soulseek-rs tries to open its listen port
|
||||||
|
on your router via{' '}
|
||||||
|
<b className="font-medium text-primary">UPnP-IGD</b> and{' '}
|
||||||
|
<b className="font-medium text-primary">NAT-PMP</b>. It’s best-effort:
|
||||||
|
if your router has those disabled it’s a no-op, and you forward the port
|
||||||
|
yourself.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The port is your <Code>--listener-port</Code> (env{' '}
|
||||||
|
<Code>SOULSEEK_LISTENER_PORT</Code>, default <Code>2234</Code>); soulseek-rs
|
||||||
|
renews it while it runs and removes it on exit. Pass{' '}
|
||||||
|
<Code>--no-listener</Code> to turn it off. Check your own network without
|
||||||
|
launching the client:
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Terminal
|
||||||
|
lines={[
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs portmap' },
|
||||||
|
{ t: 'cm', text: "# exits 0 when it works, 4 when it doesn't" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Prose>
|
||||||
|
<Callout tone="warn" title="honest about limits">
|
||||||
|
<p>
|
||||||
|
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 <Code>--listener-port</Code>, or let{' '}
|
||||||
|
<Code>portmap</Code> try UPnP.
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
</Cols>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
const Platforms = () => (
|
||||||
|
<Section id="platforms">
|
||||||
|
<SectionHead eyebrow="platform notes" title="Where things live.">
|
||||||
|
Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '}
|
||||||
|
<Code>SOULSEEK_STATE_DIR</Code> to relocate either one, which also makes a
|
||||||
|
portable install possible.
|
||||||
|
</SectionHead>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
{PLATFORMS.map((platform) => (
|
||||||
|
<div
|
||||||
|
key={platform.id}
|
||||||
|
id={`${platform.id}-paths`}
|
||||||
|
className="scroll-mt-20 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
||||||
|
>
|
||||||
|
<h3 className="mb-4 text-heading leading-[var(--text-heading--line-height)] font-medium">
|
||||||
|
{platform.name}
|
||||||
|
</h3>
|
||||||
|
<dl className="grid grid-cols-1 gap-x-3.5 gap-y-0.5 sm:grid-cols-[auto_1fr] sm:gap-y-2 lg:grid-cols-1 lg:gap-y-0.5">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['Password', platform.credentialStore, false],
|
||||||
|
['Config', platform.config, true],
|
||||||
|
['State', platform.state, true],
|
||||||
|
['Downloads', platform.downloads, true],
|
||||||
|
] as const
|
||||||
|
).map(([label, value, mono]) => (
|
||||||
|
<div key={label} className="contents">
|
||||||
|
<dt className="pt-2 text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary sm:pt-0.5 lg:pt-2">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="min-w-0 text-[12.5px] leading-[19px] text-primary">
|
||||||
|
{mono ? (
|
||||||
|
<code className="font-mono text-xs break-all">{value}</code>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</dl>
|
||||||
<div className="flex flex-col gap-4">
|
<p className="mt-4 border-t border-hairline pt-3.5 text-[12.5px] leading-5 text-secondary">
|
||||||
<Terminal label="config.toml" lines={CONFIG_EXAMPLE} />
|
{platform.note}
|
||||||
<Callout title="editing it without an editor">
|
</p>
|
||||||
<p>
|
|
||||||
<Code>config path</Code> prints the file in use, <Code>config get</Code>{' '}
|
|
||||||
reads one setting and <Code>config set</Code> writes one. It rejects
|
|
||||||
unknown keys, so a typo fails instead of doing nothing.{' '}
|
|
||||||
<Code>--no-config</Code> ignores the file entirely and{' '}
|
|
||||||
<Code>--config FILE</Code> points at another one.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
</div>
|
|
||||||
</Cols>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section band>
|
|
||||||
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
|
||||||
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 <Code>--log-file</Code> somewhere. No telemetry to opt out of.
|
|
||||||
</SectionHead>
|
|
||||||
<Cols start>
|
|
||||||
<Terminal
|
|
||||||
label="macOS · Linux"
|
|
||||||
lines={[
|
|
||||||
{ t: 'cm', text: '# while the binary is still there' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs completions uninstall' },
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
|
|
||||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
|
||||||
{ t: 'cm', text: '# config and state: searches, downloads, rooms, messages' },
|
|
||||||
{
|
|
||||||
t: 'cmd',
|
|
||||||
text: 'rm -rf ~/.config/soulseek-rs ~/.local/share/soulseek-rs',
|
|
||||||
},
|
|
||||||
{ t: 'cm', text: '# Linux: drop the saved password' },
|
|
||||||
{ t: 'cmd', text: 'secret-tool clear service soulseek-rs' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Terminal
|
|
||||||
label="Windows · PowerShell"
|
|
||||||
lines={[
|
|
||||||
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
|
|
||||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
|
||||||
{
|
|
||||||
t: 'cmd',
|
|
||||||
text: 'Remove-Item -Recurse -Force $env:APPDATA\\soulseek-rs',
|
|
||||||
},
|
|
||||||
{ t: 'cm', text: '# then remove the soulseek-rs entry from' },
|
|
||||||
{ t: 'cm', text: '# Credential Manager > Windows Credentials' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Cols>
|
|
||||||
<div className="mt-5">
|
|
||||||
<Callout className="bg-raised" title="two things it will not touch">
|
|
||||||
<p>
|
|
||||||
Your downloads and your shared directories are yours: they stay where they
|
|
||||||
are. On macOS, delete the keychain entry for <code>soulseek-rs</code> in
|
|
||||||
Keychain Access if you saved a password.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
))}
|
||||||
</>
|
</div>
|
||||||
)
|
|
||||||
}
|
<Cols start className="mt-7">
|
||||||
|
<FeatureCard title="Secrets">
|
||||||
|
Your password goes to the OS keychain, never a plain-text file. Point{' '}
|
||||||
|
<code>password_cmd</code> at your own password manager and it is never stored
|
||||||
|
at all. For CI and containers,{' '}
|
||||||
|
<code>--password-stdin</code> reads it off a pipe so it never reaches{' '}
|
||||||
|
<code>ps</code> or the environment.
|
||||||
|
</FeatureCard>
|
||||||
|
<FeatureCard title="Settings and state">
|
||||||
|
<code>config.toml</code> 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 <code>.bak</code> rather than overwriting it.
|
||||||
|
</FeatureCard>
|
||||||
|
</Cols>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
const Config = () => (
|
||||||
|
<Section id="config">
|
||||||
|
<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.{' '}
|
||||||
|
<Code>config list</Code> prints the effective value of each.
|
||||||
|
</SectionHead>
|
||||||
|
<Cols start>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{SETTINGS.map((setting) => (
|
||||||
|
<div
|
||||||
|
key={setting.key}
|
||||||
|
className="grid grid-cols-1 gap-x-4 border-t border-hairline py-3.5 first:border-t-0 first:pt-0 sm:grid-cols-[minmax(0,15rem)_1fr]"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<code className="font-mono text-[12.5px] break-all text-primary">
|
||||||
|
{setting.key}
|
||||||
|
</code>
|
||||||
|
<span className="text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary">
|
||||||
|
{setting.fallback}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[13px] leading-[21px] text-pretty text-secondary sm:mt-0 [&_code]:font-mono [&_code]:text-primary">
|
||||||
|
{setting.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Terminal label="config.toml" lines={CONFIG_EXAMPLE} />
|
||||||
|
<Callout title="editing it without an editor">
|
||||||
|
<p>
|
||||||
|
<Code>config path</Code> prints the file in use, <Code>config get</Code>{' '}
|
||||||
|
reads one setting and <Code>config set</Code> writes one. It rejects
|
||||||
|
unknown keys, so a typo fails instead of doing nothing.{' '}
|
||||||
|
<Code>--no-config</Code> ignores the file entirely and{' '}
|
||||||
|
<Code>--config FILE</Code> points at another one.
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
</Cols>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
const Uninstall = () => (
|
||||||
|
<Section band>
|
||||||
|
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
||||||
|
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 <Code>--log-file</Code> somewhere. No telemetry to opt out of.
|
||||||
|
</SectionHead>
|
||||||
|
<Cols start>
|
||||||
|
<Terminal
|
||||||
|
label="macOS · Linux"
|
||||||
|
lines={[
|
||||||
|
{ t: 'cm', text: '# while the binary is still there' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs completions uninstall' },
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
|
||||||
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||||
|
{ t: 'cm', text: '# config and state: searches, downloads, rooms, messages' },
|
||||||
|
{
|
||||||
|
t: 'cmd',
|
||||||
|
text: 'rm -rf ~/.config/soulseek-rs ~/.local/share/soulseek-rs',
|
||||||
|
},
|
||||||
|
{ t: 'cm', text: '# Linux: drop the saved password' },
|
||||||
|
{ t: 'cmd', text: 'secret-tool clear service soulseek-rs' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Terminal
|
||||||
|
label="Windows · PowerShell"
|
||||||
|
lines={[
|
||||||
|
{ t: 'cmd', text: 'soulseek-rs skills uninstall' },
|
||||||
|
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||||
|
{
|
||||||
|
t: 'cmd',
|
||||||
|
text: 'Remove-Item -Recurse -Force $env:APPDATA\\soulseek-rs',
|
||||||
|
},
|
||||||
|
{ t: 'cm', text: '# then remove the soulseek-rs entry from' },
|
||||||
|
{ t: 'cm', text: '# Credential Manager > Windows Credentials' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Cols>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Callout className="bg-raised" title="two things it will not touch">
|
||||||
|
<p>
|
||||||
|
Your downloads and your shared directories are yours: they stay where they
|
||||||
|
are. On macOS, delete the keychain entry for <code>soulseek-rs</code> in
|
||||||
|
Keychain Access if you saved a password.
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
export const Install = () => (
|
||||||
|
<>
|
||||||
|
<PageHead eyebrow="install" title="Three ways in.">
|
||||||
|
One command with Homebrew or cargo. Build from source if you’d rather. Or depend
|
||||||
|
on the library and write your own client.
|
||||||
|
</PageHead>
|
||||||
|
<Steps />
|
||||||
|
<Reachable />
|
||||||
|
<Platforms />
|
||||||
|
<Config />
|
||||||
|
<Uninstall />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user