Feedback sweep: spinners on every async button, toasts for action outcomes, top activity bar, loading states on every page, terminal connecting overlay, dashboard save indicator, task pane pinned above the package list with auto-scroll and completion toast
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d08b246b5f
commit
724f30c02b
@@ -0,0 +1,70 @@
|
||||
import { ReactNode, useEffect, 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 && !window.confirm(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' : '')} />
|
||||
}
|
||||
Reference in New Issue
Block a user