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

696 lines
28 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 { 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)
// 初始化數據載入
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}
/>
{/* Search Results */}
<SearchResults
searchState={searchState}
activeTab={activeTab}
onEdit={handleEdit}
onDelete={handleDelete}
onToggleFavorite={handleToggleFavorite}
getCEFRColor={getCEFRColor}
highlightSearchTerm={highlightSearchTerm}
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 gap-2">
<span className="text-xs text-gray-500 bg-blue-100 px-2 py-1 rounded-full">
{searchState.pagination.totalCount}
</span>
<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"> (&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
router: any
}
function SearchResults({
searchState,
activeTab,
onEdit,
onDelete,
onToggleFavorite,
getCEFRColor,
highlightSearchTerm,
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}
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
router: any
}
function FlashcardItem({ card, searchTerm, onEdit, onDelete, onToggleFavorite, getCEFRColor, highlightSearchTerm, 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="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>
)
}