2025-12-02 23:17:15 +00:00
|
|
|
import { useState } from 'react'
|
|
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
import { toast } from 'sonner'
|
|
|
|
|
import { cardsApi } from '@/api/cards'
|
|
|
|
|
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
2025-12-13 23:35:14 +00:00
|
|
|
import type { AxiosError } from 'axios'
|
2025-12-02 23:17:15 +00:00
|
|
|
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'
|
|
|
|
|
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload } from 'lucide-react'
|
2025-12-02 23:17:15 +00:00
|
|
|
|
|
|
|
|
export default function CardsPage() {
|
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
const [page, setPage] = useState(1)
|
|
|
|
|
const [search, setSearch] = useState('')
|
|
|
|
|
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-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 }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Mutations
|
|
|
|
|
const createMutation = useMutation({
|
|
|
|
|
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
|
|
|
|
toast.success('Card created successfully')
|
|
|
|
|
closeDialog()
|
|
|
|
|
},
|
2025-12-13 23:35:14 +00:00
|
|
|
onError: (error: unknown) => {
|
|
|
|
|
const axiosError = error as AxiosError<{ message?: string }>
|
|
|
|
|
toast.error(axiosError.response?.data?.message || 'Failed to create card')
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const updateMutation = useMutation({
|
|
|
|
|
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
|
|
|
|
toast.success('Card updated successfully')
|
|
|
|
|
closeDialog()
|
|
|
|
|
},
|
2025-12-13 23:35:14 +00:00
|
|
|
onError: (error: unknown) => {
|
|
|
|
|
const axiosError = error as AxiosError<{ message?: string }>
|
|
|
|
|
toast.error(axiosError.response?.data?.message || 'Failed to update card')
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const deleteMutation = useMutation({
|
|
|
|
|
mutationFn: (cardId: number) => cardsApi.deleteCard(cardId),
|
|
|
|
|
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) => {
|
|
|
|
|
const axiosError = error as AxiosError<{ message?: string }>
|
|
|
|
|
toast.error(axiosError.response?.data?.message || 'Failed to delete card')
|
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({
|
|
|
|
|
queryKey: ['packs', 1, 1000, ''],
|
|
|
|
|
queryFn: () => packsApi.getPacks({ page: 1, limit: 1000, search: '' }),
|
|
|
|
|
})
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cardData: GameCardDto = {
|
|
|
|
|
id: selectedCard?.id || -1,
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (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">
|
|
|
|
|
<p className="text-red-500">Failed to load cards. Please try again later.</p>
|
|
|
|
|
</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>
|
|
|
|
|
<CardTitle>All Cards ({data?.total || 0})</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Manage game cards in the system
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="text-center py-8">Loading cards...</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead>ID</TableHead>
|
|
|
|
|
<TableHead>Original</TableHead>
|
|
|
|
|
<TableHead>Translation</TableHead>
|
|
|
|
|
<TableHead>Mnemo</TableHead>
|
|
|
|
|
<TableHead>Actions</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{data?.items.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>
|
|
|
|
|
|
|
|
|
|
{/* 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
|
|
|
|
|
value={formData.packId}
|
2025-12-14 19:34:43 +00:00
|
|
|
onValueChange={(value: string) => setFormData(prev => ({ ...prev, packId: value }))}
|
2025-12-14 19:21:36 +00:00
|
|
|
>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="Select a pack (optional)" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="">None</SelectItem>
|
|
|
|
|
{packsData?.items.map((pack) => (
|
|
|
|
|
<SelectItem key={pack.id} value={pack.id}>
|
|
|
|
|
{pack.title}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
{selectedCard && (
|
|
|
|
|
<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
|
|
|
|
|
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>
|
|
|
|
|
)}
|
2025-12-02 23:17:15 +00:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|