2025-12-02 23:17:15 +00:00
|
|
|
import { useState } from 'react'
|
|
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
import { toast } from 'sonner'
|
2025-12-14 20:42:38 +00:00
|
|
|
import { cardsApi, isCardsApiError } from '@/api/cards'
|
2025-12-14 20:19:39 +00:00
|
|
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
2025-12-02 23:17:15 +00:00
|
|
|
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'
|
2025-12-14 18:49:59 +00:00
|
|
|
import { ImageUpload } from '@/components/ui/image-upload'
|
|
|
|
|
import { CardVoicesManager } from '@/components/CardVoicesManager'
|
2025-12-14 19:21:36 +00:00
|
|
|
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'
|
2025-12-14 22:31:58 +00:00
|
|
|
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload, List, Grid } from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
type ViewMode = 'list' | 'grid'
|
2025-12-02 23:17:15 +00:00
|
|
|
|
|
|
|
|
export default function CardsPage() {
|
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
const [page, setPage] = useState(1)
|
|
|
|
|
const [search, setSearch] = useState('')
|
2025-12-14 22:31:58 +00:00
|
|
|
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
2025-12-02 23:17:15 +00:00
|
|
|
const [selectedCard, setSelectedCard] = useState<GameCardDto | null>(null)
|
|
|
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
|
|
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
|
|
|
|
const [cardToDelete, setCardToDelete] = useState<GameCardDto | null>(null)
|
2025-12-14 19:21:36 +00:00
|
|
|
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false)
|
|
|
|
|
const [isBulkEditorOpen, setIsBulkEditorOpen] = useState(false)
|
|
|
|
|
const [uploadedImages, setUploadedImages] = useState<Array<{
|
|
|
|
|
id: string
|
|
|
|
|
file: File
|
|
|
|
|
preview: string
|
|
|
|
|
base64: string
|
|
|
|
|
}>>([])
|
2025-12-16 23:08:37 +00:00
|
|
|
const [bulkUploadPackId, setBulkUploadPackId] = useState<string | undefined>(undefined)
|
2025-12-02 23:17:15 +00:00
|
|
|
|
|
|
|
|
// Form state
|
|
|
|
|
const [formData, setFormData] = useState({
|
2025-12-14 19:21:36 +00:00
|
|
|
packId: '',
|
2025-12-02 23:17:15 +00:00
|
|
|
original: '',
|
|
|
|
|
translation: '',
|
|
|
|
|
mnemo: '',
|
|
|
|
|
transcription: '',
|
|
|
|
|
transcriptionMnemo: '',
|
|
|
|
|
back: '',
|
2025-12-14 18:49:59 +00:00
|
|
|
image: undefined as string | undefined,
|
|
|
|
|
imageBack: undefined as string | undefined,
|
2025-12-02 23:17:15 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const limit = 20
|
|
|
|
|
|
|
|
|
|
// Fetch cards
|
|
|
|
|
const { data, isLoading, error } = useQuery<PaginatedResponse<GameCardDto>>({
|
|
|
|
|
queryKey: ['cards', page, search],
|
|
|
|
|
queryFn: () => cardsApi.getCards({ page, limit, search }),
|
2025-12-14 20:19:39 +00:00
|
|
|
retry: (failureCount, error) => {
|
|
|
|
|
// Don't retry on client errors (4xx)
|
2025-12-14 20:42:38 +00:00
|
|
|
if (isCardsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
2025-12-14 20:19:39 +00:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return failureCount < 2
|
|
|
|
|
},
|
2025-12-02 23:17:15 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mutations
|
|
|
|
|
const createMutation = useMutation({
|
|
|
|
|
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
2025-12-16 23:08:37 +00:00
|
|
|
onSuccess: (response) => {
|
2025-12-02 23:17:15 +00:00
|
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
|
|
|
|
toast.success('Card created successfully')
|
|
|
|
|
closeDialog()
|
2025-12-16 23:08:37 +00:00
|
|
|
// Update selectedCard with the new ID from response
|
|
|
|
|
if (response?.card?.id) {
|
|
|
|
|
setSelectedCard(response.card)
|
|
|
|
|
}
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
2025-12-13 23:35:14 +00:00
|
|
|
onError: (error: unknown) => {
|
2025-12-14 20:42:38 +00:00
|
|
|
const errorMessage = isCardsApiError(error)
|
2025-12-14 20:19:39 +00:00
|
|
|
? error.message
|
|
|
|
|
: getDetailedErrorMessage(error, 'create', 'card')
|
|
|
|
|
toast.error(errorMessage)
|
|
|
|
|
console.error('Error creating card:', error)
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const updateMutation = useMutation({
|
|
|
|
|
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
2025-12-16 23:08:37 +00:00
|
|
|
onSuccess: (response) => {
|
2025-12-02 23:17:15 +00:00
|
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
|
|
|
|
toast.success('Card updated successfully')
|
|
|
|
|
closeDialog()
|
2025-12-16 23:08:37 +00:00
|
|
|
// Update selectedCard with the updated card from response
|
|
|
|
|
if (response?.card) {
|
|
|
|
|
setSelectedCard(response.card)
|
|
|
|
|
}
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
2025-12-13 23:35:14 +00:00
|
|
|
onError: (error: unknown) => {
|
2025-12-14 20:42:38 +00:00
|
|
|
const errorMessage = isCardsApiError(error)
|
2025-12-14 20:19:39 +00:00
|
|
|
? error.message
|
|
|
|
|
: getDetailedErrorMessage(error, 'update', 'card')
|
|
|
|
|
toast.error(errorMessage)
|
|
|
|
|
console.error('Error updating card:', error)
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const deleteMutation = useMutation({
|
2025-12-16 23:08:37 +00:00
|
|
|
mutationFn: (cardId: string) => cardsApi.deleteCard(cardId),
|
2025-12-02 23:17:15 +00:00
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
|
|
|
|
toast.success('Card deleted successfully')
|
|
|
|
|
setIsDeleteDialogOpen(false)
|
|
|
|
|
setCardToDelete(null)
|
|
|
|
|
},
|
2025-12-13 23:35:14 +00:00
|
|
|
onError: (error: unknown) => {
|
2025-12-14 20:42:38 +00:00
|
|
|
const errorMessage = isCardsApiError(error)
|
2025-12-14 20:19:39 +00:00
|
|
|
? error.message
|
|
|
|
|
: getDetailedErrorMessage(error, 'delete', 'card')
|
|
|
|
|
toast.error(errorMessage)
|
|
|
|
|
console.error('Error deleting card:', error)
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-14 19:21:36 +00:00
|
|
|
// Fetch packs for packId selection
|
|
|
|
|
const { data: packsData } = useQuery({
|
2025-12-14 19:41:32 +00:00
|
|
|
queryKey: ['packs', 1, 100, ''],
|
|
|
|
|
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }),
|
2025-12-14 19:21:36 +00:00
|
|
|
})
|
|
|
|
|
|
2025-12-02 23:17:15 +00:00
|
|
|
const openCreateDialog = () => {
|
|
|
|
|
setSelectedCard(null)
|
|
|
|
|
setFormData({
|
2025-12-14 19:21:36 +00:00
|
|
|
packId: '',
|
2025-12-02 23:17:15 +00:00
|
|
|
original: '',
|
|
|
|
|
translation: '',
|
|
|
|
|
mnemo: '',
|
|
|
|
|
transcription: '',
|
|
|
|
|
transcriptionMnemo: '',
|
|
|
|
|
back: '',
|
2025-12-14 18:49:59 +00:00
|
|
|
image: undefined,
|
|
|
|
|
imageBack: undefined,
|
2025-12-02 23:17:15 +00:00
|
|
|
})
|
|
|
|
|
setIsDialogOpen(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openEditDialog = (card: GameCardDto) => {
|
|
|
|
|
setSelectedCard(card)
|
|
|
|
|
setFormData({
|
2025-12-14 19:21:36 +00:00
|
|
|
packId: (card as any).packId || '',
|
2025-12-02 23:17:15 +00:00
|
|
|
original: card.original || '',
|
|
|
|
|
translation: card.translation || '',
|
|
|
|
|
mnemo: card.mnemo || '',
|
|
|
|
|
transcription: card.transcription || '',
|
|
|
|
|
transcriptionMnemo: card.transcriptionMnemo || '',
|
|
|
|
|
back: card.back || '',
|
2025-12-14 18:49:59 +00:00
|
|
|
image: card.image,
|
|
|
|
|
imageBack: card.imageBack,
|
2025-12-02 23:17:15 +00:00
|
|
|
})
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-16 23:08:37 +00:00
|
|
|
// Determine if this is an update or create
|
|
|
|
|
const isUpdate = selectedCard !== null && selectedCard.id !== undefined && selectedCard.id !== null && selectedCard.id !== ''
|
|
|
|
|
|
2025-12-02 23:17:15 +00:00
|
|
|
const cardData: GameCardDto = {
|
2025-12-16 23:08:37 +00:00
|
|
|
id: isUpdate ? selectedCard.id : null,
|
2025-12-14 19:21:36 +00:00
|
|
|
packId: formData.packId.trim() || undefined,
|
2025-12-02 23:17:15 +00:00
|
|
|
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,
|
2025-12-14 18:49:59 +00:00
|
|
|
image: formData.image || undefined,
|
|
|
|
|
imageBack: formData.imageBack || undefined,
|
2025-12-02 23:17:15 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-16 23:08:37 +00:00
|
|
|
if (isUpdate) {
|
2025-12-02 23:17:15 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-14 22:31:58 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-14 22:38:36 +00:00
|
|
|
// 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}`
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-02 23:17:15 +00:00
|
|
|
if (error) {
|
2025-12-14 20:45:27 +00:00
|
|
|
const errorMessage = isCardsApiError(error)
|
2025-12-14 20:19:39 +00:00
|
|
|
? error.message
|
|
|
|
|
: formatApiError(error)
|
2025-12-02 23:17:15 +00:00
|
|
|
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">
|
2025-12-14 20:19:39 +00:00
|
|
|
<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>
|
2025-12-02 23:17:15 +00:00
|
|
|
</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>
|
2025-12-14 19:21:36 +00:00
|
|
|
<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>
|
2025-12-02 23:17:15 +00:00
|
|
|
</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>
|
2025-12-14 22:31:58 +00:00
|
|
|
<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>
|
2025-12-02 23:17:15 +00:00
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="text-center py-8">Loading cards...</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2025-12-14 22:31:58 +00:00
|
|
|
{viewMode === 'list' ? (
|
|
|
|
|
<>
|
|
|
|
|
{Object.entries(groupedCards).map(([packId, cards]) => (
|
|
|
|
|
<div key={packId} className="mb-8">
|
2025-12-14 22:38:36 +00:00
|
|
|
<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>
|
2025-12-14 22:31:58 +00:00
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead>ID</TableHead>
|
|
|
|
|
<TableHead>Original</TableHead>
|
|
|
|
|
<TableHead>Translation</TableHead>
|
|
|
|
|
<TableHead>Mnemo</TableHead>
|
|
|
|
|
<TableHead>Actions</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{cards.map((card) => (
|
2025-12-16 23:08:37 +00:00
|
|
|
<TableRow key={card.id || `temp-${card.original}`}>
|
|
|
|
|
<TableCell>{card.id || '-'}</TableCell>
|
2025-12-14 22:31:58 +00:00
|
|
|
<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">
|
2025-12-14 22:38:36 +00:00
|
|
|
<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>
|
2025-12-14 22:31:58 +00:00
|
|
|
<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
|
2025-12-16 23:08:37 +00:00
|
|
|
key={card.id || `temp-${card.original}`}
|
2025-12-14 22:31:58 +00:00
|
|
|
className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer group"
|
2025-12-02 23:17:15 +00:00
|
|
|
onClick={() => openEditDialog(card)}
|
|
|
|
|
>
|
2025-12-14 22:31:58 +00:00
|
|
|
<div className="aspect-square bg-muted relative">
|
2025-12-14 22:38:36 +00:00
|
|
|
{getImageSrc(card.image) ? (
|
2025-12-14 22:31:58 +00:00
|
|
|
<img
|
2025-12-14 22:38:36 +00:00
|
|
|
src={getImageSrc(card.image)}
|
2025-12-14 22:31:58 +00:00
|
|
|
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>
|
2025-12-02 23:17:15 +00:00
|
|
|
))}
|
2025-12-14 22:31:58 +00:00
|
|
|
</>
|
|
|
|
|
)}
|
2025-12-02 23:17:15 +00:00
|
|
|
|
|
|
|
|
{/* 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}>
|
2025-12-14 18:49:59 +00:00
|
|
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
2025-12-02 23:17:15 +00:00
|
|
|
<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">
|
2025-12-14 19:21:36 +00:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="packId">Pack ID (optional)</Label>
|
|
|
|
|
<Select
|
2025-12-14 19:49:40 +00:00
|
|
|
value={formData.packId || undefined}
|
2025-12-14 20:01:01 +00:00
|
|
|
onValueChange={(value: string) => {
|
|
|
|
|
// If "none" is selected, clear the packId
|
|
|
|
|
const packId = value === '__none__' ? '' : value
|
|
|
|
|
setFormData(prev => ({ ...prev, packId }))
|
|
|
|
|
}}
|
2025-12-14 19:21:36 +00:00
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="Select a pack (optional)" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2025-12-14 20:01:01 +00:00
|
|
|
<SelectItem value="__none__">None</SelectItem>
|
2025-12-14 19:49:40 +00:00
|
|
|
{packsData?.items && packsData.items.length > 0 ? (
|
|
|
|
|
packsData.items.map((pack) => (
|
|
|
|
|
<SelectItem key={pack.id} value={pack.id}>
|
|
|
|
|
{pack.title || pack.id}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
) : null}
|
2025-12-14 19:21:36 +00:00
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-02 23:17:15 +00:00
|
|
|
<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>
|
2025-12-14 18:49:59 +00:00
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
2025-12-16 23:08:37 +00:00
|
|
|
{selectedCard && selectedCard.id && (
|
2025-12-14 18:49:59 +00:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<CardVoicesManager
|
|
|
|
|
cardId={selectedCard.id}
|
|
|
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-02 23:17:15 +00:00
|
|
|
</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>
|
2025-12-14 19:21:36 +00:00
|
|
|
|
|
|
|
|
{/* Bulk Upload Dialog */}
|
|
|
|
|
{isBulkUploadOpen && !isBulkEditorOpen && (
|
|
|
|
|
<Dialog open={isBulkUploadOpen} onOpenChange={setIsBulkUploadOpen}>
|
|
|
|
|
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
|
|
|
|
<BulkCardUpload
|
2025-12-16 23:08:37 +00:00
|
|
|
onImagesUploaded={(images, packId) => {
|
2025-12-14 19:21:36 +00:00
|
|
|
setUploadedImages(images)
|
2025-12-16 23:08:37 +00:00
|
|
|
setBulkUploadPackId(packId)
|
2025-12-14 19:21:36 +00:00
|
|
|
setIsBulkUploadOpen(false)
|
|
|
|
|
setIsBulkEditorOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setIsBulkUploadOpen(false)
|
|
|
|
|
setUploadedImages([])
|
2025-12-16 23:08:37 +00:00
|
|
|
setBulkUploadPackId(undefined)
|
2025-12-14 19:21:36 +00:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</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}
|
2025-12-16 23:08:37 +00:00
|
|
|
defaultPackId={bulkUploadPackId}
|
2025-12-14 19:21:36 +00:00
|
|
|
onComplete={() => {
|
|
|
|
|
setIsBulkEditorOpen(false)
|
|
|
|
|
setUploadedImages([])
|
2025-12-16 23:08:37 +00:00
|
|
|
setBulkUploadPackId(undefined)
|
2025-12-14 19:21:36 +00:00
|
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
|
|
|
|
toast.success('All cards saved successfully!')
|
|
|
|
|
}}
|
|
|
|
|
onCancel={() => {
|
|
|
|
|
setIsBulkEditorOpen(false)
|
|
|
|
|
setUploadedImages([])
|
2025-12-16 23:08:37 +00:00
|
|
|
setBulkUploadPackId(undefined)
|
2025-12-14 19:21:36 +00:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)}
|
2025-12-02 23:17:15 +00:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|