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

409 lines
15 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 } 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) {
if (result.data.sets.length === 0) {
// 如果沒有卡組,確保創建預設卡組
const ensureResult = await flashcardsService.ensureDefaultCardSet()
if (ensureResult.success) {
// 重新載入卡組
const retryResult = await flashcardsService.getCardSets()
if (retryResult.success && retryResult.data) {
setCardSets(retryResult.data.sets)
} else {
setError('Failed to load card sets after creating default')
}
} else {
setError(ensureResult.error || 'Failed to create default card set')
}
} else {
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>
<button
onClick={() => {
const defaultSet = cardSets.find(set => set.isDefault)
if (defaultSet) {
setSelectedSet(defaultSet.id)
setActiveTab('all-cards')
}
}}
className={`pb-4 px-1 border-b-2 font-medium text-sm ${
selectedSet && cardSets.find(set => set.id === selectedSet)?.isDefault
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
📂
</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 ${
set.isDefault ? 'ring-2 ring-gray-300' : ''
}`}
onClick={() => {
setSelectedSet(set.id)
setActiveTab('all-cards')
}}
>
<div className={`${set.isDefault ? 'bg-slate-700' : set.color} text-white p-4 rounded-t-lg`}>
<div className="flex items-center space-x-2">
{set.isDefault && <span>📂</span>}
<h4 className="font-semibold text-lg">
{set.name}
{set.isDefault && <span className="text-xs ml-2 opacity-75">()</span>}
</h4>
</div>
<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>
{/* 未分類提醒 */}
{selectedSet && cardSets.find(set => set.id === selectedSet)?.isDefault && filteredCards.length > 15 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex items-center space-x-2">
<span className="text-blue-600">💡</span>
<div className="flex-1">
<p className="text-blue-800 text-sm">
{filteredCards.length}
</p>
</div>
<button
onClick={() => setActiveTab('my-cards')}
className="text-blue-600 text-sm font-medium hover:text-blue-800"
>
</button>
</div>
</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>
)
}