513 lines
21 KiB
TypeScript
513 lines
21 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, use } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { Navigation } from '@/components/Navigation'
|
||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||
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 [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']
|
||
},
|
||
'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']
|
||
}
|
||
}
|
||
|
||
// 載入詞卡資料
|
||
useEffect(() => {
|
||
const loadFlashcard = async () => {
|
||
try {
|
||
setLoading(true)
|
||
|
||
// 首先檢查是否為假資料
|
||
if (mockCards[cardId]) {
|
||
setFlashcard(mockCards[cardId])
|
||
setEditedCard(mockCards[cardId])
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
// 載入真實詞卡
|
||
const result = await flashcardsService.getFlashcard(cardId)
|
||
if (result.success && result.data) {
|
||
setFlashcard(result.data)
|
||
setEditedCard(result.data)
|
||
} 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'
|
||
}
|
||
}
|
||
|
||
// 獲取例句圖片
|
||
const getExampleImage = (word: string) => {
|
||
const imageMap: {[key: string]: string} = {
|
||
'hello': '/images/examples/bring_up.png',
|
||
'elaborate': '/images/examples/instinct.png',
|
||
'beautiful': '/images/examples/warrant.png'
|
||
}
|
||
return imageMap[word?.toLowerCase()] || '/images/examples/bring_up.png'
|
||
}
|
||
|
||
// 處理收藏切換
|
||
const handleToggleFavorite = async () => {
|
||
if (!flashcard) return
|
||
|
||
try {
|
||
// 假資料處理
|
||
if (flashcard.id.startsWith('mock')) {
|
||
const updated = { ...flashcard, isFavorite: !flashcard.isFavorite }
|
||
setFlashcard(updated)
|
||
setEditedCard(updated)
|
||
alert(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}「${flashcard.word}」`)
|
||
return
|
||
}
|
||
|
||
// 真實API調用
|
||
const result = await flashcardsService.toggleFavorite(flashcard.id)
|
||
if (result.success) {
|
||
setFlashcard(prev => prev ? { ...prev, isFavorite: !prev.isFavorite } : null)
|
||
alert(`${flashcard.isFavorite ? '已取消收藏' : '已加入收藏'}「${flashcard.word}」`)
|
||
}
|
||
} catch (error) {
|
||
alert('操作失敗,請重試')
|
||
}
|
||
}
|
||
|
||
// 處理編輯保存
|
||
const handleSaveEdit = async () => {
|
||
if (!flashcard || !editedCard) return
|
||
|
||
try {
|
||
// 假資料處理
|
||
if (flashcard.id.startsWith('mock')) {
|
||
setFlashcard(editedCard)
|
||
setIsEditing(false)
|
||
alert('詞卡更新成功!')
|
||
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)
|
||
alert('詞卡更新成功!')
|
||
} else {
|
||
alert(result.error || '更新失敗')
|
||
}
|
||
} catch (error) {
|
||
alert('更新失敗,請重試')
|
||
}
|
||
}
|
||
|
||
// 處理刪除
|
||
const handleDelete = async () => {
|
||
if (!flashcard) return
|
||
|
||
if (!confirm(`確定要刪除詞卡「${flashcard.word}」嗎?`)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 假資料處理
|
||
if (flashcard.id.startsWith('mock')) {
|
||
alert('詞卡已刪除(模擬)')
|
||
router.push('/flashcards')
|
||
return
|
||
}
|
||
|
||
// 真實API調用
|
||
const result = await flashcardsService.deleteFlashcard(flashcard.id)
|
||
if (result.success) {
|
||
alert('詞卡已刪除')
|
||
router.push('/flashcards')
|
||
} else {
|
||
alert(result.error || '刪除失敗')
|
||
}
|
||
} catch (error) {
|
||
alert('刪除失敗,請重試')
|
||
}
|
||
}
|
||
|
||
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">
|
||
{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.word)}
|
||
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">{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>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |