image
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
498f542a7e
commit
7d1300a7a4
9 changed files with 91 additions and 30 deletions
|
|
@ -64,7 +64,7 @@ export const cardsApi = {
|
|||
},
|
||||
|
||||
// Get a specific card by ID
|
||||
getCard: async (cardId: number): Promise<GameCardDto> => {
|
||||
getCard: async (cardId: string): Promise<GameCardDto> => {
|
||||
try {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}`)
|
||||
return response.data
|
||||
|
|
@ -88,7 +88,7 @@ export const cardsApi = {
|
|||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }>
|
||||
const isUpdate = card.id && card.id > 0
|
||||
const isUpdate = card.id !== null && card.id !== undefined && card.id !== ''
|
||||
const operation = isUpdate ? 'update card' : 'create card'
|
||||
const message = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
|
|
@ -112,7 +112,7 @@ export const cardsApi = {
|
|||
},
|
||||
|
||||
// Delete a card by ID
|
||||
deleteCard: async (cardId: number): Promise<{ success: boolean; message: string }> => {
|
||||
deleteCard: async (cardId: string): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const response = await adminApiClient.delete(`/api/v2/admin/cards/${cardId}`)
|
||||
return response.data
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ export interface VoiceResponse {
|
|||
|
||||
export const voicesApi = {
|
||||
// Get all voices for a card
|
||||
getCardVoices: async (cardId: number | string): Promise<VoiceResponse> => {
|
||||
getCardVoices: async (cardId: string): Promise<VoiceResponse> => {
|
||||
const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}/voices`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Add a voice to a card
|
||||
addCardVoice: async (
|
||||
cardId: number | string,
|
||||
cardId: string,
|
||||
voiceUrl: string,
|
||||
language: string = 'en'
|
||||
): Promise<{ success: boolean; voice: VoiceDto }> => {
|
||||
|
|
@ -34,7 +34,7 @@ export const voicesApi = {
|
|||
|
||||
// Remove a voice from a card
|
||||
removeCardVoice: async (
|
||||
cardId: number | string,
|
||||
cardId: string,
|
||||
voiceId: string
|
||||
): Promise<{ success: boolean }> => {
|
||||
const response = await adminApiClient.delete(
|
||||
|
|
|
|||
|
|
@ -32,16 +32,17 @@ interface CardData {
|
|||
back: string
|
||||
imageBack?: string
|
||||
isSaved: boolean
|
||||
cardId?: number
|
||||
cardId?: string
|
||||
}
|
||||
|
||||
interface BulkCardEditorProps {
|
||||
images: UploadedImage[]
|
||||
defaultPackId?: string
|
||||
onComplete: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorProps) {
|
||||
export function BulkCardEditor({ images, defaultPackId, onComplete, onCancel }: BulkCardEditorProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [cardsData, setCardsData] = useState<Map<string, CardData>>(new Map())
|
||||
|
|
@ -86,7 +87,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
|
|||
images.forEach((image) => {
|
||||
initialData.set(image.id, {
|
||||
imageId: image.id,
|
||||
packId: '',
|
||||
packId: defaultPackId || '',
|
||||
original: '',
|
||||
translation: '',
|
||||
mnemo: '',
|
||||
|
|
@ -99,7 +100,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
|
|||
})
|
||||
setCardsData(initialData)
|
||||
}
|
||||
}, [images])
|
||||
}, [images, defaultPackId])
|
||||
|
||||
const currentImage = images[currentIndex]
|
||||
const currentCard = currentImage ? cardsData.get(currentImage.id) : undefined
|
||||
|
|
@ -162,7 +163,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
|
|||
if (!image) return
|
||||
|
||||
const cardData: GameCardDto = {
|
||||
id: currentCard.cardId || -1,
|
||||
id: currentCard.cardId || null,
|
||||
packId: currentCard.packId || undefined,
|
||||
original: currentCard.original.trim(),
|
||||
translation: currentCard.translation.trim(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button } from './ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
|
||||
import { Label } from './ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
|
||||
import { Upload, X, Check } from 'lucide-react'
|
||||
import { packsApi } from '@/api/packs'
|
||||
|
||||
interface UploadedImage {
|
||||
id: string
|
||||
|
|
@ -11,7 +15,7 @@ interface UploadedImage {
|
|||
}
|
||||
|
||||
interface BulkCardUploadProps {
|
||||
onImagesUploaded: (images: UploadedImage[]) => void
|
||||
onImagesUploaded: (images: UploadedImage[], packId?: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
|
|
@ -19,6 +23,13 @@ export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProp
|
|||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedPackId, setSelectedPackId] = useState<string>('')
|
||||
|
||||
// Fetch packs for pack selection
|
||||
const { data: packsData } = useQuery({
|
||||
queryKey: ['packs', 1, 100, ''],
|
||||
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }),
|
||||
})
|
||||
|
||||
const handleFileSelect = async (files: FileList) => {
|
||||
const newImages: UploadedImage[] = []
|
||||
|
|
@ -106,7 +117,7 @@ export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProp
|
|||
|
||||
const handleContinue = () => {
|
||||
if (uploadedImages.length > 0) {
|
||||
onImagesUploaded(uploadedImages)
|
||||
onImagesUploaded(uploadedImages, selectedPackId || undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +144,35 @@ export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProp
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="packId">Pack (optional)</Label>
|
||||
<Select
|
||||
value={selectedPackId || undefined}
|
||||
onValueChange={(value: string) => {
|
||||
const packId = value === '__none__' ? '' : value
|
||||
setSelectedPackId(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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected pack will be applied to all uploaded cards
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { X, Plus, Music, Play, Pause } from 'lucide-react'
|
|||
import type { AxiosError } from 'axios'
|
||||
|
||||
interface CardVoicesManagerProps {
|
||||
cardId: number
|
||||
cardId: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,14 +163,14 @@ export function PackCardsManager({
|
|||
</TableRow>
|
||||
) : (
|
||||
filteredCards.map((card) => {
|
||||
const cardIdStr = String(card.id)
|
||||
const cardIdStr = card.id ? String(card.id) : `temp-${card.original}`
|
||||
const selected = isCardSelected(cardIdStr)
|
||||
const inCurrentPack = isCardInCurrentPack(cardIdStr)
|
||||
const newlyRemoved = removedCards.has(cardIdStr)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={card.id}
|
||||
key={card.id || `temp-${card.original}`}
|
||||
className={selected ? 'bg-muted/50' : ''}
|
||||
onClick={() => handleToggleCard(cardIdStr)}
|
||||
>
|
||||
|
|
@ -181,7 +181,7 @@ export function PackCardsManager({
|
|||
<div className="h-4 w-4 border rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{card.id}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{card.id || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{card.original}</TableCell>
|
||||
<TableCell>{card.translation}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export default function CardsPage() {
|
|||
preview: string
|
||||
base64: string
|
||||
}>>([])
|
||||
const [bulkUploadPackId, setBulkUploadPackId] = useState<string | undefined>(undefined)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
|
|
@ -94,10 +95,14 @@ export default function CardsPage() {
|
|||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
||||
onSuccess: () => {
|
||||
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)
|
||||
|
|
@ -110,10 +115,14 @@ export default function CardsPage() {
|
|||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
|
||||
onSuccess: () => {
|
||||
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)
|
||||
|
|
@ -125,7 +134,7 @@ export default function CardsPage() {
|
|||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (cardId: number) => cardsApi.deleteCard(cardId),
|
||||
mutationFn: (cardId: string) => cardsApi.deleteCard(cardId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('Card deleted successfully')
|
||||
|
|
@ -192,8 +201,11 @@ export default function CardsPage() {
|
|||
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: selectedCard?.id || -1,
|
||||
id: isUpdate ? selectedCard.id : null,
|
||||
packId: formData.packId.trim() || undefined,
|
||||
original: formData.original.trim(),
|
||||
translation: formData.translation.trim(),
|
||||
|
|
@ -205,7 +217,7 @@ export default function CardsPage() {
|
|||
imageBack: formData.imageBack || undefined,
|
||||
}
|
||||
|
||||
if (selectedCard) {
|
||||
if (isUpdate) {
|
||||
updateMutation.mutate(cardData)
|
||||
} else {
|
||||
createMutation.mutate(cardData)
|
||||
|
|
@ -384,8 +396,8 @@ export default function CardsPage() {
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{cards.map((card) => (
|
||||
<TableRow key={card.id}>
|
||||
<TableCell>{card.id}</TableCell>
|
||||
<TableRow key={card.id || `temp-${card.original}`}>
|
||||
<TableCell>{card.id || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{card.original}</TableCell>
|
||||
<TableCell>{card.translation}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>
|
||||
|
|
@ -429,7 +441,7 @@ export default function CardsPage() {
|
|||
<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}
|
||||
key={card.id || `temp-${card.original}`}
|
||||
className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => openEditDialog(card)}
|
||||
>
|
||||
|
|
@ -654,7 +666,7 @@ export default function CardsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCard && (
|
||||
{selectedCard && selectedCard.id && (
|
||||
<div className="space-y-2">
|
||||
<CardVoicesManager
|
||||
cardId={selectedCard.id}
|
||||
|
|
@ -702,14 +714,16 @@ export default function CardsPage() {
|
|||
<Dialog open={isBulkUploadOpen} onOpenChange={setIsBulkUploadOpen}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<BulkCardUpload
|
||||
onImagesUploaded={(images) => {
|
||||
onImagesUploaded={(images, packId) => {
|
||||
setUploadedImages(images)
|
||||
setBulkUploadPackId(packId)
|
||||
setIsBulkUploadOpen(false)
|
||||
setIsBulkEditorOpen(true)
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsBulkUploadOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
|
@ -722,15 +736,18 @@ export default function CardsPage() {
|
|||
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
|
||||
<BulkCardEditor
|
||||
images={uploadedImages}
|
||||
defaultPackId={bulkUploadPackId}
|
||||
onComplete={() => {
|
||||
setIsBulkEditorOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||
toast.success('All cards saved successfully!')
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkEditorOpen(false)
|
||||
setUploadedImages([])
|
||||
setBulkUploadPackId(undefined)
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Types based on backend DTOs
|
||||
|
||||
export interface GameCardDto {
|
||||
id: number
|
||||
id: string | null
|
||||
packId?: string
|
||||
image?: string
|
||||
mnemo?: string
|
||||
|
|
|
|||
|
|
@ -198,7 +198,10 @@ class AdminCardsApiV2 {
|
|||
|
||||
// Validate required fields for new cards
|
||||
final cardIdParam = data['id'];
|
||||
final isUpdate = cardIdParam != null && cardIdParam != -1;
|
||||
// Check if this is an update: id must be present, not null, not empty string, and not -1
|
||||
// Handle both null and string 'null' cases
|
||||
final isUpdate = cardIdParam != null
|
||||
&& cardIdParam.toString().trim().isNotEmpty;
|
||||
|
||||
if (!isUpdate) {
|
||||
// Validate required fields for new cards
|
||||
|
|
|
|||
Loading…
Reference in a new issue