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:
@@ -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,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user