763 lines
30 KiB
TypeScript
763 lines
30 KiB
TypeScript
'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 { useFlashcardSearch, type SearchActions } from '@/hooks/useFlashcardSearch'
|
||
|
||
// 重構後的FlashcardsContent組件
|
||
function FlashcardsContent() {
|
||
const router = useRouter()
|
||
const toast = useToast()
|
||
const [activeTab, setActiveTab] = useState<'all-cards' | 'favorites'>('all-cards')
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [editingCard, setEditingCard] = useState<Flashcard | null>(null)
|
||
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false)
|
||
const [totalCounts, setTotalCounts] = useState({ all: 0, favorites: 0 })
|
||
|
||
// 使用新的搜尋Hook
|
||
const [searchState, searchActions] = useFlashcardSearch(activeTab)
|
||
|
||
// 例句圖片邏輯
|
||
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]
|
||
}
|
||
|
||
// 初始化數據載入
|
||
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 handleFormSuccess = async () => {
|
||
setShowForm(false)
|
||
setEditingCard(null)
|
||
await searchActions.refresh()
|
||
await loadTotalCounts()
|
||
}
|
||
|
||
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 searchActions.refresh()
|
||
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.refresh()
|
||
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 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'
|
||
}`}
|
||
>
|
||
<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-between 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}
|
||
router={router}
|
||
/>
|
||
|
||
{/* Pagination Controls */}
|
||
<PaginationControls
|
||
searchState={searchState}
|
||
searchActions={searchActions}
|
||
/>
|
||
</div>
|
||
|
||
{/* Form Modal */}
|
||
{showForm && (
|
||
<FlashcardForm
|
||
cardSets={[]}
|
||
initialData={editingCard ? {
|
||
id: editingCard.id,
|
||
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>
|
||
)
|
||
}
|
||
|
||
// 搜尋控制組件
|
||
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 items-center justify-between mb-4">
|
||
<h2 className="text-lg font-semibold text-gray-900">搜尋詞卡</h2>
|
||
<div className="flex items-center gap-4">
|
||
{/* 排序控件 */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600">排序:</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 md: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">需加強 (<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: (word: string) => string
|
||
router: any
|
||
}
|
||
|
||
function SearchResults({
|
||
searchState,
|
||
activeTab,
|
||
onEdit,
|
||
onDelete,
|
||
onToggleFavorite,
|
||
getCEFRColor,
|
||
highlightSearchTerm,
|
||
getExampleImage,
|
||
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}
|
||
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: (word: string) => string
|
||
router: any
|
||
}
|
||
|
||
function FlashcardItem({ card, searchTerm, onEdit, onDelete, onToggleFavorite, getCEFRColor, highlightSearchTerm, getExampleImage, 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 items-center gap-4 flex-1">
|
||
{/* 例句圖片 */}
|
||
<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>
|
||
</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={onToggleFavorite}
|
||
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'
|
||
}`}
|
||
>
|
||
<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={onEdit}
|
||
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"
|
||
>
|
||
<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={onDelete}
|
||
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"
|
||
>
|
||
<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"
|
||
>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
// 分頁控制組件
|
||
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() {
|
||
return (
|
||
<ProtectedRoute>
|
||
<FlashcardsContent />
|
||
</ProtectedRoute>
|
||
)
|
||
} |