mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
The index, its boards and the shelf's pages wrap their prose: server pages with <T>, the boards and the library with t(), and copy held in data with phrase(). Figures read from the runs are values, and the fields lib/kai, lib/tools, lib/coding and lib/tally hand a board are drawn through t(), so they translate once those files emit keys. The shared parts (Chapter, Fold, Stat, Tile, Panel, Mark) translate a string where they draw it and take a node from a server page. figures.spec lets the index import @hanzo/i18n: words in, words out, never a figure.
182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
|
import { ArrowDown, ArrowUp, Download, Equal, Link2, Minus } from '@hanzogui/lucide-icons-2'
|
|
import { Text, XStack } from '@hanzo/ui'
|
|
import { phrase } from '@hanzo/i18n'
|
|
import { useI18n } from '@hanzo/i18n/react'
|
|
import { BEHIND, OURS, THEM } from './parts'
|
|
|
|
/**
|
|
* The controls the /benchmarks board and library share: the inks, state kept in
|
|
* the URL, segmented choices, the action buttons, the toolbar, and export.
|
|
*
|
|
* Every control writes its state to the URL (replaceState, no reload), so a view
|
|
* can be shared exactly; a page opened with that query starts in that view. Each
|
|
* component owns its own keys, so two never write the same one.
|
|
*/
|
|
|
|
export { BEHIND, OURS, THEM }
|
|
export const INK = 'var(--text-primary)'
|
|
export const SOFT = 'var(--text-secondary)'
|
|
export const DIM = 'var(--muted-foreground)'
|
|
export const LINE = 'var(--border)'
|
|
export const TRACK = 'var(--edge)'
|
|
export const MONO = '$mono'
|
|
export const FIGURES = { fontVariantNumeric: 'tabular-nums' } as const
|
|
|
|
/* ---------------- state in the URL ---------------- */
|
|
|
|
export function useParam<T extends string>(key: string, fallback: T, allowed?: readonly T[]): [T, (v: T) => void] {
|
|
const [value, setValue] = useState<T>(fallback)
|
|
useEffect(() => {
|
|
const q = new URLSearchParams(window.location.search).get(key)
|
|
if (q != null && (!allowed || (allowed as readonly string[]).includes(q))) setValue(q as T)
|
|
// Read once, on mount: the URL is the starting view, the controls own it after.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
const set = useCallback(
|
|
(v: T) => {
|
|
setValue(v)
|
|
const u = new URL(window.location.href)
|
|
if (v === fallback || v === '') u.searchParams.delete(key)
|
|
else u.searchParams.set(key, v)
|
|
window.history.replaceState(window.history.state, '', u.toString())
|
|
},
|
|
[key, fallback],
|
|
)
|
|
return [value, set]
|
|
}
|
|
|
|
/* ---------------- the controls ---------------- */
|
|
|
|
export function Seg<T extends string>({ label, value, options, onChange }: { label: string; value: T; options: { id: T; name: string }[]; onChange: (v: T) => void }) {
|
|
return (
|
|
<XStack role="group" aria-label={label} items="center" gap={6} flexWrap="wrap" maxW="100%">
|
|
<Text fontSize="$1" color={DIM} letterSpacing={0.6} textTransform="uppercase" mr={4}>
|
|
{label}
|
|
</Text>
|
|
{options.map((o) => {
|
|
const on = o.id === value
|
|
return (
|
|
<XStack
|
|
key={o.id}
|
|
render={<button type="button" aria-pressed={on} onClick={() => onChange(o.id)} />}
|
|
px={10}
|
|
minH={30}
|
|
items="center"
|
|
rounded={6}
|
|
borderWidth={1}
|
|
borderColor={on ? 'var(--text-secondary)' : LINE}
|
|
bg={on ? 'var(--edge)' : 'transparent'}
|
|
cursor="pointer"
|
|
focusVisibleStyle={{ outlineColor: INK, outlineWidth: 2, outlineStyle: 'solid' }}
|
|
>
|
|
<Text fontSize="$2" color={on ? INK : SOFT}>
|
|
{o.name}
|
|
</Text>
|
|
</XStack>
|
|
)
|
|
})}
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
/** An icon from the one set the board uses (gui's lucide), sized and inked like the text beside it. */
|
|
type Icon = typeof Download
|
|
|
|
export function Act({ onClick, icon: Glyph, children }: { onClick: () => void; icon?: Icon; children: ReactNode }) {
|
|
return (
|
|
<XStack
|
|
render={<button type="button" onClick={onClick} />}
|
|
px={10}
|
|
minH={30}
|
|
gap={6}
|
|
items="center"
|
|
rounded={6}
|
|
borderWidth={1}
|
|
borderColor={LINE}
|
|
bg="transparent"
|
|
cursor="pointer"
|
|
focusVisibleStyle={{ outlineColor: INK, outlineWidth: 2, outlineStyle: 'solid' }}
|
|
>
|
|
{Glyph ? <Glyph size={14} color={SOFT} shrink={0} aria-hidden /> : null}
|
|
<Text fontSize="$2" color={SOFT}>
|
|
{children}
|
|
</Text>
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
/** How a result stands, as a glyph and a word in the page's ink: no colour carries it. */
|
|
const STANDS = { win: [ArrowUp, phrase('Win')], level: [Equal, phrase('Level')], loss: [ArrowDown, phrase('Loss')], unscored: [Minus, phrase('Unscored')] } as const
|
|
export function Mark({ s, word }: { s: keyof typeof STANDS; word?: string }) {
|
|
const { t } = useI18n()
|
|
const [Glyph, name] = STANDS[s]
|
|
// A caller's word is authored English too (a server page cannot translate it), so it is drawn through t.
|
|
const said = t(word ?? name)
|
|
return (
|
|
<XStack items="center" gap={4} aria-label={said}>
|
|
<Glyph size={14} color={s === 'unscored' ? THEM : INK} shrink={0} aria-hidden />
|
|
<Text fontSize="$2" fontWeight={s === 'loss' ? '600' : '500'} color={s === 'unscored' ? THEM : INK}>
|
|
{said}
|
|
</Text>
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
export function Bar({ children }: { children: ReactNode }) {
|
|
return (
|
|
<XStack flexWrap="wrap" items="center" gap={16} rowGap={10} py={12} borderTopWidth={1} borderBottomWidth={1} borderColor={LINE} my={16}>
|
|
{children}
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
/* ---------------- export ---------------- */
|
|
|
|
const csvCell = (v: unknown) => {
|
|
const s = v == null ? '' : String(v)
|
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
}
|
|
function save(name: string, type: string, body: string) {
|
|
const url = URL.createObjectURL(new Blob([body], { type }))
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = name
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
a.remove()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
export function exportRows(name: string, rows: Record<string, unknown>[], as: 'csv' | 'json') {
|
|
if (as === 'json') return save(`${name}.json`, 'application/json', JSON.stringify(rows, null, 2))
|
|
const cols = Object.keys(rows[0] ?? {})
|
|
save(`${name}.csv`, 'text/csv', [cols.join(','), ...rows.map((r) => cols.map((c) => csvCell(r[c])).join(','))].join('\n'))
|
|
}
|
|
export function Exports({ name, rows }: { name: string; rows: () => Record<string, unknown>[] }) {
|
|
const { t } = useI18n()
|
|
const [copied, setCopied] = useState(false)
|
|
return (
|
|
<XStack items="center" gap={6} flexWrap="wrap" ml="auto">
|
|
<Act icon={Download} onClick={() => exportRows(name, rows(), 'csv')}>
|
|
CSV
|
|
</Act>
|
|
<Act icon={Download} onClick={() => exportRows(name, rows(), 'json')}>
|
|
JSON
|
|
</Act>
|
|
<Act
|
|
icon={Link2}
|
|
onClick={() => {
|
|
void navigator.clipboard?.writeText(window.location.href).then(() => {
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1600)
|
|
})
|
|
}}
|
|
>
|
|
{copied ? t('Link copied') : t('Copy link')}
|
|
</Act>
|
|
</XStack>
|
|
)
|
|
}
|