135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
'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, position, isLatest }: ToastProps & { position: number, isLatest: boolean }) {
|
||
const [isVisible, setIsVisible] = useState(!isLatest) // 舊通知直接顯示,新通知需要動畫
|
||
const [mounted, setMounted] = useState(false)
|
||
const [hasShownEntrance, setHasShownEntrance] = useState(!isLatest) // 追蹤是否已經顯示過入場動畫
|
||
|
||
useEffect(() => {
|
||
setMounted(true)
|
||
|
||
// 只有最新的通知且尚未顯示過入場動畫才需要滑入動畫
|
||
if (isLatest && !hasShownEntrance) {
|
||
const showTimer = setTimeout(() => {
|
||
setIsVisible(true)
|
||
setHasShownEntrance(true)
|
||
}, 50)
|
||
|
||
return () => clearTimeout(showTimer)
|
||
}
|
||
}, [isLatest, hasShownEntrance])
|
||
|
||
useEffect(() => {
|
||
// 自動消失計時器
|
||
const hideTimer = setTimeout(() => {
|
||
setIsVisible(false)
|
||
// 等待動畫完成後關閉
|
||
setTimeout(onClose, 300)
|
||
}, duration)
|
||
|
||
return () => clearTimeout(hideTimer)
|
||
}, [duration, onClose])
|
||
|
||
if (!mounted) return null
|
||
|
||
// 計算垂直位置:第一個在 top-4,後續每個往下偏移 80px
|
||
const topPosition = 16 + (position * 80) // 16px (top-4) + 80px * position
|
||
|
||
return createPortal(
|
||
<div
|
||
className={`fixed right-4 z-50 max-w-sm w-full transform ${
|
||
isLatest
|
||
? `transition-all duration-300 ease-in-out ${isVisible ? 'translate-x-0 opacity-100 scale-100' : 'translate-x-full opacity-0 scale-95'}`
|
||
: 'transition-all duration-300 ease-in-out opacity-100 scale-100 translate-x-0'
|
||
}`}
|
||
style={{
|
||
top: `${topPosition}px`
|
||
}}
|
||
>
|
||
<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 => {
|
||
const newToasts = [...prev, { id, props }]
|
||
// 限制最多顯示 5 個通知,移除最舊的
|
||
return newToasts.length > 5 ? newToasts.slice(-5) : newToasts
|
||
})
|
||
}
|
||
|
||
const hideToast = (id: string) => {
|
||
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||
}
|
||
|
||
const ToastContainer = () => (
|
||
<>
|
||
{toasts.map(({ id, props }, index) => (
|
||
<Toast
|
||
key={id}
|
||
{...props}
|
||
position={index}
|
||
isLatest={index === toasts.length - 1} // 只有最後一個是最新的
|
||
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 })
|
||
}
|
||
} |