Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
import { cardsApi, CardsApiError } from '@/api/cards'
|
|
import { packsApi, PacksApiError } from '@/api/packs'
|
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
|
import type { GameCardDto } from '@/types/models'
|
|
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 { 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: 100, search: '' })
|
|
.then((response) => {
|
|
try {
|
|
if (response && response.items && Array.isArray(response.items)) {
|
|
const packs = response.items.map((pack: any) => ({
|
|
id: String(pack.id || ''),
|
|
title: String(pack.title || pack.id || '')
|
|
}))
|
|
setPacksData(packs)
|
|
} else {
|
|
console.warn('Unexpected packs response format:', response)
|
|
setPacksData([])
|
|
}
|
|
} catch (error) {
|
|
console.error('Error processing packs response:', error)
|
|
setPacksData([])
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
const errorMessage = error instanceof PacksApiError
|
|
? error.message
|
|
: formatApiError(error)
|
|
console.error('Failed to load packs:', error)
|
|
toast.error(`Failed to load packs: ${errorMessage}`)
|
|
// Initialize with empty array so component doesn't crash
|
|
setPacksData([])
|
|
})
|
|
}, [])
|
|
|
|
// Initialize cards data
|
|
useEffect(() => {
|
|
if (images.length > 0) {
|
|
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 = currentImage ? cardsData.get(currentImage.id) : undefined
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
|
onSuccess: (response) => {
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
|
if (currentImage) {
|
|
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 errorMessage = error instanceof CardsApiError
|
|
? error.message
|
|
: getDetailedErrorMessage(error, 'save', 'card')
|
|
toast.error(errorMessage)
|
|
console.error('Error saving card:', error)
|
|
},
|
|
})
|
|
|
|
const handleFieldChange = (field: keyof CardData, value: string) => {
|
|
if (!currentCard || !currentImage) 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
|
|
|
|
// Wait for images and cards data to be initialized
|
|
if (images.length === 0) {
|
|
return (
|
|
<div className="p-4">
|
|
<p className="text-muted-foreground">No images uploaded</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!currentImage || !currentCard) {
|
|
return (
|
|
<div className="p-4">
|
|
<p className="text-muted-foreground">Initializing card data...</p>
|
|
</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 || 'Unknown'}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
|
|
{currentImage.preview ? (
|
|
<img
|
|
src={currentImage.preview}
|
|
alt={currentImage.file?.name || 'Card image'}
|
|
className="w-full h-full object-contain"
|
|
onError={(e) => {
|
|
console.error('Failed to load image:', currentImage.preview)
|
|
e.currentTarget.style.display = 'none'
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
|
Image preview not available
|
|
</div>
|
|
)}
|
|
</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 || undefined}
|
|
onValueChange={(value: string) => {
|
|
// If "none" is selected, clear the packId
|
|
const packId = value === '__none__' ? '' : value
|
|
handleFieldChange('packId', packId)
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a pack (optional)" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">None</SelectItem>
|
|
{packsData && packsData.length > 0 ? (
|
|
packsData.map((pack) => (
|
|
<SelectItem key={pack.id} value={pack.id}>
|
|
{pack.title || pack.id}
|
|
</SelectItem>
|
|
))
|
|
) : null}
|
|
</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>
|
|
)
|
|
}
|