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

This commit is contained in:
Dmitry 2025-12-17 02:08:37 +03:00
parent 498f542a7e
commit 7d1300a7a4
9 changed files with 91 additions and 30 deletions

View file

@ -64,7 +64,7 @@ export const cardsApi = {
}, },
// Get a specific card by ID // Get a specific card by ID
getCard: async (cardId: number): Promise<GameCardDto> => { getCard: async (cardId: string): Promise<GameCardDto> => {
try { try {
const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}`) const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}`)
return response.data return response.data
@ -88,7 +88,7 @@ export const cardsApi = {
return response.data return response.data
} catch (error) { } catch (error) {
const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }> 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 operation = isUpdate ? 'update card' : 'create card'
const message = axiosError.response?.data?.message || const message = axiosError.response?.data?.message ||
axiosError.response?.data?.error || axiosError.response?.data?.error ||
@ -112,7 +112,7 @@ export const cardsApi = {
}, },
// Delete a card by ID // Delete a card by ID
deleteCard: async (cardId: number): Promise<{ success: boolean; message: string }> => { deleteCard: async (cardId: string): Promise<{ success: boolean; message: string }> => {
try { try {
const response = await adminApiClient.delete(`/api/v2/admin/cards/${cardId}`) const response = await adminApiClient.delete(`/api/v2/admin/cards/${cardId}`)
return response.data return response.data

View file

@ -14,14 +14,14 @@ export interface VoiceResponse {
export const voicesApi = { export const voicesApi = {
// Get all voices for a card // 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`) const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}/voices`)
return response.data return response.data
}, },
// Add a voice to a card // Add a voice to a card
addCardVoice: async ( addCardVoice: async (
cardId: number | string, cardId: string,
voiceUrl: string, voiceUrl: string,
language: string = 'en' language: string = 'en'
): Promise<{ success: boolean; voice: VoiceDto }> => { ): Promise<{ success: boolean; voice: VoiceDto }> => {
@ -34,7 +34,7 @@ export const voicesApi = {
// Remove a voice from a card // Remove a voice from a card
removeCardVoice: async ( removeCardVoice: async (
cardId: number | string, cardId: string,
voiceId: string voiceId: string
): Promise<{ success: boolean }> => { ): Promise<{ success: boolean }> => {
const response = await adminApiClient.delete( const response = await adminApiClient.delete(

View file

@ -32,16 +32,17 @@ interface CardData {
back: string back: string
imageBack?: string imageBack?: string
isSaved: boolean isSaved: boolean
cardId?: number cardId?: string
} }
interface BulkCardEditorProps { interface BulkCardEditorProps {
images: UploadedImage[] images: UploadedImage[]
defaultPackId?: string
onComplete: () => void onComplete: () => void
onCancel: () => void onCancel: () => void
} }
export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorProps) { export function BulkCardEditor({ images, defaultPackId, onComplete, onCancel }: BulkCardEditorProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const [cardsData, setCardsData] = useState<Map<string, CardData>>(new Map()) const [cardsData, setCardsData] = useState<Map<string, CardData>>(new Map())
@ -86,7 +87,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
images.forEach((image) => { images.forEach((image) => {
initialData.set(image.id, { initialData.set(image.id, {
imageId: image.id, imageId: image.id,
packId: '', packId: defaultPackId || '',
original: '', original: '',
translation: '', translation: '',
mnemo: '', mnemo: '',
@ -99,7 +100,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
}) })
setCardsData(initialData) setCardsData(initialData)
} }
}, [images]) }, [images, defaultPackId])
const currentImage = images[currentIndex] const currentImage = images[currentIndex]
const currentCard = currentImage ? cardsData.get(currentImage.id) : undefined const currentCard = currentImage ? cardsData.get(currentImage.id) : undefined
@ -162,7 +163,7 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP
if (!image) return if (!image) return
const cardData: GameCardDto = { const cardData: GameCardDto = {
id: currentCard.cardId || -1, id: currentCard.cardId || null,
packId: currentCard.packId || undefined, packId: currentCard.packId || undefined,
original: currentCard.original.trim(), original: currentCard.original.trim(),
translation: currentCard.translation.trim(), translation: currentCard.translation.trim(),

View file

@ -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 { Button } from './ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card' 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 { Upload, X, Check } from 'lucide-react'
import { packsApi } from '@/api/packs'
interface UploadedImage { interface UploadedImage {
id: string id: string
@ -11,7 +15,7 @@ interface UploadedImage {
} }
interface BulkCardUploadProps { interface BulkCardUploadProps {
onImagesUploaded: (images: UploadedImage[]) => void onImagesUploaded: (images: UploadedImage[], packId?: string) => void
onClose: () => void onClose: () => void
} }
@ -19,6 +23,13 @@ export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProp
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]) const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([])
const [isDragging, setIsDragging] = useState(false) 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 handleFileSelect = async (files: FileList) => {
const newImages: UploadedImage[] = [] const newImages: UploadedImage[] = []
@ -106,7 +117,7 @@ export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProp
const handleContinue = () => { const handleContinue = () => {
if (uploadedImages.length > 0) { if (uploadedImages.length > 0) {
onImagesUploaded(uploadedImages) onImagesUploaded(uploadedImages, selectedPackId || undefined)
} }
} }
@ -133,6 +144,35 @@ export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProp
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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 <div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${ className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragging isDragging

View file

@ -10,7 +10,7 @@ import { X, Plus, Music, Play, Pause } from 'lucide-react'
import type { AxiosError } from 'axios' import type { AxiosError } from 'axios'
interface CardVoicesManagerProps { interface CardVoicesManagerProps {
cardId: number cardId: string
disabled?: boolean disabled?: boolean
} }

View file

@ -163,14 +163,14 @@ export function PackCardsManager({
</TableRow> </TableRow>
) : ( ) : (
filteredCards.map((card) => { filteredCards.map((card) => {
const cardIdStr = String(card.id) const cardIdStr = card.id ? String(card.id) : `temp-${card.original}`
const selected = isCardSelected(cardIdStr) const selected = isCardSelected(cardIdStr)
const inCurrentPack = isCardInCurrentPack(cardIdStr) const inCurrentPack = isCardInCurrentPack(cardIdStr)
const newlyRemoved = removedCards.has(cardIdStr) const newlyRemoved = removedCards.has(cardIdStr)
return ( return (
<TableRow <TableRow
key={card.id} key={card.id || `temp-${card.original}`}
className={selected ? 'bg-muted/50' : ''} className={selected ? 'bg-muted/50' : ''}
onClick={() => handleToggleCard(cardIdStr)} onClick={() => handleToggleCard(cardIdStr)}
> >
@ -181,7 +181,7 @@ export function PackCardsManager({
<div className="h-4 w-4 border rounded" /> <div className="h-4 w-4 border rounded" />
)} )}
</TableCell> </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 className="font-medium">{card.original}</TableCell>
<TableCell>{card.translation}</TableCell> <TableCell>{card.translation}</TableCell>
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell> <TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>

View file

@ -62,6 +62,7 @@ export default function CardsPage() {
preview: string preview: string
base64: string base64: string
}>>([]) }>>([])
const [bulkUploadPackId, setBulkUploadPackId] = useState<string | undefined>(undefined)
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -94,10 +95,14 @@ export default function CardsPage() {
// Mutations // Mutations
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card), mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
onSuccess: () => { onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['cards'] }) queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('Card created successfully') toast.success('Card created successfully')
closeDialog() closeDialog()
// Update selectedCard with the new ID from response
if (response?.card?.id) {
setSelectedCard(response.card)
}
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
const errorMessage = isCardsApiError(error) const errorMessage = isCardsApiError(error)
@ -110,10 +115,14 @@ export default function CardsPage() {
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card), mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
onSuccess: () => { onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['cards'] }) queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('Card updated successfully') toast.success('Card updated successfully')
closeDialog() closeDialog()
// Update selectedCard with the updated card from response
if (response?.card) {
setSelectedCard(response.card)
}
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
const errorMessage = isCardsApiError(error) const errorMessage = isCardsApiError(error)
@ -125,7 +134,7 @@ export default function CardsPage() {
}) })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (cardId: number) => cardsApi.deleteCard(cardId), mutationFn: (cardId: string) => cardsApi.deleteCard(cardId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cards'] }) queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('Card deleted successfully') toast.success('Card deleted successfully')
@ -192,8 +201,11 @@ export default function CardsPage() {
return return
} }
// Determine if this is an update or create
const isUpdate = selectedCard !== null && selectedCard.id !== undefined && selectedCard.id !== null && selectedCard.id !== ''
const cardData: GameCardDto = { const cardData: GameCardDto = {
id: selectedCard?.id || -1, id: isUpdate ? selectedCard.id : null,
packId: formData.packId.trim() || undefined, packId: formData.packId.trim() || undefined,
original: formData.original.trim(), original: formData.original.trim(),
translation: formData.translation.trim(), translation: formData.translation.trim(),
@ -205,7 +217,7 @@ export default function CardsPage() {
imageBack: formData.imageBack || undefined, imageBack: formData.imageBack || undefined,
} }
if (selectedCard) { if (isUpdate) {
updateMutation.mutate(cardData) updateMutation.mutate(cardData)
} else { } else {
createMutation.mutate(cardData) createMutation.mutate(cardData)
@ -384,8 +396,8 @@ export default function CardsPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{cards.map((card) => ( {cards.map((card) => (
<TableRow key={card.id}> <TableRow key={card.id || `temp-${card.original}`}>
<TableCell>{card.id}</TableCell> <TableCell>{card.id || '-'}</TableCell>
<TableCell className="font-medium">{card.original}</TableCell> <TableCell className="font-medium">{card.original}</TableCell>
<TableCell>{card.translation}</TableCell> <TableCell>{card.translation}</TableCell>
<TableCell className="max-w-xs truncate">{card.mnemo}</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"> <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) => ( {cards.map((card) => (
<div <div
key={card.id} key={card.id || `temp-${card.original}`}
className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer group" className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => openEditDialog(card)} onClick={() => openEditDialog(card)}
> >
@ -654,7 +666,7 @@ export default function CardsPage() {
</div> </div>
</div> </div>
{selectedCard && ( {selectedCard && selectedCard.id && (
<div className="space-y-2"> <div className="space-y-2">
<CardVoicesManager <CardVoicesManager
cardId={selectedCard.id} cardId={selectedCard.id}
@ -702,14 +714,16 @@ export default function CardsPage() {
<Dialog open={isBulkUploadOpen} onOpenChange={setIsBulkUploadOpen}> <Dialog open={isBulkUploadOpen} onOpenChange={setIsBulkUploadOpen}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<BulkCardUpload <BulkCardUpload
onImagesUploaded={(images) => { onImagesUploaded={(images, packId) => {
setUploadedImages(images) setUploadedImages(images)
setBulkUploadPackId(packId)
setIsBulkUploadOpen(false) setIsBulkUploadOpen(false)
setIsBulkEditorOpen(true) setIsBulkEditorOpen(true)
}} }}
onClose={() => { onClose={() => {
setIsBulkUploadOpen(false) setIsBulkUploadOpen(false)
setUploadedImages([]) setUploadedImages([])
setBulkUploadPackId(undefined)
}} }}
/> />
</DialogContent> </DialogContent>
@ -722,15 +736,18 @@ export default function CardsPage() {
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
<BulkCardEditor <BulkCardEditor
images={uploadedImages} images={uploadedImages}
defaultPackId={bulkUploadPackId}
onComplete={() => { onComplete={() => {
setIsBulkEditorOpen(false) setIsBulkEditorOpen(false)
setUploadedImages([]) setUploadedImages([])
setBulkUploadPackId(undefined)
queryClient.invalidateQueries({ queryKey: ['cards'] }) queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('All cards saved successfully!') toast.success('All cards saved successfully!')
}} }}
onCancel={() => { onCancel={() => {
setIsBulkEditorOpen(false) setIsBulkEditorOpen(false)
setUploadedImages([]) setUploadedImages([])
setBulkUploadPackId(undefined)
}} }}
/> />
</DialogContent> </DialogContent>

View file

@ -1,7 +1,7 @@
// Types based on backend DTOs // Types based on backend DTOs
export interface GameCardDto { export interface GameCardDto {
id: number id: string | null
packId?: string packId?: string
image?: string image?: string
mnemo?: string mnemo?: string

View file

@ -198,7 +198,10 @@ class AdminCardsApiV2 {
// Validate required fields for new cards // Validate required fields for new cards
final cardIdParam = data['id']; 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) { if (!isUpdate) {
// Validate required fields for new cards // Validate required fields for new cards