feat: 實現優雅的角落通知系統取代惱人的 alert 彈窗
🎨 新增 Toast 通知組件: - 位置:右上角固定位置,不阻擋主要內容 - 動畫:優雅的滑入/滑出動畫 (300ms) - 自動消失:3秒後自動消失,無需手動點擊 - 手動關閉:可點擊 X 按鈕立即關閉 - 響應式設計:在手機上自動調整寬度 🎯 支援多種通知類型: - success: ✅ 綠色成功通知(收藏、保存、刪除成功) - error: ❌ 紅色錯誤通知(操作失敗、網路錯誤) - warning: ⚠️ 黃色警告通知(重複詞卡、數據警告) - info: ℹ️ 藍色資訊通知(提示信息) 🔄 全面替換所有 alert 調用: - 詞卡管理頁面:收藏切換、創建、刪除、編輯操作 - AI 生成頁面:詞卡保存成功、重複檢測、錯誤處理 - 詞卡詳細頁面:更新、刪除、收藏操作反饋 🚀 用戶體驗大幅提升: - 移除煩人的阻擋式 alert 彈窗 - 實現非侵入式的操作反饋 - 保持操作流程的連續性和流暢度 - 提供視覺上更加現代和優雅的交互體驗 修復編譯問題並重新啟動前端,確保所有功能正常運作。 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
bfa353bd6b
commit
724ba391b2
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, use } from 'react'
|
|||
import { useRouter } from 'next/navigation'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { useToast } from '@/components/Toast'
|
||||
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
|
||||
|
||||
interface FlashcardDetailPageProps {
|
||||
|
|
@ -24,6 +25,7 @@ export default function FlashcardDetailPage({ params }: FlashcardDetailPageProps
|
|||
|
||||
function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const [flashcard, setFlashcard] = useState<Flashcard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -135,7 +137,7 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
|||
const updated = { ...flashcard, isFavorite: !flashcard.isFavorite }
|
||||
setFlashcard(updated)
|
||||
setEditedCard(updated)
|
||||
alert(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}「${flashcard.word}」`)
|
||||
toast.success(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}「${flashcard.word}」`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -143,10 +145,10 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
|||
const result = await flashcardsService.toggleFavorite(flashcard.id)
|
||||
if (result.success) {
|
||||
setFlashcard(prev => prev ? { ...prev, isFavorite: !prev.isFavorite } : null)
|
||||
alert(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}「${flashcard.word}」`)
|
||||
toast.success(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}「${flashcard.word}」`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('操作失敗,請重試')
|
||||
toast.error('操作失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +161,7 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
|||
if (flashcard.id.startsWith('mock')) {
|
||||
setFlashcard(editedCard)
|
||||
setIsEditing(false)
|
||||
alert('詞卡更新成功!')
|
||||
toast.success('詞卡更新成功!')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -178,12 +180,12 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
|||
if (result.success) {
|
||||
setFlashcard(editedCard)
|
||||
setIsEditing(false)
|
||||
alert('詞卡更新成功!')
|
||||
toast.success('詞卡更新成功!')
|
||||
} else {
|
||||
alert(result.error || '更新失敗')
|
||||
toast.error(result.error || '更新失敗')
|
||||
}
|
||||
} catch (error) {
|
||||
alert('更新失敗,請重試')
|
||||
toast.error('更新失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +200,7 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
|||
try {
|
||||
// 假資料處理
|
||||
if (flashcard.id.startsWith('mock')) {
|
||||
alert('詞卡已刪除(模擬)')
|
||||
toast.success('詞卡已刪除(模擬)')
|
||||
router.push('/flashcards')
|
||||
return
|
||||
}
|
||||
|
|
@ -206,13 +208,13 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
|||
// 真實API調用
|
||||
const result = await flashcardsService.deleteFlashcard(flashcard.id)
|
||||
if (result.success) {
|
||||
alert('詞卡已刪除')
|
||||
toast.success('詞卡已刪除')
|
||||
router.push('/flashcards')
|
||||
} else {
|
||||
alert(result.error || '刪除失敗')
|
||||
toast.error(result.error || '刪除失敗')
|
||||
}
|
||||
} catch (error) {
|
||||
alert('刪除失敗,請重試')
|
||||
toast.error('刪除失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -507,6 +509,9 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
|
|||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toast 通知系統 */}
|
||||
<toast.ToastContainer />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
import { FlashcardForm } from '@/components/FlashcardForm'
|
||||
import { useToast } from '@/components/Toast'
|
||||
// import { flashcardsService, type CardSet, type Flashcard } from '@/lib/services/flashcards'
|
||||
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ import { useRouter } from 'next/navigation'
|
|||
|
||||
function FlashcardsContent() {
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const [activeTab, setActiveTab] = useState('all-cards')
|
||||
// const [selectedSet, setSelectedSet] = useState<string | null>(null) // 移除 CardSets 相關
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
|
@ -182,12 +184,12 @@ function FlashcardsContent() {
|
|||
if (result.success) {
|
||||
await loadFlashcards()
|
||||
await loadTotalCounts()
|
||||
alert(`詞卡「${card.word}」已刪除`)
|
||||
toast.success(`詞卡「${card.word}」已刪除`)
|
||||
} else {
|
||||
alert(result.error || '刪除失敗')
|
||||
toast.error(result.error || '刪除失敗')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('刪除失敗,請重試')
|
||||
toast.error('刪除失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +198,7 @@ function FlashcardsContent() {
|
|||
// 如果是假資料,只更新本地狀態
|
||||
if (card.id.startsWith('mock')) {
|
||||
// 模擬資料暫時只顯示提示,實際狀態更新需要實作
|
||||
alert(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`)
|
||||
toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -207,12 +209,12 @@ function FlashcardsContent() {
|
|||
await loadFlashcards()
|
||||
// 重新載入統計數量
|
||||
await loadTotalCounts()
|
||||
alert(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`)
|
||||
toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}「${card.word}」`)
|
||||
} else {
|
||||
alert(result.error || '操作失敗')
|
||||
toast.error(result.error || '操作失敗')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('操作失敗,請重試')
|
||||
toast.error('操作失敗,請重試')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -890,6 +892,9 @@ function FlashcardsContent() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast 通知系統 */}
|
||||
<toast.ToastContainer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useMemo, useCallback } from 'react'
|
|||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
import { ClickableTextV2 } from '@/components/ClickableTextV2'
|
||||
import { useToast } from '@/components/Toast'
|
||||
import { flashcardsService } from '@/lib/services/flashcards'
|
||||
import { Play } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -62,6 +63,7 @@ interface IdiomPopup {
|
|||
}
|
||||
|
||||
function GenerateContent() {
|
||||
const toast = useToast()
|
||||
const [textInput, setTextInput] = useState('')
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [showAnalysisView, setShowAnalysisView] = useState(false)
|
||||
|
|
@ -236,15 +238,15 @@ function GenerateContent() {
|
|||
|
||||
if (response.success) {
|
||||
// 顯示成功提示
|
||||
const successMessage = `✅ 已成功將「${word}」保存到詞卡庫!`
|
||||
alert(successMessage)
|
||||
console.log(successMessage)
|
||||
const successMessage = `已成功將「${word}」保存到詞卡庫!`
|
||||
toast.success(successMessage)
|
||||
console.log('✅', successMessage)
|
||||
return { success: true }
|
||||
} else if (response.error && response.error.includes('已存在')) {
|
||||
// 顯示重複提示
|
||||
const duplicateMessage = `⚠️ 詞卡「${word}」已經存在於詞卡庫中`
|
||||
alert(duplicateMessage)
|
||||
console.log(duplicateMessage)
|
||||
const duplicateMessage = `詞卡「${word}」已經存在於詞卡庫中`
|
||||
toast.warning(duplicateMessage)
|
||||
console.log('⚠️', duplicateMessage)
|
||||
return { success: false, error: 'duplicate', message: duplicateMessage }
|
||||
} else {
|
||||
throw new Error(response.error || '保存失敗')
|
||||
|
|
@ -252,7 +254,7 @@ function GenerateContent() {
|
|||
} catch (error) {
|
||||
console.error('Save word error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '保存失敗'
|
||||
alert(`❌ 保存詞卡失敗: ${errorMessage}`)
|
||||
toast.error(`保存詞卡失敗: ${errorMessage}`)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}, [])
|
||||
|
|
@ -635,6 +637,9 @@ function GenerateContent() {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Toast 通知系統 */}
|
||||
<toast.ToastContainer />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
'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 })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue