feat(website): add the project site and deploy it to GitHub Pages

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.
This commit is contained in:
Michel de Graaf
2026-07-24 23:42:41 +02:00
parent 54feb3bc5a
commit 33bfc34dc8
58 changed files with 5239 additions and 1 deletions
+135
View File
@@ -0,0 +1,135 @@
import { VERSION } from '@/lib/links'
import { cn } from '@/lib/utils'
import { CLOCK, DATE, HOST, SESSION, SHORTCUTS, WINDOWS } from './data'
interface TuiPaneProps {
title: string
num?: number
active?: boolean
children: React.ReactNode
className?: string
bodyClassName?: string
}
/**
* The TUI's bordered pane: a legend that breaks the top border, green when
* the pane has focus. Mirrors `pane_block()` in the Rust client.
*/
export const TuiPane = ({
title,
num,
active = false,
children,
className,
bodyClassName,
}: TuiPaneProps) => (
<div
className={cn(
'relative border bg-base',
active ? 'border-[var(--color-phosphor)]' : 'border-[color-mix(in_srgb,var(--color-dust)_45%,transparent)]',
className,
)}
>
<span
className={cn(
'absolute -top-[9px] left-3 bg-base px-1.5 text-[12.5px] whitespace-nowrap',
active ? 'text-[var(--color-phosphor)]' : 'text-[var(--color-dust)]',
)}
>
{num !== undefined && <span className="text-[var(--color-signal)]">[{num}] </span>}
{title}
</span>
<div className={bodyClassName}>{children}</div>
</div>
)
/** The macOS window bezel the whole demo sits in. */
export const MacWindow = ({ children }: { children: React.ReactNode }) => (
<div
className="w-[1360px] overflow-hidden rounded-xl border bg-base font-mono tabular-nums"
style={{ borderColor: 'var(--tui-bezel)' }}
>
{children}
</div>
)
export const TmuxBar = () => (
<div className="flex h-[26px] items-center border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] bg-[var(--tmux-bg)] text-[12.5px] text-[var(--color-phosphor)]">
<span className="flex h-full items-center bg-[var(--color-phosphor)] px-2.5 font-bold text-[var(--tmux-bg)]">
{SESSION}
</span>
{WINDOWS.map((w) => (
<span
key={w.n}
className={cn(
'flex h-full items-center gap-1.5 px-3',
w.active === true
? 'bg-[var(--tmux-hl)] font-bold text-[var(--tmux-hl-fg)]'
: 'text-[var(--color-phosphor)]',
)}
>
{w.n} <span className="opacity-55">›</span> {w.name}
</span>
))}
<span className="ml-auto flex gap-4 px-3 text-[var(--color-paper)]">
<span className="bg-[var(--tmux-hl)] px-2 font-semibold text-[var(--tmux-hl-fg)]">
{DATE} · {CLOCK}
</span>
<span className="font-bold">{HOST}</span>
</span>
</div>
)
export interface StatusCounts {
active: number
done: number
fail: number
queued: number
}
interface StatusLineProps {
counts: StatusCounts
pct: number
}
export const StatusLine = ({ counts, pct }: StatusLineProps) => (
<TuiPane title="Status" bodyClassName="px-3 py-2">
<div className="flex items-center gap-3.5 text-[13px] whitespace-nowrap text-[var(--color-paper)]">
<span>
<b className="font-semibold">soulseek-rs</b> 🦀 {VERSION}
</span>
<span className="text-[var(--color-dust)]">
Downloads: <span className="text-[var(--color-oxide)]">{counts.active} active</span>
, <span className="text-[var(--color-phosphor)]">{counts.done} completed</span>,{' '}
<span className="text-[var(--color-alarm)]">{counts.fail} failed</span>,{' '}
<span className="text-[var(--color-tape)]">{counts.queued} queued</span>, 0 paused
</span>
<span className="ml-auto flex items-center gap-2.5 text-[var(--color-paper)]">
67.7/205.2 MB · 33.38 MB/s
<span className="relative inline-block h-[13px] w-[340px] border border-[var(--tm-bd)] bg-[var(--tui-track)]">
<span
className="absolute inset-y-0 left-0 bg-[var(--color-signal)] opacity-55 transition-[width] duration-300"
style={{ width: `${String(pct)}%` }}
/>
</span>
<b>{pct}%</b>
</span>
</div>
</TuiPane>
)
export const ShortcutBar = ({ share }: { share: string }) => (
<TuiPane title="Shortcuts · Sharing" className="mt-2.5" bodyClassName="px-3 py-2">
<div className="mb-1.5 text-xs text-[var(--color-dust)]">
Sharing: <span className="text-[var(--color-phosphor)]">{share}</span>
</div>
<div className="flex flex-wrap gap-3.5 text-[12.5px] text-[var(--color-dust)]">
{SHORTCUTS.map(([key, action]) => (
<span key={key}>
[<span className="text-[var(--color-signal)]">{key}</span> → {action}]
</span>
))}
</div>
</TuiPane>
)
+216
View File
@@ -0,0 +1,216 @@
/*
* Sample data for the TUI recreation in the hero.
*
* Everything here is invented, public-domain, or Creative Commons. No real
* artist, album, or track names — see TONE-OF-VOICE.md §5 "Banned for legal
* reasons". Anything added here has to keep that true.
*/
export interface SearchRow {
id: string
query: string
results: number | string
status: string
}
export interface ResultRow {
name: string
size: string
user: string
bitrate: string
speed: string
slots: number
/** Marked for download with Space, not yet queued. */
marked?: boolean
/** Handed to the transfer list. */
queued?: boolean
}
export type TransferStatus = 'queued' | 'active' | 'done' | 'fail'
export interface TransferRow {
id: string
status: TransferStatus
name: string
user: string
progress: string
speed: string
/** Present only on transfers the demo drives; the static rows leave it out. */
pct?: number
sizeMb?: number
rateMbps?: number
}
interface TmuxWindow {
n: number
name: string
active?: boolean
}
export const HOST = 'michels-macbook-pro-m4'
export const CLOCK = '01:45 PM'
export const DATE = '2026-07-24'
export const SESSION = 'develop'
export const SHARE = '/Users/micheldegraaf/Downloads/Soulseek'
export const WINDOWS: readonly TmuxWindow[] = [
{ n: 1, name: 'fish #' },
{ n: 2, name: 'fish #' },
{ n: 3, name: 'fish #' },
{ n: 4, name: 'fish #-' },
{ n: 5, name: 'fish *', active: true },
]
export const SEARCHES: readonly SearchRow[] = [
{ id: 's1', query: 'public domain field recordings', results: 606, status: 'Done' },
{ id: 's2', query: 'creative commons ambient', results: 33, status: 'Done' },
{ id: 's3', query: 'netlabel 2004', results: 608, status: 'Done' },
]
const pd = (name: string, size: string, bitrate: string): ResultRow => ({
name: `shared\\archive\\PD Field Recordings\\${name}`,
size,
user: 'loopdigger',
bitrate,
speed: '3.35 MB/s',
slots: 1,
})
export const RESULTS: Readonly<Record<string, readonly ResultRow[]>> = {
s1: [
pd('01 - Coastal Dawn (Sunrise Take).flac', '30.5 MB', '675 kbps'),
pd('02 - Reservoir Wind.flac', '22.6 MB', '569 kbps'),
pd('cover.jpg', '0.0 MB', '-'),
pd('03 - Marsh at Night (Long).flac', '48.0 MB', '920 kbps'),
pd('04 - Tram Depot, 06h.flac', '32.2 MB', '725 kbps'),
pd('05 - Harbour Bells.flac', '52.5 MB', '902 kbps'),
pd('06 - Rain on Zinc Roof.flac', '30.4 MB', '804 kbps'),
pd('07 - Empty Chapel Tone.flac', '17.4 MB', '836 kbps'),
pd('08 - Forest Floor.flac', '38.0 MB', '741 kbps'),
pd('09 - Quarry Echo.flac', '34.6 MB', '753 kbps'),
pd('10 - Night Ferry.flac', '18.7 MB', '723 kbps'),
pd('11 - Last Train.flac', '19.5 MB', '621 kbps'),
{
name: 'shared\\netlabel\\Explorers of the Interior [CC-BY]\\readme.txt',
size: '1.0 MB',
user: 'loopdigger',
bitrate: '128 kbps',
speed: '3.35 MB/s',
slots: 1,
},
],
s2: [
{
name: 'shared\\cc\\Ambient Works [CC0]\\01 - Slow Meadow.flac',
size: '41.2 MB',
user: 'tapehiss',
bitrate: '911 kbps',
speed: '2.10 MB/s',
slots: 2,
},
{
name: 'shared\\cc\\Ambient Works [CC0]\\02 - Held Note.flac',
size: '28.9 MB',
user: 'tapehiss',
bitrate: '740 kbps',
speed: '2.10 MB/s',
slots: 2,
},
{
name: 'shared\\cc\\Ambient Works [CC0]\\03 - Grain.flac',
size: '33.1 MB',
user: 'tapehiss',
bitrate: '812 kbps',
speed: '2.10 MB/s',
slots: 2,
},
],
s3: [
{
name: 'shared\\netlabel\\2004 Comp [CC-BY-NC]\\01 - Opener.mp3',
size: '9.4 MB',
user: 'nectoUA',
bitrate: '320 kbps',
speed: '1.05 MB/s',
slots: 0,
},
{
name: 'shared\\netlabel\\2004 Comp [CC-BY-NC]\\02 - Interlude.mp3',
size: '5.1 MB',
user: 'nectoUA',
bitrate: '320 kbps',
speed: '1.05 MB/s',
slots: 0,
},
],
}
export const TRANSFERS: readonly TransferRow[] = [
{
id: 'x1',
status: 'done',
name: '@@lrfkq\\Contents\\Rotterdam',
user: 'Dan Dillion',
progress: 'Completed',
speed: '-',
},
{
id: 'x2',
status: 'done',
name: '@@lrfkq\\Contents\\Journal',
user: 'Dan Dillion',
progress: 'Completed',
speed: '-',
},
{
id: 'x3',
status: 'active',
name: '@@lrfkq\\Contents\\Context',
user: 'Dan Dillion',
progress: '37.5 MB/53.4 MB (70%)',
speed: '12.4 MB/s',
},
{
id: 'x4',
status: 'fail',
name: '@@gufoo\\put all musics',
user: 'publicdomainbot',
progress: 'Failed',
speed: '-',
},
{
id: 'x5',
status: 'active',
name: '02-RawMusic\\Embark Studio',
user: 'tapehiss',
progress: '10.4 MB/14.3 MB (72%)',
speed: '11.5 MB/s',
},
{
id: 'x6',
status: 'fail',
name: '@@medvw\\Soulseek\\complete',
user: 'x100',
progress: 'Failed',
speed: '-',
},
{
id: 'x7',
status: 'active',
name: '@@hnttf\\complete\\(2004 netlabel)',
user: 'nectoUA',
progress: '10.5 MB/25.5 MB (41%)',
speed: '9.3 MB/s',
},
]
export const SHORTCUTS: readonly (readonly [string, string])[] = [
['Space', 'select'],
['Enter', 'download'],
['b', 'browse owner'],
['c', 'chat'],
['/', 'filter'],
['a/A', 'select all/none'],
['1-3', 'focus pane'],
['q', 'quit'],
]
@@ -0,0 +1,482 @@
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>
)
}
+286
View File
@@ -0,0 +1,286 @@
import { useState, type SyntheticEvent } from 'react'
import { cn } from '@/lib/utils'
import { TuiPane } from './chrome'
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px'
const TRANSFER_COLS = '48px 1fr 96px 150px 74px'
interface SearchesPaneProps {
searches: readonly SearchRow[]
activeId: string
active: boolean
onPick: (id: string) => void
onSubmit: (query: string) => void
}
export const SearchesPane = ({
searches,
activeId,
active,
onPick,
onSubmit,
}: SearchesPaneProps) => {
const [query, setQuery] = useState('')
const submit = (event: SyntheticEvent<HTMLFormElement, SubmitEvent>) => {
event.preventDefault()
const trimmed = query.trim()
if (trimmed === '') return
onSubmit(trimmed)
setQuery('')
}
return (
<TuiPane
title="Searches"
num={1}
active={active}
className="h-full"
bodyClassName="px-3 pt-4 pb-3"
>
<form
onSubmit={submit}
className="mb-3 flex items-center gap-1.5 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] pb-2.5"
>
<span className="text-[13px] text-[var(--color-oxide)]">/</span>
<input
value={query}
onChange={(event) => {
setQuery(event.target.value)
}}
placeholder="search the network…"
aria-label="Search the network"
className="grow border-0 bg-transparent font-mono text-[13px] text-[var(--color-paper)] outline-none placeholder:text-[var(--color-dust)]"
/>
<span className="h-[15px] w-2 rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
</form>
<div
className="grid px-1 pb-1.5 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: '56px 1fr 58px' }}
>
<span>Status</span>
<span>Query</span>
<span className="text-right">Results</span>
</div>
{searches.map((search) => (
<button
type="button"
key={search.id}
onClick={() => {
onPick(search.id)
}}
className={cn(
'grid w-full cursor-pointer p-1 text-left text-[13px] text-[var(--color-paper)]',
search.id === activeId && 'bg-[color-mix(in_srgb,var(--color-signal)_14%,transparent)]',
)}
style={{ gridTemplateColumns: '56px 1fr 58px' }}
>
<span className="text-[var(--color-phosphor)]">
{search.id === activeId ? '→' : ' '}
{search.status}
</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{search.query}
</span>
<span className="text-right text-[var(--color-dust)]">{search.results}</span>
</button>
))}
</TuiPane>
)
}
const resultGlyph = (row: ResultRow): { glyph: string; tone: string } => {
if (row.queued === true) return { glyph: '✓', tone: 'text-[var(--color-phosphor)]' }
if (row.marked === true) return { glyph: '[✓]', tone: 'text-[var(--color-oxide)]' }
return { glyph: '[ ]', tone: 'text-[var(--color-dust)]' }
}
interface ResultsPaneProps {
query: string
rows: readonly ResultRow[]
selected: number
active: boolean
onSelect: (index: number) => void
onQueue: (index: number) => void
}
export const ResultsPane = ({
query,
rows,
selected,
active,
onSelect,
onQueue,
}: ResultsPaneProps) => (
<TuiPane
title={`Results: ${query}`}
num={2}
active={active}
className="flex h-full flex-col"
bodyClassName="flex min-h-0 flex-1 flex-col pt-4"
>
<div
className="grid gap-2 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] px-3.5 pb-2 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: RESULT_COLS }}
>
<span>✓</span>
<span>Filename</span>
<span>Size</span>
<span>User</span>
<span>Bitrate</span>
<span>Speed</span>
<span>Slots</span>
</div>
<div className="flex-1 overflow-y-auto">
{rows.map((row, i) => {
const { glyph, tone } = resultGlyph(row)
const isSelected = selected === i
return (
<button
type="button"
key={row.name}
onClick={() => {
onSelect(i)
}}
onDoubleClick={() => {
onQueue(i)
}}
className={cn(
'grid w-full cursor-pointer gap-2 px-3.5 py-[3px] text-left text-[12.5px] text-[var(--color-paper)]',
isSelected && 'bg-[color-mix(in_srgb,var(--color-oxide)_16%,transparent)]',
)}
style={{ gridTemplateColumns: RESULT_COLS }}
>
<span className={cn('whitespace-nowrap', tone)}>{glyph}</span>
<span
className={cn(
'overflow-hidden text-ellipsis whitespace-nowrap',
isSelected ? 'text-[var(--color-paper)]' : 'text-[var(--color-paper-dim)]',
)}
>
{row.name}
</span>
<span className="text-right text-[var(--color-tape)]">{row.size}</span>
<span className="text-[var(--color-signal)]">{row.user}</span>
<span className="text-[var(--color-dust)]">{row.bitrate}</span>
<span className="text-[var(--color-dust)]">{row.speed}</span>
<span className="text-center text-[var(--color-dust)]">{row.slots}</span>
</button>
)
})}
</div>
</TuiPane>
)
const TRANSFER_GLYPH: Record<TransferStatus, readonly [string, string]> = {
queued: ['⋯', 'text-[var(--color-tape)]'],
done: ['✓', 'text-[var(--color-phosphor)]'],
active: ['↯', 'text-[var(--color-oxide)]'],
fail: ['✗', 'text-[var(--color-alarm)]'],
}
const PROGRESS_TONE: Record<TransferStatus, string> = {
queued: 'text-[var(--color-paper)]',
active: 'text-[var(--color-paper)]',
done: 'text-[var(--color-phosphor)]',
fail: 'text-[var(--color-alarm)]',
}
interface TransfersPaneProps {
rows: readonly TransferRow[]
active: boolean
}
export const TransfersPane = ({ rows, active }: TransfersPaneProps) => (
<TuiPane
title="Downloads/Uploads"
num={3}
active={active}
className="flex h-full flex-col"
bodyClassName="flex min-h-0 flex-1 flex-col pt-4"
>
<div
className="grid gap-2 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] px-3.5 pb-2 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: TRANSFER_COLS }}
>
<span>Status</span>
<span>Filename</span>
<span>User</span>
<span>Progress</span>
<span>Speed</span>
</div>
<div className="flex-1 overflow-y-auto">
{rows.map((row) => {
const [glyph, tone] = TRANSFER_GLYPH[row.status]
return (
<div
key={row.id}
className="grid gap-2 px-3.5 py-[3px] text-[12.5px] text-[var(--color-paper-dim)]"
style={{ gridTemplateColumns: TRANSFER_COLS }}
>
<span className={tone}>{glyph}</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{row.name}
</span>
<span className="text-[var(--color-signal)]">{row.user}</span>
<span className={PROGRESS_TONE[row.status]}>{row.progress}</span>
<span className="text-[var(--color-tape)]">{row.speed}</span>
</div>
)
})}
</div>
</TuiPane>
)
interface InfoPaneProps {
row: ResultRow | undefined
}
export const InfoPane = ({ row }: InfoPaneProps) => {
const parts = (row?.name ?? '').split('\\')
const file = parts.at(-1) ?? '—'
const path = parts.slice(0, -1).join('/')
const queued = row?.queued === true
const fields: readonly (readonly [string, string, string])[] = [
['User', row?.user ?? '—', 'text-[var(--color-signal)]'],
['Size', row?.size ?? '—', 'text-[var(--color-tape)]'],
[
'Status',
queued ? 'Queued' : 'Available',
queued ? 'text-[var(--color-tape)]' : 'text-[var(--color-phosphor)]',
],
['Bitrate', row?.bitrate ?? '-', 'text-[var(--color-paper)]'],
]
return (
<TuiPane title="Info" active className="h-full" bodyClassName="px-3.5 pt-4 pb-3.5">
<div className="mb-1 text-[13.5px] break-all text-[var(--color-oxide)]">{file}</div>
<div className="mb-4 text-xs break-all text-[var(--color-dust)]">
{path === '' ? '—' : path}
</div>
<div
className="grid gap-y-[7px] text-[12.5px]"
style={{ gridTemplateColumns: '88px 1fr' }}
>
{fields.map(([key, value, tone]) => (
<div key={key} className="contents">
<span className="text-[var(--color-dust)]">{key}</span>
<span className={cn('break-all', tone)}>{value}</span>
</div>
))}
<span className="mt-2.5 text-[var(--color-dust)]">Queue position</span>
<span className="mt-2.5 text-[var(--color-paper)]">{queued ? '3' : 'unknown'}</span>
<span className="text-[var(--color-dust)]">Free slots</span>
<span className="text-[var(--color-phosphor)]">
{row !== undefined && row.slots > 0 ? `${String(row.slots)} available` : '0'}
</span>
</div>
</TuiPane>
)
}
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState, type RefObject } from 'react'
interface FitScale {
wrapRef: RefObject<HTMLDivElement | null>
innerRef: RefObject<HTMLDivElement | null>
scale: number
height: number
ready: boolean
}
/**
* Scales a fixed-width design down to whatever width it is given.
*
* The TUI is laid out on a 1360px character grid; reflowing it would stop it
* looking like a terminal. So it renders at native width and gets a CSS
* transform, and the wrapper is told the resulting height so the page below
* does not sit under it.
*
* `minScale` stops the shrink at a floor on small screens — past that the
* wrapper scrolls sideways instead, which beats unreadable 4px text.
*/
export const useFitScale = (designWidth: number, minScale: number): FitScale => {
const wrapRef = useRef<HTMLDivElement>(null)
const innerRef = useRef<HTMLDivElement>(null)
const [{ scale, height }, setState] = useState({ scale: 0, height: 0 })
useEffect(() => {
// ResizeObserver is browser-only; prerendering never reaches this effect.
if (typeof ResizeObserver === 'undefined') return
const measure = () => {
const wrap = wrapRef.current
const inner = innerRef.current
if (wrap === null || inner === null) return
const width = wrap.clientWidth
if (width === 0) return
const next = Math.max(minScale, Math.min(1, width / designWidth))
const nextHeight = inner.offsetHeight * next
if (nextHeight === 0) return
setState((prev) =>
Math.abs(prev.scale - next) < 0.001 && Math.abs(prev.height - nextHeight) < 0.5
? prev
: { scale: next, height: nextHeight },
)
}
measure()
// Both boxes matter: the wrapper for available width, the inner for the
// height that width produces once the webfont has swapped in.
const observer = new ResizeObserver(measure)
if (wrapRef.current !== null) observer.observe(wrapRef.current)
if (innerRef.current !== null) observer.observe(innerRef.current)
return () => {
observer.disconnect()
}
}, [designWidth, minScale])
return { wrapRef, innerRef, scale, height, ready: scale > 0 }
}