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
|
- Prevents localhost:8000 routing issues during development
|
||||||
- Separate API client for admin endpoints ensures proper server targeting
|
- 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
|
- **Service Reliability**: Enhanced service management and monitoring
|
||||||
- Port conflict detection and resolution
|
- Port conflict detection and resolution
|
||||||
- Orphaned process cleanup
|
- Orphaned process cleanup
|
||||||
|
|
|
||||||
3
TODO.md
3
TODO.md
|
|
@ -108,6 +108,9 @@
|
||||||
- Isar collection for voices linked to cards
|
- Isar collection for voices linked to cards
|
||||||
- Public API for voices list and mp3 delivery
|
- Public API for voices list and mp3 delivery
|
||||||
- Web UI playback with error handling and tests
|
- 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
|
- [ ] **User Experience**: Enhanced user experience features
|
||||||
- Offline mode support
|
- Offline mode support
|
||||||
- Progressive Web App (PWA)
|
- 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 { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { ImageUpload } from '@/components/ui/image-upload'
|
import { ImageUpload } from '@/components/ui/image-upload'
|
||||||
|
import { AudioUpload } from '@/components/ui/audio-upload'
|
||||||
import { CardVoicesManager } from '@/components/CardVoicesManager'
|
import { CardVoicesManager } from '@/components/CardVoicesManager'
|
||||||
|
|
||||||
interface CardEditorPreviewProps {
|
interface CardEditorPreviewProps {
|
||||||
|
|
@ -14,6 +15,8 @@ interface CardEditorPreviewProps {
|
||||||
back: string
|
back: string
|
||||||
image: string | undefined
|
image: string | undefined
|
||||||
imageBack: string | undefined
|
imageBack: string | undefined
|
||||||
|
voice: string | undefined
|
||||||
|
voiceLanguage: string
|
||||||
}
|
}
|
||||||
onFormDataChange: (updates: Partial<CardEditorPreviewProps['formData']>) => void
|
onFormDataChange: (updates: Partial<CardEditorPreviewProps['formData']>) => void
|
||||||
cardId?: string
|
cardId?: string
|
||||||
|
|
@ -315,6 +318,23 @@ export function CardEditorPreview({
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Voice Controls - only show if cardId exists */}
|
||||||
{cardId && (
|
{cardId && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManage
|
||||||
const { data: voicesData, isLoading } = useQuery({
|
const { data: voicesData, isLoading } = useQuery({
|
||||||
queryKey: ['cardVoices', cardId],
|
queryKey: ['cardVoices', cardId],
|
||||||
queryFn: () => voicesApi.getCardVoices(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
|
// Add voice mutation
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cardsApi, isCardsApiError } from '@/api/cards'
|
import { cardsApi, isCardsApiError } from '@/api/cards'
|
||||||
|
import { upsertCardWithOptionalVoice } from '@/api/cardsWithVoice'
|
||||||
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||||||
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
import type { GameCardDto, PaginatedResponse } from '@/types/models'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -73,6 +74,8 @@ export default function CardsPage() {
|
||||||
back: '',
|
back: '',
|
||||||
image: undefined as string | undefined,
|
image: undefined as string | undefined,
|
||||||
imageBack: undefined as string | undefined,
|
imageBack: undefined as string | undefined,
|
||||||
|
voice: undefined as string | undefined,
|
||||||
|
voiceLanguage: 'en',
|
||||||
})
|
})
|
||||||
|
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
@ -92,7 +95,8 @@ export default function CardsPage() {
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const createMutation = useMutation({
|
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) => {
|
onSuccess: (response) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||||
toast.success('Card created successfully')
|
toast.success('Card created successfully')
|
||||||
|
|
@ -112,7 +116,8 @@ export default function CardsPage() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
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) => {
|
onSuccess: (response) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
queryClient.invalidateQueries({ queryKey: ['cards'] })
|
||||||
toast.success('Card updated successfully')
|
toast.success('Card updated successfully')
|
||||||
|
|
@ -166,6 +171,8 @@ export default function CardsPage() {
|
||||||
back: '',
|
back: '',
|
||||||
image: undefined,
|
image: undefined,
|
||||||
imageBack: undefined,
|
imageBack: undefined,
|
||||||
|
voice: undefined,
|
||||||
|
voiceLanguage: 'en',
|
||||||
})
|
})
|
||||||
setIsDialogOpen(true)
|
setIsDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +180,7 @@ export default function CardsPage() {
|
||||||
const openEditDialog = (card: GameCardDto) => {
|
const openEditDialog = (card: GameCardDto) => {
|
||||||
setSelectedCard(card)
|
setSelectedCard(card)
|
||||||
setFormData({
|
setFormData({
|
||||||
packId: (card as any).packId || '',
|
packId: card.packId ?? '',
|
||||||
original: card.original || '',
|
original: card.original || '',
|
||||||
translation: card.translation || '',
|
translation: card.translation || '',
|
||||||
mnemo: card.mnemo || '',
|
mnemo: card.mnemo || '',
|
||||||
|
|
@ -182,6 +189,8 @@ export default function CardsPage() {
|
||||||
back: card.back || '',
|
back: card.back || '',
|
||||||
image: card.image,
|
image: card.image,
|
||||||
imageBack: card.imageBack,
|
imageBack: card.imageBack,
|
||||||
|
voice: undefined,
|
||||||
|
voiceLanguage: 'en',
|
||||||
})
|
})
|
||||||
setIsDialogOpen(true)
|
setIsDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
@ -218,10 +227,15 @@ export default function CardsPage() {
|
||||||
imageBack: formData.imageBack || undefined,
|
imageBack: formData.imageBack || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voice =
|
||||||
|
formData.voice && formData.voice.trim()
|
||||||
|
? { voiceUrl: formData.voice, language: formData.voiceLanguage || 'en' }
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
updateMutation.mutate(cardData)
|
updateMutation.mutate({ card: cardData, voice })
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(cardData)
|
createMutation.mutate({ card: cardData, voice })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue