mnemo_cards/mnemo_cards_admin/src/pages/CardsPage.tsx
2025-12-15 01:38:36 +03:00

741 lines
28 KiB
TypeScript

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<ViewMode>('list')
const [selectedCard, setSelectedCard] = useState<GameCardDto | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [cardToDelete, setCardToDelete] = useState<GameCardDto | null>(null)
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false)
const [isBulkEditorOpen, setIsBulkEditorOpen] = useState(false)
const [uploadedImages, setUploadedImages] = useState<Array<{
id: string
file: File
preview: string
base64: string
}>>([])
// 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<PaginatedResponse<GameCardDto>>({
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: () => {
queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('Card created successfully')
closeDialog()
},
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: () => {
queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('Card updated successfully')
closeDialog()
},
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: number) => 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
}
const cardData: GameCardDto = {
id: selectedCard?.id || -1,
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 (selectedCard) {
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<string, GameCardDto[]>) || {}
// 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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Cards Management</h1>
<p className="text-muted-foreground">Error loading cards</p>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<p className="text-red-500 font-medium">Failed to load cards</p>
<p className="text-sm text-muted-foreground">{errorMessage}</p>
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['cards'] })}
className="mt-2"
>
Retry
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Cards Management</h1>
<p className="text-muted-foreground">
View, create, edit and delete game cards
</p>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" onClick={() => setIsBulkUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" />
Bulk Upload
</Button>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Card
</Button>
</div>
</div>
{/* Search */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search cards..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="max-w-sm"
/>
</div>
</CardContent>
</Card>
{/* Cards Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>All Cards ({data?.total || 0})</CardTitle>
<CardDescription>
Manage game cards in the system
</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Button
variant={viewMode === 'list' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
>
<Grid className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading cards...</div>
) : (
<>
{viewMode === 'list' ? (
<>
{Object.entries(groupedCards).map(([packId, cards]) => (
<div key={packId} className="mb-8">
<div className="bg-muted/50 px-4 py-3 rounded-t-lg border-b-2 border-primary/20 mb-0">
<h3 className="text-xl font-bold">
{getPackName(packId)}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{cards.length} {cards.length === 1 ? 'card' : 'cards'}
</p>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Original</TableHead>
<TableHead>Translation</TableHead>
<TableHead>Mnemo</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cards.map((card) => (
<TableRow key={card.id}>
<TableCell>{card.id}</TableCell>
<TableCell className="font-medium">{card.original}</TableCell>
<TableCell>{card.translation}</TableCell>
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(card)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(card)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</>
) : (
<>
{Object.entries(groupedCards).map(([packId, cards]) => (
<div key={packId} className="mb-8">
<div className="bg-muted/50 px-4 py-3 rounded-lg border-b-2 border-primary/20 mb-4">
<h3 className="text-xl font-bold">
{getPackName(packId)}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{cards.length} {cards.length === 1 ? 'card' : 'cards'}
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{cards.map((card) => (
<div
key={card.id}
className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => openEditDialog(card)}
>
<div className="aspect-square bg-muted relative">
{getImageSrc(card.image) ? (
<img
src={getImageSrc(card.image)}
alt={card.original}
className="w-full h-full object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
No Image
</div>
)}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex space-x-1">
<Button
variant="secondary"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation()
openEditDialog(card)
}}
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation()
handleDelete(card)
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
<div className="p-2">
<p className="font-medium text-sm truncate" title={card.original}>
{card.original}
</p>
{card.translation && (
<p className="text-xs text-muted-foreground truncate" title={card.translation}>
{card.translation}
</p>
)}
</div>
</div>
))}
</div>
</div>
))}
</>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} cards
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= data.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Create/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{selectedCard ? 'Edit Card' : 'Create New Card'}
</DialogTitle>
<DialogDescription>
{selectedCard ? 'Update the card information' : 'Add a new card to the system'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="packId">Pack ID (optional)</Label>
<Select
value={formData.packId || undefined}
onValueChange={(value: string) => {
// If "none" is selected, clear the packId
const packId = value === '__none__' ? '' : value
setFormData(prev => ({ ...prev, packId }))
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a pack (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{packsData?.items && packsData.items.length > 0 ? (
packsData.items.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={formData.original}
onChange={(e) => setFormData(prev => ({ ...prev, original: e.target.value }))}
placeholder="e.g. cerdo"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="translation">Translation *</Label>
<Input
id="translation"
value={formData.translation}
onChange={(e) => setFormData(prev => ({ ...prev, translation: e.target.value }))}
placeholder="e.g. pig"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="mnemo">Mnemo *</Label>
<Input
id="mnemo"
value={formData.mnemo}
onChange={(e) => setFormData(prev => ({ ...prev, mnemo: e.target.value }))}
placeholder="e.g. [pig] with heart"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="transcription">Transcription</Label>
<Input
id="transcription"
value={formData.transcription}
onChange={(e) => setFormData(prev => ({ ...prev, 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={formData.transcriptionMnemo}
onChange={(e) => setFormData(prev => ({ ...prev, 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={formData.back}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData(prev => ({ ...prev, back: e.target.value }))}
placeholder="Additional information on the back of the card"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<ImageUpload
label="Front Image"
value={formData.image}
onChange={(value) => setFormData(prev => ({ ...prev, image: value }))}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
<div className="space-y-2">
<ImageUpload
label="Back Image"
value={formData.imageBack}
onChange={(value) => setFormData(prev => ({ ...prev, imageBack: value }))}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
</div>
{selectedCard && (
<div className="space-y-2">
<CardVoicesManager
cardId={selectedCard.id}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending ? 'Saving...' : (selectedCard ? 'Update' : 'Create')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Card</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the card "{cardToDelete?.original}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bulk Upload Dialog */}
{isBulkUploadOpen && !isBulkEditorOpen && (
<Dialog open={isBulkUploadOpen} onOpenChange={setIsBulkUploadOpen}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<BulkCardUpload
onImagesUploaded={(images) => {
setUploadedImages(images)
setIsBulkUploadOpen(false)
setIsBulkEditorOpen(true)
}}
onClose={() => {
setIsBulkUploadOpen(false)
setUploadedImages([])
}}
/>
</DialogContent>
</Dialog>
)}
{/* Bulk Editor Dialog */}
{isBulkEditorOpen && (
<Dialog open={isBulkEditorOpen} onOpenChange={setIsBulkEditorOpen}>
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
<BulkCardEditor
images={uploadedImages}
onComplete={() => {
setIsBulkEditorOpen(false)
setUploadedImages([])
queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('All cards saved successfully!')
}}
onCancel={() => {
setIsBulkEditorOpen(false)
setUploadedImages([])
}}
/>
</DialogContent>
</Dialog>
)}
</div>
)
}