diff --git a/bun.lock b/bun.lock index 471ba9e..7a96926 100644 --- a/bun.lock +++ b/bun.lock @@ -42,7 +42,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", @@ -50,7 +49,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", @@ -787,8 +785,6 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], - "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -873,8 +869,6 @@ "seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="], - "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/frontend/package.json b/frontend/package.json index 2df65ad..3acecb9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/Common/Notifications.tsx b/frontend/src/components/Common/Notifications.tsx new file mode 100644 index 0000000..b8590ad --- /dev/null +++ b/frontend/src/components/Common/Notifications.tsx @@ -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. +
+ + {[...items].reverse().map((item) => ( + + ))} + +
+ ) +} + +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 ( + 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" + > +
+ + {/* Wraps rather than truncates: a message worth interrupting for is + worth reading whole. */} +
{item.message}
+ +
+ {/* Under the message rather than beside it, so the text keeps the whole + card width however long the labels are. */} + {item.actions?.length ? ( +
+ {item.actions.map((action) => ( + + ))} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx deleted file mode 100644 index 9b20afe..0000000 --- a/frontend/src/components/ui/sonner.tsx +++ /dev/null @@ -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 ( - , - info: , - warning: , - error: , - loading: , - }} - style={ - { - "--normal-bg": "var(--popover)", - "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", - "--border-radius": "var(--radius)", - } as React.CSSProperties - } - {...props} - /> - ) -} - -export { Toaster } diff --git a/frontend/src/hooks/useCustomToast.ts b/frontend/src/hooks/useCustomToast.ts index ab03265..aeac002 100644 --- a/frontend/src/hooks/useCustomToast.ts +++ b/frontend/src/hooks/useCustomToast.ts @@ -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 } diff --git a/frontend/src/lib/notificationStore.ts b/frontend/src/lib/notificationStore.ts new file mode 100644 index 0000000..007d12a --- /dev/null +++ b/frontend/src/lib/notificationStore.ts @@ -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 + }, +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1e5ddf9..b3a52a1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - +