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
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:
parent
5df622e574
commit
50e7e75a56
15 changed files with 1451 additions and 212 deletions
45
mnemo_cards_admin/src/api/voices.ts
Normal file
45
mnemo_cards_admin/src/api/voices.ts
Normal 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
|
||||
},
|
||||
}
|
||||
232
mnemo_cards_admin/src/components/CardVoicesManager.tsx
Normal file
232
mnemo_cards_admin/src/components/CardVoicesManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
211
mnemo_cards_admin/src/components/PackCardsManager.tsx
Normal file
211
mnemo_cards_admin/src/components/PackCardsManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
240
mnemo_cards_admin/src/components/ui/audio-upload.tsx
Normal file
240
mnemo_cards_admin/src/components/ui/audio-upload.tsx
Normal 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)
|
||||
})
|
||||
}
|
||||
180
mnemo_cards_admin/src/components/ui/image-upload.tsx
Normal file
180
mnemo_cards_admin/src/components/ui/image-upload.tsx
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedCard ? 'Edit Card' : 'Create New Card'}
|
||||
|
|
@ -390,6 +400,34 @@ export default function CardsPage() {
|
|||
rows={3}
|
||||
/>
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import {
|
|||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
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'
|
||||
|
||||
export default function PacksPage() {
|
||||
|
|
@ -60,8 +62,14 @@ export default function PacksPage() {
|
|||
appStoreId: '',
|
||||
price: '',
|
||||
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
|
||||
|
||||
// Fetch packs
|
||||
|
|
@ -124,7 +132,11 @@ export default function PacksPage() {
|
|||
appStoreId: '',
|
||||
price: '',
|
||||
order: 0,
|
||||
cover: undefined,
|
||||
})
|
||||
setCurrentCardIds([])
|
||||
setCardsToAdd([])
|
||||
setCardsToRemove([])
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +155,13 @@ export default function PacksPage() {
|
|||
appStoreId: fullPack.appStoreId || '',
|
||||
price: fullPack.price || '',
|
||||
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)
|
||||
} catch {
|
||||
toast.error('Failed to load pack details')
|
||||
|
|
@ -153,6 +171,14 @@ export default function PacksPage() {
|
|||
const closeDialog = () => {
|
||||
setIsDialogOpen(false)
|
||||
setSelectedPack(null)
|
||||
setCurrentCardIds([])
|
||||
setCardsToAdd([])
|
||||
setCardsToRemove([])
|
||||
}
|
||||
|
||||
const handleCardsChange = (addIds: string[], removeIds: string[]) => {
|
||||
setCardsToAdd(addIds)
|
||||
setCardsToRemove(removeIds)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
|
|
@ -175,6 +201,9 @@ export default function PacksPage() {
|
|||
appStoreId: formData.appStoreId.trim() || undefined,
|
||||
price: formData.price.trim() || undefined,
|
||||
order: formData.order,
|
||||
cover: formData.cover || undefined,
|
||||
addCardIds: cardsToAdd.length > 0 ? cardsToAdd : undefined,
|
||||
removeCardIds: cardsToRemove.length > 0 ? cardsToRemove : undefined,
|
||||
}
|
||||
|
||||
if (selectedPack) {
|
||||
|
|
@ -481,6 +510,25 @@ export default function PacksPage() {
|
|||
placeholder="Free, $1.99, etc."
|
||||
/>
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface EditCardPackDto {
|
|||
removeTestIds?: string[]
|
||||
previewCards?: string[]
|
||||
order?: number
|
||||
cardsOrder?: number[]
|
||||
cardsOrder?: string[]
|
||||
}
|
||||
|
||||
export interface CardPackPreviewDto {
|
||||
|
|
|
|||
|
|
@ -142,10 +142,11 @@ class AdminCardsApiV2 {
|
|||
'translation': card.translation,
|
||||
'mnemo': card.mnemo,
|
||||
'image': card.image,
|
||||
'imageBack': card.imageBack,
|
||||
'back': card.back,
|
||||
'transcription': card.transcription,
|
||||
'createdAt': card.createdAt.dateTime.toIso8601String(),
|
||||
'updatedAt': card.updatedAt.dateTime.toIso8601String(),
|
||||
'updatedAt': card.updatedAt.dateTime.toIso8601String(),
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
|
@ -158,13 +159,53 @@ class AdminCardsApiV2 {
|
|||
}
|
||||
|
||||
/// POST /api/v2/admin/cards
|
||||
/// Create a new card
|
||||
/// Create or update a card (upsert)
|
||||
@Route.post('/admin/cards')
|
||||
Future<Response> createCard(Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
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(
|
||||
packId: data['packId'],
|
||||
original: data['original'] as String,
|
||||
|
|
@ -177,12 +218,34 @@ class AdminCardsApiV2 {
|
|||
);
|
||||
|
||||
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(
|
||||
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'},
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
print('Error in createCard: $e\n$s');
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': e.toString(), 'success': false}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
|
@ -218,6 +281,7 @@ class AdminCardsApiV2 {
|
|||
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()),
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -13,5 +13,12 @@ Router _$AdminCardsApiV2Router(AdminCardsApiV2 service) {
|
|||
router.add('POST', r'/admin/cards', service.createCard);
|
||||
router.add('PUT', r'/admin/cards/<cardId>', service.updateCard);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,4 +294,9 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
|
|||
Future<VoiceModel?> getVoiceById(String id) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
mnemo_cards_web_v2/codegen.sh
Normal file
2
mnemo_cards_web_v2/codegen.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
|
@ -9,6 +9,7 @@ import 'package:yx_state_flutter/yx_state_flutter.dart';
|
|||
|
||||
import '../../../domain/config/api_config_v2.dart';
|
||||
import '../../../domain/state/card_flipper_state_manager.dart';
|
||||
import '../../../presentation/widgets/card_voice_controls.dart';
|
||||
import '../../../presentation/widgets/mnemo_text.dart';
|
||||
|
||||
/// Card flipper widget for studying cards
|
||||
|
|
@ -772,6 +773,12 @@ class _CardSide extends StatelessWidget {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CardVoiceControls(
|
||||
packId: packId,
|
||||
cardId: card.id,
|
||||
accentColor: packColor,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (card.original != null && card.original!.isNotEmpty)
|
||||
MnemoText(
|
||||
card.original,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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 '../../di/user_scope/user_scope.dart';
|
||||
import '../../domain/config/api_config_v2.dart';
|
||||
import '../../presentation/theme/app_colors.dart';
|
||||
import 'card_voice_controls.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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue