feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
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>
This commit is contained in:
116
dashboard/src/components/ui/toast.tsx
Normal file
116
dashboard/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user