diff --git a/ui/dist/app.js b/ui/dist/app.js index dde559d..cd45ba4 100644 --- a/ui/dist/app.js +++ b/ui/dist/app.js @@ -1,5 +1,5 @@ import { h, render, Component } from 'preact'; -import { useState, useEffect, useCallback } from 'preact/hooks'; +import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; import htm from 'htm'; const html = htm.bind(h); @@ -23,12 +23,53 @@ async function api(method, path, body) { headers: { 'Authorization': `Bearer ${getApiKey()}`, 'Content-Type': 'application/json' }, }; if (body) opts.body = JSON.stringify(body); - const res = await fetch(BASE_PATH + path, opts); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Request failed'); + let res; + try { + res = await fetch(BASE_PATH + path, opts); + } catch (e) { + throw new Error('Network error — is the server reachable?'); + } + // A mid-session 401 means the admin key expired/was revoked: clear it and let + // the app bounce to the login screen instead of spraying error toasts. + if (res.status === 401) { + clearApiKey(); + window.dispatchEvent(new CustomEvent('sa-auth-expired')); + throw new Error('Session expired — please sign in again'); + } + // Tolerate non-JSON responses (e.g. a 502 HTML page from a proxy) so the UI + // shows a friendly message rather than a raw "Unexpected token <" crash. + const ct = res.headers.get('content-type') || ''; + let data = null; + if (ct.includes('application/json')) { + data = await res.json().catch(() => null); + } else { + const text = await res.text().catch(() => ''); + if (!res.ok) throw new Error(`Server error (${res.status})${text ? ': ' + text.slice(0, 140) : ''}`); + return text; + } + if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`); return data; } +// === Data hooks === +// useResource gives every screen consistent {data, loading, error, reload} +// instead of each page re-hand-rolling fetch + loading + swallowed errors. +function useResource(method, path, deps = []) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const reload = useCallback(() => { + setLoading(true); + setError(null); + return api(method, path) + .then((d) => { setData(d); return d; }) + .catch((e) => { setError(e.message || 'Failed to load'); throw e; }) + .finally(() => setLoading(false)); + }, [method, path]); + useEffect(() => { reload().catch(() => {}); }, deps); // eslint-disable-line + return { data, loading, error, reload, setData }; +} + // === Icons (inline SVG) === const icons = { dashboard: html``, @@ -56,13 +97,33 @@ function Toast({ message, type }) { } // === Modal === -function Modal({ title, onClose, children }) { +// Accessible dialog: role/aria, Escape-to-close, focus the first field on open, +// trap Tab inside, and restore focus to the trigger on close. Every modal in the +// app uses this, so the whole console gets keyboard accessibility for free. +function Modal({ title, onClose, children, size }) { + const ref = useRef(null); + useEffect(() => { + const prev = document.activeElement; + const onKey = (e) => { + if (e.key === 'Escape') { e.preventDefault(); onClose(); return; } + if (e.key !== 'Tab' || !ref.current) return; + const f = ref.current.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'); + if (!f.length) return; + const first = f[0], last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + }; + document.addEventListener('keydown', onKey); + const el = ref.current && ref.current.querySelector('input:not([type=hidden]), select, textarea, button'); + if (el) setTimeout(() => el.focus(), 0); + return () => { document.removeEventListener('keydown', onKey); if (prev && prev.focus) prev.focus(); }; + }, []); return html`