2025-12-14 19:21:36 +00:00
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
import { toast } from 'sonner'
|
|
|
|
|
import { cardsApi } from '@/api/cards'
|
|
|
|
|
import type { GameCardDto } from '@/types/models'
|
|
|
|
|
import type { AxiosError } from 'axios'
|
|
|
|
|
import { Button } from './ui/button'
|
|
|
|
|
import { Input } from './ui/input'
|
|
|
|
|
import { Label } from './ui/label'
|
|
|
|
|
import { Textarea } from './ui/textarea'
|
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
|
|
|
|
|
import { Badge } from './ui/badge'
|
|
|
|
|
import { packsApi } from '@/api/packs'
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
|
|
|
|
|
import { ChevronLeft, ChevronRight, Save, X } from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
interface UploadedImage {
|
|
|
|
|
id: string
|
|
|
|
|
file: File
|
|
|
|
|
preview: string
|
|
|
|
|
base64: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CardData {
|
|
|
|
|
imageId: string
|
|
|
|
|
packId: string
|
|
|
|
|
original: string
|
|
|
|
|
translation: string
|
|
|
|
|
mnemo: string
|
|
|
|
|
transcription: string
|
|
|
|
|
transcriptionMnemo: string
|
|
|
|
|
back: string
|
|
|
|
|
imageBack?: string
|
|
|
|
|
isSaved: boolean
|
|
|
|
|
cardId?: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface BulkCardEditorProps {
|
|
|
|
|
images: UploadedImage[]
|
|
|
|
|
onComplete: () => void
|
|
|
|
|
onCancel: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorProps) {
|
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
const [currentIndex, setCurrentIndex] = useState(0)
|
|
|
|
|
const [cardsData, setCardsData] = useState<Map<string, CardData>>(new Map())
|
|
|
|
|
const [packsData, setPacksData] = useState<{ id: string; title: string }[]>([])
|
|
|
|
|
|
|
|
|
|
// Load packs
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
packsApi
|
|
|
|
|
.getPacks({ page: 1, limit: 1000, search: '' })
|
|
|
|
|
.then((response) => {
|
|
|
|
|
setPacksData(response.items.map((pack) => ({ id: pack.id, title: pack.title })))
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
toast.error('Failed to load packs')
|
|
|
|
|
})
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// Initialize cards data
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const initialData = new Map<string, CardData>()
|
|
|
|
|
images.forEach((image) => {
|
|
|
|
|
initialData.set(image.id, {
|
|
|
|
|
imageId: image.id,
|
|
|
|
|
packId: '',
|
|
|
|
|
original: '',
|
|
|
|
|
translation: '',
|
|
|
|
|
mnemo: '',
|
|
|
|
|
transcription: '',
|
|
|
|
|
transcriptionMnemo: '',
|
|
|
|
|
back: '',
|
|
|
|
|
imageBack: undefined,
|
|
|
|
|
isSaved: false,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
setCardsData(initialData)
|
|
|
|
|
}, [images])
|
|
|
|
|
|
|
|
|
|
const currentImage = images[currentIndex]
|
|
|
|
|
const currentCard = cardsData.get(currentImage?.id || '')
|
|
|
|
|
|
|
|
|
|
const updateMutation = useMutation({
|
|
|
|
|
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
|
|
|
|
onSuccess: (response) => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
|
|
|
|
const imageId = currentImage.id
|
|
|
|
|
setCardsData((prev) => {
|
|
|
|
|
const newData = new Map(prev)
|
|
|
|
|
const cardData = newData.get(imageId)
|
|
|
|
|
if (cardData && response.card) {
|
|
|
|
|
newData.set(imageId, {
|
|
|
|
|
...cardData,
|
|
|
|
|
isSaved: true,
|
|
|
|
|
cardId: response.card.id,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return newData
|
|
|
|
|
})
|
|
|
|
|
toast.success('Card saved successfully')
|
|
|
|
|
},
|
|
|
|
|
onError: (error: unknown) => {
|
|
|
|
|
const axiosError = error as AxiosError<{ message?: string }>
|
|
|
|
|
toast.error(axiosError.response?.data?.message || 'Failed to save card')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const handleFieldChange = (field: keyof CardData, value: string) => {
|
|
|
|
|
if (!currentCard) return
|
|
|
|
|
|
|
|
|
|
setCardsData((prev) => {
|
|
|
|
|
const newData = new Map(prev)
|
|
|
|
|
const cardData = newData.get(currentImage.id)
|
|
|
|
|
if (cardData) {
|
|
|
|
|
newData.set(currentImage.id, {
|
|
|
|
|
...cardData,
|
|
|
|
|
[field]: value,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return newData
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSave = () => {
|
|
|
|
|
if (!currentCard) return
|
|
|
|
|
|
|
|
|
|
if (!currentCard.original.trim() || !currentCard.translation.trim() || !currentCard.mnemo.trim()) {
|
|
|
|
|
toast.error('Original, translation and mnemo are required')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const image = images.find((img) => img.id === currentCard.imageId)
|
|
|
|
|
if (!image) return
|
|
|
|
|
|
|
|
|
|
const cardData: GameCardDto = {
|
|
|
|
|
id: currentCard.cardId || -1,
|
|
|
|
|
packId: currentCard.packId || undefined,
|
|
|
|
|
original: currentCard.original.trim(),
|
|
|
|
|
translation: currentCard.translation.trim(),
|
|
|
|
|
mnemo: currentCard.mnemo.trim(),
|
|
|
|
|
transcription: currentCard.transcription.trim() || undefined,
|
|
|
|
|
transcriptionMnemo: currentCard.transcriptionMnemo.trim() || undefined,
|
|
|
|
|
back: currentCard.back.trim() || undefined,
|
|
|
|
|
image: image.base64,
|
|
|
|
|
imageBack: currentCard.imageBack || undefined,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateMutation.mutate(cardData)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleNext = () => {
|
|
|
|
|
if (currentIndex < images.length - 1) {
|
|
|
|
|
setCurrentIndex(currentIndex + 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handlePrevious = () => {
|
|
|
|
|
if (currentIndex > 0) {
|
|
|
|
|
setCurrentIndex(currentIndex - 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const savedCount = Array.from(cardsData.values()).filter((card) => card.isSaved).length
|
|
|
|
|
const totalCount = images.length
|
|
|
|
|
|
|
|
|
|
if (!currentImage || !currentCard) {
|
|
|
|
|
return <div>Loading...</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-2xl font-bold">Fill Card Details</h2>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Card {currentIndex + 1} of {totalCount} • {savedCount} saved
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Badge variant={currentCard.isSaved ? 'default' : 'secondary'}>
|
|
|
|
|
{currentCard.isSaved ? 'Saved' : 'Not Saved'}
|
|
|
|
|
</Badge>
|
|
|
|
|
<Button variant="outline" onClick={onCancel}>
|
|
|
|
|
<X className="h-4 w-4 mr-2" />
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
|
{/* Image Preview */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Image Preview</CardTitle>
|
|
|
|
|
<CardDescription>{currentImage.file.name}</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
|
|
|
|
|
<img
|
|
|
|
|
src={currentImage.preview}
|
|
|
|
|
alt={currentImage.file.name}
|
|
|
|
|
className="w-full h-full object-contain"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Form */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Card Information</CardTitle>
|
|
|
|
|
<CardDescription>Fill in the details for this card</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="packId">Pack ID (optional)</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={currentCard.packId}
|
2025-12-14 19:34:43 +00:00
|
|
|
onValueChange={(value: string) => handleFieldChange('packId', value)}
|
2025-12-14 19:21:36 +00:00
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="Select a pack (optional)" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="">None</SelectItem>
|
|
|
|
|
{packsData.map((pack) => (
|
|
|
|
|
<SelectItem key={pack.id} value={pack.id}>
|
|
|
|
|
{pack.title}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="original">Original *</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="original"
|
|
|
|
|
value={currentCard.original}
|
|
|
|
|
onChange={(e) => handleFieldChange('original', e.target.value)}
|
|
|
|
|
placeholder="e.g. cerdo"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="translation">Translation *</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="translation"
|
|
|
|
|
value={currentCard.translation}
|
|
|
|
|
onChange={(e) => handleFieldChange('translation', e.target.value)}
|
|
|
|
|
placeholder="e.g. pig"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="mnemo">Mnemo *</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="mnemo"
|
|
|
|
|
value={currentCard.mnemo}
|
|
|
|
|
onChange={(e) => handleFieldChange('mnemo', e.target.value)}
|
|
|
|
|
placeholder="e.g. [pig] with heart"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="transcription">Transcription</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="transcription"
|
|
|
|
|
value={currentCard.transcription}
|
|
|
|
|
onChange={(e) => handleFieldChange('transcription', e.target.value)}
|
|
|
|
|
placeholder="e.g. sɛrdo"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="transcriptionMnemo">Transcription Mnemo</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="transcriptionMnemo"
|
|
|
|
|
value={currentCard.transcriptionMnemo}
|
|
|
|
|
onChange={(e) => handleFieldChange('transcriptionMnemo', e.target.value)}
|
|
|
|
|
placeholder="e.g. pig with heart"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="back">Back Side</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="back"
|
|
|
|
|
value={currentCard.back}
|
|
|
|
|
onChange={(e) => handleFieldChange('back', e.target.value)}
|
|
|
|
|
placeholder="Additional information on the back of the card"
|
|
|
|
|
rows={3}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center space-x-2 pt-4">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={handlePrevious}
|
|
|
|
|
disabled={currentIndex === 0}
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
|
|
|
|
Previous
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={updateMutation.isPending}
|
|
|
|
|
className="flex-1"
|
|
|
|
|
>
|
|
|
|
|
<Save className="h-4 w-4 mr-2" />
|
|
|
|
|
{updateMutation.isPending ? 'Saving...' : currentCard.isSaved ? 'Update' : 'Save'}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={handleNext}
|
|
|
|
|
disabled={currentIndex === images.length - 1}
|
|
|
|
|
>
|
|
|
|
|
Next
|
|
|
|
|
<ChevronRight className="h-4 w-4 ml-2" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Progress indicator */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
|
|
|
<span>Progress</span>
|
|
|
|
|
<span>{savedCount} / {totalCount} saved</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="w-full bg-muted rounded-full h-2">
|
|
|
|
|
<div
|
|
|
|
|
className="bg-primary h-2 rounded-full transition-all"
|
|
|
|
|
style={{ width: `${(savedCount / totalCount) * 100}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{savedCount === totalCount && (
|
|
|
|
|
<div className="mt-4 flex justify-end">
|
|
|
|
|
<Button onClick={onComplete}>
|
|
|
|
|
Complete
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|