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, type StatusCounts } from './chrome'
|
||||
import {
|
||||
RESULTS,
|
||||
SEARCHES,
|
||||
SHARE,
|
||||
TRANSFERS,
|
||||
type ResultRow,
|
||||
type SearchRow,
|
||||
type TransferRow,
|
||||
} from './data'
|
||||
import { MacWindow, ShortcutBar, StatusLine } from './chrome'
|
||||
import { SHARE } from './data'
|
||||
import { InfoPane, ResultsPane, SearchesPane, TransfersPane } from './panes'
|
||||
import { useFitScale } from './use-fit-scale'
|
||||
import { useHeroDemo } from './use-hero-demo'
|
||||
|
||||
const DESIGN_WIDTH = 1360
|
||||
const DESIGN_HEIGHT = 863
|
||||
const TICK_MS = 480
|
||||
const MAX_CONCURRENT = 2
|
||||
const RESTING_ROW = 5
|
||||
|
||||
type Phase = 'searching' | 'results' | 'interactive'
|
||||
|
||||
const isBrowser = typeof window !== 'undefined'
|
||||
|
||||
const matches = (query: string): boolean =>
|
||||
isBrowser && window.matchMedia(query).matches
|
||||
|
||||
const prefersReducedMotion = (): boolean => matches('(prefers-reduced-motion: reduce)')
|
||||
|
||||
const sizeMb = (size: string): number => {
|
||||
const parsed = Number.parseFloat(size)
|
||||
return Number.isNaN(parsed) || parsed === 0 ? 12 : parsed
|
||||
}
|
||||
|
||||
const toTransfer = (row: ResultRow): TransferRow => ({
|
||||
id: `t:${row.name}`,
|
||||
status: 'queued',
|
||||
name: row.name,
|
||||
user: row.user,
|
||||
progress: 'Queued',
|
||||
speed: '-',
|
||||
pct: 0,
|
||||
sizeMb: sizeMb(row.size),
|
||||
rateMbps: 1.4 + (row.name.length % 7) * 0.4,
|
||||
})
|
||||
|
||||
export const HeroTerminal = () => {
|
||||
const [searches, setSearches] = useState<readonly SearchRow[]>(SEARCHES)
|
||||
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 demo = useHeroDemo()
|
||||
const { wrapRef, innerRef, scale, height, ready } = useFitScale(DESIGN_WIDTH)
|
||||
|
||||
const shownSearches = searches.map((search, i) =>
|
||||
i === 0 && phase === 'searching' ? { ...search, status: '⋯', results: '—' } : search,
|
||||
)
|
||||
const shownRows = phase === 'searching' ? [] : rows
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
@@ -341,48 +29,36 @@ export const HeroTerminal = () => {
|
||||
>
|
||||
<MacWindow>
|
||||
<div
|
||||
ref={shellRef}
|
||||
tabIndex={0}
|
||||
role="application"
|
||||
aria-label="Interactive soulseek-rs terminal demo"
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDown={demo.onKeyDown}
|
||||
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="min-h-[498px]">
|
||||
<SearchesPane
|
||||
searches={shownSearches}
|
||||
activeId={activeId}
|
||||
active={focus === 1}
|
||||
onPick={(id) => {
|
||||
setActiveId(id)
|
||||
setSelected(0)
|
||||
setFocus(1)
|
||||
setPhase('interactive')
|
||||
}}
|
||||
onSubmit={submitSearch}
|
||||
searches={demo.searches}
|
||||
activeId={demo.activeId}
|
||||
active={demo.focus === 1}
|
||||
onPick={demo.pickSearch}
|
||||
onSubmit={demo.submitSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}>
|
||||
<div className="relative min-h-[250px] overflow-hidden">
|
||||
<ResultsPane
|
||||
query={activeSearch?.query ?? ''}
|
||||
rows={shownRows}
|
||||
selected={selected}
|
||||
active={focus === 2}
|
||||
onSelect={(i) => {
|
||||
setSelected(i)
|
||||
setFocus(2)
|
||||
}}
|
||||
onQueue={(i) => {
|
||||
queueIndexes([i])
|
||||
setFocus(3)
|
||||
}}
|
||||
query={demo.query}
|
||||
rows={demo.rows}
|
||||
selected={demo.selected}
|
||||
active={demo.focus === 2}
|
||||
onSelect={demo.selectRow}
|
||||
onQueue={demo.queueRow}
|
||||
/>
|
||||
{phase === 'searching' && (
|
||||
{demo.searching && (
|
||||
<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
|
||||
<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' }}
|
||||
>
|
||||
<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 className="min-h-0 min-w-0 overflow-hidden">
|
||||
<InfoPane row={phase === 'searching' ? undefined : rows[selected]} />
|
||||
<InfoPane row={demo.infoRow} />
|
||||
</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 }
|
||||
}
|
||||
Reference in New Issue
Block a user