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

135 lines
4.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, 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 })
}
}