mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-26 14:54:55 +00:00
refactor: reorganize sonner toast components
This commit is contained in:
@@ -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<ToastHandlers>({
|
||||
addToast: () => {},
|
||||
removeToast: () => {},
|
||||
})
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (toast: Toast) => void
|
||||
removeToast: (id?: string | number) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const ToastContext = createContext<ToastContextValue | null>(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<Toast[]>([])
|
||||
const [handlers, setHandlers] = useState<ToastHandlers>({
|
||||
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 (
|
||||
<ToastContext.Provider value={{ addToast, removeToast }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
...(position?.includes('top') ? { top: 16 } : { bottom: 16 }),
|
||||
...(position?.includes('left') ? { left: 16 } : position?.includes('center') ? { left: '50%', transform: 'translateX(-50%)' } : { right: 16 }),
|
||||
}}
|
||||
>
|
||||
{toasts.map((t, index) => (
|
||||
<Snackbar
|
||||
key={t.id}
|
||||
open
|
||||
autoHideDuration={t.duration || null}
|
||||
onClose={() => removeToast(t.id)}
|
||||
anchorOrigin={getAnchorOrigin()}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity={t.type === 'default' ? 'info' : t.type}
|
||||
variant={richColors ? 'filled' : 'standard'}
|
||||
onClose={closeButton ? () => removeToast(t.id) : undefined}
|
||||
sx={{ width: '100%', minWidth: 300 }}
|
||||
>
|
||||
{t.message}
|
||||
{t.description && (
|
||||
<Box component="div" sx={{ fontSize: '0.875rem', opacity: 0.8, mt: 0.5 }}>
|
||||
{t.description}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</Box>
|
||||
<ToastContext.Provider value={handlers}>
|
||||
<ToastContainer
|
||||
position={position}
|
||||
richColors={richColors}
|
||||
expand={expand}
|
||||
closeButton={closeButton}
|
||||
onRegister={registerHandlers}
|
||||
/>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
93
frontends/nextjs/src/components/ui/sonner/ToastContainer.tsx
Normal file
93
frontends/nextjs/src/components/ui/sonner/ToastContainer.tsx
Normal file
@@ -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<Toast[]>([])
|
||||
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
...containerPosition,
|
||||
}}
|
||||
>
|
||||
{toasts.map(t => (
|
||||
<Snackbar
|
||||
key={t.id}
|
||||
open
|
||||
autoHideDuration={t.duration || null}
|
||||
onClose={() => removeToast(t.id)}
|
||||
anchorOrigin={anchorOrigin}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity={t.type === 'default' ? 'info' : t.type}
|
||||
variant={richColors ? 'filled' : 'standard'}
|
||||
onClose={closeButton ? () => removeToast(t.id) : undefined}
|
||||
sx={{ width: expand ? '100%' : 'auto', minWidth: 300 }}
|
||||
>
|
||||
{t.message}
|
||||
{t.description && (
|
||||
<Box component="div" sx={{ fontSize: '0.875rem', opacity: 0.8, mt: 0.5 }}>
|
||||
{t.description}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
47
frontends/nextjs/src/components/ui/sonner/config.ts
Normal file
47
frontends/nextjs/src/components/ui/sonner/config.ts
Normal file
@@ -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 }),
|
||||
})
|
||||
Reference in New Issue
Block a user