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,27 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LogoProps {
|
||||
/** Height in px. Width follows the artwork's aspect ratio. */
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Native aspect ratio of the logo artwork (186.407 × 211). */
|
||||
const ASPECT = 186.407 / 211
|
||||
|
||||
/*
|
||||
* Two assets rather than one recoloured file — the ink and light cuts differ
|
||||
* in more than colour — swapped by CSS.
|
||||
*
|
||||
* Background images, not <img>: a hidden <img> is still downloaded, so the
|
||||
* two-element version fetched both cuts on every visit (~12-23 kB gzip wasted
|
||||
* above the fold). A background on a non-painted rule is never requested.
|
||||
*/
|
||||
export const Logo = ({ size = 180, className }: LogoProps) => (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="soulseek-rs"
|
||||
className={cn('logo-mark shrink-0 bg-contain bg-no-repeat', className)}
|
||||
style={{ height: size, width: size * ASPECT }}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface WordmarkProps {
|
||||
/** Font size in px. */
|
||||
size?: number
|
||||
blink?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary soulseek-rs mark: lowercase mono wordmark plus a solid block
|
||||
* cursor. Live text rather than an asset, so it blinks for free and stays
|
||||
* crisp at any size.
|
||||
*/
|
||||
export const Wordmark = ({ size = 28, blink = false, className }: WordmarkProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center whitespace-nowrap font-mono font-medium leading-none tracking-[-0.01em] text-primary',
|
||||
className,
|
||||
)}
|
||||
style={{ fontSize: size }}
|
||||
>
|
||||
soulseek-rs
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'ml-[0.06em] inline-block h-[1.02em] w-[0.58em] translate-y-[0.04em] rounded-[1.5px] bg-accent',
|
||||
blink && 'motion-safe:animate-cursor-blink',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { Wordmark } from '@/components/brand/wordmark'
|
||||
import { Wrap } from '@/components/ui/layout'
|
||||
import { LINKS, VERSION } from '@/lib/links'
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
interface FooterColumn {
|
||||
heading: string
|
||||
links: readonly FooterLink[]
|
||||
}
|
||||
|
||||
const COLUMNS: readonly FooterColumn[] = [
|
||||
{
|
||||
heading: 'Project',
|
||||
links: [
|
||||
{ label: 'GitHub', href: LINKS.gh },
|
||||
{ label: 'Issues', href: LINKS.issues },
|
||||
{ label: 'Releases', href: LINKS.releases },
|
||||
{ label: 'Changelog', href: LINKS.changelog },
|
||||
{ label: 'soulfind server', href: LINKS.soulfind },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Packages',
|
||||
links: [
|
||||
{ label: 'crates.io · client', href: LINKS.cratesClient },
|
||||
{ label: 'crates.io · lib', href: LINKS.cratesLib },
|
||||
{ label: 'docs.rs', href: LINKS.docsrs },
|
||||
{ label: 'rustup', href: LINKS.rustup },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Read',
|
||||
links: [
|
||||
{ label: 'Docs', href: '/docs' },
|
||||
{ label: 'Install', href: '/install' },
|
||||
{ label: 'Community', href: '/community' },
|
||||
{ label: 'License · MIT', href: LINKS.license },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const LINK_CLASS = 'text-[13px] text-secondary hover:text-primary'
|
||||
|
||||
const FooterAnchor = ({ label, href }: FooterLink) =>
|
||||
href.startsWith('/') ? (
|
||||
<Link to={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
|
||||
export const Footer = () => (
|
||||
<footer className="border-t border-hairline bg-base pt-11 pb-10">
|
||||
<Wrap>
|
||||
<div className="grid grid-cols-1 gap-8 min-[520px]:grid-cols-2 md:grid-cols-[1.6fr_1fr_1fr_1fr]">
|
||||
<div className="flex max-w-[320px] flex-col gap-3.5">
|
||||
<Wordmark size={22} />
|
||||
<p className="text-[13px] leading-[22px] text-secondary">
|
||||
A Soulseek client and protocol library for Rust.
|
||||
</p>
|
||||
<p className="font-forum text-xs text-muted">
|
||||
Not affiliated with or endorsed by the Soulseek project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column.heading}>
|
||||
<h2 className="mb-3.5 text-label uppercase tracking-[var(--tracking-label)] text-secondary">
|
||||
{column.heading}
|
||||
</h2>
|
||||
<ul>
|
||||
{column.links.map((link) => (
|
||||
<li key={link.label} className="mb-2.5">
|
||||
<FooterAnchor {...link} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-11 flex flex-wrap items-center gap-x-6 gap-y-2 border-t border-hairline pt-5.5 text-[12.5px] text-muted">
|
||||
<span>MIT · © 2026 Michel de Graaf</span>
|
||||
<span className="text-success">No telemetry. Ever.</span>
|
||||
<span className="ml-auto">{VERSION}</span>
|
||||
</div>
|
||||
</Wrap>
|
||||
</footer>
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
import { Wordmark } from '@/components/brand/wordmark'
|
||||
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
||||
import { LINKS } from '@/lib/links'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ITEMS = [
|
||||
{ label: 'home', to: '/' },
|
||||
{ label: 'install', to: '/install' },
|
||||
{ label: 'docs', to: '/docs' },
|
||||
{ label: 'community', to: '/community' },
|
||||
] as const
|
||||
|
||||
/*
|
||||
* No hamburger. Below 820px the links become their own horizontally
|
||||
* scrollable row, which keeps every destination one tap away instead of
|
||||
* two — the site has four pages, a drawer would be ceremony.
|
||||
*/
|
||||
export const Nav = () => (
|
||||
<header className="sticky top-0 z-50 border-b border-hairline bg-[color-mix(in_srgb,var(--bg-base)_88%,transparent)] backdrop-blur-[8px]">
|
||||
<div className="mx-auto flex max-w-[1140px] flex-wrap items-center gap-x-4 gap-y-3 px-[18px] pt-2.5 sm:h-[60px] sm:flex-nowrap sm:gap-6 sm:px-7 sm:pt-0">
|
||||
<NavLink to="/" aria-label="soulseek-rs home" className="mr-auto flex items-center">
|
||||
<Wordmark size={19} blink />
|
||||
</NavLink>
|
||||
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="order-3 flex w-full items-center gap-0.5 overflow-x-auto border-t border-hairline py-1 [-ms-overflow-style:none] [scrollbar-width:none] sm:order-none sm:w-auto sm:border-t-0 sm:py-0 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'relative inline-flex shrink-0 items-center rounded-md px-3.5 py-1.5 text-label uppercase tracking-[var(--tracking-label)] max-sm:min-h-11 sm:px-3',
|
||||
isActive
|
||||
? "bg-raised text-primary before:text-accent before:content-['›_'] after:absolute after:inset-x-3 after:bottom-px after:h-0.5 after:bg-accent"
|
||||
: 'text-secondary hover:bg-[var(--bg-selection)] hover:text-primary',
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={LINKS.gh}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-[38px] items-center gap-1.5 rounded-md border border-hairline px-2.5 font-mono text-xs text-secondary hover:border-line hover:bg-[var(--bg-selection)] hover:text-primary sm:h-8"
|
||||
>
|
||||
GitHub <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router'
|
||||
|
||||
import { Footer } from '@/components/layout/footer'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { usePageMeta } from '@/hooks/use-page-meta'
|
||||
|
||||
/**
|
||||
* On navigation: jump to the anchor if the URL carries one, otherwise back to
|
||||
* the top, and move keyboard focus into the main landmark.
|
||||
*
|
||||
* That last part matters — without it focus stays on the link that was
|
||||
* clicked, so a screen reader never announces that the page changed.
|
||||
*
|
||||
* replaces react-router's <ScrollRestoration>, which only works
|
||||
* under a data router. Restoring exact scroll offsets on back/forward would
|
||||
* mean adopting createBrowserRouter for four static pages.
|
||||
*/
|
||||
const useNavigationEffects = () => {
|
||||
const { pathname, hash } = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (hash !== '') {
|
||||
document.querySelector(hash)?.scrollIntoView()
|
||||
} else {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
document.getElementById('main')?.focus({ preventScroll: true })
|
||||
}, [pathname, hash])
|
||||
}
|
||||
|
||||
export const SiteLayout = () => {
|
||||
useNavigationEffects()
|
||||
usePageMeta()
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only rounded-md border border-line bg-panel px-4 py-2 focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-100"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Nav />
|
||||
<main id="main" tabIndex={-1} className="outline-none">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useTheme } from '@/theme/use-theme'
|
||||
import type { Theme } from '@/theme/theme-context'
|
||||
|
||||
/*
|
||||
* Cycles system → light → dark → system rather than a plain two-way flip, so
|
||||
* "follow the OS" stays reachable after the first click. The glyph shows what
|
||||
* is painted; the label says which mode is selected.
|
||||
*/
|
||||
const NEXT: Record<Theme, Theme> = {
|
||||
system: 'light',
|
||||
light: 'dark',
|
||||
dark: 'system',
|
||||
}
|
||||
|
||||
const LABEL: Record<Theme, string> = {
|
||||
system: 'system',
|
||||
light: 'light',
|
||||
dark: 'dark',
|
||||
}
|
||||
|
||||
export const ThemeToggle = () => {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(NEXT[theme])
|
||||
}}
|
||||
aria-label={`Theme: ${LABEL[theme]}. Switch to ${LABEL[NEXT[theme]]}.`}
|
||||
title={`Theme: ${LABEL[theme]}`}
|
||||
className="inline-flex h-[38px] items-center justify-center gap-1.5 rounded-md border border-hairline bg-transparent px-2.5 font-mono text-xs text-secondary hover:border-line hover:bg-[var(--bg-selection)] hover:text-primary sm:h-8"
|
||||
>
|
||||
<span aria-hidden="true">{resolvedTheme === 'light' ? '☀' : '☾'}</span>
|
||||
{theme === 'system' && (
|
||||
<span aria-hidden="true" className="text-[10px] opacity-70">
|
||||
auto
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center gap-2 rounded-md border font-mono font-medium transition-[background,border-color] duration-[120ms] disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-line bg-transparent text-primary hover:border-line-strong hover:bg-[var(--bg-selection)]',
|
||||
accent:
|
||||
'border-accent bg-accent text-white hover:border-accent-hover hover:bg-accent-hover',
|
||||
},
|
||||
size: {
|
||||
default: 'h-11 px-5 text-sm max-sm:h-[42px] max-sm:px-4 max-sm:text-[13px]',
|
||||
sm: 'h-8 px-2.5 text-xs',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
|
||||
interface ButtonProps extends ButtonVariants {
|
||||
/** Internal route path, or an absolute URL for an off-site link. */
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const isExternal = (href: string): boolean => /^https?:\/\//.test(href)
|
||||
|
||||
/**
|
||||
* Every call to action on the site is a link, so this renders an anchor
|
||||
* rather than a `<button>`. External links open in a new tab and carry
|
||||
* `rel="noopener"`; internal ones route through React Router so navigation
|
||||
* stays client-side.
|
||||
*/
|
||||
export const Button = ({ href, variant, size, children, className }: ButtonProps) => {
|
||||
const classes = cn(buttonVariants({ variant, size }), className)
|
||||
|
||||
if (isExternal(href))
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={classes}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<Link to={href} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCopy } from '@/hooks/use-copy'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CommandPillProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The one-line install command, click-to-copy. */
|
||||
export const CommandPill = ({ text, className }: CommandPillProps) => {
|
||||
const { copied, copy } = useCopy()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="Copy command"
|
||||
onClick={() => {
|
||||
copy(text)
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-11 items-center gap-2.5 rounded-md border border-line bg-panel py-0 pr-2.5 pl-3.5 font-mono text-[13.5px] tabular-nums text-primary hover:border-line-strong hover:bg-raised max-sm:w-full max-sm:justify-between',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-accent">
|
||||
$
|
||||
</span>
|
||||
<code className="font-mono">{text}</code>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'border-l border-hairline pl-2.5 text-[11px]',
|
||||
copied ? 'text-success' : 'text-secondary',
|
||||
)}
|
||||
>
|
||||
{copied ? '✓ copied' : '⧉ copy'}
|
||||
</span>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{copied ? 'Copied to clipboard' : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCopy } from '@/hooks/use-copy'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The copy control in a terminal header. Styled against the terminal
|
||||
* surface tokens, not the page ones, so it reads as part of the window. */
|
||||
export const CopyButton = ({ text, className }: CopyButtonProps) => {
|
||||
const { copied, copy } = useCopy()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copy(text)
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] font-mono text-[11px] transition-colors',
|
||||
copied
|
||||
? 'border-[var(--tm-ok)] text-[var(--tm-ok)]'
|
||||
: 'border-[var(--tm-bd)] text-[var(--tm-dim)] hover:border-[var(--tm-bd3)] hover:text-[var(--tm-fg)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true">{copied ? '✓' : '⧉'}</span>
|
||||
{copied ? 'copied' : 'copy'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCopy } from '@/hooks/use-copy'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FeatureCommandProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
const FeatureCommand = ({ text }: FeatureCommandProps) => {
|
||||
const { copied, copy } = useCopy()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="Copy command"
|
||||
onClick={() => {
|
||||
copy(text)
|
||||
}}
|
||||
className={cn(
|
||||
'mt-auto flex w-full items-center gap-2 rounded-md border bg-[var(--cmd-bg)] px-2.5 py-2 text-left font-mono text-[11.5px] leading-[1.5] text-[var(--tm-fg)]',
|
||||
copied
|
||||
? 'border-[var(--tm-ok)]'
|
||||
: 'border-[var(--tm-bd)] hover:border-[var(--tm-bd3)]',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="shrink-0 text-[var(--tm-pr)]">
|
||||
$
|
||||
</span>
|
||||
<code className="grow font-mono break-words whitespace-pre-wrap">{text}</code>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('shrink-0', copied ? 'text-[var(--tm-ok)]' : 'text-[var(--tm-dim)]')}
|
||||
>
|
||||
{copied ? '✓' : '⧉'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface FeatureCardProps {
|
||||
/** A single glyph. Decorative — the title carries the meaning. */
|
||||
glyph?: string
|
||||
/** A CSS colour for the glyph, from the semantic token set. */
|
||||
color?: string
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
cmd?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const FeatureCard = ({
|
||||
glyph,
|
||||
color,
|
||||
title,
|
||||
children,
|
||||
cmd,
|
||||
className,
|
||||
}: FeatureCardProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2.5 rounded-md border border-hairline bg-panel p-[18px] transition-[border-color,background] duration-[120ms] hover:border-line hover:bg-raised sm:p-[22px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{glyph !== undefined && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex size-[34px] shrink-0 items-center justify-center rounded-md border border-hairline text-xl leading-none"
|
||||
style={color !== undefined ? { color } : undefined}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[15px] font-medium text-primary">{title}</div>
|
||||
</div>
|
||||
<div className="text-[13px] leading-[21px] text-pretty text-secondary [&_code]:font-mono [&_code]:text-primary">
|
||||
{children}
|
||||
</div>
|
||||
{cmd !== undefined && <FeatureCommand text={cmd} />}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CodeProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Anything typeable: flags, commands, env vars, crate names, paths. */
|
||||
export const Code = ({ children, className }: CodeProps) => (
|
||||
<code
|
||||
className={cn(
|
||||
'whitespace-nowrap rounded-md border border-hairline bg-raised px-1.5 py-px font-mono text-[0.92em] text-primary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface WrapProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The site's single content column. One max-width, one gutter, everywhere. */
|
||||
export const Wrap = ({ children, className }: WrapProps) => (
|
||||
<div className={cn('mx-auto w-full max-w-[1140px] px-[18px] sm:px-7', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface SectionProps {
|
||||
children: React.ReactNode
|
||||
/** Drops the top hairline — for the first section under the nav. */
|
||||
flush?: boolean
|
||||
/** Panel background plus rules top and bottom. */
|
||||
band?: boolean
|
||||
id?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Section = ({ children, flush, band, id, className }: SectionProps) => (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'py-6 pb-10 sm:pt-[30px] sm:pb-13',
|
||||
band
|
||||
? 'border-y border-hairline bg-panel'
|
||||
: !flush && 'border-t border-hairline',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Wrap>{children}</Wrap>
|
||||
</section>
|
||||
)
|
||||
|
||||
interface EyebrowProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Eyebrow = ({ children, className }: EyebrowProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'text-label uppercase tracking-[var(--tracking-label)] text-accent',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
interface SectionHeadProps {
|
||||
eyebrow?: string
|
||||
title: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SectionHead = ({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: SectionHeadProps) => (
|
||||
<div className={cn('mb-11 flex max-w-[660px] flex-col gap-3.5', className)}>
|
||||
{eyebrow !== undefined && <Eyebrow>{eyebrow}</Eyebrow>}
|
||||
<h2 className="text-[26px] leading-[34px] font-medium tracking-[-0.005em] text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
{title}
|
||||
</h2>
|
||||
{children !== undefined && (
|
||||
<p className="text-base leading-[26px] text-pretty text-secondary">{children}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface PageHeadProps {
|
||||
eyebrow: string
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** The h1 block every subpage opens with. */
|
||||
export const PageHead = ({ eyebrow, title, children }: PageHeadProps) => (
|
||||
<Section flush className="pt-10">
|
||||
<div className="flex max-w-[660px] flex-col gap-3.5">
|
||||
<Eyebrow>{eyebrow}</Eyebrow>
|
||||
<h1 className="text-[26px] leading-[34px] font-medium tracking-[-0.005em] text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-base leading-[26px] text-pretty text-secondary">{children}</p>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
|
||||
interface ColsProps {
|
||||
children: React.ReactNode
|
||||
/** Aligns columns to their tops instead of stretching. */
|
||||
start?: boolean
|
||||
center?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The site's only multi-column layout: two up on desktop, stacked below. */
|
||||
export const Cols = ({ children, start, center, className }: ColsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-7 md:grid-cols-2 md:gap-10',
|
||||
start === true && 'items-start',
|
||||
center === true && 'items-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ProseProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Body copy: secondary colour, generous leading, `+p` spacing. */
|
||||
export const Prose = ({ children, className }: ProseProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'[&>p+p]:mt-4 [&>p]:leading-[26px] [&>p]:text-pretty [&>p]:text-secondary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
export type OsName = 'macOS' | 'Linux' | 'Windows'
|
||||
|
||||
const PATHS: Record<Exclude<OsName, 'Windows'>, string> = {
|
||||
macOS:
|
||||
'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701',
|
||||
Linux:
|
||||
'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139z',
|
||||
}
|
||||
|
||||
const WINDOWS_PATH =
|
||||
'M2 3.6l8.5-1.2v9.1H2V3.6zm10 -1.35L22.5 1v10.5H12V2.25zM2 12.5h8.5v9.1L2 20.4v-7.9zm10 0h10.5V23L12 21.75V12.5z'
|
||||
|
||||
/** Decorative — the adjacent label already names the platform. */
|
||||
export const OsIcon = ({ name }: { name: OsName }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className="block size-[22px] shrink-0 text-primary"
|
||||
>
|
||||
<path fill="currentColor" d={name === 'Windows' ? WINDOWS_PATH : PATHS[name]} />
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PanelProps {
|
||||
/** Pane number, rendered in signal blue like the TUI's `[2]` tags. */
|
||||
num?: number | undefined
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The fieldset/legend motif lifted from the TUI: a bordered box whose title
|
||||
* breaks the top border. `bg-base` on the legend is what punches the gap, so
|
||||
* it has to match whatever surface the panel sits on.
|
||||
*/
|
||||
export const Panel = ({ num, title, children, className }: PanelProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-md border border-hairline bg-panel px-[18px] py-[22px] sm:px-6 sm:py-[26px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="absolute -top-2.5 left-4 bg-base px-2 text-label uppercase tracking-[var(--tracking-label)] text-secondary">
|
||||
{/* --color-info, not the raw --color-signal: this panel renders on the
|
||||
page, outside .tui-content, where the raw palette never re-cuts. */}
|
||||
{num !== undefined && <span className="text-info">[{num}] </span>}
|
||||
{title}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CalloutProps {
|
||||
title?: string
|
||||
tone?: 'accent' | 'warn'
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** An aside with a coloured left rule. `warn` carries the tape amber. */
|
||||
export const Callout = ({ title, tone = 'accent', children, className }: CalloutProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-hairline border-l-[3px] bg-panel px-[18px] py-5 sm:px-[22px]',
|
||||
tone === 'warn' ? 'border-l-warning' : 'border-l-accent',
|
||||
'[&>p]:leading-[25px] [&>p]:text-secondary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title !== undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 text-label uppercase tracking-[var(--tracking-label)]',
|
||||
tone === 'warn' ? 'text-warning' : 'text-accent',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** `cmd` gets a prompt glyph; `cm` is a comment; `code` and `out` are plain. */
|
||||
export type TermLineKind = 'cmd' | 'cm' | 'code' | 'out'
|
||||
|
||||
export interface TermLine {
|
||||
t: TermLineKind
|
||||
text: string
|
||||
}
|
||||
|
||||
const LINE_TONE: Record<TermLineKind, string> = {
|
||||
cmd: '',
|
||||
cm: 'text-[var(--tm-cm)]',
|
||||
code: '',
|
||||
out: '',
|
||||
}
|
||||
|
||||
interface TerminalProps {
|
||||
label?: string
|
||||
lines: readonly TermLine[]
|
||||
/** Overrides what the copy button yields. Defaults to the commands only. */
|
||||
copy?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A terminal window. Paints from the `--tm-*` token set rather than the page
|
||||
* tokens, so it stays a dark terminal in light mode instead of dissolving
|
||||
* into the page.
|
||||
*/
|
||||
export const Terminal = ({ label, lines, copy, className }: TerminalProps) => {
|
||||
const copyText =
|
||||
copy ??
|
||||
lines
|
||||
.filter((line) => line.t === 'cmd')
|
||||
.map((line) => line.text)
|
||||
.join('\n')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border border-[var(--tm-bd)] bg-[var(--tm-bg)] font-mono tabular-nums',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--tm-bd2)] bg-[var(--tm-hd)] px-3 py-2">
|
||||
<span className="text-label uppercase tracking-[var(--tracking-label)] text-[var(--tm-dim)]">
|
||||
{label ?? 'terminal'}
|
||||
</span>
|
||||
<CopyButton text={copyText} />
|
||||
</div>
|
||||
<div className="overflow-x-auto px-4 py-4 text-[12.5px] leading-[1.75] text-[var(--tm-fg)] sm:text-[13.5px]">
|
||||
{lines.map((line, i) => (
|
||||
<span
|
||||
// Lines are static content, never reordered — index is stable here.
|
||||
key={i}
|
||||
className={cn('block whitespace-pre', LINE_TONE[line.t])}
|
||||
>
|
||||
{line.t === 'cmd' && (
|
||||
<>
|
||||
<span className="text-[var(--tm-pr)]">$</span>{' '}
|
||||
</>
|
||||
)}
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user