dramaling-vocab-learning/frontend/components/Toast.tsx

113 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
export interface ToastProps {
message: string
type: 'success' | 'error' | 'warning' | 'info'
duration?: number
onClose: () => void
}
const TOAST_ICONS = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
} as const
const TOAST_STYLES = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
info: 'bg-blue-50 border-blue-200 text-blue-800'
} as const
export function Toast({ message, type, duration = 3000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
// 出現動畫
const showTimer = setTimeout(() => setIsVisible(true), 50)
// 自動消失
const hideTimer = setTimeout(() => {
setIsVisible(false)
// 等待動畫完成後關閉
setTimeout(onClose, 300)
}, duration)
return () => {
clearTimeout(showTimer)
clearTimeout(hideTimer)
}
}, [duration, onClose])
if (!mounted) return null
return createPortal(
<div
className={`fixed top-4 right-4 z-50 max-w-sm w-full transform transition-all duration-300 ease-in-out ${
isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
}`}
>
<div className={`rounded-lg border p-4 shadow-lg backdrop-blur-sm ${TOAST_STYLES[type]}`}>
<div className="flex items-center gap-3">
<span className="text-lg flex-shrink-0">{TOAST_ICONS[type]}</span>
<p className="font-medium text-sm leading-relaxed flex-1">{message}</p>
<button
onClick={() => {
setIsVisible(false)
setTimeout(onClose, 300)
}}
className="text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>,
document.body
)
}
// Toast 管理 Hook
export function useToast() {
const [toasts, setToasts] = useState<Array<{ id: string; props: Omit<ToastProps, 'onClose'> }>>([])
const showToast = (props: Omit<ToastProps, 'onClose'>) => {
const id = Math.random().toString(36).substring(2, 11)
setToasts(prev => [...prev, { id, props }])
}
const hideToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id))
}
const ToastContainer = () => (
<>
{toasts.map(({ id, props }) => (
<Toast
key={id}
{...props}
onClose={() => hideToast(id)}
/>
))}
</>
)
return {
showToast,
ToastContainer,
// 便捷方法
success: (message: string, duration?: number) => showToast({ message, type: 'success', duration }),
error: (message: string, duration?: number) => showToast({ message, type: 'error', duration }),
warning: (message: string, duration?: number) => showToast({ message, type: 'warning', duration }),
info: (message: string, duration?: number) => showToast({ message, type: 'info', duration })
}
}