Replace sonner toasts with one top-centre notification stack
The app said things in two places: sonner's bottom-right toasts and the custom top-centre chrome. This is the second of those, generalised — a module-level store anyone can call, drawn as a stack of frosted cards that go on their own unless raised as persistent, with room for buttons and for a message longer than a line. useCustomToast keeps its signatures, so the forty-odd call sites behind it and handleError are untouched. The component and its store are byte-identical with the portal's copy and checked by `make design-check`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,6 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"motion": "^13.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.2.3",
|
||||
@@ -56,7 +55,6 @@
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"uplot": "^1.6.32",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The one place the app says something back.
|
||||
*
|
||||
* A single stack at the top centre, over whatever is below it. Newest on top,
|
||||
* each card going on its own after a few seconds unless it was raised as
|
||||
* persistent — an offline notice stays until the installation answers again.
|
||||
*
|
||||
* Raised from anywhere through `notify()` in `lib/notificationStore`, which is
|
||||
* where the semantics live; this only draws them.
|
||||
*
|
||||
* Byte-identical with the portal's copy — see DESIGN-GUIDELINES.md § Sync rule.
|
||||
*/
|
||||
|
||||
import { CircleAlert, CircleCheck, Info, TriangleAlert, X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useEffect, useState, useSyncExternalStore } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { duration, easeStandard, transitions } from "@/lib/motion"
|
||||
import {
|
||||
type AppNotification,
|
||||
dismiss,
|
||||
type NotificationSeverity,
|
||||
notificationStore,
|
||||
} from "@/lib/notificationStore"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** Long enough to read a sentence, short enough not to sit in the way. */
|
||||
const AUTO_HIDE_MS = 5_000
|
||||
|
||||
const SEVERITY: Record<
|
||||
NotificationSeverity,
|
||||
{ icon: typeof Info; accent: string }
|
||||
> = {
|
||||
error: { icon: CircleAlert, accent: "text-destructive" },
|
||||
warning: { icon: TriangleAlert, accent: "text-primary" },
|
||||
success: { icon: CircleCheck, accent: "text-status-success" },
|
||||
info: { icon: Info, accent: "text-muted-foreground" },
|
||||
}
|
||||
|
||||
const exitTransition = { duration: duration.fast, ease: easeStandard }
|
||||
|
||||
export function Notifications() {
|
||||
const items = useSyncExternalStore(
|
||||
notificationStore.subscribe,
|
||||
notificationStore.snapshot,
|
||||
// The portal prerenders its shell, where there is no store to read yet.
|
||||
notificationStore.snapshot,
|
||||
)
|
||||
|
||||
return (
|
||||
// Over dialogs, which sit at z-50: a notification is often what explains
|
||||
// why the dialog did nothing. Not interactive except on the cards, so the
|
||||
// page underneath stays clickable through the empty column.
|
||||
<div className="pointer-events-none fixed inset-x-0 top-4 z-[100] flex flex-col items-center gap-2 px-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{[...items].reverse().map((item) => (
|
||||
<Card key={item.id} item={item} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ item }: { item: AppNotification }) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Hovering holds it: somebody is reading it, or about to press one of its
|
||||
// buttons. Leaving starts the full countdown again rather than resuming a
|
||||
// second of it.
|
||||
if (item.persistent || hovered) return
|
||||
const timer = setTimeout(() => dismiss(item.id), AUTO_HIDE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [item.id, item.persistent, hovered])
|
||||
|
||||
const { icon: Icon, accent } = SEVERITY[item.severity]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
role="alert"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0, transition: transitions.emphasized }}
|
||||
exit={{ opacity: 0, y: -16, transition: exitTransition }}
|
||||
className="pointer-events-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2 rounded-lg border border-border bg-card/80 p-3 shadow-e2 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={cn("mt-0.5 size-5 shrink-0", accent)} />
|
||||
{/* Wraps rather than truncates: a message worth interrupting for is
|
||||
worth reading whole. */}
|
||||
<div className="min-w-0 flex-1 text-sm">{item.message}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Dismiss"
|
||||
className="-mr-1 -mt-1 shrink-0"
|
||||
onClick={() => dismiss(item.id)}
|
||||
>
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Under the message rather than beside it, so the text keeps the whole
|
||||
card width however long the labels are. */}
|
||||
{item.actions?.length ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{item.actions.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
variant={action.variant ?? "outline"}
|
||||
size="sm"
|
||||
className="h-auto max-w-full whitespace-normal py-1.5 text-left"
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -1,16 +1,20 @@
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/lib/notificationStore"
|
||||
|
||||
/**
|
||||
* The two things most call sites have to say.
|
||||
*
|
||||
* A thin front on `notify` — the description is the whole message now, since a
|
||||
* card that leads with "Success!" spends its first line saying nothing. Kept as
|
||||
* a hook because that is how it is called everywhere; anything needing a
|
||||
* severity, a button or a persistent card calls `notify` directly.
|
||||
*/
|
||||
const useCustomToast = () => {
|
||||
const showSuccessToast = (description: string) => {
|
||||
toast.success("Success!", {
|
||||
description,
|
||||
})
|
||||
notify(description, "success")
|
||||
}
|
||||
|
||||
const showErrorToast = (description: string) => {
|
||||
toast.error("Something went wrong!", {
|
||||
description,
|
||||
})
|
||||
notify(description, "error")
|
||||
}
|
||||
|
||||
return { showSuccessToast, showErrorToast }
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* What the app has to say to whoever is looking at it.
|
||||
*
|
||||
* One stack, top centre, for both shells. Anything can call `notify(...)`; the
|
||||
* component that draws them subscribes. Kept out of React so a query's error
|
||||
* handler and a socket callback can raise one without a hook.
|
||||
*
|
||||
* Same hand-rolled external store as `connectionStore`, for the same reason: a
|
||||
* `useSyncExternalStore` snapshot is the whole requirement.
|
||||
*
|
||||
* Byte-identical with the portal's copy — see DESIGN-GUIDELINES.md § Sync rule.
|
||||
*/
|
||||
|
||||
export type NotificationSeverity = "error" | "warning" | "info" | "success"
|
||||
|
||||
export interface NotificationAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: "destructive"
|
||||
}
|
||||
|
||||
export interface NotifyOptions {
|
||||
/** Naming one that already exists replaces it, rather than stacking a second. */
|
||||
id?: string
|
||||
/** Stays until it is dismissed. Otherwise it goes on its own. */
|
||||
persistent?: boolean
|
||||
actions?: NotificationAction[]
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
message: string
|
||||
severity: NotificationSeverity
|
||||
persistent?: boolean
|
||||
actions?: NotificationAction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* How many transient cards are kept.
|
||||
*
|
||||
* A storm of failures says the same thing five times over; past that the stack
|
||||
* is taller than the screen and the newest — the one being read — is the one
|
||||
* pushed off it.
|
||||
*/
|
||||
const MAX_TRANSIENT = 5
|
||||
|
||||
let state: AppNotification[] = []
|
||||
let counter = 0
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function emit(next: AppNotification[]) {
|
||||
state = next
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise one. Returns its id, so a caller that wants to update or take it back
|
||||
* later can hold on to it.
|
||||
*/
|
||||
export function notify(
|
||||
message: string,
|
||||
severity: NotificationSeverity = "info",
|
||||
options: NotifyOptions = {},
|
||||
): string {
|
||||
const id = options.id ?? `n${++counter}`
|
||||
const entry: AppNotification = {
|
||||
id,
|
||||
message,
|
||||
severity,
|
||||
persistent: options.persistent,
|
||||
actions: options.actions,
|
||||
}
|
||||
|
||||
const existing = state.findIndex((item) => item.id === id)
|
||||
if (existing >= 0) {
|
||||
// In place, so a card that keeps being told the same thing — an offline
|
||||
// notice counting up — does not re-enter and restart its own timer.
|
||||
const next = [...state]
|
||||
next[existing] = entry
|
||||
emit(next)
|
||||
return id
|
||||
}
|
||||
|
||||
const appended = [...state, entry]
|
||||
// Persistent ones are somebody's to take back, so the trim only reaches the
|
||||
// rest — a stack of five errors cannot push out the offline notice.
|
||||
const transient = appended.filter((item) => !item.persistent)
|
||||
const dropped = new Set(
|
||||
transient.slice(0, Math.max(0, transient.length - MAX_TRANSIENT)),
|
||||
)
|
||||
emit(dropped.size ? appended.filter((item) => !dropped.has(item)) : appended)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Take one back. Unknown ids are a no-op: it may have expired already. */
|
||||
export function dismiss(id: string): void {
|
||||
if (!state.some((item) => item.id === id)) return
|
||||
emit(state.filter((item) => item.id !== id))
|
||||
}
|
||||
|
||||
export const notificationStore = {
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
snapshot(): AppNotification[] {
|
||||
return state
|
||||
},
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { MotionConfig } from "motion/react"
|
||||
import { StrictMode } from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { toast } from "sonner"
|
||||
import { ApiError, OpenAPI } from "./client"
|
||||
import { PageLoading } from "./components/Common/Loading"
|
||||
import { Notifications } from "./components/Common/Notifications"
|
||||
import { ThemeProvider } from "./components/theme-provider"
|
||||
import { Toaster } from "./components/ui/sonner"
|
||||
import "./index.css"
|
||||
import { connectionStore, offlineDetail } from "./lib/connectionStore"
|
||||
import { notify } from "./lib/notificationStore"
|
||||
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
@@ -74,10 +74,10 @@ const handleApiError = (error: Error) => {
|
||||
}
|
||||
if (isForbidden(error) && !appRoute().startsWith("/panel")) {
|
||||
// The session stands, so stay put — but a refused change that says nothing
|
||||
// reads as a broken button. On a panel there is nobody to read a toast: a
|
||||
// reads as a broken button. On a panel there is nobody there to read it: a
|
||||
// 403 there is a dashboard it was just unassigned from, which its next read
|
||||
// of the panel corrects on its own.
|
||||
toast.error("You do not have permission to do that")
|
||||
notify("You do not have permission to do that", "error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<MotionConfig reducedMotion="user">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors closeButton />
|
||||
<Notifications />
|
||||
</QueryClientProvider>
|
||||
</MotionConfig>
|
||||
</ThemeProvider>
|
||||
|
||||
Reference in New Issue
Block a user