feat: 完善通知系統堆疊效果並添加分頁 emoji

🎨 Toast 通知系統改進:
- 實現智能通知堆疊:新通知推動舊通知下移而不覆蓋
- 優化動畫邏輯:只有最新通知從右側滑入,舊通知僅平滑移位
- 新增位置追蹤:每個通知按堆疊順序計算垂直位置 (80px 間隔)
- 修復閃爍問題:防止舊通知重複執行入場動畫
- 限制通知數量:最多顯示 5 個通知,自動移除最舊的
- 改進動畫細節:分離入場動畫和位置移動動畫

📚 視覺設計優化:
- 為「所有詞卡」分頁添加 📚 書籍 emoji
- 完善分頁視覺層次:📚 所有詞卡 與  收藏詞卡 形成完美對比
- 提升學習應用的視覺識別度和用戶友善性

🚀 用戶體驗提升:
- 支援快速連續操作:每個動作都有自己的通知反饋
- 非侵入式設計:通知不會阻擋用戶操作流程
- 平滑的視覺效果:所有動畫過渡自然流暢

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-24 14:34:01 +08:00
parent 724ba391b2
commit 70bf3f8fed
2 changed files with 39 additions and 14 deletions

View File

@ -327,7 +327,10 @@ function FlashcardsContent() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span className="flex items-center gap-1">
<span className="text-blue-500">📚</span>
({totalCounts.all})
</span>
</button>
<button
onClick={() => setActiveTab('favorites')}

View File

@ -24,35 +24,51 @@ const TOAST_STYLES = {
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)
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)
// 出現動畫
const showTimer = setTimeout(() => setIsVisible(true), 50)
// 自動消失
// 只有最新的通知且尚未顯示過入場動畫才需要滑入動畫
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(showTimer)
clearTimeout(hideTimer)
}
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 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'
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">
@ -82,7 +98,11 @@ export function useToast() {
const showToast = (props: Omit<ToastProps, 'onClose'>) => {
const id = Math.random().toString(36).substring(2, 11)
setToasts(prev => [...prev, { id, props }])
setToasts(prev => {
const newToasts = [...prev, { id, props }]
// 限制最多顯示 5 個通知,移除最舊的
return newToasts.length > 5 ? newToasts.slice(-5) : newToasts
})
}
const hideToast = (id: string) => {
@ -91,10 +111,12 @@ export function useToast() {
const ToastContainer = () => (
<>
{toasts.map(({ id, props }) => (
{toasts.map(({ id, props }, index) => (
<Toast
key={id}
{...props}
position={index}
isLatest={index === toasts.length - 1} // 只有最後一個是最新的
onClose={() => hideToast(id)}
/>
))}