Replace Sonner with custom Sera UI-inspired toast system (#296)

* refactor(ui): replace Sonner with custom Sera UI-inspired toast system

Replace the Sonner toast library with a custom implementation inspired by
Sera UI's glassmorphism design. The new system uses an external store pattern
with useSyncExternalStore for React integration, keeping the same
toast.success()/error()/warning() API so all 172 call sites required only
an import path change.

Key changes:
- New toast-store.ts: singleton store with identical API to Sonner
- New toast.tsx: Sera UI-faithful Notification component with Framer Motion
  animations, frosted glass (backdrop-blur-xl), type gradient overlays,
  animated progress bar (green→blue→sky gradient), and hover:scale-105
- Removed sonner and next-themes dependencies
- Rewired all 19 consumer files to import from the new store

* fix(ui): resolve ESLint errors in toast system

- Use const for listeners Set (prefer-const)
- Initialize startRef with 0 instead of Date.now() to satisfy
  react-hooks/purity rule, set actual value inside useEffect
This commit is contained in:
Anso
2026-03-31 21:34:49 -04:00
committed by GitHub
parent 8d988d6b08
commit d393d06885
25 changed files with 241 additions and 74 deletions
-22
View File
@@ -38,14 +38,12 @@
"lucide-react": "^1.6.0",
"monaco-editor": "^0.55.1",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-is": "^19.2.4",
"react-use-measure": "^2.1.7",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"xterm-addon-search": "^0.13.0",
@@ -4896,16 +4894,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -5458,16 +5446,6 @@
"node": ">=8"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
-2
View File
@@ -40,14 +40,12 @@
"lucide-react": "^1.6.0",
"monaco-editor": "^0.55.1",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-is": "^19.2.4",
"react-use-measure": "^2.1.7",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"xterm-addon-search": "^0.13.0",
+2 -2
View File
@@ -33,13 +33,13 @@ function AppContent() {
);
}
import { Toaster } from 'sonner';
import { ToastContainer } from './components/ui/toast';
function App() {
return (
<AuthProvider>
<AppContent />
<Toaster position="bottom-right" richColors />
<ToastContainer />
</AuthProvider>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { TierBadge } from './TierBadge';
+1 -1
View File
@@ -7,7 +7,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFo
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Search, Rocket, Loader2, Info, ExternalLink, Star } from "lucide-react";
import { toast } from "sonner";
import { toast } from "@/components/ui/toast-store";
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
+1 -1
View File
@@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { ChevronLeft, ChevronRight, Search, ScrollText, RefreshCw, Download, ChevronDown } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
interface AuditEntry {
id: number;
+1 -1
View File
@@ -22,7 +22,7 @@ import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pe
import type { LucideIcon } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
import { apiFetch, fetchForNode } from '@/lib/api';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { Label } from './ui/label';
import { Command, CommandInput, CommandList, CommandItem } from './ui/command';
import { ScrollArea } from './ui/scroll-area';
+1 -1
View File
@@ -19,7 +19,7 @@ import {
} from '@/components/ui/alert-dialog';
import { apiFetch } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
// --- Types ---
+1 -1
View File
@@ -17,7 +17,7 @@ import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from './ProGate';
import FleetSnapshots from './FleetSnapshots';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
// --- Types ---
+1 -1
View File
@@ -8,7 +8,7 @@ import { Input } from './ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import { Activity, Square, ArrowRight, Plus, Cpu, HardDrive, MemoryStick, Network } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { Label } from './ui/label';
interface Stats {
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
import { Button } from './ui/button';
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { TierBadge } from './TierBadge';
+1 -1
View File
@@ -9,7 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck } from 'lucide-react';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
+1 -1
View File
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { TierBadge } from './TierBadge';
@@ -12,7 +12,7 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import cronstrue from 'cronstrue';
+1 -1
View File
@@ -8,7 +8,7 @@ import {
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import {
Shield, Activity, Bell, Code, Server, Package,
+1 -1
View File
@@ -12,7 +12,7 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { RefreshCw } from 'lucide-react';
import type { PatchableSettings } from './types';
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
import {
@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { RefreshCw, Info } from 'lucide-react';
@@ -9,7 +9,7 @@ import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { ProGate } from '@/components/ProGate';
import { TierBadge } from '@/components/TierBadge';
-29
View File
@@ -1,29 +0,0 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }
+51
View File
@@ -0,0 +1,51 @@
import { useSyncExternalStore } from 'react';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
message: string;
createdAt: number;
}
let toasts: Toast[] = [];
const listeners: Set<() => void> = new Set();
let idCounter = 0;
function notify() {
listeners.forEach((fn) => fn());
}
function addToast(type: ToastType, message: string) {
const id = `toast-${++idCounter}-${Date.now()}`;
toasts = [...toasts, { id, type, message, createdAt: Date.now() }];
notify();
}
export function removeToast(id: string) {
toasts = toasts.filter((t) => t.id !== id);
notify();
}
function subscribe(callback: () => void) {
listeners.add(callback);
return () => {
listeners.delete(callback);
};
}
function getSnapshot() {
return toasts;
}
export function useToasts() {
return useSyncExternalStore(subscribe, getSnapshot);
}
export const toast = {
success: (message: string) => addToast('success', message),
error: (message: string) => addToast('error', message),
warning: (message: string) => addToast('warning', message),
info: (message: string) => addToast('info', message),
};
+169
View File
@@ -0,0 +1,169 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'motion/react';
import { useToasts, removeToast, type ToastType } from './toast-store';
/* ── Durations & Config ── */
const DURATIONS: Record<ToastType, number> = {
success: 4000,
error: 6000,
warning: 5000,
info: 4000,
};
const MAX_VISIBLE = 5;
/* ── SVG Icons (matching Sera UI exactly — h-6 w-6, stroke-based) ── */
const InfoIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
const SuccessIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
const WarningIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
);
const ErrorIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
/* ── Type config (matching Sera UI's notificationConfig) ── */
const notificationConfig: Record<ToastType, {
iconColor: string;
icon: React.ReactNode;
gradient: string;
}> = {
info: {
iconColor: 'text-blue-500 dark:text-blue-400',
icon: <InfoIcon className="h-6 w-6" />,
gradient: 'from-blue-100/60 to-transparent dark:from-blue-900/20 dark:to-transparent',
},
success: {
iconColor: 'text-green-500 dark:text-green-400',
icon: <SuccessIcon className="h-6 w-6" />,
gradient: 'from-green-100/60 to-transparent dark:from-green-900/20 dark:to-transparent',
},
warning: {
iconColor: 'text-yellow-500 dark:text-yellow-400',
icon: <WarningIcon className="h-6 w-6" />,
gradient: 'from-yellow-100/60 to-transparent dark:from-yellow-900/20 dark:to-transparent',
},
error: {
iconColor: 'text-red-500 dark:text-red-400',
icon: <ErrorIcon className="h-6 w-6" />,
gradient: 'from-red-100/60 to-transparent dark:from-red-900/20 dark:to-transparent',
},
};
/* ── ToastItem — faithful Sera UI Notification replica ── */
function ToastItem({ id, type, message }: { id: string; type: ToastType; message: string }) {
const [hovered, setHovered] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const remainingRef = useRef(DURATIONS[type]);
const startRef = useRef(0);
const config = notificationConfig[type];
const duration = DURATIONS[type];
const dismiss = useCallback(() => {
removeToast(id);
}, [id]);
// Auto-dismiss timer with hover pause
useEffect(() => {
if (hovered) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
remainingRef.current -= Date.now() - startRef.current;
return;
}
startRef.current = Date.now();
timerRef.current = setTimeout(dismiss, remainingRef.current);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [hovered, dismiss]);
return (
<motion.div
layout
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
transition={{ duration: 0.3 }}
className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-white/15 dark:bg-black/15 border border-gray-300/60 dark:border-gray-700/60 overflow-hidden ring-1 ring-gray-200/40 dark:ring-gray-700/40 drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Gradient overlay — exact Sera UI pattern */}
<div className={`absolute top-0 left-0 h-full w-full bg-gradient-to-br ${config.gradient} opacity-50`} />
{/* Content */}
<div className="relative z-10 flex items-center space-x-4">
<div className={`flex-shrink-0 ${config.iconColor}`}>
{config.icon}
</div>
<div className="flex-1">
<p className="font-normal text-gray-900 dark:text-gray-100 text-lg">{message}</p>
</div>
<button
onClick={dismiss}
className="flex-shrink-0 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Close notification"
>
<CloseIcon className="h-5 w-5" />
</button>
</div>
{/* Progress bar — Sera UI style with Framer Motion */}
<div className="absolute bottom-0 left-0 h-1 w-full bg-gray-300/50 dark:bg-gray-600/50 rounded-b-xl overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: hovered ? undefined : '100%' }}
transition={{ duration: duration / 1000, ease: 'linear' }}
className="h-full bg-gradient-to-r from-green-400 via-blue-400 to-sky-400 dark:from-green-500 dark:via-blue-500 dark:to-sky-500"
/>
</div>
</motion.div>
);
}
/* ── ToastContainer ── */
export function ToastContainer() {
const toasts = useToasts();
const visible = toasts.slice(-MAX_VISIBLE);
return createPortal(
<div className="fixed p-4 space-y-2 w-full max-w-sm z-50 bottom-4 right-4">
<AnimatePresence mode="popLayout">
{visible.map((t) => (
<ToastItem key={t.id} id={t.id} type={t.type} message={t.message} />
))}
</AnimatePresence>
</div>,
document.body
);
}