dramaling-vocab-learning/frontend/app/flashcards/[id]/page.tsx

543 lines
23 KiB
TypeScript

'use client'
import { useState, useEffect, use } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Navigation } from '@/components/shared/Navigation'
import { ProtectedRoute } from '@/components/shared/ProtectedRoute'
import { useToast } from '@/components/shared/Toast'
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
import { imageGenerationService } from '@/lib/services/imageGeneration'
import { getPartOfSpeechDisplay, getCEFRColor, getFlashcardImageUrl } from '@/lib/utils/flashcardUtils'
import { useTTSPlayer } from '@/hooks/shared/useTTSPlayer'
import { useFlashcardDetailData } from '@/hooks/flashcards/useFlashcardDetailData'
import { TTSButton } from '@/components/shared/TTSButton'
import { FlashcardDetailHeader } from '@/components/flashcards/FlashcardDetailHeader'
import { FlashcardContentBlocks } from '@/components/flashcards/FlashcardContentBlocks'
interface FlashcardDetailPageProps {
params: Promise<{
id: string
}>
}
export default function FlashcardDetailPage({ params }: FlashcardDetailPageProps) {
const { id } = use(params)
return (
<ProtectedRoute>
<FlashcardDetailContent cardId={id} />
</ProtectedRoute>
)
}
function FlashcardDetailContent({ cardId }: { cardId: string }) {
const router = useRouter()
const toast = useToast()
// 使用數據管理Hook
const {
flashcard,
loading,
error,
isEditing,
editedCard,
setFlashcard,
setIsEditing,
setEditedCard
} = useFlashcardDetailData(cardId)
// 圖片生成狀態
const [isGeneratingImage, setIsGeneratingImage] = useState(false)
const [generationProgress, setGenerationProgress] = useState<string>('')
// 使用TTS Hook
const { isPlayingWord, isPlayingExample, toggleWordTTS, toggleExampleTTS } = useTTSPlayer()
// 編輯變更處理函數
const handleEditChange = (field: string, value: string) => {
setEditedCard((prev: any) => ({ ...prev, [field]: value }))
}
// 處理收藏切換
const handleToggleFavorite = async () => {
if (!flashcard) return
try {
// 假資料處理
if (flashcard.id.startsWith('mock')) {
const updated = { ...flashcard, isFavorite: !flashcard.isFavorite }
setFlashcard(updated)
setEditedCard(updated)
toast.success(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}${flashcard.word}`)
return
}
// 真實API調用
const result = await flashcardsService.toggleFavorite(flashcard.id)
if (result.success) {
setFlashcard(prev => prev ? { ...prev, isFavorite: !prev.isFavorite } : null)
toast.success(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}${flashcard.word}`)
}
} catch (error) {
toast.error('操作失敗,請重試')
}
}
// 處理編輯保存
const handleSaveEdit = async () => {
if (!flashcard || !editedCard) return
try {
// 假資料處理
if (flashcard.id.startsWith('mock')) {
setFlashcard(editedCard)
setIsEditing(false)
toast.success('詞卡更新成功!')
return
}
// 真實API調用
const result = await flashcardsService.updateFlashcard(flashcard.id, {
word: editedCard.word,
translation: editedCard.translation,
definition: editedCard.definition,
pronunciation: editedCard.pronunciation,
partOfSpeech: editedCard.partOfSpeech,
example: editedCard.example,
exampleTranslation: editedCard.exampleTranslation,
cefr: editedCard.cefr
})
if (result.success) {
setFlashcard(editedCard)
setIsEditing(false)
toast.success('詞卡更新成功!')
} else {
toast.error(result.error || '更新失敗')
}
} catch (error) {
toast.error('更新失敗,請重試')
}
}
// 處理刪除
const handleDelete = async () => {
if (!flashcard) return
if (!confirm(`確定要刪除詞卡「${flashcard.word}」嗎?`)) {
return
}
try {
// 假資料處理
if (flashcard.id.startsWith('mock')) {
toast.success('詞卡已刪除(模擬)')
router.push('/flashcards')
return
}
// 真實API調用
const result = await flashcardsService.deleteFlashcard(flashcard.id)
if (result.success) {
toast.success('詞卡已刪除')
router.push('/flashcards')
} else {
toast.error(result.error || '刪除失敗')
}
} catch (error) {
toast.error('刪除失敗,請重試')
}
}
// 處理圖片生成
const handleGenerateImage = async () => {
if (!flashcard || isGeneratingImage) return
try {
setIsGeneratingImage(true)
setGenerationProgress('啟動生成中...')
toast.info(`開始為「${flashcard.word}」生成例句圖片...`)
const generateResult = await imageGenerationService.generateImage(flashcard.id)
if (!generateResult.success || !generateResult.data) {
throw new Error(generateResult.error || '啟動生成失敗')
}
const requestId = generateResult.data.requestId
setGenerationProgress('Gemini 生成描述中...')
const finalStatus = await imageGenerationService.pollUntilComplete(
requestId,
(status: any) => {
const stage = status.stages.gemini.status === 'completed'
? 'Replicate 生成圖片中...' : 'Gemini 生成描述中...'
setGenerationProgress(stage)
},
5
)
if (finalStatus.overallStatus === 'completed') {
setGenerationProgress('生成完成,載入中...')
// 重新載入詞卡資料
const result = await flashcardsService.getFlashcard(cardId)
if (result.success && result.data) {
setFlashcard(result.data)
setEditedCard(result.data)
}
toast.success(`${flashcard.word}」的例句圖片生成完成!`)
} else {
throw new Error('圖片生成未完成')
}
} catch (error: any) {
toast.error(`圖片生成失敗: ${error.message || '未知錯誤'}`)
} finally {
setIsGeneratingImage(false)
setGenerationProgress('')
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-lg text-gray-600">...</div>
</div>
)
}
if (error || !flashcard) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="text-red-600 text-lg mb-4">{error || '詞卡不存在'}</div>
<button
onClick={() => router.push('/flashcards')}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
>
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<Navigation />
<div className="max-w-4xl mx-auto px-4 py-8">
{/* 導航欄 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/flashcards')}
className="text-gray-600 hover:text-gray-900 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
</div>
{/* 主要詞卡內容 - 學習功能風格 */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden mb-6 relative">
{/* CEFR標籤 - 右上角 */}
<div className="absolute top-4 right-4 z-10">
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getCEFRColor(flashcard.cefr || 'A1')}`}>
{flashcard.cefr || 'A1'}
</span>
</div>
{/* 標題區 */}
<FlashcardDetailHeader
flashcard={flashcard}
isPlayingWord={isPlayingWord}
isPlayingExample={isPlayingExample}
onToggleWordTTS={toggleWordTTS}
/>
{/* 內容區 - 學習卡片風格 */}
<div className="p-6 space-y-6">
{/* 翻譯區塊 */}
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
<h3 className="font-semibold text-green-900 mb-3 text-left"></h3>
{isEditing ? (
<input
type="text"
value={editedCard?.translation || ''}
onChange={(e) => setEditedCard((prev: any) => ({ ...prev, translation: e.target.value }))}
className="w-full p-3 border border-green-300 rounded-lg focus:ring-2 focus:ring-green-500 bg-white"
placeholder="輸入中文翻譯"
/>
) : (
<p className="text-green-800 font-medium text-left text-lg">
{flashcard.translation}
</p>
)}
</div>
{/* 定義區塊 */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h3 className="font-semibold text-gray-900 mb-3 text-left"></h3>
{isEditing ? (
<textarea
value={editedCard?.definition || ''}
onChange={(e) => setEditedCard((prev: any) => ({ ...prev, definition: e.target.value }))}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white h-20 resize-none"
placeholder="輸入英文定義"
/>
) : (
<p className="text-gray-700 text-left leading-relaxed">
{flashcard.definition}
</p>
)}
</div>
{/* 例句區塊 */}
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
<h3 className="font-semibold text-blue-900 mb-3 text-left"></h3>
{/* 例句圖片 */}
<div className="mb-4 relative">
{getFlashcardImageUrl(flashcard) ? (
<img
src={getFlashcardImageUrl(flashcard)!}
alt={`${flashcard.word} example`}
className="w-full max-w-md mx-auto rounded-lg border border-blue-300"
/>
) : (
<div className="w-full max-w-md mx-auto h-48 bg-gray-100 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<div className="text-center text-gray-500">
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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>
<p className="text-sm"></p>
<button
onClick={handleGenerateImage}
disabled={isGeneratingImage}
className="mt-2 px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors disabled:opacity-50"
>
{isGeneratingImage ? generationProgress : '生成圖片'}
</button>
</div>
</div>
)}
{/* 圖片上的生成按鈕 */}
{getFlashcardImageUrl(flashcard) && !isGeneratingImage && (
<button
onClick={handleGenerateImage}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-white bg-opacity-90 text-gray-700 rounded-md hover:bg-opacity-100 transition-all shadow-sm"
>
</button>
)}
{/* 生成進度覆蓋 */}
{isGeneratingImage && getFlashcardImageUrl(flashcard) && (
<div className="absolute inset-0 bg-white bg-opacity-90 rounded-lg flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-sm text-gray-600">{generationProgress}</p>
</div>
</div>
)}
</div>
<div className="space-y-3">
{isEditing ? (
<>
<textarea
value={editedCard?.example || ''}
onChange={(e) => setEditedCard((prev: any) => ({ ...prev, example: e.target.value }))}
className="w-full p-3 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white h-16 resize-none"
placeholder="輸入英文例句"
/>
<textarea
value={editedCard?.exampleTranslation || ''}
onChange={(e) => setEditedCard((prev: any) => ({ ...prev, exampleTranslation: e.target.value }))}
className="w-full p-3 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 bg-white h-16 resize-none"
placeholder="輸入例句翻譯"
/>
</>
) : (
<>
<div className="relative">
<p className="text-blue-800 text-left italic text-lg pr-12">
"{flashcard.example}"
</p>
<div className="absolute bottom-0 right-0">
<button
onClick={() => toggleExampleTTS(flashcard.example, 'en-US')}
disabled={isPlayingWord}
title={isPlayingExample ? "點擊停止播放" : "點擊聽例句發音"}
aria-label={isPlayingExample ? `停止播放例句:${flashcard.example}` : `播放例句發音:${flashcard.example}`}
className={`group relative w-12 h-12 rounded-full shadow-lg transform transition-all duration-200
${isPlayingExample
? 'bg-gradient-to-br from-green-500 to-green-600 shadow-green-200 scale-105'
: 'bg-gradient-to-br from-blue-500 to-blue-600 hover:shadow-xl hover:scale-105 shadow-blue-200'
} ${isPlayingWord ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
`}
>
{/* 播放中波紋效果 */}
{isPlayingExample && (
<div className="absolute inset-0 bg-blue-400 rounded-full animate-ping opacity-40"></div>
)}
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{isPlayingExample ? (
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
<svg className="w-6 h-6 text-white group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</div>
{/* 懸停提示光環 */}
{!isPlayingWord && !isPlayingExample && (
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
)}
</button>
</div>
</div>
<p className="text-blue-700 text-left text-base">
"{flashcard.exampleTranslation}"
</p>
</>
)}
</div>
</div>
{/* 同義詞區塊 */}
{(flashcard as any).synonyms && (flashcard as any).synonyms.length > 0 && (
<div className="bg-purple-50 rounded-lg p-4 border border-purple-200">
<h3 className="font-semibold text-purple-900 mb-3 text-left"></h3>
<div className="flex flex-wrap gap-2">
{(flashcard as any).synonyms.map((synonym: string, index: number) => (
<span
key={index}
className="bg-white text-purple-700 px-3 py-1 rounded-full text-sm border border-purple-200 font-medium"
>
{synonym}
</span>
))}
</div>
</div>
)}
{/* 詞卡資訊 */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h3 className="font-semibold text-gray-900 mb-3 text-left"></h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{getPartOfSpeechDisplay(flashcard.partOfSpeech)}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{new Date(flashcard.createdAt).toLocaleDateString()}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{new Date(flashcard.nextReviewDate).toLocaleDateString()}</span>
</div>
<div>
<span className="text-gray-600">:</span>
<span className="ml-2 font-medium">{flashcard.timesReviewed} </span>
</div>
</div>
</div>
</div>
{/* 編輯模式的操作按鈕 */}
{isEditing && (
<div className="px-6 pb-6">
<div className="flex gap-3">
<button
onClick={handleSaveEdit}
className="flex-1 bg-green-600 text-white py-3 rounded-lg font-medium hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
onClick={() => {
setIsEditing(false)
setEditedCard(flashcard)
}}
className="flex-1 bg-gray-500 text-white py-3 rounded-lg font-medium hover:bg-gray-600 transition-colors"
>
</button>
</div>
</div>
)}
</div>
{/* 底部操作區 - 平均延展按鈕 */}
<div className="flex gap-3">
<button
onClick={handleToggleFavorite}
className={`flex-1 py-3 rounded-lg font-medium transition-colors ${
flashcard.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'
}`}
>
<div className="flex items-center justify-center gap-2">
<svg className="w-4 h-4" fill={flashcard.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>
{flashcard.isFavorite ? '已收藏' : '收藏'}
</div>
</button>
<button
onClick={() => setIsEditing(!isEditing)}
className={`flex-1 py-3 rounded-lg font-medium transition-colors ${
isEditing
? 'bg-gray-100 text-gray-700 border border-gray-300'
: 'bg-blue-100 text-blue-700 border border-blue-300 hover:bg-blue-200'
}`}
>
<div className="flex items-center justify-center gap-2">
<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>
{isEditing ? '取消編輯' : '編輯詞卡'}
</div>
</button>
<button
onClick={handleDelete}
className="flex-1 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
<div className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" 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>
</div>
</button>
</div>
{/* Toast 通知系統 */}
<toast.ToastContainer />
</div>
</div>
)
}