From d393d068858dbfd482374fa717abf7ade2c90d96 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 31 Mar 2026 21:34:49 -0400 Subject: [PATCH] Replace Sonner with custom Sera UI-inspired toast system (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- frontend/package-lock.json | 22 --- frontend/package.json | 2 - frontend/src/App.tsx | 4 +- frontend/src/components/ApiTokensSection.tsx | 2 +- frontend/src/components/AppStoreView.tsx | 2 +- frontend/src/components/AuditLogView.tsx | 2 +- frontend/src/components/EditorLayout.tsx | 2 +- frontend/src/components/FleetSnapshots.tsx | 2 +- frontend/src/components/FleetView.tsx | 2 +- frontend/src/components/HomeDashboard.tsx | 2 +- frontend/src/components/NodeManager.tsx | 2 +- frontend/src/components/RegistriesSection.tsx | 2 +- frontend/src/components/ResourcesView.tsx | 2 +- frontend/src/components/SSOSection.tsx | 2 +- .../components/ScheduledOperationsView.tsx | 2 +- frontend/src/components/SettingsModal.tsx | 2 +- frontend/src/components/StackAlertSheet.tsx | 2 +- .../components/settings/AppStoreSection.tsx | 2 +- .../components/settings/LicenseSection.tsx | 2 +- .../settings/NotificationsSection.tsx | 2 +- .../src/components/settings/UsersSection.tsx | 2 +- .../components/settings/WebhooksSection.tsx | 2 +- frontend/src/components/ui/sonner.tsx | 29 --- frontend/src/components/ui/toast-store.ts | 51 ++++++ frontend/src/components/ui/toast.tsx | 169 ++++++++++++++++++ 25 files changed, 241 insertions(+), 74 deletions(-) delete mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/toast-store.ts create mode 100644 frontend/src/components/ui/toast.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d79b87c..b8bb01d8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 310d1235..10cca7a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3bb1da70..fbcaa9a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,13 +33,13 @@ function AppContent() { ); } -import { Toaster } from 'sonner'; +import { ToastContainer } from './components/ui/toast'; function App() { return ( - + ); } diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index b2271cf4..972e768f 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -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'; diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index d23fde51..6a00f98a 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -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'; diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx index d615c26f..4d1be187 100644 --- a/frontend/src/components/AuditLogView.tsx +++ b/frontend/src/components/AuditLogView.tsx @@ -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; diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index fc081f6f..7cd7d9f9 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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'; diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index fb96ce66..c134cfea 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -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 --- diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 27544638..2f4da2ab 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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 --- diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index ea09d1ef..3eb7c999 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -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 { diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 69524e80..238f91fc 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -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'; diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index 7b1c1955..b1c4236d 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -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'; diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 94f327dc..2836585e 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -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'; diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 11ed457c..07b3467d 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -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'; diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 035604b8..5130564e 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -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'; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 1d1292a7..0db67909 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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, diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index 1dde86e6..887f3772 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -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'; diff --git a/frontend/src/components/settings/AppStoreSection.tsx b/frontend/src/components/settings/AppStoreSection.tsx index 413eca89..bf078823 100644 --- a/frontend/src/components/settings/AppStoreSection.tsx +++ b/frontend/src/components/settings/AppStoreSection.tsx @@ -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'; diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index 53c20d1b..e4faddce 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -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 { diff --git a/frontend/src/components/settings/NotificationsSection.tsx b/frontend/src/components/settings/NotificationsSection.tsx index 51eef9ea..febff745 100644 --- a/frontend/src/components/settings/NotificationsSection.tsx +++ b/frontend/src/components/settings/NotificationsSection.tsx @@ -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'; diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 84ee66a9..19375e19 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -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'; diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx index dd9c2577..6c85e1e4 100644 --- a/frontend/src/components/settings/WebhooksSection.tsx +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -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'; diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx deleted file mode 100644 index 1128edfc..00000000 --- a/frontend/src/components/ui/sonner.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" - -type ToasterProps = React.ComponentProps - -const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() - - return ( - - ) -} - -export { Toaster } diff --git a/frontend/src/components/ui/toast-store.ts b/frontend/src/components/ui/toast-store.ts new file mode 100644 index 00000000..63795724 --- /dev/null +++ b/frontend/src/components/ui/toast-store.ts @@ -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), +}; diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx new file mode 100644 index 00000000..c83470b8 --- /dev/null +++ b/frontend/src/components/ui/toast.tsx @@ -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 = { + 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 }) => ( + + + +); + +const SuccessIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const WarningIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const ErrorIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const CloseIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +/* ── Type config (matching Sera UI's notificationConfig) ── */ + +const notificationConfig: Record = { + info: { + iconColor: 'text-blue-500 dark:text-blue-400', + icon: , + 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: , + 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: , + 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: , + 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 | 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 ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Gradient overlay — exact Sera UI pattern */} +
+ + {/* Content */} +
+
+ {config.icon} +
+
+

{message}

+
+ +
+ + {/* Progress bar — Sera UI style with Framer Motion */} +
+ +
+ + ); +} + +/* ── ToastContainer ── */ + +export function ToastContainer() { + const toasts = useToasts(); + const visible = toasts.slice(-MAX_VISIBLE); + + return createPortal( +
+ + {visible.map((t) => ( + + ))} + +
, + document.body + ); +}