Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
136 lines
6.1 KiB
TypeScript
136 lines
6.1 KiB
TypeScript
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" />
|
|
}
|
|
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.
|
|
export function useEscape(fn: () => void, active = true) {
|
|
const ref = useRef(fn); ref.current = fn
|
|
useEffect(() => {
|
|
if (!active) return
|
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') 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)
|
|
return (
|
|
<div className="modal-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
|
|
}
|