349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import Link from 'next/link'
|
|
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
|
import { Navigation } from '@/components/Navigation'
|
|
import { FlashcardForm } from '@/components/FlashcardForm'
|
|
import { flashcardsService, type CardSet, type Flashcard } from '@/lib/services/flashcards'
|
|
|
|
function FlashcardsContent() {
|
|
const [activeTab, setActiveTab] = useState('my-cards')
|
|
const [selectedSet, setSelectedSet] = useState<string | null>(null)
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
|
|
// Real data from API
|
|
const [cardSets, setCardSets] = useState<CardSet[]>([])
|
|
const [flashcards, setFlashcards] = useState<Flashcard[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Form states
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [editingCard, setEditingCard] = useState<Flashcard | null>(null)
|
|
|
|
// Load data from API
|
|
useEffect(() => {
|
|
loadCardSets()
|
|
loadFlashcards()
|
|
}, [])
|
|
|
|
const loadCardSets = async () => {
|
|
try {
|
|
const result = await flashcardsService.getCardSets()
|
|
if (result.success && result.data) {
|
|
setCardSets(result.data.sets)
|
|
} else {
|
|
setError(result.error || 'Failed to load card sets')
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to load card sets')
|
|
}
|
|
}
|
|
|
|
const loadFlashcards = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const result = await flashcardsService.getFlashcards(selectedSet || undefined)
|
|
if (result.success && result.data) {
|
|
setFlashcards(result.data.flashcards)
|
|
} else {
|
|
setError(result.error || 'Failed to load flashcards')
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to load flashcards')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Reload flashcards when selectedSet changes
|
|
useEffect(() => {
|
|
loadFlashcards()
|
|
}, [selectedSet])
|
|
|
|
// Handle form operations
|
|
const handleFormSuccess = () => {
|
|
setShowForm(false)
|
|
setEditingCard(null)
|
|
loadFlashcards()
|
|
loadCardSets()
|
|
}
|
|
|
|
const handleEdit = (card: Flashcard) => {
|
|
setEditingCard(card)
|
|
setShowForm(true)
|
|
}
|
|
|
|
const handleDelete = async (card: Flashcard) => {
|
|
if (!confirm(`確定要刪除詞卡「${card.word}」嗎?`)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const result = await flashcardsService.deleteFlashcard(card.id)
|
|
if (result.success) {
|
|
loadFlashcards()
|
|
loadCardSets()
|
|
} else {
|
|
alert(result.error || '刪除失敗')
|
|
}
|
|
} catch (err) {
|
|
alert('刪除失敗,請重試')
|
|
}
|
|
}
|
|
|
|
// Filter data
|
|
const filteredSets = cardSets.filter(set =>
|
|
set.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
set.description.toLowerCase().includes(searchTerm.toLowerCase())
|
|
)
|
|
|
|
const filteredCards = flashcards.filter(card => {
|
|
if (searchTerm) {
|
|
return card.word?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
card.translation?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Add loading and error states
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-lg">載入中...</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-red-600">{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 justify-between items-center mb-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">詞卡管理</h1>
|
|
<p className="mt-1 text-sm text-gray-500">管理你的詞卡集合</p>
|
|
</div>
|
|
<div className="flex items-center space-x-4">
|
|
<button
|
|
onClick={() => setShowForm(true)}
|
|
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors"
|
|
>
|
|
新增詞卡
|
|
</button>
|
|
<Link
|
|
href="/generate"
|
|
className="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"
|
|
>
|
|
AI 生成詞卡
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex space-x-8 mb-6 border-b border-gray-200">
|
|
<button
|
|
onClick={() => setActiveTab('my-cards')}
|
|
className={`pb-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === 'my-cards'
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
我的卡組 ({filteredSets.length})
|
|
</button>
|
|
<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'
|
|
}`}
|
|
>
|
|
所有詞卡 ({filteredCards.length})
|
|
</button>
|
|
</div>
|
|
{/* Search */}
|
|
<div className="mb-6">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="搜尋詞卡或卡組..."
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
/>
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card Sets Tab */}
|
|
{activeTab === 'my-cards' && (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-semibold">共 {filteredSets.length} 個卡組</h3>
|
|
</div>
|
|
|
|
{filteredSets.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500 mb-4">還沒有詞卡集合</p>
|
|
<Link
|
|
href="/generate"
|
|
className="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"
|
|
>
|
|
創建第一個卡組
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filteredSets.map(set => (
|
|
<div
|
|
key={set.id}
|
|
className="border rounded-lg hover:shadow-lg transition-shadow cursor-pointer"
|
|
onClick={() => {
|
|
setSelectedSet(set.id)
|
|
setActiveTab('all-cards')
|
|
}}
|
|
>
|
|
<div className={`${set.color} text-white p-4 rounded-t-lg`}>
|
|
<h4 className="font-semibold text-lg">{set.name}</h4>
|
|
<p className="text-sm opacity-90">{set.description}</p>
|
|
</div>
|
|
<div className="p-4 bg-white rounded-b-lg">
|
|
<div className="flex justify-between items-center text-sm text-gray-600">
|
|
<span>{set.cardCount} 張詞卡</span>
|
|
<span>進度: {set.progress}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* All Cards Tab */}
|
|
{activeTab === 'all-cards' && (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-semibold">共 {filteredCards.length} 個詞卡</h3>
|
|
{selectedSet && (
|
|
<button
|
|
onClick={() => setSelectedSet(null)}
|
|
className="text-sm text-gray-600 hover:text-gray-900"
|
|
>
|
|
顯示所有詞卡
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{filteredCards.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500 mb-4">沒有找到詞卡</p>
|
|
<Link
|
|
href="/generate"
|
|
className="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"
|
|
>
|
|
創建新詞卡
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{filteredCards.map(card => (
|
|
<div key={card.id} className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center space-x-2">
|
|
<h4 className="font-semibold text-lg">{card.word || '未設定'}</h4>
|
|
<span className="text-sm text-gray-500">{card.partOfSpeech}</span>
|
|
{card.pronunciation && (
|
|
<span className="text-sm text-blue-600">{card.pronunciation}</span>
|
|
)}
|
|
</div>
|
|
<p className="text-gray-700 mt-1">{card.translation || '未設定'}</p>
|
|
{card.example && (
|
|
<p className="text-sm text-gray-600 mt-2 italic">例句: {card.example}</p>
|
|
)}
|
|
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
|
|
<span>卡組: {card.cardSet.name}</span>
|
|
<span>熟練度: {card.masteryLevel}/5</span>
|
|
<span>複習: {card.timesReviewed} 次</span>
|
|
<span>下次複習: {new Date(card.nextReviewDate).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={() => handleEdit(card)}
|
|
className="text-blue-600 hover:text-blue-800 text-sm"
|
|
>
|
|
編輯
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(card)}
|
|
className="text-red-600 hover:text-red-800 text-sm"
|
|
>
|
|
刪除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Flashcard Form Modal */}
|
|
{showForm && (
|
|
<FlashcardForm
|
|
cardSets={cardSets}
|
|
initialData={editingCard ? {
|
|
id: editingCard.id,
|
|
cardSetId: editingCard.cardSet ? cardSets.find(cs => cs.name === editingCard.cardSet.name)?.id || cardSets[0]?.id : cardSets[0]?.id,
|
|
english: editingCard.word,
|
|
chinese: editingCard.translation,
|
|
pronunciation: editingCard.pronunciation,
|
|
partOfSpeech: editingCard.partOfSpeech,
|
|
example: editingCard.example,
|
|
} : undefined}
|
|
isEdit={!!editingCard}
|
|
onSuccess={handleFormSuccess}
|
|
onCancel={() => {
|
|
setShowForm(false)
|
|
setEditingCard(null)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function FlashcardsPage() {
|
|
return (
|
|
<ProtectedRoute>
|
|
<FlashcardsContent />
|
|
</ProtectedRoute>
|
|
)
|
|
} |