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
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:
parent
f18dd0d7ac
commit
a5d1628828
7 changed files with 163 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
TODO.md
3
TODO.md
|
|
@ -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)
|
||||
|
|
|
|||
87
mnemo_cards_admin/src/api/cardsWithVoice.test.ts
Normal file
87
mnemo_cards_admin/src/api/cardsWithVoice.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
||||
27
mnemo_cards_admin/src/api/cardsWithVoice.ts
Normal file
27
mnemo_cards_admin/src/api/cardsWithVoice.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue