All checks were successful
React + Vite + TypeScript SPA: scaffold, operations-terminal design system, Bearer-token auth, and the Machines view. - Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan), Hanken Grotesk + JetBrains Mono, status-color language (online/offline/granted/pending/denied/not_required), motion with prefers-reduced-motion honored. - Auth: token in sessionStorage via ref (never React state), protected routes, 401 session teardown, admin-gated per-agent-key UI. - Machines view: data table (sticky header, keyboard-activated rows, skeleton loading, actionable empty/error states), non-blocking detail drawer, delete confirm, admin key management with copy-once reveal. - UI primitives: Modal (focus trap + inert + portal + dialogStack), Drawer, Table, Badge/StatusDot, toast, states. - Typed API client normalizing the two error-envelope shapes. Passed Code Review (no blockers), impeccable critique-and-polish, and local gates (tsc/lint/build green). Dev-only Vite proxy to :3002. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
3.2 KiB
TypeScript
117 lines
3.2 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
import {
|
|
ToastContext,
|
|
type ToastApi,
|
|
type ToastItem,
|
|
type ToastTone,
|
|
} from "./toast-context";
|
|
|
|
const AUTO_DISMISS_MS = 4500;
|
|
|
|
/** Mounts the toast stack and provides the imperative toast API to descendants. */
|
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
const nextId = useRef(1);
|
|
|
|
const dismiss = useCallback((id: number) => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
}, []);
|
|
|
|
const push = useCallback(
|
|
(tone: ToastTone, title: string, message?: string) => {
|
|
const id = nextId.current++;
|
|
setToasts((prev) => [...prev, { id, tone, title, message }]);
|
|
window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
|
|
},
|
|
[dismiss],
|
|
);
|
|
|
|
const api = useMemo<ToastApi>(
|
|
() => ({
|
|
success: (title, message) => push("success", title, message),
|
|
error: (title, message) => push("error", title, message),
|
|
info: (title, message) => push("info", title, message),
|
|
}),
|
|
[push],
|
|
);
|
|
|
|
return (
|
|
<ToastContext.Provider value={api}>
|
|
{children}
|
|
{/* Polite region for success/info; errors below are assertive. */}
|
|
<div className="toast-stack">
|
|
{toasts.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
className={`toast toast--${t.tone}`}
|
|
role={t.tone === "error" ? "alert" : "status"}
|
|
aria-live={t.tone === "error" ? "assertive" : "polite"}
|
|
>
|
|
<span className={`toast__icon toast__icon--${t.tone}`} aria-hidden="true">
|
|
<ToastGlyph tone={t.tone} />
|
|
</span>
|
|
<div className="toast__body">
|
|
<div className="toast__title">{t.title}</div>
|
|
{t.message && <div className="toast__msg">{t.message}</div>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="iconbtn"
|
|
onClick={() => dismiss(t.id)}
|
|
aria-label="Dismiss notification"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M6 6l12 12M18 6 6 18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|
|
|
|
function ToastGlyph({ tone }: { tone: ToastTone }) {
|
|
const common = {
|
|
width: 16,
|
|
height: 16,
|
|
viewBox: "0 0 24 24",
|
|
fill: "none",
|
|
stroke: "currentColor",
|
|
strokeWidth: 2,
|
|
strokeLinecap: "round" as const,
|
|
strokeLinejoin: "round" as const,
|
|
};
|
|
if (tone === "success") {
|
|
return (
|
|
<svg {...common}>
|
|
<path d="M20 6 9 17l-5-5" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (tone === "error") {
|
|
return (
|
|
<svg {...common}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="M12 8v5M12 16h.01" />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg {...common}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="M12 11v5M12 8h.01" />
|
|
</svg>
|
|
);
|
|
}
|