dramaling-vocab-learning/frontend/app/flashcards/page.tsx

908 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 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'
// 暫時為了兼容性定義 CardSet 類型
type CardSet = {
id: string;
name: string;
color: string;
}
// 使用簡化的 Flashcard 類型
type Flashcard = Flashcard
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('')
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false)
const [searchFilters, setSearchFilters] = useState({
cefrLevel: '',
partOfSpeech: '',
masteryLevel: '',
onlyFavorites: false
})
// Real data from API
// const [cardSets, setCardSets] = useState<CardSet[]>([]) // 移除 CardSets 狀態
const [flashcards, setFlashcards] = useState<Flashcard[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [totalCounts, setTotalCounts] = useState({ all: 0, favorites: 0 })
// 臨時使用學習功能的例句圖片作為測試
const getExampleImage = (word: string): string => {
const availableImages = [
'/images/examples/bring_up.png',
'/images/examples/instinct.png',
'/images/examples/warrant.png'
]
const imageMap: {[key: string]: string} = {
'brought': '/images/examples/bring_up.png',
'instincts': '/images/examples/instinct.png',
'warrants': '/images/examples/warrant.png',
'hello': '/images/examples/bring_up.png',
'beautiful': '/images/examples/instinct.png',
'understand': '/images/examples/warrant.png',
'elaborate': '/images/examples/bring_up.png',
'sophisticated': '/images/examples/instinct.png',
'ubiquitous': '/images/examples/warrant.png'
}
// 根據詞彙返回對應圖片,如果沒有則根據字母分配
const mappedImage = imageMap[word?.toLowerCase()]
if (mappedImage) return mappedImage
// 根據首字母分配圖片
const firstChar = (word || 'a')[0].toLowerCase()
const charCode = firstChar.charCodeAt(0) - 97 // a=0, b=1, c=2...
const imageIndex = charCode % availableImages.length
return availableImages[imageIndex]
}
// Form states
const [showForm, setShowForm] = useState(false)
const [editingCard, setEditingCard] = useState<Flashcard | null>(null)
// 添加假資料用於展示CEFR效果 (更新為 Flashcard 格式)
const mockFlashcards: Flashcard[] = [
{ id: 'mock1', word: 'hello', translation: '你好', partOfSpeech: 'interjection', pronunciation: '/həˈloʊ/', masteryLevel: 95, timesReviewed: 15, isFavorite: true, nextReviewDate: '2025-09-21', difficultyLevel: 'A1', definition: 'A greeting word', example: 'Hello, how are you?', createdAt: '2025-09-17', updatedAt: '2025-09-17' },
{ id: 'mock2', word: 'beautiful', translation: '美麗的', partOfSpeech: 'adjective', pronunciation: '/ˈbjuːtɪfəl/', masteryLevel: 78, timesReviewed: 8, isFavorite: false, nextReviewDate: '2025-09-22', difficultyLevel: 'A2', definition: 'Pleasing to look at', example: 'The beautiful sunset', createdAt: '2025-09-16', updatedAt: '2025-09-16' },
{ id: 'mock3', word: 'understand', translation: '理解', partOfSpeech: 'verb', pronunciation: '/ˌʌndərˈstænd/', masteryLevel: 65, timesReviewed: 12, isFavorite: true, nextReviewDate: '2025-09-20', difficultyLevel: 'B1', definition: 'To comprehend', example: 'I understand the concept', createdAt: '2025-09-15', updatedAt: '2025-09-15' },
{ id: 'mock4', word: 'elaborate', translation: '詳細說明', partOfSpeech: 'verb', pronunciation: '/ɪˈlæbərət/', masteryLevel: 45, timesReviewed: 5, isFavorite: false, nextReviewDate: '2025-09-19', difficultyLevel: 'B2', definition: 'To explain in detail', example: 'Please elaborate on your idea', createdAt: '2025-09-14', updatedAt: '2025-09-14' },
{ id: 'mock5', word: 'sophisticated', translation: '精密的', partOfSpeech: 'adjective', pronunciation: '/səˈfɪstɪkeɪtɪd/', masteryLevel: 30, timesReviewed: 3, isFavorite: true, nextReviewDate: '2025-09-18', difficultyLevel: 'C1', definition: 'Highly developed', example: 'A sophisticated system', createdAt: '2025-09-13', updatedAt: '2025-09-13' },
{ id: 'mock6', word: 'ubiquitous', translation: '無處不在的', partOfSpeech: 'adjective', pronunciation: '/juːˈbɪkwɪtəs/', masteryLevel: 15, timesReviewed: 1, isFavorite: false, nextReviewDate: '2025-09-17', difficultyLevel: 'C2', definition: 'Present everywhere', example: 'Smartphones are ubiquitous', createdAt: '2025-09-12', updatedAt: '2025-09-12' }
]
// 載入總數統計
const loadTotalCounts = async () => {
try {
// 載入所有詞卡數量
const allResult = await flashcardsService.getFlashcards()
const allCount = allResult.success && allResult.data ? allResult.data.count : 0
// 載入收藏詞卡數量
const favoritesResult = await flashcardsService.getFlashcards(undefined, true)
const favoritesCount = favoritesResult.success && favoritesResult.data ? favoritesResult.data.count : 0
setTotalCounts({ all: allCount, favorites: favoritesCount })
} catch (err) {
console.error('載入統計失敗:', err)
}
}
// Load data from API
useEffect(() => {
// 載入詞卡和統計
loadFlashcards()
loadTotalCounts()
}, [])
// 監聽搜尋和篩選條件變化,重新載入資料
useEffect(() => {
const timeoutId = setTimeout(() => {
loadFlashcards()
}, 300) // 300ms 防抖
return () => clearTimeout(timeoutId)
}, [searchTerm, searchFilters, activeTab])
// 暫時移除 CardSets 功能,直接設定空陣列
// const loadCardSets = async () => {
// setCardSets([])
// }
const loadFlashcards = async () => {
try {
setLoading(true)
setError(null) // 清除之前的錯誤
// 使用進階篩選參數呼叫 API
const result = await flashcardsService.getFlashcards(
searchTerm || undefined,
activeTab === 'favorites',
searchFilters.cefrLevel || undefined,
searchFilters.partOfSpeech || undefined,
searchFilters.masteryLevel || undefined
)
if (result.success && result.data) {
setFlashcards(result.data.flashcards)
console.log('✅ 詞卡載入成功:', result.data.flashcards.length, '個詞卡')
} else {
setError(result.error || 'Failed to load flashcards')
console.error('❌ 詞卡載入失敗:', result.error)
}
} catch (err) {
const errorMessage = 'Failed to load flashcards'
setError(errorMessage)
console.error('❌ 詞卡載入異常:', err)
} finally {
setLoading(false)
}
}
// 移除 selectedSet 依賴的 useEffect
// useEffect(() => {
// loadFlashcards()
// }, [selectedSet])
// Handle form operations
const handleFormSuccess = async () => {
setShowForm(false)
setEditingCard(null)
await loadFlashcards()
await loadTotalCounts()
// 移除 loadCardSets() 調用
}
const handleEdit = (card: Flashcard) => {
setEditingCard(card)
setShowForm(true)
}
const handleDelete = async (card: Flashcard) => {
if (!confirm(`確定要刪除詞卡「${card.word}」嗎?`)) {
return
}
try {
const result = await flashcardsService.deleteFlashcard(card.id)
if (result.success) {
await loadFlashcards()
await loadTotalCounts()
toast.success(`詞卡「${card.word}」已刪除`)
} else {
toast.error(result.error || '刪除失敗')
}
} catch (err) {
toast.error('刪除失敗,請重試')
}
}
const handleToggleFavorite = async (card: any) => {
try {
// 如果是假資料,只更新本地狀態
if (card.id.startsWith('mock')) {
// 模擬資料暫時只顯示提示,實際狀態更新需要實作
toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`)
return
}
// 真實API調用
const result = await flashcardsService.toggleFavorite(card.id)
if (result.success) {
// 重新載入詞卡以反映最新的收藏狀態
await loadFlashcards()
// 重新載入統計數量
await loadTotalCounts()
toast.success(`${card.isFavorite ? '已取消收藏' : '已加入收藏'}${card.word}`)
} else {
toast.error(result.error || '操作失敗')
}
} catch (err) {
toast.error('操作失敗,請重試')
}
}
// 獲取CEFR等級顏色
const getCEFRColor = (level: string) => {
switch (level) {
case 'A1': return 'bg-green-100 text-green-700 border-green-200' // 淺綠 - 最基礎
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200' // 淺藍 - 基礎
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200' // 淺黃 - 中級
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200' // 淺橙 - 中高級
case 'C1': return 'bg-red-100 text-red-700 border-red-200' // 淺紅 - 高級
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200' // 淺紫 - 精通
default: return 'bg-gray-100 text-gray-700 border-gray-200' // 預設灰色
}
}
// 由於後端已處理篩選,直接使用 API 回傳的結果
const allCards = [...flashcards, ...mockFlashcards] // 保留模擬資料用於展示
const filteredCards = flashcards // 直接使用從 API 取得的已篩選結果
// 清除所有篩選
const clearAllFilters = () => {
setSearchTerm('')
setSearchFilters({
cefrLevel: '',
partOfSpeech: '',
masteryLevel: '',
onlyFavorites: false
})
}
// 檢查是否有活動篩選
const hasActiveFilters = searchTerm ||
searchFilters.cefrLevel ||
searchFilters.partOfSpeech ||
searchFilters.masteryLevel ||
searchFilters.onlyFavorites
// 搜尋結果高亮函數
const highlightSearchTerm = (text: string, searchTerm: string) => {
if (!searchTerm || !text) return text
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
const parts = text.split(regex)
return parts.map((part, index) =>
regex.test(part) ? (
<mark key={index} className="bg-yellow-200 text-yellow-900 px-1 rounded">
{part}
</mark>
) : (
part
)
)
}
// Add loading and error states
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-lg">...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-red-600">{error}</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<Navigation />
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Page Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => setShowForm(true)}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors"
>
</button>
<Link
href="/generate"
className="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"
>
AI
</Link>
</div>
</div>
{/* 簡化的Tabs - 移除卡組功能 */}
<div className="flex space-x-8 mb-6 border-b border-gray-200">
<button
onClick={() => setActiveTab('all-cards')}
className={`pb-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'all-cards'
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
({totalCounts.all})
</button>
<button
onClick={() => setActiveTab('favorites')}
className={`pb-4 px-1 border-b-2 font-medium text-sm flex items-center gap-1 ${
activeTab === 'favorites'
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span className="text-yellow-500"></span>
({totalCounts.favorites})
</button>
</div>
{/* 進階搜尋區域 */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<button
onClick={() => setShowAdvancedSearch(!showAdvancedSearch)}
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
{showAdvancedSearch ? '收起篩選' : '進階篩選'}
</button>
</div>
{/* 主要搜尋框 */}
<div className="relative mb-4">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜尋詞彙、翻譯或定義..."
className="w-full pl-12 pr-20 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-base"
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchTerm('')
}
}}
/>
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{(searchTerm || hasActiveFilters) && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center gap-2">
<span className="text-xs text-gray-500 bg-blue-100 px-2 py-1 rounded-full">
{filteredCards.length}
</span>
<button
onClick={clearAllFilters}
className="text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors"
title="清除搜尋"
>
<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>
{/* 進階篩選選項 */}
{showAdvancedSearch && (
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* CEFR等級篩選 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">CEFR等級</label>
<select
value={searchFilters.cefrLevel}
onChange={(e) => setSearchFilters(prev => ({ ...prev, cefrLevel: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary focus:border-primary"
>
<option value=""></option>
<option value="A1">A1 - </option>
<option value="A2">A2 - </option>
<option value="B1">B1 - </option>
<option value="B2">B2 - </option>
<option value="C1">C1 - </option>
<option value="C2">C2 - </option>
</select>
</div>
{/* 詞性篩選 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<select
value={searchFilters.partOfSpeech}
onChange={(e) => setSearchFilters(prev => ({ ...prev, partOfSpeech: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary focus:border-primary"
>
<option value=""></option>
<option value="noun"> (noun)</option>
<option value="verb"> (verb)</option>
<option value="adjective"> (adjective)</option>
<option value="adverb"> (adverb)</option>
<option value="preposition"> (preposition)</option>
<option value="interjection"> (interjection)</option>
<option value="phrase"> (phrase)</option>
</select>
</div>
{/* 掌握度篩選 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<select
value={searchFilters.masteryLevel}
onChange={(e) => setSearchFilters(prev => ({ ...prev, masteryLevel: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary focus:border-primary"
>
<option value=""></option>
<option value="high"> (80%+)</option>
<option value="medium"> (60-79%)</option>
<option value="low"> (&lt;60%)</option>
</select>
</div>
{/* 收藏篩選 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={searchFilters.onlyFavorites}
onChange={(e) => setSearchFilters(prev => ({ ...prev, onlyFavorites: e.target.checked }))}
className="w-4 h-4 text-yellow-600 bg-gray-100 border-gray-300 rounded focus:ring-yellow-500"
/>
<span className="ml-2 text-sm text-gray-700 flex items-center gap-1">
<span className="text-yellow-500"></span>
</span>
</label>
</div>
</div>
{/* 快速篩選按鈕 */}
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
<span className="text-sm text-gray-600">:</span>
<button
onClick={() => setSearchFilters(prev => ({ ...prev, masteryLevel: 'low' }))}
className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium hover:bg-red-200 transition-colors"
>
</button>
<button
onClick={() => setSearchFilters(prev => ({ ...prev, onlyFavorites: true }))}
className="px-3 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium hover:bg-yellow-200 transition-colors"
>
</button>
<button
onClick={() => setSearchFilters(prev => ({ ...prev, cefrLevel: 'C1' }))}
className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-xs font-medium hover:bg-purple-200 transition-colors"
>
(C1)
</button>
<button
onClick={() => setSearchFilters(prev => ({ ...prev, cefrLevel: 'C2' }))}
className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-xs font-medium hover:bg-purple-200 transition-colors"
>
(C2)
</button>
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-xs font-medium hover:bg-gray-200 transition-colors"
>
</button>
)}
</div>
</div>
)}
{/* 搜尋結果統計 */}
{(searchTerm || hasActiveFilters) && (
<div className="flex items-center justify-between text-sm text-gray-600 bg-blue-50 px-4 py-2 rounded-lg">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span>
<strong className="text-blue-700">{filteredCards.length}</strong>
{searchTerm && (
<span> "<strong className="text-blue-700">{searchTerm}</strong>"</span>
)}
</span>
</div>
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="text-blue-600 hover:text-blue-700 font-medium"
>
</button>
)}
</div>
)}
</div>
{/* Favorites Tab */}
{activeTab === 'favorites' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"> {filteredCards.length} </h3>
</div>
{filteredCards.length === 0 ? (
<div className="text-center py-12">
<div className="text-yellow-500 text-6xl mb-4"></div>
<p className="text-gray-500 mb-4"></p>
<p className="text-sm text-gray-400"></p>
</div>
) : (
<div className="space-y-2">
{filteredCards.map(card => (
<div key={card.id} className="bg-white border border-gray-200 rounded-lg hover:shadow-md transition-all duration-200 relative">
<div className="p-4">
{/* 收藏詞卡內容 - 與普通詞卡相同的佈局 */}
<div className="flex items-center justify-between">
<div className="absolute top-3 right-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium border ${getCEFRColor((card as any).difficultyLevel || 'A1')}`}>
{(card as any).difficultyLevel || 'A1'}
</span>
</div>
<div className="flex items-center gap-4">
<div className="w-54 h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center">
<img
src={getExampleImage(card.word)}
alt={`${card.word} example`}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.parentElement!.innerHTML = `
<div class="text-gray-400 text-xs text-center">
<svg class="w-6 h-6 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
例句圖
</div>
`
}}
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-gray-900">
{searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')}
</h3>
<span className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
{card.partOfSpeech || 'unknown'}
</span>
</div>
<div className="flex items-center gap-4 mt-1">
<span className="text-lg text-gray-900 font-medium">
{searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')}
</span>
{card.pronunciation && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{card.pronunciation}</span>
<button
onClick={(e) => {
e.stopPropagation()
console.log(`播放 ${card.word} 的發音`)
}}
className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors"
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span>: {new Date(card.createdAt).toLocaleDateString()}</span>
<span>: {card.masteryLevel}%</span>
</div>
</div>
</div>
{/* 右側:重新設計的操作按鈕區 */}
<div className="flex items-center gap-2">
{/* 收藏按鈕 */}
<button
onClick={() => handleToggleFavorite(card)}
className={`px-3 py-2 rounded-lg font-medium transition-colors ${
card.isFavorite
? 'bg-yellow-100 text-yellow-700 border border-yellow-300 hover:bg-yellow-200'
: 'bg-gray-100 text-gray-600 border border-gray-300 hover:bg-yellow-50 hover:text-yellow-600 hover:border-yellow-300'
}`}
title={card.isFavorite ? "取消收藏" : "加入收藏"}
>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill={card.isFavorite ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
<span className="text-sm">
{card.isFavorite ? '已收藏' : '收藏'}
</span>
</div>
</button>
{/* 編輯按鈕 */}
<button
onClick={() => handleEdit(card)}
className="px-3 py-2 bg-blue-100 text-blue-700 border border-blue-300 rounded-lg font-medium hover:bg-blue-200 transition-colors"
title="編輯詞卡"
>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span className="text-sm"></span>
</div>
</button>
{/* 刪除按鈕 */}
<button
onClick={() => handleDelete(card)}
className="px-3 py-2 bg-red-100 text-red-700 border border-red-300 rounded-lg font-medium hover:bg-red-200 transition-colors"
title="刪除詞卡"
>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm"></span>
</div>
</button>
{/* 查看詳情按鈕 - 導航到詳細頁面 */}
<button
onClick={() => {
router.push(`/flashcards/${card.id}`)
}}
className="px-4 py-2 bg-gray-100 text-gray-700 border border-gray-300 rounded-lg font-medium hover:bg-gray-200 hover:text-gray-900 transition-colors"
title="查看詳細資訊"
>
<div className="flex items-center gap-1">
<span className="text-sm"></span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* All Cards Tab */}
{activeTab === 'all-cards' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"> {filteredCards.length} </h3>
</div>
{filteredCards.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 mb-4"></p>
<Link
href="/generate"
className="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"
>
</Link>
</div>
) : (
<div className="space-y-2">
{filteredCards.map(card => (
<div key={card.id} className="bg-white border border-gray-200 rounded-lg hover:shadow-md transition-all duration-200 relative">
<div className="p-4">
<div className="flex items-center justify-between">
{/* 詞卡右上角CEFR標註 */}
<div className="absolute top-3 right-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium border ${getCEFRColor((card as any).difficultyLevel || 'A1')}`}>
{(card as any).difficultyLevel || 'A1'}
</span>
</div>
{/* 左側:詞彙基本信息 */}
<div className="flex items-center gap-4">
{/* 例句圖片 - 超大尺寸 */}
<div className="w-54 h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center">
<img
src={getExampleImage(card.word)}
alt={`${card.word} example`}
className="w-full h-full object-cover"
onError={(e) => {
// 圖片載入失敗時顯示佔位符
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.parentElement!.innerHTML = `
<div class="text-gray-400 text-xs text-center">
<svg class="w-6 h-6 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
例句圖
</div>
`
}}
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-gray-900">
{searchTerm ? highlightSearchTerm(card.word || '未設定', searchTerm) : (card.word || '未設定')}
</h3>
<span className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
{card.partOfSpeech || 'unknown'}
</span>
</div>
<div className="flex items-center gap-4 mt-1">
<span className="text-lg text-gray-900 font-medium">
{searchTerm ? highlightSearchTerm(card.translation || '未設定', searchTerm) : (card.translation || '未設定')}
</span>
{card.pronunciation && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{card.pronunciation}</span>
<button
onClick={(e) => {
e.stopPropagation()
// TODO: 播放發音
console.log(`播放 ${card.word} 的發音`)
}}
className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors"
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
)}
</div>
{/* 簡要統計 */}
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span>: {new Date(card.createdAt).toLocaleDateString()}</span>
<span>: {card.masteryLevel}%</span>
</div>
</div>
</div>
{/* 右側:操作按鈕 */}
<div className="flex items-center gap-3">
{/* 重新設計的操作按鈕區 */}
<div className="flex items-center gap-2">
{/* 收藏按鈕 */}
<button
onClick={() => handleToggleFavorite(card)}
className={`px-3 py-2 rounded-lg font-medium transition-colors ${
card.isFavorite
? 'bg-yellow-100 text-yellow-700 border border-yellow-300 hover:bg-yellow-200'
: 'bg-gray-100 text-gray-600 border border-gray-300 hover:bg-yellow-50 hover:text-yellow-600 hover:border-yellow-300'
}`}
title={card.isFavorite ? "取消收藏" : "加入收藏"}
>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill={card.isFavorite ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
<span className="text-sm">
{card.isFavorite ? '已收藏' : '收藏'}
</span>
</div>
</button>
{/* 編輯按鈕 */}
<button
onClick={() => handleEdit(card)}
className="px-3 py-2 bg-blue-100 text-blue-700 border border-blue-300 rounded-lg font-medium hover:bg-blue-200 transition-colors"
title="編輯詞卡"
>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span className="text-sm"></span>
</div>
</button>
{/* 刪除按鈕 */}
<button
onClick={() => handleDelete(card)}
className="px-3 py-2 bg-red-100 text-red-700 border border-red-300 rounded-lg font-medium hover:bg-red-200 transition-colors"
title="刪除詞卡"
>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm"></span>
</div>
</button>
{/* 查看詳情按鈕 - 導航到詳細頁面 */}
<button
onClick={() => {
router.push(`/flashcards/${card.id}`)
}}
className="px-4 py-2 bg-gray-100 text-gray-700 border border-gray-300 rounded-lg font-medium hover:bg-gray-200 hover:text-gray-900 transition-colors"
title="查看詳細資訊"
>
<div className="flex items-center gap-1">
<span className="text-sm"></span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Flashcard Form Modal */}
{showForm && (
<FlashcardForm
cardSets={[]} // 傳遞空陣列移除 CardSets 依賴
initialData={editingCard ? {
id: editingCard.id,
// 移除 cardSetId 邏輯
word: editingCard.word,
translation: editingCard.translation,
definition: editingCard.definition,
pronunciation: editingCard.pronunciation,
partOfSpeech: editingCard.partOfSpeech,
example: editingCard.example,
} : undefined}
isEdit={!!editingCard}
onSuccess={handleFormSuccess}
onCancel={() => {
setShowForm(false)
setEditingCard(null)
}}
/>
)}
{/* Toast 通知系統 */}
<toast.ToastContainer />
</div>
)
}
export default function FlashcardsPage() {
return (
<ProtectedRoute>
<FlashcardsContent />
</ProtectedRoute>
)
}