diff --git a/frontends/nextjs/src/components/ui/sonner.tsx b/frontends/nextjs/src/components/ui/sonner.tsx index 746a8da84..b70734cdf 100644 --- a/frontends/nextjs/src/components/ui/sonner.tsx +++ b/frontends/nextjs/src/components/ui/sonner.tsx @@ -3,94 +3,50 @@ /** * Sonner-compatible toast API using MUI Snackbar * Provides a drop-in replacement for the 'sonner' package - * - * Usage: - * import { toast } from '@/components/ui/sonner' - * toast.success('Saved!') - * toast.error('Failed to save') - * toast('Default message') */ -import React, { createContext, useContext, useCallback, useState, useEffect } from 'react' -import { Snackbar, Alert, type AlertColor, Box } from '@mui/material' +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react' -// Types -export interface ToastOptions { - description?: string - duration?: number - position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' - id?: string | number -} +import { ToastContainer, type ToastHandlers } from './sonner/ToastContainer' +import { + DEFAULT_DURATION, + type Toast, + type ToastOptions, + type ToastType, +} from './sonner/config' -interface Toast { - id: string | number - message: string - description?: string - type: AlertColor | 'default' - duration: number -} +const ToastContext = createContext({ + addToast: () => {}, + removeToast: () => {}, +}) -interface ToastContextValue { - addToast: (toast: Toast) => void - removeToast: (id?: string | number) => void -} - -// Context -const ToastContext = createContext(null) - -// Global toast queue for when called outside provider let globalAddToast: ((toast: Toast) => void) | null = null let globalRemoveToast: ((id?: string | number) => void) | null = null let toastIdCounter = 0 const generateId = () => `toast-${++toastIdCounter}` -/** - * Creates a toast message - */ -const createToast = (message: string, type: AlertColor | 'default', options?: ToastOptions): Toast => ({ +const createToast = (message: string, type: ToastType, options?: ToastOptions): Toast => ({ id: options?.id ?? generateId(), message, description: options?.description, type, - duration: options?.duration ?? 4000, + duration: options?.duration ?? DEFAULT_DURATION, }) -/** - * Toast API - Sonner-compatible interface - */ +const enqueueToast = (toast: Toast) => { + globalAddToast?.(toast) + return toast.id +} + export const toast = Object.assign( - (message: string, options?: ToastOptions) => { - const t = createToast(message, 'default', options) - globalAddToast?.(t) - return t.id - }, + (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'default', options)), { - success: (message: string, options?: ToastOptions) => { - const t = createToast(message, 'success', options) - globalAddToast?.(t) - return t.id - }, - error: (message: string, options?: ToastOptions) => { - const t = createToast(message, 'error', options) - globalAddToast?.(t) - return t.id - }, - warning: (message: string, options?: ToastOptions) => { - const t = createToast(message, 'warning', options) - globalAddToast?.(t) - return t.id - }, - info: (message: string, options?: ToastOptions) => { - const t = createToast(message, 'info', options) - globalAddToast?.(t) - return t.id - }, - loading: (message: string, options?: ToastOptions) => { - const t = createToast(message, 'info', { ...options, duration: 0 }) - globalAddToast?.(t) - return t.id - }, + success: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'success', options)), + error: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'error', options)), + warning: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'warning', options)), + info: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'info', options)), + loading: (message: string, options?: ToastOptions) => enqueueToast(createToast(message, 'info', { ...options, duration: 0 })), dismiss: (id?: string | number) => { globalRemoveToast?.(id) }, @@ -107,16 +63,12 @@ export const toast = Object.assign( try { const result = await promise toast.dismiss(id) - const successMessage = typeof messages.success === 'function' - ? messages.success(result) - : messages.success + const successMessage = typeof messages.success === 'function' ? messages.success(result) : messages.success toast.success(successMessage, options) return result } catch (error) { toast.dismiss(id) - const errorMessage = typeof messages.error === 'function' - ? messages.error(error) - : messages.error + const errorMessage = typeof messages.error === 'function' ? messages.error(error) : messages.error toast.error(errorMessage, options) throw error } @@ -124,11 +76,7 @@ export const toast = Object.assign( } ) -/** - * Toaster component - Renders toast notifications - * Place this at the root of your app (in layout.tsx) - */ -export function Toaster({ +export function Toaster({ position = 'bottom-right', richColors = false, expand = false, @@ -139,89 +87,40 @@ export function Toaster({ expand?: boolean closeButton?: boolean }) { - const [toasts, setToasts] = useState([]) + const [handlers, setHandlers] = useState({ + addToast: () => {}, + removeToast: () => {}, + }) - const addToast = useCallback((toast: Toast) => { - setToasts(prev => [...prev, toast]) - }, []) - - const removeToast = useCallback((id?: string | number) => { - if (typeof id === 'undefined') { - setToasts([]) - return - } - setToasts(prev => prev.filter(t => t.id !== id)) - }, []) - - // Register global handler useEffect(() => { - globalAddToast = addToast - globalRemoveToast = removeToast return () => { globalAddToast = null globalRemoveToast = null } - }, [addToast, removeToast]) + }, []) - // Map position to MUI anchor origin - const getAnchorOrigin = () => { - const vertical = position?.startsWith('top') ? 'top' : 'bottom' - const horizontal = position?.includes('left') ? 'left' : position?.includes('center') ? 'center' : 'right' - return { vertical, horizontal } as const - } + const registerHandlers = useCallback((nextHandlers: ToastHandlers) => { + setHandlers(nextHandlers) + globalAddToast = nextHandlers.addToast + globalRemoveToast = nextHandlers.removeToast + }, []) return ( - - - {toasts.map((t, index) => ( - removeToast(t.id)} - anchorOrigin={getAnchorOrigin()} - sx={{ - position: 'relative', - mb: 1, - }} - > - removeToast(t.id) : undefined} - sx={{ width: '100%', minWidth: 300 }} - > - {t.message} - {t.description && ( - - {t.description} - - )} - - - ))} - + + ) } -/** - * Hook to access toast context - */ export function useToast() { const context = useContext(ToastContext) - return { - toast, - ...(context ?? { addToast: () => {}, removeToast: () => {} }), - } + return { toast, ...context } } -// Default export for sonner compatibility export default toast diff --git a/frontends/nextjs/src/components/ui/sonner/ToastContainer.tsx b/frontends/nextjs/src/components/ui/sonner/ToastContainer.tsx new file mode 100644 index 000000000..904626cd2 --- /dev/null +++ b/frontends/nextjs/src/components/ui/sonner/ToastContainer.tsx @@ -0,0 +1,93 @@ +'use client' + +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Alert, Box, Snackbar } from '@mui/material' + +import { + anchorFromPosition, + containerPlacement, + type Toast, + type ToastOptions, +} from './config' + +export interface ToastHandlers { + addToast: (toast: Toast) => void + removeToast: (id?: string | number) => void +} + +interface ToastContainerProps extends ToastOptions { + richColors?: boolean + expand?: boolean + closeButton?: boolean + onRegister?: (handlers: ToastHandlers) => void +} + +export function ToastContainer({ + position = 'bottom-right', + richColors = false, + expand = false, + closeButton = false, + onRegister, +}: ToastContainerProps) { + const [toasts, setToasts] = useState([]) + + const addToast = useCallback((toast: Toast) => { + setToasts(prev => [...prev, toast]) + }, []) + + const removeToast = useCallback((id?: string | number) => { + if (typeof id === 'undefined') { + setToasts([]) + return + } + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + useEffect(() => { + onRegister?.({ addToast, removeToast }) + }, [addToast, onRegister, removeToast]) + + const anchorOrigin = useMemo(() => anchorFromPosition(position), [position]) + const containerPosition = useMemo( + () => containerPlacement(position), + [position] + ) + + return ( + + {toasts.map(t => ( + removeToast(t.id)} + anchorOrigin={anchorOrigin} + sx={{ + position: 'relative', + mb: 1, + }} + > + removeToast(t.id) : undefined} + sx={{ width: expand ? '100%' : 'auto', minWidth: 300 }} + > + {t.message} + {t.description && ( + + {t.description} + + )} + + + ))} + + ) +} diff --git a/frontends/nextjs/src/components/ui/sonner/config.ts b/frontends/nextjs/src/components/ui/sonner/config.ts new file mode 100644 index 000000000..ea74c1242 --- /dev/null +++ b/frontends/nextjs/src/components/ui/sonner/config.ts @@ -0,0 +1,47 @@ +import { type AlertColor } from '@mui/material' + +export type ToastPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right' + +export interface ToastOptions { + description?: string + duration?: number + position?: ToastPosition + id?: string | number +} + +export type ToastType = AlertColor | 'default' + +export interface Toast { + id: string | number + message: string + description?: string + type: ToastType + duration: number +} + +export const DEFAULT_DURATION = 4000 + +export const anchorFromPosition = (position?: ToastPosition) => { + const vertical = position?.startsWith('top') ? 'top' : 'bottom' + const horizontal = position?.includes('left') + ? 'left' + : position?.includes('center') + ? 'center' + : 'right' + return { vertical, horizontal } as const +} + +export const containerPlacement = (position?: ToastPosition) => ({ + ...(position?.includes('top') ? { top: 16 } : { bottom: 16 }), + ...(position?.includes('left') + ? { left: 16 } + : position?.includes('center') + ? { left: '50%', transform: 'translateX(-50%)' } + : { right: 16 }), +})