384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
'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>
|
||
)
|
||
} |