import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { cardsApi, isCardsApiError } from '@/api/cards' import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils' import type { GameCardDto, PaginatedResponse } from '@/types/models' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { ImageUpload } from '@/components/ui/image-upload' import { CardVoicesManager } from '@/components/CardVoicesManager' import { BulkCardUpload } from '@/components/BulkCardUpload' import { BulkCardEditor } from '@/components/BulkCardEditor' import { packsApi } from '@/api/packs' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, List, Grid } from 'lucide-react' type ViewMode = 'list' | 'grid' export default function CardsPage() { const queryClient = useQueryClient() const [page, setPage] = useState(1) const [search, setSearch] = useState('') const [viewMode, setViewMode] = useState('list') const [selectedCard, setSelectedCard] = useState(null) const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [cardToDelete, setCardToDelete] = useState(null) const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false) const [isBulkEditorOpen, setIsBulkEditorOpen] = useState(false) const [uploadedImages, setUploadedImages] = useState>([]) const [bulkUploadPackId, setBulkUploadPackId] = useState(undefined) // Form state const [formData, setFormData] = useState({ packId: '', original: '', translation: '', mnemo: '', transcription: '', transcriptionMnemo: '', back: '', image: undefined as string | undefined, imageBack: undefined as string | undefined, }) const limit = 20 // Fetch cards const { data, isLoading, error } = useQuery>({ queryKey: ['cards', page, search], queryFn: () => cardsApi.getCards({ page, limit, search }), retry: (failureCount, error) => { // Don't retry on client errors (4xx) if (isCardsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { return false } return failureCount < 2 }, }) // Mutations const createMutation = useMutation({ mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card), onSuccess: (response) => { queryClient.invalidateQueries({ queryKey: ['cards'] }) toast.success('Card created successfully') closeDialog() // Update selectedCard with the new ID from response if (response?.card?.id) { setSelectedCard(response.card) } }, onError: (error: unknown) => { const errorMessage = isCardsApiError(error) ? error.message : getDetailedErrorMessage(error, 'create', 'card') toast.error(errorMessage) console.error('Error creating card:', error) }, }) const updateMutation = useMutation({ mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card), onSuccess: (response) => { queryClient.invalidateQueries({ queryKey: ['cards'] }) toast.success('Card updated successfully') closeDialog() // Update selectedCard with the updated card from response if (response?.card) { setSelectedCard(response.card) } }, onError: (error: unknown) => { const errorMessage = isCardsApiError(error) ? error.message : getDetailedErrorMessage(error, 'update', 'card') toast.error(errorMessage) console.error('Error updating card:', error) }, }) const deleteMutation = useMutation({ mutationFn: (cardId: string) => cardsApi.deleteCard(cardId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cards'] }) toast.success('Card deleted successfully') setIsDeleteDialogOpen(false) setCardToDelete(null) }, onError: (error: unknown) => { const errorMessage = isCardsApiError(error) ? error.message : getDetailedErrorMessage(error, 'delete', 'card') toast.error(errorMessage) console.error('Error deleting card:', error) }, }) // Fetch packs for packId selection const { data: packsData } = useQuery({ queryKey: ['packs', 1, 100, ''], queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }), }) const openCreateDialog = () => { setSelectedCard(null) setFormData({ packId: '', original: '', translation: '', mnemo: '', transcription: '', transcriptionMnemo: '', back: '', image: undefined, imageBack: undefined, }) setIsDialogOpen(true) } const openEditDialog = (card: GameCardDto) => { setSelectedCard(card) setFormData({ packId: (card as any).packId || '', original: card.original || '', translation: card.translation || '', mnemo: card.mnemo || '', transcription: card.transcription || '', transcriptionMnemo: card.transcriptionMnemo || '', back: card.back || '', image: card.image, imageBack: card.imageBack, }) setIsDialogOpen(true) } const closeDialog = () => { setIsDialogOpen(false) setSelectedCard(null) } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!formData.original.trim() || !formData.translation.trim() || !formData.mnemo.trim()) { toast.error('Original, translation and mnemo are required') return } // Determine if this is an update or create const isUpdate = selectedCard !== null && selectedCard.id !== undefined && selectedCard.id !== null && selectedCard.id !== '' const cardData: GameCardDto = { id: isUpdate ? selectedCard.id : null, packId: formData.packId.trim() || undefined, original: formData.original.trim(), translation: formData.translation.trim(), mnemo: formData.mnemo.trim(), transcription: formData.transcription.trim() || undefined, transcriptionMnemo: formData.transcriptionMnemo.trim() || undefined, back: formData.back.trim() || undefined, image: formData.image || undefined, imageBack: formData.imageBack || undefined, } if (isUpdate) { updateMutation.mutate(cardData) } else { createMutation.mutate(cardData) } } const handleDelete = (card: GameCardDto) => { setCardToDelete(card) setIsDeleteDialogOpen(true) } const confirmDelete = () => { if (cardToDelete) { deleteMutation.mutate(cardToDelete.id) } } const handleSearch = (value: string) => { setSearch(value) setPage(1) // Reset to first page when searching } // Group cards by pack const groupedCards = data?.items.reduce((acc, card) => { const packId = card.packId || '__no_pack__' if (!acc[packId]) { acc[packId] = [] } acc[packId].push(card) return acc }, {} as Record) || {} // Get pack name by ID const getPackName = (packId: string): string => { if (packId === '__no_pack__') { return 'No Pack' } const pack = packsData?.items.find(p => p.id === packId) return pack?.title || packId } // Get image source - handles both base64 and URLs const getImageSrc = (image?: string): string | undefined => { if (!image) return undefined // If it's already a data URL or http/https URL, return as is if (image.startsWith('data:') || image.startsWith('http://') || image.startsWith('https://')) { return image } // Otherwise, assume it's base64 and add the data URL prefix // Try to detect image type from base64 or default to png return `data:image/png;base64,${image}` } if (error) { const errorMessage = isCardsApiError(error) ? error.message : formatApiError(error) return (

Cards Management

Error loading cards

Failed to load cards

{errorMessage}

) } return (

Cards Management

View, create, edit and delete game cards

{/* Search */}
handleSearch(e.target.value)} className="max-w-sm" />
{/* Cards Table */}
All Cards ({data?.total || 0}) Manage game cards in the system
{isLoading ? (
Loading cards...
) : ( <> {viewMode === 'list' ? ( <> {Object.entries(groupedCards).map(([packId, cards]) => (

{getPackName(packId)}

{cards.length} {cards.length === 1 ? 'card' : 'cards'}

ID Original Translation Mnemo Actions {cards.map((card) => ( {card.id || '-'} {card.original} {card.translation} {card.mnemo}
))}
))} ) : ( <> {Object.entries(groupedCards).map(([packId, cards]) => (

{getPackName(packId)}

{cards.length} {cards.length === 1 ? 'card' : 'cards'}

{cards.map((card) => (
openEditDialog(card)} >
{getImageSrc(card.image) ? ( {card.original} { const target = e.target as HTMLImageElement target.style.display = 'none' }} /> ) : (
No Image
)}

{card.original}

{card.translation && (

{card.translation}

)}
))}
))} )} {/* Pagination */} {data && data.totalPages > 1 && (
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} cards
Page {page} of {data.totalPages}
)} )}
{/* Create/Edit Dialog */} {selectedCard ? 'Edit Card' : 'Create New Card'} {selectedCard ? 'Update the card information' : 'Add a new card to the system'}
setFormData(prev => ({ ...prev, original: e.target.value }))} placeholder="e.g. cerdo" required />
setFormData(prev => ({ ...prev, translation: e.target.value }))} placeholder="e.g. pig" required />
setFormData(prev => ({ ...prev, mnemo: e.target.value }))} placeholder="e.g. [pig] with heart" required />
setFormData(prev => ({ ...prev, transcription: e.target.value }))} placeholder="e.g. sɛrdo" />
setFormData(prev => ({ ...prev, transcriptionMnemo: e.target.value }))} placeholder="e.g. pig with heart" />