voice
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App 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-14 21:49:59 +03:00
parent 5df622e574
commit 50e7e75a56
15 changed files with 1451 additions and 212 deletions

View file

@ -0,0 +1,45 @@
import { adminApiClient } from './client'
export interface VoiceDto {
id: string
cardId: string
voiceUrl: string
language: string
createdAt: string
}
export interface VoiceResponse {
items: VoiceDto[]
}
export const voicesApi = {
// Get all voices for a card
getCardVoices: async (cardId: number | 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,
voiceUrl: string,
language: string = 'en'
): Promise<{ success: boolean; voice: VoiceDto }> => {
const response = await adminApiClient.post(`/api/v2/admin/cards/${cardId}/voices`, {
voiceUrl,
language,
})
return response.data
},
// Remove a voice from a card
removeCardVoice: async (
cardId: number | string,
voiceId: string
): Promise<{ success: boolean }> => {
const response = await adminApiClient.delete(
`/api/v2/admin/cards/${cardId}/voices/${voiceId}`
)
return response.data
},
}

View file

@ -0,0 +1,232 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { voicesApi, type VoiceDto } from '@/api/voices'
import { AudioUpload } from '@/components/ui/audio-upload'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { X, Plus, Music } from 'lucide-react'
import type { AxiosError } from 'axios'
interface CardVoicesManagerProps {
cardId: number
disabled?: boolean
}
export function CardVoicesManager({ cardId, disabled = false }: CardVoicesManagerProps) {
const queryClient = useQueryClient()
const [showAddForm, setShowAddForm] = useState(false)
const [newAudio, setNewAudio] = useState<string | undefined>(undefined)
const [newLanguage, setNewLanguage] = useState('en')
// Load voices for the card
const { data: voicesData, isLoading } = useQuery({
queryKey: ['cardVoices', cardId],
queryFn: () => voicesApi.getCardVoices(cardId),
enabled: !!cardId && !disabled,
})
// Add voice mutation
const addVoiceMutation = useMutation({
mutationFn: ({ voiceUrl, language }: { voiceUrl: string; language: string }) =>
voicesApi.addCardVoice(cardId, voiceUrl, language),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cardVoices', cardId] })
toast.success('Voice added successfully')
setShowAddForm(false)
setNewAudio(undefined)
setNewLanguage('en')
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(axiosError.response?.data?.message || 'Failed to add voice')
},
})
// Remove voice mutation
const removeVoiceMutation = useMutation({
mutationFn: (voiceId: string) => voicesApi.removeCardVoice(cardId, voiceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cardVoices', cardId] })
toast.success('Voice removed successfully')
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(axiosError.response?.data?.message || 'Failed to remove voice')
},
})
const handleAddVoice = () => {
if (!newAudio) {
toast.error('Please upload an audio file')
return
}
addVoiceMutation.mutate({
voiceUrl: newAudio,
language: newLanguage,
})
}
const handleRemoveVoice = (voiceId: string) => {
if (confirm('Are you sure you want to remove this voice?')) {
removeVoiceMutation.mutate(voiceId)
}
}
const voices = voicesData?.items || []
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Card Voices ({voices.length})</Label>
{!showAddForm && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowAddForm(true)}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-2" />
Add Voice
</Button>
)}
</div>
{/* Add voice form */}
{showAddForm && (
<div className="p-4 border rounded-lg bg-muted/50">
<div className="space-y-4">
<AudioUpload
label="Audio File"
value={newAudio}
onChange={setNewAudio}
language={newLanguage}
onLanguageChange={setNewLanguage}
disabled={disabled || addVoiceMutation.isPending}
/>
<div className="flex items-center space-x-2">
<Button
type="button"
size="sm"
onClick={handleAddVoice}
disabled={!newAudio || disabled || addVoiceMutation.isPending}
>
{addVoiceMutation.isPending ? 'Adding...' : 'Add Voice'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowAddForm(false)
setNewAudio(undefined)
setNewLanguage('en')
}}
disabled={disabled || addVoiceMutation.isPending}
>
Cancel
</Button>
</div>
</div>
</div>
)}
{/* Voices list */}
{isLoading ? (
<div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div>
) : voices.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
No voices added yet
</div>
) : (
<div className="space-y-2">
{voices.map((voice) => (
<VoiceItem
key={voice.id}
voice={voice}
onRemove={() => handleRemoveVoice(voice.id)}
disabled={disabled || removeVoiceMutation.isPending}
/>
))}
</div>
)}
</div>
)
}
interface VoiceItemProps {
voice: VoiceDto
onRemove: () => void
disabled?: boolean
}
function VoiceItem({ voice, onRemove, disabled }: VoiceItemProps) {
const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const handlePlayPause = () => {
if (!audioRef.current) {
const audio = new Audio(`data:audio/mpeg;base64,${voice.voiceUrl}`)
audioRef.current = audio
audio.onended = () => {
setIsPlaying(false)
audioRef.current = null
}
audio.onerror = () => {
alert('Failed to play audio')
setIsPlaying(false)
audioRef.current = null
}
audio.play()
setIsPlaying(true)
} else {
if (isPlaying) {
audioRef.current.pause()
setIsPlaying(false)
} else {
audioRef.current.play()
setIsPlaying(true)
}
}
}
return (
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center space-x-3">
<Music className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium">Voice</span>
<Badge variant="outline">{voice.language}</Badge>
</div>
<p className="text-xs text-muted-foreground">
Added {new Date(voice.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handlePlayPause}
disabled={disabled}
>
{isPlaying ? 'Pause' : 'Play'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRemove}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,211 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { cardsApi } from '@/api/cards'
import type { GameCardDto } from '@/types/models'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Search, Plus, X, Check } from 'lucide-react'
interface PackCardsManagerProps {
currentCardIds: string[]
onCardsChange: (addIds: string[], removeIds: string[]) => void
disabled?: boolean
}
export function PackCardsManager({
currentCardIds,
onCardsChange,
disabled = false,
}: PackCardsManagerProps) {
const [search, setSearch] = useState('')
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [removedCards, setRemovedCards] = useState<Set<string>>(new Set())
// Load all cards with search
const { data: cardsData, isLoading } = useQuery({
queryKey: ['cards', 1, 1000, search], // Large limit to get all cards
queryFn: () => cardsApi.getCards({ page: 1, limit: 1000, search }),
enabled: !disabled,
})
// Initialize selected cards from currentCardIds when it changes
useEffect(() => {
const initialSelected = new Set<string>(
currentCardIds.filter((id) => !removedCards.has(id.toString()))
)
setSelectedCards(initialSelected)
// Reset removed cards when currentCardIds changes (e.g., when opening dialog)
setRemovedCards(new Set())
}, [currentCardIds.join(',')]) // Use join to detect array changes
// Calculate which cards to add/remove when selection changes
useEffect(() => {
const currentlySelected = Array.from(selectedCards)
const removed = Array.from(removedCards)
// Cards to add: selected but not in currentCardIds and not removed
const toAdd = currentlySelected.filter(
(id) => !currentCardIds.includes(id) && !removed.includes(id)
)
// Cards to remove: in removedCards
const toRemove = removed.filter((id) => currentCardIds.includes(id))
if (toAdd.length > 0 || toRemove.length > 0) {
onCardsChange(toAdd, toRemove)
} else if (selectedCards.size > 0 || removedCards.size > 0) {
// Also notify if selection was cleared
onCardsChange([], [])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCards.size, removedCards.size, currentCardIds.join(',')])
const handleToggleCard = (cardId: string | number) => {
if (disabled) return
const cardIdStr = String(cardId)
const isCurrentlySelected = selectedCards.has(cardIdStr) && !removedCards.has(cardIdStr)
const isInCurrentPack = currentCardIds.includes(cardIdStr)
if (isCurrentlySelected) {
// Deselect card
const newSelected = new Set(selectedCards)
newSelected.delete(cardIdStr)
setSelectedCards(newSelected)
// If it was in current pack, mark as removed
if (isInCurrentPack) {
setRemovedCards((prev) => new Set([...prev, cardIdStr]))
}
} else {
// Select card
const newSelected = new Set(selectedCards)
newSelected.add(cardIdStr)
setSelectedCards(newSelected)
// If it was marked as removed, unmark it
if (removedCards.has(cardIdStr)) {
setRemovedCards((prev) => {
const newRemoved = new Set(prev)
newRemoved.delete(cardIdStr)
return newRemoved
})
}
}
}
const isCardSelected = (cardId: string | number) => {
const cardIdStr = String(cardId)
return selectedCards.has(cardIdStr) && !removedCards.has(cardIdStr)
}
const isCardInCurrentPack = (cardId: string | number) => {
return currentCardIds.includes(String(cardId))
}
const allCards = cardsData?.items || []
const filteredCards = search
? allCards.filter(
(card) =>
card.original?.toLowerCase().includes(search.toLowerCase()) ||
card.translation?.toLowerCase().includes(search.toLowerCase()) ||
card.mnemo?.toLowerCase().includes(search.toLowerCase())
)
: allCards
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Manage Cards in Pack</Label>
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search cards by original, translation, or mnemo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
disabled={disabled}
/>
</div>
<p className="text-sm text-muted-foreground">
Selected cards: {selectedCards.size - removedCards.size} / {allCards.length}
</p>
</div>
{isLoading ? (
<div className="text-center py-4">Loading cards...</div>
) : (
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>ID</TableHead>
<TableHead>Original</TableHead>
<TableHead>Translation</TableHead>
<TableHead>Mnemo</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCards.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No cards found
</TableCell>
</TableRow>
) : (
filteredCards.map((card) => {
const cardIdStr = String(card.id)
const selected = isCardSelected(cardIdStr)
const inCurrentPack = isCardInCurrentPack(cardIdStr)
const newlyRemoved = removedCards.has(cardIdStr)
return (
<TableRow
key={card.id}
className={selected ? 'bg-muted/50' : ''}
onClick={() => handleToggleCard(cardIdStr)}
>
<TableCell>
{selected ? (
<Check className="h-4 w-4 text-primary" />
) : (
<div className="h-4 w-4 border rounded" />
)}
</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>
<TableCell>
{newlyRemoved ? (
<Badge variant="destructive">Removed</Badge>
) : selected && inCurrentPack ? (
<Badge variant="default">In Pack</Badge>
) : selected ? (
<Badge variant="secondary">Selected</Badge>
) : inCurrentPack ? (
<Badge variant="outline">In Pack</Badge>
) : null}
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,240 @@
import { useRef, useState } from 'react'
import { Button } from './button'
import { Label } from './label'
import { Input } from './input'
import { X, Upload, Music, Play, Pause } from 'lucide-react'
interface AudioUploadProps {
label?: string
value?: string // base64 string
onChange: (value: string | undefined) => void
language?: string
onLanguageChange?: (language: string) => void
accept?: string
maxSizeMB?: number
disabled?: boolean
}
export function AudioUpload({
label,
value,
onChange,
language = 'en',
onLanguageChange,
accept = 'audio/*',
maxSizeMB = 10,
disabled = false,
}: AudioUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const handleFileSelect = async (file: File) => {
// Validate file size
const fileSizeMB = file.size / (1024 * 1024)
if (fileSizeMB > maxSizeMB) {
alert(`File size must be less than ${maxSizeMB}MB`)
return
}
// Validate file type
if (!file.type.startsWith('audio/')) {
alert('Please select an audio file')
return
}
try {
// Convert to base64
const base64 = await fileToBase64(file)
onChange(base64)
} catch (error) {
console.error('Error converting file to base64:', error)
alert('Failed to process audio. Please try again.')
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleFileSelect(file)
}
// Reset input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragging(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (disabled) return
const file = e.dataTransfer.files?.[0]
if (file) {
handleFileSelect(file)
}
}
const handleRemove = () => {
onChange(undefined)
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setIsPlaying(false)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleClick = () => {
if (!disabled) {
fileInputRef.current?.click()
}
}
const handlePlayPause = () => {
if (!value) return
if (!audioRef.current) {
const audio = new Audio(`data:audio/mpeg;base64,${value}`)
audioRef.current = audio
audio.onended = () => {
setIsPlaying(false)
audioRef.current = null
}
audio.onerror = () => {
alert('Failed to play audio')
setIsPlaying(false)
audioRef.current = null
}
}
if (isPlaying) {
audioRef.current?.pause()
setIsPlaying(false)
} else {
audioRef.current?.play()
setIsPlaying(true)
}
}
const audioUrl = value ? `data:audio/mpeg;base64,${value}` : null
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
{value ? (
<div className="relative">
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted">
<div className="flex items-center space-x-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={handlePlayPause}
disabled={disabled}
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Music className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Audio file loaded</span>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemove}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</div>
{onLanguageChange && (
<div className="mt-2">
<Label htmlFor="language" className="text-xs">Language</Label>
<Input
id="language"
value={language}
onChange={(e) => onLanguageChange(e.target.value)}
placeholder="en"
className="mt-1"
disabled={disabled}
/>
</div>
)}
<p className="text-sm text-muted-foreground mt-1">
Click to change audio file
</p>
</div>
) : (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleInputChange}
className="hidden"
disabled={disabled}
/>
<div className="flex flex-col items-center justify-center space-y-2">
<Music className="h-10 w-10 text-muted-foreground" />
<div className="text-sm">
<span className="text-primary font-medium">Click to upload</span> or drag and drop
</div>
<p className="text-xs text-muted-foreground">
MP3, WAV, OGG up to {maxSizeMB}MB
</p>
</div>
</div>
)}
</div>
)
}
// Helper function to convert file to base64
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Remove data:audio/...;base64, prefix
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}

View file

@ -0,0 +1,180 @@
import { useRef, useState } from 'react'
import { Button } from './button'
import { Label } from './label'
import { X, Image as ImageIcon } from 'lucide-react'
interface ImageUploadProps {
label?: string
value?: string // base64 string
onChange: (value: string | undefined) => void
accept?: string
maxSizeMB?: number
disabled?: boolean
}
export function ImageUpload({
label,
value,
onChange,
accept = 'image/*',
maxSizeMB = 5,
disabled = false,
}: ImageUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const handleFileSelect = async (file: File) => {
// Validate file size
const fileSizeMB = file.size / (1024 * 1024)
if (fileSizeMB > maxSizeMB) {
alert(`File size must be less than ${maxSizeMB}MB`)
return
}
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file')
return
}
try {
// Convert to base64 (without data URL prefix)
const base64 = await fileToBase64(file)
onChange(base64)
} catch (error) {
console.error('Error converting file to base64:', error)
alert('Failed to process image. Please try again.')
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleFileSelect(file)
}
// Reset input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragging(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (disabled) return
const file = e.dataTransfer.files?.[0]
if (file) {
handleFileSelect(file)
}
}
const handleRemove = () => {
onChange(undefined)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleClick = () => {
if (!disabled) {
fileInputRef.current?.click()
}
}
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
{value ? (
<div className="relative">
<div className="relative w-full border rounded-lg overflow-hidden bg-muted">
<img
src={`data:image/png;base64,${value}`}
alt="Preview"
className="w-full h-48 object-contain cursor-pointer"
onClick={handleClick}
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2"
onClick={(e) => {
e.stopPropagation()
handleRemove()
}}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground mt-1">
Click image to change
</p>
</div>
) : (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleInputChange}
className="hidden"
disabled={disabled}
/>
<div className="flex flex-col items-center justify-center space-y-2">
<ImageIcon className="h-10 w-10 text-muted-foreground" />
<div className="text-sm">
<span className="text-primary font-medium">Click to upload</span> or drag and drop
</div>
<p className="text-xs text-muted-foreground">
PNG, JPG, GIF up to {maxSizeMB}MB
</p>
</div>
</div>
)}
</div>
)
}
// Helper function to convert file to base64
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Remove data:image/...;base64, prefix
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}

View file

@ -35,6 +35,8 @@ import {
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { ImageUpload } from '@/components/ui/image-upload'
import { CardVoicesManager } from '@/components/CardVoicesManager'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react' import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
export default function CardsPage() { export default function CardsPage() {
@ -54,6 +56,8 @@ export default function CardsPage() {
transcription: '', transcription: '',
transcriptionMnemo: '', transcriptionMnemo: '',
back: '', back: '',
image: undefined as string | undefined,
imageBack: undefined as string | undefined,
}) })
const limit = 20 const limit = 20
@ -114,6 +118,8 @@ export default function CardsPage() {
transcription: '', transcription: '',
transcriptionMnemo: '', transcriptionMnemo: '',
back: '', back: '',
image: undefined,
imageBack: undefined,
}) })
setIsDialogOpen(true) setIsDialogOpen(true)
} }
@ -127,6 +133,8 @@ export default function CardsPage() {
transcription: card.transcription || '', transcription: card.transcription || '',
transcriptionMnemo: card.transcriptionMnemo || '', transcriptionMnemo: card.transcriptionMnemo || '',
back: card.back || '', back: card.back || '',
image: card.image,
imageBack: card.imageBack,
}) })
setIsDialogOpen(true) setIsDialogOpen(true)
} }
@ -152,6 +160,8 @@ export default function CardsPage() {
transcription: formData.transcription.trim() || undefined, transcription: formData.transcription.trim() || undefined,
transcriptionMnemo: formData.transcriptionMnemo.trim() || undefined, transcriptionMnemo: formData.transcriptionMnemo.trim() || undefined,
back: formData.back.trim() || undefined, back: formData.back.trim() || undefined,
image: formData.image || undefined,
imageBack: formData.imageBack || undefined,
} }
if (selectedCard) { if (selectedCard) {
@ -314,7 +324,7 @@ export default function CardsPage() {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[600px]"> <DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{selectedCard ? 'Edit Card' : 'Create New Card'} {selectedCard ? 'Edit Card' : 'Create New Card'}
@ -390,6 +400,34 @@ export default function CardsPage() {
rows={3} rows={3}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<ImageUpload
label="Front Image"
value={formData.image}
onChange={(value) => setFormData(prev => ({ ...prev, image: value }))}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
<div className="space-y-2">
<ImageUpload
label="Back Image"
value={formData.imageBack}
onChange={(value) => setFormData(prev => ({ ...prev, imageBack: value }))}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
</div>
{selectedCard && (
<div className="space-y-2">
<CardVoicesManager
cardId={selectedCard.id}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog}> <Button type="button" variant="outline" onClick={closeDialog}>

View file

@ -36,6 +36,8 @@ import {
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { ImageUpload } from '@/components/ui/image-upload'
import { PackCardsManager } from '@/components/PackCardsManager'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react' import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
export default function PacksPage() { export default function PacksPage() {
@ -60,8 +62,14 @@ export default function PacksPage() {
appStoreId: '', appStoreId: '',
price: '', price: '',
order: 0, order: 0,
cover: undefined as string | undefined,
}) })
// Card management state
const [currentCardIds, setCurrentCardIds] = useState<string[]>([])
const [cardsToAdd, setCardsToAdd] = useState<string[]>([])
const [cardsToRemove, setCardsToRemove] = useState<string[]>([])
const limit = 20 const limit = 20
// Fetch packs // Fetch packs
@ -124,7 +132,11 @@ export default function PacksPage() {
appStoreId: '', appStoreId: '',
price: '', price: '',
order: 0, order: 0,
cover: undefined,
}) })
setCurrentCardIds([])
setCardsToAdd([])
setCardsToRemove([])
setIsDialogOpen(true) setIsDialogOpen(true)
} }
@ -143,7 +155,13 @@ export default function PacksPage() {
appStoreId: fullPack.appStoreId || '', appStoreId: fullPack.appStoreId || '',
price: fullPack.price || '', price: fullPack.price || '',
order: fullPack.order || 0, order: fullPack.order || 0,
cover: fullPack.cover,
}) })
// Initialize current card IDs from addCardIds (which contains all cards in pack)
const cardIds = fullPack.addCardIds?.map((id) => id.toString()) || []
setCurrentCardIds(cardIds)
setCardsToAdd([])
setCardsToRemove([])
setIsDialogOpen(true) setIsDialogOpen(true)
} catch { } catch {
toast.error('Failed to load pack details') toast.error('Failed to load pack details')
@ -153,6 +171,14 @@ export default function PacksPage() {
const closeDialog = () => { const closeDialog = () => {
setIsDialogOpen(false) setIsDialogOpen(false)
setSelectedPack(null) setSelectedPack(null)
setCurrentCardIds([])
setCardsToAdd([])
setCardsToRemove([])
}
const handleCardsChange = (addIds: string[], removeIds: string[]) => {
setCardsToAdd(addIds)
setCardsToRemove(removeIds)
} }
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@ -175,6 +201,9 @@ export default function PacksPage() {
appStoreId: formData.appStoreId.trim() || undefined, appStoreId: formData.appStoreId.trim() || undefined,
price: formData.price.trim() || undefined, price: formData.price.trim() || undefined,
order: formData.order, order: formData.order,
cover: formData.cover || undefined,
addCardIds: cardsToAdd.length > 0 ? cardsToAdd : undefined,
removeCardIds: cardsToRemove.length > 0 ? cardsToRemove : undefined,
} }
if (selectedPack) { if (selectedPack) {
@ -481,6 +510,25 @@ export default function PacksPage() {
placeholder="Free, $1.99, etc." placeholder="Free, $1.99, etc."
/> />
</div> </div>
<div className="space-y-2">
<ImageUpload
label="Cover Image"
value={formData.cover}
onChange={(value) => setFormData(prev => ({ ...prev, cover: value }))}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
{selectedPack && (
<div className="space-y-2">
<PackCardsManager
currentCardIds={currentCardIds}
onCardsChange={handleCardsChange}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog}> <Button type="button" variant="outline" onClick={closeDialog}>

View file

@ -32,7 +32,7 @@ export interface EditCardPackDto {
removeTestIds?: string[] removeTestIds?: string[]
previewCards?: string[] previewCards?: string[]
order?: number order?: number
cardsOrder?: number[] cardsOrder?: string[]
} }
export interface CardPackPreviewDto { export interface CardPackPreviewDto {

View file

@ -142,10 +142,11 @@ class AdminCardsApiV2 {
'translation': card.translation, 'translation': card.translation,
'mnemo': card.mnemo, 'mnemo': card.mnemo,
'image': card.image, 'image': card.image,
'imageBack': card.imageBack,
'back': card.back, 'back': card.back,
'transcription': card.transcription, 'transcription': card.transcription,
'createdAt': card.createdAt.dateTime.toIso8601String(), 'createdAt': card.createdAt.dateTime.toIso8601String(),
'updatedAt': card.updatedAt.dateTime.toIso8601String(), 'updatedAt': card.updatedAt.dateTime.toIso8601String(),
}), }),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
); );
@ -158,13 +159,53 @@ class AdminCardsApiV2 {
} }
/// POST /api/v2/admin/cards /// POST /api/v2/admin/cards
/// Create a new card /// Create or update a card (upsert)
@Route.post('/admin/cards') @Route.post('/admin/cards')
Future<Response> createCard(Request request) async { Future<Response> createCard(Request request) async {
try { try {
final body = await request.readAsString(); final body = await request.readAsString();
final data = json.decode(body) as Map<String, dynamic>; final data = json.decode(body) as Map<String, dynamic>;
// Check if this is an update (has valid id)
final cardIdParam = data['id'];
if (cardIdParam != null && cardIdParam != -1) {
final cardId = cardIdParam.toString();
final existing = await _db.packDao.getCardById(cardId);
if (existing != null) {
// Update existing card
final updated = existing.copyWith(
original: data['original'] ?? existing.original,
translation: data['translation'] ?? existing.translation,
mnemo: data['mnemo'] ?? existing.mnemo,
image: data['image'] ?? existing.image,
imageBack: data['imageBack'] ?? existing.imageBack,
back: data['back'] ?? existing.back,
transcription: data['transcription'] ?? existing.transcription,
updatedAt: PgDateTime(DateTime.now()),
);
await _db.packDao.updateCard(updated);
final cardIdInt = int.tryParse(updated.id) ?? 0;
return Response.ok(
json.encode({
'success': true,
'card': {
'id': cardIdInt,
'packId': updated.packId,
'original': updated.original,
'translation': updated.translation,
'mnemo': updated.mnemo,
'image': updated.image,
'imageBack': updated.imageBack,
'back': updated.back,
'transcription': updated.transcription,
},
}),
headers: {'Content-Type': 'application/json'},
);
}
}
// Create new card
final companion = GameCardsCompanion.insert( final companion = GameCardsCompanion.insert(
packId: data['packId'], packId: data['packId'],
original: data['original'] as String, original: data['original'] as String,
@ -177,12 +218,34 @@ class AdminCardsApiV2 {
); );
final cardId = await _db.packDao.createCard(companion); final cardId = await _db.packDao.createCard(companion);
final created = await _db.packDao.getCardById(cardId);
if (created == null) {
return Response.internalServerError(
body: json.encode({'error': 'Failed to retrieve created card', 'success': false}),
headers: {'Content-Type': 'application/json'},
);
}
final cardIdInt = int.tryParse(created.id) ?? 0;
return Response.ok( return Response.ok(
json.encode({'success': true, 'cardId': cardId}), json.encode({
'success': true,
'card': {
'id': cardIdInt,
'packId': created.packId,
'original': created.original,
'translation': created.translation,
'mnemo': created.mnemo,
'image': created.image,
'imageBack': created.imageBack,
'back': created.back,
'transcription': created.transcription,
},
}),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
); );
} catch (e) { } catch (e, s) {
print('Error in createCard: $e\n$s');
return Response.internalServerError( return Response.internalServerError(
body: json.encode({'error': e.toString(), 'success': false}), body: json.encode({'error': e.toString(), 'success': false}),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -218,6 +281,7 @@ class AdminCardsApiV2 {
translation: data['translation'] ?? existing.translation, translation: data['translation'] ?? existing.translation,
mnemo: data['mnemo'] ?? existing.mnemo, mnemo: data['mnemo'] ?? existing.mnemo,
image: data['image'] ?? existing.image, image: data['image'] ?? existing.image,
imageBack: data['imageBack'] ?? existing.imageBack,
back: data['back'] ?? existing.back, back: data['back'] ?? existing.back,
transcription: data['transcription'] ?? existing.transcription, transcription: data['transcription'] ?? existing.transcription,
updatedAt: PgDateTime(DateTime.now()), updatedAt: PgDateTime(DateTime.now()),
@ -263,5 +327,156 @@ class AdminCardsApiV2 {
} }
} }
/// GET /api/v2/admin/cards/{cardId}/voices
/// Get all voices for a card
@Route.get('/admin/cards/<cardId>/voices')
Future<Response> getCardVoices(Request request, String cardId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
if (cardId.isEmpty) {
return Response.badRequest(
body: json.encode({'error': 'Invalid card ID'}),
headers: {'Content-Type': 'application/json'},
);
}
final voices = await _db.packDao.getCardVoices(cardId);
return Response.ok(
json.encode({
'items': voices.map((voice) => {
'id': voice.id,
'cardId': voice.cardId,
'voiceUrl': voice.voiceUrl,
'language': voice.language,
'createdAt': voice.createdAt.dateTime.toIso8601String(),
}).toList(),
}),
headers: {'Content-Type': 'application/json'},
);
} catch (e, s) {
print('Error in getCardVoices: $e\n$s');
return Response.internalServerError(
body: json.encode({'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// POST /api/v2/admin/cards/{cardId}/voices
/// Add a voice to a card
@Route.post('/admin/cards/<cardId>/voices')
Future<Response> addCardVoice(Request request, String cardId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
if (cardId.isEmpty) {
return Response.badRequest(
body: json.encode({'error': 'Invalid card ID'}),
headers: {'Content-Type': 'application/json'},
);
}
final body = await request.readAsString();
final data = json.decode(body) as Map<String, dynamic>;
final voiceUrl = data['voiceUrl'] as String?;
final language = data['language'] as String? ?? 'en';
if (voiceUrl == null || voiceUrl.isEmpty) {
return Response.badRequest(
body: json.encode({'error': 'voiceUrl is required'}),
headers: {'Content-Type': 'application/json'},
);
}
// Verify card exists
final card = await _db.packDao.getCardById(cardId);
if (card == null) {
return Response.notFound(
json.encode({'error': 'Card not found'}),
headers: {'Content-Type': 'application/json'},
);
}
// Create voice model
final voiceCompanion = VoiceModelsCompanion.insert(
cardId: cardId,
voiceUrl: voiceUrl,
language: language,
);
final voiceId = await _db.packDao.createVoice(voiceCompanion);
// Link voice to card
await _db.packDao.addVoiceToCard(cardId, voiceId);
final voice = await _db.packDao.getVoiceById(voiceId);
return Response.ok(
json.encode({
'success': true,
'voice': voice != null ? {
'id': voice.id,
'cardId': voice.cardId,
'voiceUrl': voice.voiceUrl,
'language': voice.language,
'createdAt': voice.createdAt.dateTime.toIso8601String(),
} : null,
}),
headers: {'Content-Type': 'application/json'},
);
} catch (e, s) {
print('Error in addCardVoice: $e\n$s');
return Response.internalServerError(
body: json.encode({'error': e.toString(), 'success': false}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// DELETE /api/v2/admin/cards/{cardId}/voices/{voiceId}
/// Remove a voice from a card
@Route.delete('/admin/cards/<cardId>/voices/<voiceId>')
Future<Response> removeCardVoice(Request request, String cardId, String voiceId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
if (cardId.isEmpty || voiceId.isEmpty) {
return Response.badRequest(
body: json.encode({'error': 'Invalid card ID or voice ID'}),
headers: {'Content-Type': 'application/json'},
);
}
// Remove voice from card first (removes the relation)
await _db.packDao.removeVoiceFromCard(cardId, voiceId);
// Delete voice model
await _db.packDao.deleteVoice(voiceId);
return Response.ok(
json.encode({'success': true}),
headers: {'Content-Type': 'application/json'},
);
} catch (e, s) {
print('Error in removeCardVoice: $e\n$s');
return Response.internalServerError(
body: json.encode({'error': e.toString(), 'success': false}),
headers: {'Content-Type': 'application/json'},
);
}
}
Router get router => _$AdminCardsApiV2Router(this); Router get router => _$AdminCardsApiV2Router(this);
} }

View file

@ -13,5 +13,12 @@ Router _$AdminCardsApiV2Router(AdminCardsApiV2 service) {
router.add('POST', r'/admin/cards', service.createCard); router.add('POST', r'/admin/cards', service.createCard);
router.add('PUT', r'/admin/cards/<cardId>', service.updateCard); router.add('PUT', r'/admin/cards/<cardId>', service.updateCard);
router.add('DELETE', r'/admin/cards/<cardId>', service.deleteCard); router.add('DELETE', r'/admin/cards/<cardId>', service.deleteCard);
router.add('GET', r'/admin/cards/<cardId>/voices', service.getCardVoices);
router.add('POST', r'/admin/cards/<cardId>/voices', service.addCardVoice);
router.add(
'DELETE',
r'/admin/cards/<cardId>/voices/<voiceId>',
service.removeCardVoice,
);
return router; return router;
} }

View file

@ -294,4 +294,9 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
Future<VoiceModel?> getVoiceById(String id) { Future<VoiceModel?> getVoiceById(String id) {
return (select(voiceModels)..where((v) => v.id.equals(id))).getSingleOrNull(); return (select(voiceModels)..where((v) => v.id.equals(id))).getSingleOrNull();
} }
/// Удалить голосовую модель
Future<void> deleteVoice(String voiceId) async {
await (delete(voiceModels)..where((v) => v.id.equals(voiceId))).go();
}
} }

View file

@ -0,0 +1,2 @@
#!/bin/bash
dart run build_runner build --delete-conflicting-outputs

View file

@ -9,6 +9,7 @@ import 'package:yx_state_flutter/yx_state_flutter.dart';
import '../../../domain/config/api_config_v2.dart'; import '../../../domain/config/api_config_v2.dart';
import '../../../domain/state/card_flipper_state_manager.dart'; import '../../../domain/state/card_flipper_state_manager.dart';
import '../../../presentation/widgets/card_voice_controls.dart';
import '../../../presentation/widgets/mnemo_text.dart'; import '../../../presentation/widgets/mnemo_text.dart';
/// Card flipper widget for studying cards /// Card flipper widget for studying cards
@ -772,6 +773,12 @@ class _CardSide extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CardVoiceControls(
packId: packId,
cardId: card.id,
accentColor: packColor,
),
const SizedBox(height: 12),
if (card.original != null && card.original!.isNotEmpty) if (card.original != null && card.original!.isNotEmpty)
MnemoText( MnemoText(
card.original, card.original,

View file

@ -1,16 +1,14 @@
import 'dart:developer';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart'; import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import '../../di/app_scope/app_scope_container.dart';
import '../../di/user_scope/user_scope.dart'; import '../../di/user_scope/user_scope.dart';
import '../../domain/config/api_config_v2.dart'; import '../../domain/config/api_config_v2.dart';
import '../../presentation/theme/app_colors.dart'; import '../../presentation/theme/app_colors.dart';
import 'card_voice_controls.dart';
import 'mnemo_text.dart'; import 'mnemo_text.dart';
/// Виджет для полноэкранного просмотра карточек /// Виджет для полноэкранного просмотра карточек
@ -574,207 +572,4 @@ class _CardSide extends StatelessWidget {
} }
} }
class CardVoiceControls extends StatefulWidget {
const CardVoiceControls({
required this.packId,
required this.cardId,
required this.accentColor,
super.key,
});
final String packId;
final String cardId;
final Color accentColor;
@override
State<CardVoiceControls> createState() => _CardVoiceControlsState();
}
class _CardVoiceControlsState extends State<CardVoiceControls> {
late Future<List<VoiceDto>> _voicesFuture;
final AudioPlayer _player = AudioPlayer();
bool _isPlaying = false;
String? _currentVoiceId;
String? _playError;
@override
void initState() {
super.initState();
_voicesFuture = _loadVoices();
_player.onPlayerComplete.listen((_) {
if (!mounted) {
return;
}
setState(() {
_isPlaying = false;
});
});
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
Future<List<VoiceDto>> _loadVoices() async {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
if (appScope == null) {
throw Exception('Scope not available');
}
return appScope.httpRepository.getCardVoices(
widget.packId,
widget.cardId,
);
}
Future<void> _playVoice(VoiceDto voice) async {
setState(() {
_playError = null;
_isPlaying = true;
_currentVoiceId = voice.id;
});
try {
await _player.stop();
await _player.play(
UrlSource(ApiConfigV2.getVoiceFileUrl(voice.id)),
);
} catch (e, s) {
log(
'Voice playback failed',
name: 'CardVoiceControls',
error: e,
stackTrace: s,
);
if (!mounted) {
return;
}
setState(() {
_playError = 'Не удалось воспроизвести озвучку';
_isPlaying = false;
});
}
}
Future<void> _stop() async {
await _player.stop();
if (!mounted) {
return;
}
setState(() {
_isPlaying = false;
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<VoiceDto>>(
future: _voicesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: SizedBox(
height: 32,
width: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
color: widget.accentColor,
),
),
),
);
}
if (snapshot.hasError) {
return _errorText(
'Не удалось загрузить озвучку: ${snapshot.error}',
);
}
final voices = snapshot.data ?? const <VoiceDto>[];
if (voices.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...voices.map(_voiceRow),
if (_playError != null) _errorText(_playError!),
],
);
},
);
}
Widget _voiceRow(VoiceDto voice) {
final isCurrent = _currentVoiceId == voice.id && _isPlaying;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
IconButton(
onPressed: isCurrent ? _stop : () => _playVoice(voice),
icon: Icon(isCurrent ? Icons.stop : Icons.play_arrow),
color: widget.accentColor,
tooltip: isCurrent ? 'Остановить' : 'Воспроизвести',
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
voice.phrase,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: widget.accentColor,
),
),
Text(
voice.speaker,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (isCurrent)
const Icon(
Icons.equalizer,
color: Colors.green,
),
],
),
);
}
Widget _errorText(String message) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SelectableText.rich(
TextSpan(
children: [
const WidgetSpan(
child: Icon(
Icons.error_outline,
color: Colors.red,
size: 16,
),
),
const TextSpan(text: ' '),
TextSpan(
text: message,
style: const TextStyle(color: Colors.red),
),
],
),
),
);
}
}

View file

@ -0,0 +1,214 @@
import 'dart:developer';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import '../../di/app_scope/app_scope_container.dart';
import '../../domain/config/api_config_v2.dart';
/// Виджет для управления воспроизведением аудио карточек
class CardVoiceControls extends StatefulWidget {
const CardVoiceControls({
required this.packId,
required this.cardId,
required this.accentColor,
super.key,
});
final String packId;
final String cardId;
final Color accentColor;
@override
State<CardVoiceControls> createState() => _CardVoiceControlsState();
}
class _CardVoiceControlsState extends State<CardVoiceControls> {
late Future<List<VoiceDto>> _voicesFuture;
final AudioPlayer _player = AudioPlayer();
bool _isPlaying = false;
String? _currentVoiceId;
String? _playError;
@override
void initState() {
super.initState();
_voicesFuture = _loadVoices();
_player.onPlayerComplete.listen((_) {
if (!mounted) {
return;
}
setState(() {
_isPlaying = false;
});
});
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
Future<List<VoiceDto>> _loadVoices() async {
final appScope = ScopeProvider.of<AppScopeContainer>(
context,
listen: false,
);
if (appScope == null) {
throw Exception('Scope not available');
}
return appScope.httpRepository.getCardVoices(
widget.packId,
widget.cardId,
);
}
Future<void> _playVoice(VoiceDto voice) async {
setState(() {
_playError = null;
_isPlaying = true;
_currentVoiceId = voice.id;
});
try {
await _player.stop();
await _player.play(
UrlSource(ApiConfigV2.getVoiceFileUrl(voice.id)),
);
} catch (e, s) {
log(
'Voice playback failed',
name: 'CardVoiceControls',
error: e,
stackTrace: s,
);
if (!mounted) {
return;
}
setState(() {
_playError = 'Не удалось воспроизвести озвучку';
_isPlaying = false;
});
}
}
Future<void> _stop() async {
await _player.stop();
if (!mounted) {
return;
}
setState(() {
_isPlaying = false;
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<VoiceDto>>(
future: _voicesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: SizedBox(
height: 32,
width: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
color: widget.accentColor,
),
),
),
);
}
if (snapshot.hasError) {
return _errorText(
'Не удалось загрузить озвучку: ${snapshot.error}',
);
}
final voices = snapshot.data ?? const <VoiceDto>[];
if (voices.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...voices.map(_voiceRow),
if (_playError != null) _errorText(_playError!),
],
);
},
);
}
Widget _voiceRow(VoiceDto voice) {
final isCurrent = _currentVoiceId == voice.id && _isPlaying;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
IconButton(
onPressed: isCurrent ? _stop : () => _playVoice(voice),
icon: Icon(isCurrent ? Icons.stop : Icons.play_arrow),
color: widget.accentColor,
tooltip: isCurrent ? 'Остановить' : 'Воспроизвести',
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
voice.phrase,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: widget.accentColor,
),
),
Text(
voice.speaker,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (isCurrent)
const Icon(
Icons.equalizer,
color: Colors.green,
),
],
),
);
}
Widget _errorText(String message) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SelectableText.rich(
TextSpan(
children: [
const WidgetSpan(
child: Icon(
Icons.error_outline,
color: Colors.red,
size: 16,
),
),
const TextSpan(text: ' '),
TextSpan(
text: message,
style: const TextStyle(color: Colors.red),
),
],
),
),
);
}
}