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