Files
soulseek-rs-modded/website/src/components/tui/use-hero-demo.ts
T
Michel de Graaf 2e9cd6b860 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.
2026-07-26 15:40:58 +02:00

216 lines
5.3 KiB
TypeScript

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,
)
},
}
}