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

897 lines
36 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 { useRouter } from 'next/navigation'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Navigation } from '@/components/Navigation'
import { FlashcardForm } from '@/components/FlashcardForm'
import { useToast } from '@/components/Toast'
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
import { imageGenerationService } from '@/lib/services/imageGeneration'
import { useFlashcardSearch, type SearchActions } from '@/hooks/useFlashcardSearch'
// 詞性簡寫轉換 (全域函數)
const getPartOfSpeechDisplay = (partOfSpeech: string): string => {
const shortMap: {[key: string]: string} = {
'noun': 'n.',
'verb': 'v.',
'adjective': 'adj.',
'adverb': 'adv.',
'preposition': 'prep.',
'interjection': 'int.',
'phrase': 'phr.'
}
// 處理複合詞性 (如 "preposition/adverb")
if (partOfSpeech?.includes('/')) {
return partOfSpeech.split('/').map(p => shortMap[p.trim()] || p.trim()).join('/')
}
return shortMap[partOfSpeech] || partOfSpeech || ''
}
// 重構後的FlashcardsContent組件
function FlashcardsContent({ showForm, setShowForm }: { showForm: boolean; setShowForm: (show: boolean) => void }) {
const router = useRouter()
const toast = useToast()
const [activeTab, setActiveTab] = useState<'all-cards' | 'favorites'>('all-cards')
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false)
const [totalCounts, setTotalCounts] = useState({ all: 0, favorites: 0 })
// 使用新的搜尋Hook
const [searchState, searchActions] = useFlashcardSearch(activeTab)
// 圖片生成狀態管理
const [generatingCards, setGeneratingCards] = useState<Set<string>>(new Set())
const [generationProgress, setGenerationProgress] = useState<{[cardId: string]: string}>({})
// 例句圖片邏輯 - 使用 API 資料
const getExampleImage = (card: Flashcard): string | null => {
return card.primaryImageUrl || null
}
// 檢查詞彙是否有例句圖片 - 使用 API 資料
const hasExampleImage = (card: Flashcard): boolean => {
return card.hasExampleImage
}
// 處理AI生成例句圖片 - 完整實現
const handleGenerateExampleImage = async (card: Flashcard) => {
try {
// 檢查是否已在生成中
if (generatingCards.has(card.id)) {
toast.error('該詞卡正在生成圖片中,請稍候...')
return
}
// 標記為生成中
setGeneratingCards(prev => new Set([...prev, card.id]))
setGenerationProgress(prev => ({ ...prev, [card.id]: '啟動生成中...' }))
toast.info(`開始為「${card.word}」生成例句圖片...`)
// 1. 啟動圖片生成
const generateResult = await imageGenerationService.generateImage(card.id)
if (!generateResult.success || !generateResult.data) {
throw new Error(generateResult.error || '啟動生成失敗')
}
const requestId = generateResult.data.requestId
setGenerationProgress(prev => ({ ...prev, [card.id]: 'Gemini 生成描述中...' }))
// 2. 輪詢生成進度
const finalStatus = await imageGenerationService.pollUntilComplete(
requestId,
(status) => {
// 更新進度顯示
const stage = status.stages.gemini.status === 'completed'
? 'Replicate 生成圖片中...'
: 'Gemini 生成描述中...'
setGenerationProgress(prev => ({ ...prev, [card.id]: stage }))
},
5 // 5分鐘超時
)
// 3. 生成成功,刷新資料
if (finalStatus.overallStatus === 'completed') {
setGenerationProgress(prev => ({ ...prev, [card.id]: '生成完成,載入中...' }))
// 清除快取並重新載入詞卡列表以顯示新圖片
await searchActions.refetch()
toast.success(`${card.word}」的例句圖片生成完成!`)
} else {
throw new Error('圖片生成未完成')
}
} catch (error: any) {
console.error('圖片生成失敗:', error)
toast.error(`圖片生成失敗: ${error.message || '未知錯誤'}`)
} finally {
// 清理狀態
setGeneratingCards(prev => {
const newSet = new Set(prev)
newSet.delete(card.id)
return newSet
})
setGenerationProgress(prev => {
const newProgress = { ...prev }
delete newProgress[card.id]
return newProgress
})
}
}
// 初始化數據載入
useEffect(() => {
loadTotalCounts()
}, [])
// 載入總數統計
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)
}
}
const handleEdit = (card: Flashcard) => {
router.push(`/flashcards/${card.id}?edit=true`)
}
const handleDelete = async (card: Flashcard) => {
if (!confirm(`確定要刪除詞卡「${card.word}」嗎?`)) {
return
}
try {
const result = await flashcardsService.deleteFlashcard(card.id)
if (result.success) {
await searchActions.refetch()
await loadTotalCounts()
toast.success(`詞卡「${card.word}」已刪除`)
} else {
toast.error(result.error || '刪除失敗')
}
} catch (err) {
toast.error('刪除失敗,請重試')
}
}
const handleToggleFavorite = async (card: Flashcard) => {
try {
const result = await flashcardsService.toggleFavorite(card.id)
if (result.success) {
await searchActions.refetch()
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'
}
}
// 搜尋結果高亮函數
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
)
)
}
// 載入狀態處理
if (searchState.loading && searchState.isInitialLoad) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-lg">...</div>
</div>
)
}
if (searchState.error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-red-600">{searchState.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 flex-col sm:flex-row sm:justify-between sm:items-center mb-6 gap-4 sm:gap-0">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900"></h1>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4">
<button
onClick={() => setShowForm(true)}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors text-center"
>
</button>
<Link
href="/generate"
className="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center"
>
AI
</Link>
</div>
</div>
{/* Tabs */}
<div className="flex space-x-4 sm:space-x-8 mb-6 border-b border-gray-200 overflow-x-auto">
<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'
}`}
>
<span className="flex items-center gap-1">
<span className="text-blue-500">📚</span>
({totalCounts.all})
</span>
</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>
{/* Search Controls */}
<SearchControls
searchState={searchState}
searchActions={searchActions}
showAdvancedSearch={showAdvancedSearch}
setShowAdvancedSearch={setShowAdvancedSearch}
/>
{/* 詞卡數目統計 */}
<div className="flex justify-end items-center mb-4">
<h3 className="text-lg font-semibold">
{searchState.pagination.totalCount}
{searchState.pagination.totalPages > 1 && (
<span className="text-sm font-normal text-gray-600 ml-2">
( {searchState.pagination.currentPage} {searchState.flashcards.length} )
</span>
)}
</h3>
</div>
{/* Search Results */}
<SearchResults
searchState={searchState}
activeTab={activeTab}
onEdit={handleEdit}
onDelete={handleDelete}
onToggleFavorite={handleToggleFavorite}
getCEFRColor={getCEFRColor}
highlightSearchTerm={highlightSearchTerm}
getExampleImage={getExampleImage}
hasExampleImage={hasExampleImage}
onGenerateExampleImage={handleGenerateExampleImage}
generatingCards={generatingCards}
generationProgress={generationProgress}
router={router}
/>
{/* Pagination Controls */}
<PaginationControls
searchState={searchState}
searchActions={searchActions}
/>
</div>
{/* Toast */}
<toast.ToastContainer />
</div>
)
}
// 搜尋控制組件
interface SearchControlsProps {
searchState: any
searchActions: SearchActions
showAdvancedSearch: boolean
setShowAdvancedSearch: (show: boolean) => void
}
function SearchControls({ searchState, searchActions, showAdvancedSearch, setShowAdvancedSearch }: SearchControlsProps) {
return (
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3 sm:gap-0">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
{/* 排序控件 */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 hidden sm:inline">:</span>
<select
value={searchState.sorting.sortBy}
onChange={(e) => searchActions.updateSorting({ sortBy: e.target.value })}
className="text-sm border border-gray-300 rounded-md px-3 py-1 focus:ring-2 focus:ring-primary focus:border-primary"
>
<option value="createdAt"></option>
<option value="masteryLevel"></option>
<option value="word"></option>
<option value="difficultyLevel">CEFR等級</option>
<option value="timesReviewed"></option>
</select>
<button
onClick={searchActions.toggleSortOrder}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title={searchState.sorting.sortOrder === 'asc' ? '升序 (點擊改為降序)' : '降序 (點擊改為升序)'}
>
<svg className={`w-4 h-4 transition-transform ${searchState.sorting.sortOrder === 'desc' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 11l5-5m0 0l5 5m-5-5v12" />
</svg>
</button>
</div>
<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>
{/* 主要搜尋框 */}
<div className="relative mb-4">
<input
type="text"
value={searchState.filters.search}
onChange={(e) => searchActions.updateFilters({ search: 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') {
searchActions.clearFilters()
}
}}
/>
<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>
{(searchState.filters.search || (searchState as any).hasActiveFilters) && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
onClick={searchActions.clearFilters}
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 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* CEFR等級篩選 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">CEFR等級</label>
<select
value={searchState.filters.difficultyLevel}
onChange={(e) => searchActions.updateFilters({ difficultyLevel: 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={searchState.filters.partOfSpeech}
onChange={(e) => searchActions.updateFilters({ 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={searchState.filters.masteryLevel}
onChange={(e) => searchActions.updateFilters({ 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={searchState.filters.favoritesOnly}
onChange={(e) => searchActions.updateFilters({ favoritesOnly: 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>
)}
</div>
)
}
// 搜尋結果組件
interface SearchResultsProps {
searchState: any
activeTab: string
onEdit: (card: Flashcard) => void
onDelete: (card: Flashcard) => void
onToggleFavorite: (card: Flashcard) => void
getCEFRColor: (level: string) => string
highlightSearchTerm: (text: string, term: string) => React.ReactNode
getExampleImage: (card: Flashcard) => string | null
hasExampleImage: (card: Flashcard) => boolean
onGenerateExampleImage: (card: Flashcard) => void
generatingCards: Set<string>
generationProgress: {[cardId: string]: string}
router: any
}
function SearchResults({
searchState,
activeTab,
onEdit,
onDelete,
onToggleFavorite,
getCEFRColor,
highlightSearchTerm,
getExampleImage,
hasExampleImage,
onGenerateExampleImage,
generatingCards,
generationProgress,
router
}: SearchResultsProps) {
if (searchState.flashcards.length === 0) {
return (
<div className="text-center py-12">
{activeTab === 'favorites' ? (
<>
<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>
</>
) : (
<>
<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>
)
}
return (
<div className="space-y-2">
{searchState.flashcards.map((card: Flashcard) => (
<FlashcardItem
key={card.id}
card={card}
searchTerm={searchState.filters.search}
onEdit={() => onEdit(card)}
onDelete={() => onDelete(card)}
onToggleFavorite={() => onToggleFavorite(card)}
getCEFRColor={getCEFRColor}
highlightSearchTerm={highlightSearchTerm}
getExampleImage={getExampleImage}
hasExampleImage={hasExampleImage}
onGenerateExampleImage={() => onGenerateExampleImage(card)}
generatingCards={generatingCards}
generationProgress={generationProgress}
router={router}
/>
))}
</div>
)
}
// 詞卡項目組件
interface FlashcardItemProps {
card: Flashcard
searchTerm: string
onEdit: () => void
onDelete: () => void
onToggleFavorite: () => void
getCEFRColor: (level: string) => string
highlightSearchTerm: (text: string, term: string) => React.ReactNode
getExampleImage: (card: Flashcard) => string | null
hasExampleImage: (card: Flashcard) => boolean
onGenerateExampleImage: () => void
generatingCards: Set<string>
generationProgress: {[cardId: string]: string}
router: any
}
function FlashcardItem({ card, searchTerm, onEdit, onDelete, onToggleFavorite, getCEFRColor, highlightSearchTerm, getExampleImage, hasExampleImage, onGenerateExampleImage, generatingCards, generationProgress, router }: FlashcardItemProps) {
return (
<div 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 flex-col md:flex-row md:items-center gap-3 md:gap-4 flex-1">
{/* 例句圖片區域 - 響應式設計 */}
<div className="w-32 h-20 sm:w-40 sm:h-24 md:w-48 md:h-32 lg:w-54 lg:h-36 bg-gray-100 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center flex-shrink-0">
{hasExampleImage(card) ? (
// 有例句圖片時顯示圖片
<img
src={getExampleImage(card)!}
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
className="w-full h-full flex items-center justify-center cursor-pointer hover:bg-blue-50 transition-colors group"
onClick={onGenerateExampleImage}
title="點擊生成例句圖片"
>
<div className="text-center">
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400 group-hover:text-blue-600 group-hover:scale-110 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="text-xs text-gray-500 group-hover:text-blue-600 transition-colors"></span>
</div>
</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">
{getPartOfSpeechDisplay(card.partOfSpeech)}
</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>
</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 flex-wrap md:flex-nowrap items-center gap-1 md:gap-2 mt-3 md:mt-0">
{/* 收藏按鈕 */}
<button
onClick={onToggleFavorite}
className={`px-2 md: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'
}`}
>
<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 hidden sm:inline">
{card.isFavorite ? '已收藏' : '收藏'}
</span>
</div>
</button>
{/* 編輯按鈕 */}
<button
onClick={onEdit}
className="px-2 md:px-3 py-2 bg-blue-100 text-blue-700 border border-blue-300 rounded-lg font-medium hover:bg-blue-200 transition-colors"
>
<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 hidden sm:inline"></span>
</div>
</button>
{/* 刪除按鈕 */}
<button
onClick={onDelete}
className="px-2 md:px-3 py-2 bg-red-100 text-red-700 border border-red-300 rounded-lg font-medium hover:bg-red-200 transition-colors"
>
<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 hidden sm:inline"></span>
</div>
</button>
{/* 詳細按鈕 */}
<button
onClick={() => router.push(`/flashcards/${card.id}`)}
className="px-2 md: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"
>
<div className="flex items-center gap-1">
<span className="text-sm hidden md:inline"></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>
)
}
// 分頁控制組件
interface PaginationControlsProps {
searchState: any
searchActions: SearchActions
}
function PaginationControls({ searchState, searchActions }: PaginationControlsProps) {
if (searchState.pagination.totalPages <= 1) {
return null
}
return (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{searchState.pagination.currentPage} {searchState.pagination.totalPages}
</span>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">:</span>
<select
value={searchState.pagination.pageSize}
onChange={(e) => searchActions.changePageSize(Number(e.target.value))}
className="text-sm border border-gray-300 rounded-md px-2 py-1 focus:ring-2 focus:ring-primary focus:border-primary"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
{/* 上一頁 */}
<button
onClick={searchActions.goToPrevPage}
disabled={!searchState.pagination.hasPrev}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
{/* 頁碼 */}
<div className="flex items-center gap-1">
{[...Array(Math.min(5, searchState.pagination.totalPages))].map((_, index) => {
let pageNum
if (searchState.pagination.totalPages <= 5) {
pageNum = index + 1
} else if (searchState.pagination.currentPage <= 3) {
pageNum = index + 1
} else if (searchState.pagination.currentPage >= searchState.pagination.totalPages - 2) {
pageNum = searchState.pagination.totalPages - 4 + index
} else {
pageNum = searchState.pagination.currentPage - 2 + index
}
return (
<button
key={pageNum}
onClick={() => searchActions.goToPage(pageNum)}
className={`px-3 py-1 text-sm rounded-md ${
searchState.pagination.currentPage === pageNum
? 'bg-primary text-white'
: 'border border-gray-300 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
)
})}
</div>
{/* 下一頁 */}
<button
onClick={searchActions.goToNextPage}
disabled={!searchState.pagination.hasNext}
className="px-3 py-1 text-sm border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
)
}
export default function FlashcardsPage() {
const [showForm, setShowForm] = useState(false)
return (
<ProtectedRoute>
<FlashcardsContent showForm={showForm} setShowForm={setShowForm} />
{/* 全域模態框 - 在最外層 */}
{showForm && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999999
}}
onClick={() => setShowForm(false)}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '32px',
maxWidth: '600px',
width: '90%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}></h2>
<button
onClick={() => setShowForm(false)}
style={{ fontSize: '24px', color: '#666', background: 'none', border: 'none', cursor: 'pointer' }}
>
</button>
</div>
<FlashcardForm
onSuccess={() => {
console.log('詞卡創建成功');
setShowForm(false);
// TODO: 刷新詞卡列表
}}
onCancel={() => setShowForm(false)}
/>
</div>
</div>
)}
</ProtectedRoute>
)
}