A Vite + React + TypeScript + Tailwind v4 site under website/, built from the soulseek-rs design system so the site and the TUI stay one product. The colour tokens in src/index.css are the same values as soulseek-rs/src/ui/styles.rs; change one, change both. The hero is a working recreation of the TUI: it plays one scripted run and then takes the keyboard, or lands on the resting state directly under prefers-reduced-motion. Its sample data is invented or public domain, per the tone-of-voice guardrail against naming copyrighted material. Static output rather than an SPA shell. `bun run build` compiles the client, compiles the same app for Node, then renders every route in src/lib/routes.ts to its own file with that route's title, description and canonical URL. A direct link to /install therefore answers 200 with real markup instead of GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding one without the other fails typecheck rather than shipping a page whose body says "not found" under the right title. Theme is resolved once by an inline script before first paint and adopted by React through useSyncExternalStore, so there is no flash and no hydration mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor and tape read on vinyl but wash out on manila. CI and release skip website-only pushes, and Pages skips Rust-only ones. The two Rust workflows share one paths-ignore list so they can only skip together, which keeps the release ci-gate from polling for a run that was never queued. That gate also now treats a cancelled CI as "keep waiting" rather than a failure, since cancel-in-progress cancels the older SHA on every back-to-back push.
483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|
|
|
import { MacWindow, ShortcutBar, StatusLine, TmuxBar, 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 { useFitScale } from './use-fit-scale'
|
|
|
|
const DESIGN_WIDTH = 1360
|
|
const TICK_MS = 480
|
|
const MAX_CONCURRENT = 2
|
|
/** Where the scripted run leaves the cursor. */
|
|
const RESTING_ROW = 5
|
|
|
|
type Phase = 'searching' | 'results' | 'interactive'
|
|
|
|
/** Pages are prerendered in Node, where there is no window. */
|
|
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
|
|
}
|
|
|
|
/*
|
|
* Deterministic on purpose: the id is derived from the filename and the rate
|
|
* from its length, so queueing the same row twice is a no-op and nothing
|
|
* random runs inside a state updater. Both matter under StrictMode, which
|
|
* double-invokes updaters in development.
|
|
*/
|
|
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,
|
|
})
|
|
|
|
/**
|
|
* The hero: a working recreation of the TUI.
|
|
*
|
|
* It plays one scripted run (search → results → queue two files) and then
|
|
* hands over to the keyboard. Only transfers carrying a `pct` are simulated;
|
|
* the seeded rows stay put so the pane looks lived-in from the first frame.
|
|
*
|
|
* Under `prefers-reduced-motion` it starts at the end state instead, so the
|
|
* page never animates at someone who asked it not to.
|
|
*/
|
|
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)
|
|
|
|
// Reduced motion skips the performance, so the resting state is the initial
|
|
// state. Resolved once, in the initialiser, to keep it out of an effect.
|
|
const [phase, setPhase] = useState<Phase>(() =>
|
|
prefersReducedMotion() ? 'interactive' : 'searching',
|
|
)
|
|
const [selected, setSelected] = useState(() =>
|
|
prefersReducedMotion() ? RESTING_ROW : 0,
|
|
)
|
|
|
|
const shellRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Mirrors `results` so queueFrom can read the latest rows without taking a
|
|
// dependency on them. Synced after commit, which is soon enough: every
|
|
// reader is a timer or an event handler, never a render.
|
|
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])
|
|
|
|
// Derived, not stored: the status bar is a pure function of what is moving.
|
|
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])
|
|
|
|
/** True while any simulated transfer still has somewhere to go. */
|
|
const hasWork = useMemo(
|
|
() => transfers.some((t) => t.pct !== undefined && t.status !== 'done'),
|
|
[transfers],
|
|
)
|
|
|
|
// Stops once everything has finished. Without the `hasWork` gate the timer
|
|
// would tick twice a second for as long as the tab is open, re-rendering
|
|
// the whole TUI to produce identical output.
|
|
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
|
|
|
|
// Step size varies per file but is a pure function of it, so the
|
|
// updater stays free of side effects under StrictMode's double call.
|
|
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))}%)`,
|
|
}
|
|
})
|
|
|
|
// map() always allocates, so without this every tick would hand React
|
|
// a fresh array and force a re-render that produces identical output.
|
|
// Returning the original lets React bail out instead.
|
|
const moved = next.some((t, i) => t !== list[i])
|
|
return moved ? next : list
|
|
})
|
|
}, TICK_MS)
|
|
|
|
return () => {
|
|
clearInterval(timer)
|
|
}
|
|
}, [hasWork])
|
|
|
|
/*
|
|
* Queue rows by index within a named search.
|
|
*
|
|
* Reads the current rows through a ref rather than closing over `results`,
|
|
* so the callback identity never changes — the opening script can then
|
|
* depend on it without restarting itself every time state moves.
|
|
*/
|
|
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
|
|
}
|
|
}
|
|
|
|
/*
|
|
* The scripted opening run.
|
|
*
|
|
* No "has this already started" ref: the cleanup cancels every timer, so a
|
|
* StrictMode remount simply replays from the top. A ref guard would let the
|
|
* first cleanup cancel the run and the re-run skip scheduling it, leaving
|
|
* the demo frozen on its first frame in development.
|
|
*
|
|
* It targets 's1' directly rather than whatever is selected, because the
|
|
* script only ever describes the opening state.
|
|
*/
|
|
useEffect(() => {
|
|
if (prefersReducedMotion()) return
|
|
|
|
const timers: ReturnType<typeof setTimeout>[] = []
|
|
const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
|
|
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 () => {
|
|
for (const timer of timers) clearTimeout(timer)
|
|
}
|
|
}, [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')
|
|
}
|
|
|
|
// Below 820px, stop shrinking at half size and let the wrapper scroll.
|
|
const [minScale, setMinScale] = useState(() =>
|
|
matches('(max-width: 820px)') ? 0.5 : 0,
|
|
)
|
|
useEffect(() => {
|
|
const query = window.matchMedia('(max-width: 820px)')
|
|
const sync = (event: MediaQueryListEvent) => {
|
|
setMinScale(event.matches ? 0.5 : 0)
|
|
}
|
|
query.addEventListener('change', sync)
|
|
return () => {
|
|
query.removeEventListener('change', sync)
|
|
}
|
|
}, [])
|
|
|
|
const { wrapRef, innerRef, scale, height, ready } = useFitScale(DESIGN_WIDTH, minScale)
|
|
|
|
const shownSearches = searches.map((search, i) =>
|
|
i === 0 && phase === 'searching' ? { ...search, status: '⋯', results: '—' } : search,
|
|
)
|
|
const shownRows = phase === 'searching' ? [] : rows
|
|
|
|
return (
|
|
<div
|
|
ref={wrapRef}
|
|
className="w-full overflow-x-auto overflow-y-hidden"
|
|
style={ready ? { height } : undefined}
|
|
>
|
|
<div style={ready ? { width: DESIGN_WIDTH * scale, height } : undefined}>
|
|
<div
|
|
ref={innerRef}
|
|
className="origin-top-left"
|
|
style={{
|
|
width: DESIGN_WIDTH,
|
|
transform: ready ? `scale(${String(scale)})` : undefined,
|
|
visibility: ready ? 'visible' : 'hidden',
|
|
}}
|
|
>
|
|
<MacWindow>
|
|
<TmuxBar />
|
|
{/*
|
|
role="application" is the ARIA escape hatch for a widget that
|
|
handles its own keys: it stops screen readers intercepting
|
|
j/k/arrows so the demo behaves like the real TUI. Every control
|
|
inside is still a native button or input.
|
|
*/}
|
|
<div
|
|
ref={shellRef}
|
|
tabIndex={0}
|
|
role="application"
|
|
aria-label="Interactive soulseek-rs terminal demo"
|
|
onKeyDown={onKeyDown}
|
|
className="tui-content p-2.5 outline-none"
|
|
>
|
|
<StatusLine counts={counts} pct={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}
|
|
/>
|
|
</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)
|
|
}}
|
|
/>
|
|
{phase === '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" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className="grid min-h-0 gap-2.5"
|
|
style={{ gridTemplateColumns: '1fr 340px' }}
|
|
>
|
|
<div className="min-h-0 min-w-0 overflow-hidden">
|
|
<TransfersPane rows={transfers.slice(0, 7)} active={focus === 3} />
|
|
</div>
|
|
<div className="min-h-0 min-w-0 overflow-hidden">
|
|
<InfoPane row={phase === 'searching' ? undefined : rows[selected]} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ShortcutBar share={SHARE} />
|
|
</div>
|
|
</MacWindow>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|