Files
sing-box-extended/service/admin_panel/web/src/notifications/NotificationsProvider.tsx

347 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Alert, Box, Collapse, IconButton, Slide } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { ApiError, UnauthorizedError } from "../api/client";
// NotificationsProvider drives the small stack of "toast"-style alerts
// that flash in from the top-right whenever the panel completes (or
// fails) a user-visible action: a record was created / deleted, a CRUD
// request hit a network error, the manager-api returned 401, etc. The
// alerts auto-dismiss after a short delay and de-duplicate so a flurry
// of identical errors (e.g. a paginated reload that retries) shows up as
// a single chip.
//
// Two consumption styles are exposed via `useNotify`:
// - `notify({ severity, message })` for the full payload
// - convenience shorthands `notify.success(msg) / .error(msg) / …`
//
// `notifyApiError` is a pre-canned error-toast helper used by every
// CRUD callsite — it picks a useful description based on the error
// type (connection vs. unauthorized vs. generic API) so callers don't
// have to hand-format messages.
export type NotificationSeverity = "success" | "info" | "warning" | "error";
export interface NotificationOptions {
severity: NotificationSeverity;
message: string;
// Auto-dismiss timer (ms). `null` keeps the toast on screen until the
// user dismisses it manually. Defaults to ~4.5 s, long enough to read
// a sentence-length message without parking on screen.
duration?: number | null;
}
interface QueuedNotification extends NotificationOptions {
id: number;
// Drives the enter/exit transitions: items start with `open: true`,
// flip to `false` when the user clicks × or the auto-timer fires, and
// are finally removed from the array when the Collapse exit transition
// completes (via its `onExited` callback). Keeping the entry alive in
// state for the duration of the exit transition is what gives the
// close animation room to play instead of snapping the chip out.
open: boolean;
}
export interface NotificationsApi {
notify: (n: NotificationOptions) => number;
success: (message: string, options?: Partial<NotificationOptions>) => number;
info: (message: string, options?: Partial<NotificationOptions>) => number;
warning: (message: string, options?: Partial<NotificationOptions>) => number;
error: (message: string, options?: Partial<NotificationOptions>) => number;
dismiss: (id: number) => void;
}
const Ctx = createContext<NotificationsApi | undefined>(undefined);
const DEFAULT_DURATION = 4500;
// Cap on the number of toasts shown at once. Anything over this limit
// drops the oldest entry — a misbehaving callsite that fires hundreds
// of notifications in a row should not be able to fill the viewport.
const MAX_VISIBLE = 5;
// Transition timings (ms). Both Slide (horizontal motion) and Collapse
// (height collapse) share the same timeouts so the chip's slide-out and
// the stack's reflow finish together — a mismatch here would leave a
// phantom gap or a half-collapsed shadow visible for a frame after the
// chip itself has gone.
//
// 250 ms matches MUI's "standard" duration: the toast slides in /
// out at a familiar pace, neither lingering on screen nor flashing
// past too fast to register.
const TRANSITION_MS = 250;
const TRANSITION_TIMEOUT = { enter: TRANSITION_MS, exit: TRANSITION_MS } as const;
// Easing is intentionally left to MUI's defaults — Slide and Collapse
// fall back to `theme.transitions.easing.easeOut` on enter and
// `easeIn` on exit, which gives a clean "fade out to the right" feel
// without any overshoot or bounce.
export function NotificationsProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<QueuedNotification[]>([]);
const idRef = useRef(0);
// Track the active auto-dismiss timers so unmounting the provider (or a
// very stale toast) cleans them up rather than firing into a torn-down
// tree.
const timers = useRef<Map<number, number>>(new Map());
// dismiss flips the toast's `open` flag to false, kicking off its exit
// transition (Slide back out to the right + Collapse height to 0). The
// entry stays in `items` until `finalize` removes it once the Collapse
// `onExited` callback fires — that is what gives the close animation
// time to play instead of snapping the chip out of the DOM. Calling
// `dismiss` more than once for the same id (auto-timer + user click
// race) is a safe no-op.
const dismiss = useCallback((id: number) => {
setItems((prev) =>
prev.map((n) => (n.id === id && n.open ? { ...n, open: false } : n)),
);
const handle = timers.current.get(id);
if (handle !== undefined) {
window.clearTimeout(handle);
timers.current.delete(id);
}
}, []);
// finalize is invoked by the Collapse `onExited` callback once the exit
// transition has fully played out, removing the entry from state and
// letting React unmount the underlying DOM nodes.
const finalize = useCallback((id: number) => {
setItems((prev) => prev.filter((n) => n.id !== id));
}, []);
const notify = useCallback(
(n: NotificationOptions): number => {
const id = ++idRef.current;
setItems((prev) => {
// De-duplicate: if the most recent *visible* toast carries the
// same severity and message, swallow this one. A failed reload
// that retries shouldn't stack identical chips on top of each
// other. Items already in their exit transition (`open: false`)
// are skipped so a quickly-cleared duplicate doesn't suppress a
// legitimate repeat.
for (let i = prev.length - 1; i >= 0; i--) {
const p = prev[i];
if (!p.open) continue;
if (p.severity === n.severity && p.message === n.message) {
return prev;
}
break;
}
const next: QueuedNotification[] = [...prev, { ...n, id, open: true }];
// Cap the number of *visible* (still-open) toasts. Any overflow
// gets gracefully dismissed — flipped to `open: false` — so the
// overflowing oldest entry plays its close animation instead of
// popping out of existence. `finalize` will remove it from the
// array when its transition finishes.
let visible = 0;
for (const p of next) if (p.open) visible++;
let toClose = visible - MAX_VISIBLE;
for (let i = 0; i < next.length && toClose > 0; i++) {
if (next[i].open) {
next[i] = { ...next[i], open: false };
const stale = timers.current.get(next[i].id);
if (stale !== undefined) {
window.clearTimeout(stale);
timers.current.delete(next[i].id);
}
toClose--;
}
}
return next;
});
const duration = n.duration === undefined ? DEFAULT_DURATION : n.duration;
if (duration !== null && duration > 0) {
const handle = window.setTimeout(() => dismiss(id), duration);
timers.current.set(id, handle);
}
return id;
},
[dismiss],
);
// Cleanup pending timers on unmount.
useEffect(() => {
const map = timers.current;
return () => {
for (const handle of map.values()) window.clearTimeout(handle);
map.clear();
};
}, []);
const value = useMemo<NotificationsApi>(
() => ({
notify,
success: (message, options) => notify({ severity: "success", message, ...options }),
info: (message, options) => notify({ severity: "info", message, ...options }),
warning: (message, options) => notify({ severity: "warning", message, ...options }),
error: (message, options) => notify({ severity: "error", message, ...options }),
dismiss,
}),
[notify, dismiss],
);
return (
<Ctx.Provider value={value}>
{children}
{/* Stack of toasts pinned to the top-right of the viewport. A
high `zIndex` keeps them above MUI Dialog (1300) and Drawer
(1200) so a notification triggered from inside a modal
(e.g. CRUD form submission error) is still visible without
having to close the dialog first.
`pointerEvents: "none"` on the container lets clicks pass
through to the page underneath the empty space between
alerts; each Alert re-enables pointer events for itself so
its own close button still works. */}
<Box
sx={{
position: "fixed",
top: { xs: 4, sm: 8 },
right: { xs: 12, sm: 16 },
zIndex: 2000,
display: "flex",
flexDirection: "column",
// No `gap` here on purpose — the inter-toast spacing lives
// inside each Collapse below (as a top padding). That way
// the spacing height animates *together* with the toast
// instead of leaving a stranded gap during the close
// animation, and the stack reflows smoothly when an entry
// disappears.
maxWidth: "min(92vw, 420px)",
pointerEvents: "none",
}}
aria-live="polite"
aria-atomic="false"
>
{items.map((n) => (
// Collapse owns the *vertical* exit motion: when `open` flips
// to false it shrinks the chip's height to 0, pulling the
// toasts below it up smoothly. Its `onExited` callback then
// hands off to `finalize`, which removes the entry from
// state once the animation has fully played out.
<Collapse
key={n.id}
in={n.open}
appear
timeout={TRANSITION_TIMEOUT}
onExited={() => finalize(n.id)}
>
{/* Padding-top on the inner wrapper — not a parent flex gap
— so the spacing collapses together with the toast on
exit. The first toast inherits the same 8 px top
padding; combined with the container's `top: 8` it
lines up at 16 px from the viewport edge, which reads
cleanly without an explicit "no padding for index 0"
special case (which would jump-snap when the topmost
toast is dismissed). */}
<Box sx={{ pt: 1 }}>
{/* Slide owns the *horizontal* motion: enters by sliding
left from off-screen on the right, exits by sliding
back out to the right. Sharing the timeout shape with
Collapse keeps the two transitions in lockstep so the
chip lands / leaves cleanly. */}
<Slide
in={n.open}
direction="left"
appear
timeout={TRANSITION_TIMEOUT}
>
<Alert
severity={n.severity}
variant="filled"
onClose={() => dismiss(n.id)}
action={
<IconButton
aria-label="Dismiss notification"
size="small"
color="inherit"
onClick={() => dismiss(n.id)}
>
<CloseIcon fontSize="inherit" />
</IconButton>
}
sx={(theme) => {
// Force the toast contents to follow the active
// theme rather than MUI's default "always white on
// a coloured background" rule for the filled Alert
// variant: white text in dark mode, black text in
// light mode. Applied to the message body, the
// severity icon, and the dismiss button so all
// three follow the same contrast rule and read as
// a single tonal pair.
const fg = theme.palette.mode === "dark" ? "#ffffff" : "#000000";
return {
pointerEvents: "auto",
boxShadow: 6,
alignItems: "center",
color: fg,
"& .MuiAlert-icon": { color: fg },
"& .MuiAlert-action": { color: fg },
"& .MuiAlert-action .MuiIconButton-root": { color: fg },
// Long error bodies (e.g. an HTTP response excerpt)
// should wrap rather than overflow the chip and steal
// the close button.
"& .MuiAlert-message": {
color: fg,
wordBreak: "break-word",
overflowWrap: "anywhere",
},
};
}}
>
{n.message}
</Alert>
</Slide>
</Box>
</Collapse>
))}
</Box>
</Ctx.Provider>
);
}
export function useNotify(): NotificationsApi {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useNotify must be used within NotificationsProvider");
return ctx;
}
// notifyApiError emits a "toast"-style error notification for an exception
// thrown by the API client, picking a useful description based on the
// error type:
//
// - `UnauthorizedError` (HTTP 401) — silently skipped: the global
// handler in AuthContext already surfaces a "session expired"
// toast and redirects to the login screen, so emitting another
// here would double up.
// - `ApiError` with `status === 0` — connection/network error
// (fetch itself failed before a response arrived). Surfaced as a
// "Connection error" toast so the user understands the panel
// didn't reach the manager-api at all.
// - any other `ApiError` — formatted with the HTTP status and
// server-provided body excerpt.
// - anything else — message of the underlying Error / String fallback.
export function notifyApiError(
notify: NotificationsApi,
prefix: string,
e: unknown,
): void {
if (e instanceof UnauthorizedError) return;
if (e instanceof ApiError && e.status === 0) {
notify.error(`${prefix}: connection error — ${e.body || e.message}`);
return;
}
if (e instanceof ApiError) {
notify.error(`${prefix} (HTTP ${e.status}): ${e.body || e.message}`);
return;
}
notify.error(`${prefix}: ${e instanceof Error ? e.message : String(e)}`);
}