dramaling-vocab-learning/frontend/app/flashcards/page.tsx

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>
)
}