diff --git a/frontend/app/flashcards/[id]/page.tsx b/frontend/app/flashcards/[id]/page.tsx index ec98431..9edfcdb 100644 --- a/frontend/app/flashcards/[id]/page.tsx +++ b/frontend/app/flashcards/[id]/page.tsx @@ -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(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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 }) { + + {/* Toast 通知系統 */} + ) diff --git a/frontend/app/flashcards/page.tsx b/frontend/app/flashcards/page.tsx index 08bbbb2..bab4f59 100644 --- a/frontend/app/flashcards/page.tsx +++ b/frontend/app/flashcards/page.tsx @@ -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(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 通知系統 */} + ) } diff --git a/frontend/app/generate/page.tsx b/frontend/app/generate/page.tsx index bd036a4..a48e351 100644 --- a/frontend/app/generate/page.tsx +++ b/frontend/app/generate/page.tsx @@ -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() { )} + + {/* Toast 通知系統 */} + ) diff --git a/frontend/components/Toast.tsx b/frontend/components/Toast.tsx new file mode 100644 index 0000000..e1d1661 --- /dev/null +++ b/frontend/components/Toast.tsx @@ -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( +
+
+
+ {TOAST_ICONS[type]} +

{message}

+ +
+
+
, + document.body + ) +} + +// Toast 管理 Hook +export function useToast() { + const [toasts, setToasts] = useState }>>([]) + + const showToast = (props: Omit) => { + 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 }) => ( + 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 }) + } +} \ No newline at end of file