267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useMemo } from 'react'
|
||
import { Modal } from './ui/Modal'
|
||
import { Check, Loader2 } from 'lucide-react'
|
||
|
||
interface GeneratedCard {
|
||
word: string
|
||
translation: string
|
||
definition: string
|
||
partOfSpeech?: string
|
||
pronunciation?: string
|
||
example?: string
|
||
exampleTranslation?: string
|
||
synonyms?: string[]
|
||
difficultyLevel?: string
|
||
}
|
||
|
||
interface CardSet {
|
||
id: string
|
||
name: string
|
||
color: string
|
||
}
|
||
|
||
interface CardSelectionDialogProps {
|
||
isOpen: boolean
|
||
generatedCards: GeneratedCard[]
|
||
cardSets: CardSet[]
|
||
onClose: () => void
|
||
onSave: (selectedCards: GeneratedCard[], cardSetId?: string) => Promise<void>
|
||
}
|
||
|
||
export const CardSelectionDialog: React.FC<CardSelectionDialogProps> = ({
|
||
isOpen,
|
||
generatedCards,
|
||
cardSets,
|
||
onClose,
|
||
onSave
|
||
}) => {
|
||
const [selectedCardIndices, setSelectedCardIndices] = useState<Set<number>>(
|
||
new Set(generatedCards.map((_, index) => index)) // 預設全選
|
||
)
|
||
const [selectedCardSetId, setSelectedCardSetId] = useState<string>('')
|
||
const [isSaving, setIsSaving] = useState(false)
|
||
|
||
const selectedCount = selectedCardIndices.size
|
||
|
||
const handleSelectAll = (checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedCardIndices(new Set(generatedCards.map((_, index) => index)))
|
||
} else {
|
||
setSelectedCardIndices(new Set())
|
||
}
|
||
}
|
||
|
||
const handleCardToggle = (index: number, checked: boolean) => {
|
||
const newSelected = new Set(selectedCardIndices)
|
||
if (checked) {
|
||
newSelected.add(index)
|
||
} else {
|
||
newSelected.delete(index)
|
||
}
|
||
setSelectedCardIndices(newSelected)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (selectedCount === 0) {
|
||
alert('請至少選擇一張詞卡')
|
||
return
|
||
}
|
||
|
||
setIsSaving(true)
|
||
try {
|
||
const selectedCards = Array.from(selectedCardIndices).map(index => generatedCards[index])
|
||
await onSave(selectedCards, selectedCardSetId || undefined)
|
||
} catch (error) {
|
||
console.error('Save error:', error)
|
||
alert(`保存失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const isAllSelected = selectedCount === generatedCards.length
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
title="選擇要保存的詞卡"
|
||
size="xl"
|
||
>
|
||
<div className="p-6 space-y-6">
|
||
{/* 操作工具列 */}
|
||
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
|
||
<div className="flex items-center space-x-4">
|
||
<label className="flex items-center space-x-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={isAllSelected}
|
||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm font-medium">
|
||
全選 ({selectedCount}/{generatedCards.length})
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-sm text-gray-600">保存到:</span>
|
||
<select
|
||
value={selectedCardSetId}
|
||
onChange={(e) => setSelectedCardSetId(e.target.value)}
|
||
className="border border-gray-300 rounded-md px-3 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
>
|
||
<option value="">預設卡組</option>
|
||
{cardSets.map(set => (
|
||
<option key={set.id} value={set.id}>
|
||
{set.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 詞卡列表 */}
|
||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||
{generatedCards.map((card, index) => (
|
||
<CardPreviewItem
|
||
key={index}
|
||
card={card}
|
||
index={index}
|
||
isSelected={selectedCardIndices.has(index)}
|
||
onToggle={(checked) => handleCardToggle(index, checked)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* 底部操作按鈕 */}
|
||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||
<button
|
||
onClick={onClose}
|
||
disabled={isSaving}
|
||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors disabled:opacity-50"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={selectedCount === 0 || isSaving}
|
||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{isSaving ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
<span>保存中...</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Check className="w-4 h-4" />
|
||
<span>保存 {selectedCount} 張詞卡</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
interface CardPreviewItemProps {
|
||
card: GeneratedCard
|
||
index: number
|
||
isSelected: boolean
|
||
onToggle: (checked: boolean) => void
|
||
}
|
||
|
||
const CardPreviewItem: React.FC<CardPreviewItemProps> = ({
|
||
card,
|
||
index,
|
||
isSelected,
|
||
onToggle
|
||
}) => {
|
||
return (
|
||
<div className={`
|
||
border rounded-lg p-4 transition-all duration-200
|
||
${isSelected
|
||
? 'border-blue-500 bg-blue-50 shadow-sm'
|
||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||
}
|
||
`}>
|
||
<div className="flex items-start space-x-3">
|
||
<label className="flex items-center cursor-pointer mt-1">
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={(e) => onToggle(e.target.checked)}
|
||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||
/>
|
||
</label>
|
||
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-lg font-semibold text-gray-900">
|
||
{card.word}
|
||
</h3>
|
||
{card.difficultyLevel && (
|
||
<span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded">
|
||
{card.difficultyLevel}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||
<div>
|
||
<span className="font-medium text-gray-700">翻譯:</span>
|
||
<span className="text-gray-900">{card.translation}</span>
|
||
</div>
|
||
|
||
{card.partOfSpeech && (
|
||
<div>
|
||
<span className="font-medium text-gray-700">詞性:</span>
|
||
<span className="text-gray-900">{card.partOfSpeech}</span>
|
||
</div>
|
||
)}
|
||
|
||
{card.pronunciation && (
|
||
<div>
|
||
<span className="font-medium text-gray-700">發音:</span>
|
||
<span className="text-gray-900">{card.pronunciation}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<span className="font-medium text-gray-700">定義:</span>
|
||
<p className="text-gray-900 leading-relaxed">{card.definition}</p>
|
||
</div>
|
||
|
||
{card.example && (
|
||
<div>
|
||
<span className="font-medium text-gray-700">例句:</span>
|
||
<p className="text-gray-900 italic">{card.example}</p>
|
||
{card.exampleTranslation && (
|
||
<p className="text-gray-600 text-sm mt-1">{card.exampleTranslation}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{card.synonyms && card.synonyms.length > 0 && (
|
||
<div>
|
||
<span className="font-medium text-gray-700">同義詞:</span>
|
||
<div className="flex flex-wrap gap-1 mt-1">
|
||
{card.synonyms.map((synonym, idx) => (
|
||
<span key={idx} className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
|
||
{synonym}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |