diff --git a/mnemo_cards_admin/src/components/BulkCardEditor.tsx b/mnemo_cards_admin/src/components/BulkCardEditor.tsx new file mode 100644 index 0000000..1378511 --- /dev/null +++ b/mnemo_cards_admin/src/components/BulkCardEditor.tsx @@ -0,0 +1,356 @@ +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>(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() + 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
Loading...
+ } + + return ( +
+
+
+

Fill Card Details

+

+ Card {currentIndex + 1} of {totalCount} • {savedCount} saved +

+
+
+ + {currentCard.isSaved ? 'Saved' : 'Not Saved'} + + +
+
+ +
+ {/* Image Preview */} + + + Image Preview + {currentImage.file.name} + + +
+ {currentImage.file.name} +
+
+
+ + {/* Form */} + + + Card Information + Fill in the details for this card + + +
+ + +
+ +
+
+ + handleFieldChange('original', e.target.value)} + placeholder="e.g. cerdo" + /> +
+
+ + handleFieldChange('translation', e.target.value)} + placeholder="e.g. pig" + /> +
+
+ +
+ + handleFieldChange('mnemo', e.target.value)} + placeholder="e.g. [pig] with heart" + /> +
+ +
+
+ + handleFieldChange('transcription', e.target.value)} + placeholder="e.g. sɛrdo" + /> +
+
+ + handleFieldChange('transcriptionMnemo', e.target.value)} + placeholder="e.g. pig with heart" + /> +
+
+ +
+ +