admin
Some checks are pending
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-18 22:47:17 +03:00
parent f18dd0d7ac
commit a5d1628828
7 changed files with 163 additions and 6 deletions

View file

@ -32,6 +32,11 @@
- Prevents localhost:8000 routing issues during development
- Separate API client for admin endpoints ensures proper server targeting
- **Admin Cards: Voice Upload on Save**: Fixed voice upload when updating/creating cards in admin
- Added "Voice (will be added on Save)" to card editor and upload on Create/Update
- Ensured voices list still loads while the form is disabled during save
- Added unit tests for the upsert+voice flow (`mnemo_cards_admin`)
- **Service Reliability**: Enhanced service management and monitoring
- Port conflict detection and resolution
- Orphaned process cleanup

View file

@ -108,6 +108,9 @@
- Isar collection for voices linked to cards
- Public API for voices list and mp3 delivery
- Web UI playback with error handling and tests
- [x] **Admin Card Voices**: Upload voice on card Create/Update
- Added voice uploader to card editor (upload on Save)
- Added unit tests for upsert+voice flow
- [ ] **User Experience**: Enhanced user experience features
- Offline mode support
- Progressive Web App (PWA)

View file

@ -0,0 +1,87 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { GameCardDto } from '@/types/models'
import { cardsApi } from './cards'
import { voicesApi } from './voices'
import { upsertCardWithOptionalVoice } from './cardsWithVoice'
describe('upsertCardWithOptionalVoice', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('does not add voice when none provided', async () => {
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
success: true,
card: { id: 'card-1' } satisfies GameCardDto,
})
const addVoiceSpy = vi
.spyOn(voicesApi, 'addCardVoice')
.mockResolvedValue({
success: true,
voice: {
id: 'voice-1',
cardId: 'card-1',
voiceUrl: 'BASE64',
language: 'en',
createdAt: new Date(0).toISOString(),
},
})
await upsertCardWithOptionalVoice({ id: null } satisfies GameCardDto)
expect(addVoiceSpy).not.toHaveBeenCalled()
})
it('adds voice after upsert when provided (uses response card id)', async () => {
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
success: true,
card: { id: 'card-42' } satisfies GameCardDto,
})
const addVoiceSpy = vi
.spyOn(voicesApi, 'addCardVoice')
.mockResolvedValue({
success: true,
voice: {
id: 'voice-1',
cardId: 'card-42',
voiceUrl: 'BASE64',
language: 'en',
createdAt: new Date(0).toISOString(),
},
})
await upsertCardWithOptionalVoice(
{ id: null } satisfies GameCardDto,
{ voiceUrl: 'BASE64', language: 'en' },
)
expect(addVoiceSpy).toHaveBeenCalledWith('card-42', 'BASE64', 'en')
})
it('falls back to request card id when response card id is missing', async () => {
vi.spyOn(cardsApi, 'upsertCard').mockResolvedValue({
success: true,
card: { id: null } satisfies GameCardDto,
})
const addVoiceSpy = vi
.spyOn(voicesApi, 'addCardVoice')
.mockResolvedValue({
success: true,
voice: {
id: 'voice-1',
cardId: 'card-req',
voiceUrl: 'BASE64',
language: 'es',
createdAt: new Date(0).toISOString(),
},
})
await upsertCardWithOptionalVoice(
{ id: 'card-req' } satisfies GameCardDto,
{ voiceUrl: 'BASE64', language: 'es' },
)
expect(addVoiceSpy).toHaveBeenCalledWith('card-req', 'BASE64', 'es')
})
})

View file

@ -0,0 +1,27 @@
import type { GameCardDto } from '@/types/models'
import { cardsApi } from './cards'
import { voicesApi } from './voices'
export interface PendingVoiceUpload {
voiceUrl: string
language: string
}
export async function upsertCardWithOptionalVoice(
card: GameCardDto,
voice?: PendingVoiceUpload,
): Promise<{ success: boolean; card: GameCardDto }> {
const response = await cardsApi.upsertCard(card)
const responseCardId = response.card?.id != null ? String(response.card.id) : ''
const requestCardId = card.id != null ? String(card.id) : ''
const cardId = responseCardId || requestCardId
if (!voice || !voice.voiceUrl || !cardId) {
return response
}
await voicesApi.addCardVoice(cardId, voice.voiceUrl, voice.language)
return response
}

View file

@ -1,6 +1,7 @@
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { ImageUpload } from '@/components/ui/image-upload'
import { AudioUpload } from '@/components/ui/audio-upload'
import { CardVoicesManager } from '@/components/CardVoicesManager'
interface CardEditorPreviewProps {
@ -14,6 +15,8 @@ interface CardEditorPreviewProps {
back: string
image: string | undefined
imageBack: string | undefined
voice: string | undefined
voiceLanguage: string
}
onFormDataChange: (updates: Partial<CardEditorPreviewProps['formData']>) => void
cardId?: string
@ -315,6 +318,23 @@ export function CardEditorPreview({
</div>
</div>
<div className="space-y-2">
<AudioUpload
label="Voice (will be added on Save)"
value={formData.voice}
onChange={(voice) => onFormDataChange({ voice })}
language={formData.voiceLanguage}
onLanguageChange={(voiceLanguage) =>
onFormDataChange({ voiceLanguage })
}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
Выберите аудиофайл — он будет загружен и привязан к карточке при
нажатии Create/Update.
</p>
</div>
{/* Voice Controls - only show if cardId exists */}
{cardId && (
<div className="space-y-2">

View file

@ -24,7 +24,8 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
const { data: voicesData, isLoading } = useQuery({
queryKey: ['cardVoices', cardId],
queryFn: () => voicesApi.getCardVoices(cardId),
enabled: !!cardId && !disabled,
// Voices should still load even if parent form is disabled (e.g. during save)
enabled: !!cardId,
})
// Add voice mutation

View file

@ -2,6 +2,7 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { cardsApi, isCardsApiError } from '@/api/cards'
import { upsertCardWithOptionalVoice } from '@/api/cardsWithVoice'
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
import type { GameCardDto, PaginatedResponse } from '@/types/models'
import { Button } from '@/components/ui/button'
@ -73,6 +74,8 @@ export default function CardsPage() {
back: '',
image: undefined as string | undefined,
imageBack: undefined as string | undefined,
voice: undefined as string | undefined,
voiceLanguage: 'en',
})
const limit = 20
@ -92,7 +95,8 @@ export default function CardsPage() {
// Mutations
const createMutation = useMutation({
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
mutationFn: (payload: { card: GameCardDto; voice?: { voiceUrl: string; language: string } }) =>
upsertCardWithOptionalVoice(payload.card, payload.voice),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('Card created successfully')
@ -112,7 +116,8 @@ export default function CardsPage() {
})
const updateMutation = useMutation({
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
mutationFn: (payload: { card: GameCardDto; voice?: { voiceUrl: string; language: string } }) =>
upsertCardWithOptionalVoice(payload.card, payload.voice),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('Card updated successfully')
@ -166,6 +171,8 @@ export default function CardsPage() {
back: '',
image: undefined,
imageBack: undefined,
voice: undefined,
voiceLanguage: 'en',
})
setIsDialogOpen(true)
}
@ -173,7 +180,7 @@ export default function CardsPage() {
const openEditDialog = (card: GameCardDto) => {
setSelectedCard(card)
setFormData({
packId: (card as any).packId || '',
packId: card.packId ?? '',
original: card.original || '',
translation: card.translation || '',
mnemo: card.mnemo || '',
@ -182,6 +189,8 @@ export default function CardsPage() {
back: card.back || '',
image: card.image,
imageBack: card.imageBack,
voice: undefined,
voiceLanguage: 'en',
})
setIsDialogOpen(true)
}
@ -218,10 +227,15 @@ export default function CardsPage() {
imageBack: formData.imageBack || undefined,
}
const voice =
formData.voice && formData.voice.trim()
? { voiceUrl: formData.voice, language: formData.voiceLanguage || 'en' }
: undefined
if (isUpdate) {
updateMutation.mutate(cardData)
updateMutation.mutate({ card: cardData, voice })
} else {
createMutation.mutate(cardData)
createMutation.mutate({ card: cardData, voice })
}
}