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

551 lines
23 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, use } from 'react'
import { useRouter } from 'next/navigation'
import { Navigation } from '@/components/Navigation'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { useToast } from '@/components/Toast'
import { flashcardsService, type Flashcard } from '@/lib/services/flashcards'
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()
const [flashcard, setFlashcard] = useState<Flashcard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editedCard, setEditedCard] = useState<any>(null)
// 假資料 - 用於展示效果
const mockCards: {[key: string]: any} = {
'mock1': {
id: 'mock1',
word: 'hello',
translation: '你好',
partOfSpeech: 'interjection',
pronunciation: '/həˈloʊ/',
definition: 'A greeting word used when meeting someone or beginning a phone conversation',
example: 'Hello, how are you today?',
exampleTranslation: '你好,你今天怎麼樣?',
masteryLevel: 95,
timesReviewed: 15,
isFavorite: true,
nextReviewDate: '2025-09-21',
cardSet: { name: '基礎詞彙', color: 'bg-blue-500' },
difficultyLevel: 'A1',
createdAt: '2025-09-17',
synonyms: ['hi', 'greetings', 'good day'],
// 添加圖片欄位
exampleImages: [],
hasExampleImage: false,
primaryImageUrl: null
},
'mock2': {
id: 'mock2',
word: 'elaborate',
translation: '詳細說明',
partOfSpeech: 'verb',
pronunciation: '/ɪˈlæbərət/',
definition: 'To explain something in more detail; to develop or present a theory, policy, or system in further detail',
example: 'Could you elaborate on your proposal?',
exampleTranslation: '你能詳細說明一下你的提案嗎?',
masteryLevel: 45,
timesReviewed: 5,
isFavorite: false,
nextReviewDate: '2025-09-19',
cardSet: { name: '高級詞彙', color: 'bg-purple-500' },
difficultyLevel: 'B2',
createdAt: '2025-09-14',
synonyms: ['explain', 'detail', 'expand', 'clarify'],
// 添加圖片欄位
exampleImages: [],
hasExampleImage: false,
primaryImageUrl: null
}
}
// 載入詞卡資料
useEffect(() => {
const loadFlashcard = async () => {
try {
setLoading(true)
// 首先檢查是否為假資料
if (mockCards[cardId]) {
setFlashcard(mockCards[cardId])
setEditedCard(mockCards[cardId])
setLoading(false)
return
}
// 載入真實詞卡 - 使用列表 API 然後找到對應詞卡 (因為列表 API 有圖片資訊)
const result = await flashcardsService.getFlashcards()
if (result.success && result.data) {
const targetCard = result.data.flashcards.find(card => card.id === cardId)
if (targetCard) {
setFlashcard(targetCard)
setEditedCard(targetCard)
} else {
throw new Error('詞卡不存在')
}
} else {
throw new Error(result.error || '載入詞卡失敗')
}
} catch (err) {
setError('載入詞卡時發生錯誤')
} finally {
setLoading(false)
}
}
loadFlashcard()
}, [cardId])
// 獲取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'
}
}
// 獲取例句圖片 - 使用 API 資料
const getExampleImage = (card: Flashcard): string | null => {
return card.primaryImageUrl || null
}
// 檢查詞彙是否有例句圖片 - 使用 API 資料
const hasExampleImage = (card: Flashcard): boolean => {
return card.hasExampleImage
}
// 詞性簡寫轉換
const getPartOfSpeechDisplay = (partOfSpeech: string): string => {
const shortMap: {[key: string]: string} = {
'noun': 'n.',
'verb': 'v.',
'adjective': 'adj.',
'adverb': 'adv.',
'preposition': 'prep.',
'interjection': 'int.',
'phrase': 'phr.'
}
// 處理複合詞性 (如 "preposition/adverb")
if (partOfSpeech?.includes('/')) {
return partOfSpeech.split('/').map(p => shortMap[p.trim()] || p.trim()).join('/')
}
return shortMap[partOfSpeech] || partOfSpeech || ''
}
// 處理收藏切換
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,
difficultyLevel: editedCard.difficultyLevel
})
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('刪除失敗,請重試')
}
}
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 as any).difficultyLevel || 'A1')}`}>
{(flashcard as any).difficultyLevel || 'A1'}
</span>
</div>
{/* 標題區 */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 border-b border-blue-200">
<div className="pr-16">
<h1 className="text-4xl font-bold text-gray-900 mb-3">{flashcard.word}</h1>
<div className="flex items-center gap-3">
<span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
{getPartOfSpeechDisplay(flashcard.partOfSpeech)}
</span>
<span className="text-lg text-gray-600">{flashcard.pronunciation}</span>
<button className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</button>
</div>
</div>
{/* 學習統計 */}
<div className="grid grid-cols-3 gap-4 text-center mt-4">
<div className="bg-white bg-opacity-60 rounded-lg p-3">
<div className="text-2xl font-bold text-gray-900">{flashcard.masteryLevel}%</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="bg-white bg-opacity-60 rounded-lg p-3">
<div className="text-2xl font-bold text-gray-900">{flashcard.timesReviewed}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="bg-white bg-opacity-60 rounded-lg p-3">
<div className="text-2xl font-bold text-gray-900">
{Math.ceil((new Date(flashcard.nextReviewDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}
</div>
<div className="text-sm text-gray-600"></div>
</div>
</div>
</div>
{/* 內容區 - 學習卡片風格 */}
<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">
<img
src={getExampleImage(flashcard) || '/images/examples/bring_up.png'}
alt={`${flashcard.word} example`}
className="w-full max-w-md mx-auto rounded-lg border border-blue-300"
/>
</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 className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</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>
)
}