Files
hamlab/web/src/ui.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 7e3e916f25 Arrange the rail: drag pages into your own order, kept with the account
The close cross on a detail pane is drawn, not a glyph, and sits in no box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:59:25 -07:00

152 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ReactNode, useEffect, useRef, useState } from 'react'
// ---- spinners ----
export function Spinner({ size = 14 }: { size?: number }) {
return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" />
}
// The × in the corner of a detail pane. A drawn cross rather than the × glyph, which sits
// differently in every font and theme.
export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; title?: string }) {
return (
<button type="button" className="detail-close" title={title} aria-label={title} onClick={onClick}>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" /></svg>
</button>
)
}
export function Loading({ label = 'Loading…' }: { label?: string }) {
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
}
// Button that shows a spinner while its async handler runs; `confirm` asks first.
export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title, children }: {
onClick: () => unknown | Promise<unknown>; busy?: boolean; confirm?: string; className?: string; disabled?: boolean; title?: string; children: ReactNode
}) {
const [inner, setInner] = useState(false)
const b = !!busy || inner
return (
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
onClick={async () => {
if (confirm && !(await askYes(confirm))) return
const r = onClick()
if (r && typeof (r as Promise<unknown>).then === 'function') {
setInner(true)
try { await r } finally { setInner(false) }
}
}}>
{b && <Spinner />}{children}
</button>
)
}
// ---- toasts: short feedback for actions ----
interface Toast { id: number; kind: 'ok' | 'err' | 'info'; msg: string }
const listeners = new Set<(t: Toast[]) => void>()
let toasts: Toast[] = []
let seq = 0
function emit() { listeners.forEach((l) => l(toasts)) }
export function dismissToast(id: number) { toasts = toasts.filter((t) => t.id !== id); emit() }
export function toast(msg: string, kind: Toast['kind'] = 'ok') {
const t = { id: ++seq, kind, msg }
toasts = [...toasts.slice(-4), t]
emit()
window.setTimeout(() => dismissToast(t.id), kind === 'err' ? 8000 : 3500)
}
export function Toasts() {
const [list, setList] = useState(toasts)
useEffect(() => { listeners.add(setList); return () => { listeners.delete(setList) } }, [])
return (
<div className="toasts">
{list.map((t) => <div key={t.id} className={'toast ' + t.kind} onClick={() => dismissToast(t.id)}>{t.msg}</div>)}
</div>
)
}
// act runs an action and toasts its outcome. Returns whether it succeeded.
export async function act(label: string, fn: () => Promise<unknown>): Promise<boolean> {
try { await fn(); toast(label); return true }
catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
}
// ---- thin activity bar at the top while any request is in flight ----
export function NetBar() {
const [n, setN] = useState(0)
useEffect(() => {
const h = (e: Event) => setN((e as CustomEvent<number>).detail)
window.addEventListener('hamlab:inflight', h)
return () => window.removeEventListener('hamlab:inflight', h)
}, [])
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
}
// useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way.
// A question (askYes/askText) sits on top of whatever asked it: Escape answers the question and
// goes no further, or one key would cancel the question and close the dialog underneath as well.
let escapeTaken: Event | null = null
export function useEscape(fn: () => void, active = true, isAsk = false) {
const ref = useRef(fn); ref.current = fn
useEffect(() => {
if (!active) return
const h = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
if (isAsk) { escapeTaken = e; ref.current() } else if (asks.length === 0 && escapeTaken !== e) ref.current()
}
window.addEventListener('keydown', h)
return () => window.removeEventListener('keydown', h)
}, [active])
}
// ---- dialogs: askYes / askText replace the browser's confirm() and prompt() ----
// Same promise shape as the natives (false / null on cancel), drawn like the rest of the
// console, usable on a phone, closed by Escape or a tap outside.
interface Ask { id: number; msg: string; input?: string; ok: string; danger: boolean; done: (v: string | boolean | null) => void }
let asks: Ask[] = []
let nextId = 0
const askListeners = new Set<(a: Ask[]) => void>()
const emitAsks = () => askListeners.forEach((l) => l(asks))
function ask(msg: string, input: string | undefined, ok?: string): Promise<string | boolean | null> {
return new Promise((resolve) => {
const id = ++nextId
const danger = /^(delete|remove|revoke|close|send sig|stop|disable|reset|discard|force)/i.test(msg) || /\bforce\b/i.test(msg)
asks = [...asks, { id, msg, input, ok: ok ?? (input !== undefined ? 'OK' : danger ? msg.split(/[ ?]/)[0] : 'OK'), danger, done: (v) => { asks = asks.filter((a) => a.id !== id); emitAsks(); resolve(v) } }]
emitAsks()
})
}
export const askYes = (msg: string, ok?: string) => ask(msg, undefined, ok) as Promise<boolean>
export const askText = (msg: string, initial = '', ok?: string) => ask(msg, initial, ok) as Promise<string | null>
function AskBox({ a }: { a: Ask }) {
const [val, setVal] = useState(a.input ?? '')
const cancel = () => a.done(a.input !== undefined ? null : false)
const accept = () => a.done(a.input !== undefined ? val : true)
useEscape(cancel, true, true)
return (
<div className="modal-back ask-back" onClick={cancel}>
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
<div className="ask-msg">{a.msg}</div>
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
<div className="svc-actions">
<button type="submit" autoFocus={a.input === undefined} className={'btn ' + (a.danger ? 'danger' : 'on')}>{a.ok}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={cancel}>Cancel</button>
</div>
</form>
</div>
)
}
export function Dialogs() {
const [list, setList] = useState<Ask[]>(asks)
useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, [])
const top = list[list.length - 1]
return top ? <AskBox key={top.id} a={top} /> : null
}
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
let unsaved = ''
export function setUnsaved(what: string) { unsaved = what }
export const hasUnsaved = () => unsaved !== ''
export async function okToLeave(): Promise<boolean> {
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
return false
}