From 50e7e75a56e0247d4fed8b2a659c6bf5d57c1948 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 14 Dec 2025 21:49:59 +0300 Subject: [PATCH] voice --- mnemo_cards_admin/src/api/voices.ts | 45 ++++ .../src/components/CardVoicesManager.tsx | 232 +++++++++++++++++ .../src/components/PackCardsManager.tsx | 211 +++++++++++++++ .../src/components/ui/audio-upload.tsx | 240 ++++++++++++++++++ .../src/components/ui/image-upload.tsx | 180 +++++++++++++ mnemo_cards_admin/src/pages/CardsPage.tsx | 40 ++- mnemo_cards_admin/src/pages/PacksPage.tsx | 48 ++++ mnemo_cards_admin/src/types/models.ts | 2 +- .../lib/api/v2/admin_cards_api_v2.dart | 223 +++++++++++++++- .../lib/api/v2/admin_cards_api_v2.g.dart | 7 + .../lib/database/daos/pack_dao.dart | 5 + mnemo_cards_web_v2/codegen.sh | 2 + .../widgets/card_flipper/card_flipper.dart | 7 + .../lib/presentation/widgets/card_viewer.dart | 207 +-------------- .../widgets/card_voice_controls.dart | 214 ++++++++++++++++ 15 files changed, 1451 insertions(+), 212 deletions(-) create mode 100644 mnemo_cards_admin/src/api/voices.ts create mode 100644 mnemo_cards_admin/src/components/CardVoicesManager.tsx create mode 100644 mnemo_cards_admin/src/components/PackCardsManager.tsx create mode 100644 mnemo_cards_admin/src/components/ui/audio-upload.tsx create mode 100644 mnemo_cards_admin/src/components/ui/image-upload.tsx create mode 100644 mnemo_cards_web_v2/codegen.sh create mode 100644 mnemo_cards_web_v2/lib/presentation/widgets/card_voice_controls.dart diff --git a/mnemo_cards_admin/src/api/voices.ts b/mnemo_cards_admin/src/api/voices.ts new file mode 100644 index 0000000..aebb423 --- /dev/null +++ b/mnemo_cards_admin/src/api/voices.ts @@ -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 => { + 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 + }, +} \ No newline at end of file diff --git a/mnemo_cards_admin/src/components/CardVoicesManager.tsx b/mnemo_cards_admin/src/components/CardVoicesManager.tsx new file mode 100644 index 0000000..750b3a5 --- /dev/null +++ b/mnemo_cards_admin/src/components/CardVoicesManager.tsx @@ -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(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 ( +
+
+ + {!showAddForm && ( + + )} +
+ + {/* Add voice form */} + {showAddForm && ( +
+
+ +
+ + +
+
+
+ )} + + {/* Voices list */} + {isLoading ? ( +
Loading voices...
+ ) : voices.length === 0 ? ( +
+ No voices added yet +
+ ) : ( +
+ {voices.map((voice) => ( + handleRemoveVoice(voice.id)} + disabled={disabled || removeVoiceMutation.isPending} + /> + ))} +
+ )} +
+ ) +} + +interface VoiceItemProps { + voice: VoiceDto + onRemove: () => void + disabled?: boolean +} + +function VoiceItem({ voice, onRemove, disabled }: VoiceItemProps) { + const [isPlaying, setIsPlaying] = useState(false) + const audioRef = useRef(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 ( +
+
+ +
+
+ Voice + {voice.language} +
+

+ Added {new Date(voice.createdAt).toLocaleDateString()} +

+
+
+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/mnemo_cards_admin/src/components/PackCardsManager.tsx b/mnemo_cards_admin/src/components/PackCardsManager.tsx new file mode 100644 index 0000000..a3ec0b8 --- /dev/null +++ b/mnemo_cards_admin/src/components/PackCardsManager.tsx @@ -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>(new Set()) + const [removedCards, setRemovedCards] = useState>(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( + 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 ( +
+
+ +
+ + setSearch(e.target.value)} + className="max-w-sm" + disabled={disabled} + /> +
+

+ Selected cards: {selectedCards.size - removedCards.size} / {allCards.length} +

+
+ + {isLoading ? ( +
Loading cards...
+ ) : ( +
+ + + + + ID + Original + Translation + Mnemo + Status + + + + {filteredCards.length === 0 ? ( + + + No cards found + + + ) : ( + filteredCards.map((card) => { + const cardIdStr = String(card.id) + const selected = isCardSelected(cardIdStr) + const inCurrentPack = isCardInCurrentPack(cardIdStr) + const newlyRemoved = removedCards.has(cardIdStr) + + return ( + handleToggleCard(cardIdStr)} + > + + {selected ? ( + + ) : ( +
+ )} + + {card.id} + {card.original} + {card.translation} + {card.mnemo} + + {newlyRemoved ? ( + Removed + ) : selected && inCurrentPack ? ( + In Pack + ) : selected ? ( + Selected + ) : inCurrentPack ? ( + In Pack + ) : null} + + + ) + }) + )} + +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/mnemo_cards_admin/src/components/ui/audio-upload.tsx b/mnemo_cards_admin/src/components/ui/audio-upload.tsx new file mode 100644 index 0000000..e6896b1 --- /dev/null +++ b/mnemo_cards_admin/src/components/ui/audio-upload.tsx @@ -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(null) + const audioRef = useRef(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) => { + 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 ( +
+ {label && } + + {value ? ( +
+
+
+ + + Audio file loaded +
+ +
+ {onLanguageChange && ( +
+ + onLanguageChange(e.target.value)} + placeholder="en" + className="mt-1" + disabled={disabled} + /> +
+ )} +

+ Click to change audio file +

+
+ ) : ( +
+ +
+ +
+ Click to upload or drag and drop +
+

+ MP3, WAV, OGG up to {maxSizeMB}MB +

+
+
+ )} +
+ ) +} + +// Helper function to convert file to base64 +function fileToBase64(file: File): Promise { + 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) + }) +} \ No newline at end of file diff --git a/mnemo_cards_admin/src/components/ui/image-upload.tsx b/mnemo_cards_admin/src/components/ui/image-upload.tsx new file mode 100644 index 0000000..46ea073 --- /dev/null +++ b/mnemo_cards_admin/src/components/ui/image-upload.tsx @@ -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(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) => { + 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 ( +
+ {label && } + + {value ? ( +
+
+ Preview + +
+

+ Click image to change +

+
+ ) : ( +
+ +
+ +
+ Click to upload or drag and drop +
+

+ PNG, JPG, GIF up to {maxSizeMB}MB +

+
+
+ )} +
+ ) +} + +// Helper function to convert file to base64 +function fileToBase64(file: File): Promise { + 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) + }) +} \ No newline at end of file diff --git a/mnemo_cards_admin/src/pages/CardsPage.tsx b/mnemo_cards_admin/src/pages/CardsPage.tsx index 6ff0ada..f0437f7 100644 --- a/mnemo_cards_admin/src/pages/CardsPage.tsx +++ b/mnemo_cards_admin/src/pages/CardsPage.tsx @@ -35,6 +35,8 @@ import { } from '@/components/ui/alert-dialog' import { Label } from '@/components/ui/label' 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' export default function CardsPage() { @@ -54,6 +56,8 @@ export default function CardsPage() { transcription: '', transcriptionMnemo: '', back: '', + image: undefined as string | undefined, + imageBack: undefined as string | undefined, }) const limit = 20 @@ -114,6 +118,8 @@ export default function CardsPage() { transcription: '', transcriptionMnemo: '', back: '', + image: undefined, + imageBack: undefined, }) setIsDialogOpen(true) } @@ -127,6 +133,8 @@ export default function CardsPage() { transcription: card.transcription || '', transcriptionMnemo: card.transcriptionMnemo || '', back: card.back || '', + image: card.image, + imageBack: card.imageBack, }) setIsDialogOpen(true) } @@ -152,6 +160,8 @@ export default function CardsPage() { transcription: formData.transcription.trim() || undefined, transcriptionMnemo: formData.transcriptionMnemo.trim() || undefined, back: formData.back.trim() || undefined, + image: formData.image || undefined, + imageBack: formData.imageBack || undefined, } if (selectedCard) { @@ -314,7 +324,7 @@ export default function CardsPage() { {/* Create/Edit Dialog */} - + {selectedCard ? 'Edit Card' : 'Create New Card'} @@ -390,6 +400,34 @@ export default function CardsPage() { rows={3} /> + +
+
+ setFormData(prev => ({ ...prev, image: value }))} + disabled={createMutation.isPending || updateMutation.isPending} + /> +
+
+ setFormData(prev => ({ ...prev, imageBack: value }))} + disabled={createMutation.isPending || updateMutation.isPending} + /> +
+
+ + {selectedCard && ( +
+ +
+ )}