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

384 lines
13 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/shared/ProtectedRoute'
import { Navigation } from '@/components/shared/Navigation'
import { FlashcardForm } from '@/components/flashcards/FlashcardForm'
import { useToast } from '@/components/shared/Toast'
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
import { imageGenerationService } from '@/lib/services/imageGeneration'
import { useFlashcardSearch } from '@/hooks/flashcards/useFlashcardSearch'
import { SearchControls } from '@/components/flashcards/SearchControls'
import { SearchResults as FlashcardSearchResults } from '@/components/flashcards/SearchResults'
import { PaginationControls as SharedPaginationControls } from '@/components/shared/PaginationControls'
import { getCEFRColor } from '@/lib/utils/flashcardUtils'
// 重構後的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('操作失敗,請重試')
}
}
// 搜尋結果高亮函數
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 */}
<FlashcardSearchResults
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 */}
<SharedPaginationControls
currentPage={searchState.pagination.currentPage}
totalPages={searchState.pagination.totalPages}
pageSize={searchState.pagination.pageSize}
totalCount={searchState.pagination.totalCount}
hasNext={searchState.pagination.hasNext}
hasPrev={searchState.pagination.hasPrev}
onPageChange={searchActions.goToPage}
onPageSizeChange={searchActions.changePageSize}
/>
</div>
{/* Toast */}
<toast.ToastContainer />
</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>
)
}