Compare commits

..

12 Commits

Author SHA1 Message Date
JSC
d551223566 Merge branch 'tts'
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
2025-09-21 18:18:57 +02:00
JSC
1d2c27abbd feat: enhance loading states with table structure and improve sound type filtering in SoundsPage 2025-09-21 18:17:55 +02:00
JSC
41659c9299 feat: add badge to display sound type in SoundCard component 2025-09-21 15:39:04 +02:00
JSC
7faf2d38ab feat: add language selection combobox and update TTS dialog for improved language handling 2025-09-21 15:20:12 +02:00
JSC
7ac979a4f4 refactor: remove unnecessary reset of selected provider in CreateTTSDialog 2025-09-21 14:43:27 +02:00
JSC
92846c6d3a feat: implement TTS event handling for creation, completion, and failure in TTSPage and SocketContext 2025-09-21 14:39:15 +02:00
JSC
75b52caf85 refactor: update TASK_TYPES to comment out 'credit_recharge' in CreateTaskDialog and SchedulersHeader
feat: add filter icon to SelectTrigger in SoundsPage for improved UI
2025-09-21 13:56:50 +02:00
JSC
fde19f47c8 refactor: update TTSTable component to improve date formatting and remove Sound ID display 2025-09-21 13:40:56 +02:00
JSC
3516f7f205 fix: update TTS API endpoints for consistency in request and history retrieval 2025-09-21 13:38:05 +02:00
JSC
d48291f6ed feat: add type filter to SoundsPage for improved sound categorization 2025-09-21 13:31:19 +02:00
JSC
620418c405 refactor: update TTSHeader component for improved sorting and search functionality 2025-09-21 13:21:02 +02:00
JSC
6f477a1aa7 feat: add Text to Speech (TTS) functionality with provider selection, generation, and history management
- Implemented CreateTTSDialog for generating TTS from user input.
- Added TTSHeader for search, sorting, and creation controls.
- Created TTSList to display TTS history with filtering and sorting capabilities.
- Developed TTSLoadingStates for handling loading and error states.
- Introduced TTSRow for individual TTS entries with play and delete options.
- Built TTSTable for structured display of TTS history.
- Integrated TTS service API for generating and managing TTS data.
- Added TTSPage to encapsulate the TTS feature with pagination and state management.
2025-09-20 23:11:21 +02:00
18 changed files with 1636 additions and 19 deletions

View File

@@ -15,6 +15,7 @@ import { RegisterPage } from './pages/RegisterPage'
import { SchedulersPage } from './pages/SchedulersPage' import { SchedulersPage } from './pages/SchedulersPage'
import { SequencerPage } from './pages/SequencerPage' import { SequencerPage } from './pages/SequencerPage'
import { SoundsPage } from './pages/SoundsPage' import { SoundsPage } from './pages/SoundsPage'
import { TTSPage } from './pages/TTSPage'
import { SettingsPage } from './pages/admin/SettingsPage' import { SettingsPage } from './pages/admin/SettingsPage'
import { UsersPage } from './pages/admin/UsersPage' import { UsersPage } from './pages/admin/UsersPage'
@@ -112,6 +113,14 @@ function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/tts"
element={
<ProtectedRoute>
<TTSPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/sequencer" path="/sequencer"
element={ element={

View File

@@ -16,6 +16,7 @@ import {
Settings, Settings,
Users, Users,
AudioLines, AudioLines,
Mic,
} from 'lucide-react' } from 'lucide-react'
import { CreditsNav } from './nav/CreditsNav' import { CreditsNav } from './nav/CreditsNav'
import { NavGroup } from './nav/NavGroup' import { NavGroup } from './nav/NavGroup'
@@ -50,6 +51,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
<NavItem href="/sounds" icon={Music} title="Sounds" /> <NavItem href="/sounds" icon={Music} title="Sounds" />
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" /> <NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
<NavItem href="/extractions" icon={Download} title="Extractions" /> <NavItem href="/extractions" icon={Download} title="Extractions" />
<NavItem href="/tts" icon={Mic} title="Text to Speech" />
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" /> <NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
<NavItem href="/sequencer" icon={AudioLines} title="Sequencer (WIP)" /> <NavItem href="/sequencer" icon={AudioLines} title="Sequencer (WIP)" />
</NavGroup> </NavGroup>

View File

@@ -19,7 +19,6 @@ import {
import { soundsService } from '@/lib/api/services/sounds' import { soundsService } from '@/lib/api/services/sounds'
import { PLAYER_EVENTS, playerEvents } from '@/lib/events' import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { import {
ArrowRight, ArrowRight,
ArrowRightToLine, ArrowRightToLine,
@@ -44,6 +43,7 @@ import {
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Playlist } from './Playlist' import { Playlist } from './Playlist'
import { NumberFlowDuration } from '../ui/number-flow-duration'
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar' export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
@@ -422,8 +422,8 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}} }}
/> />
<div className="flex justify-between text-xs text-muted-foreground mt-1"> <div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{formatDuration(state.position)}</span> <NumberFlowDuration duration={state.position} />
<span>{formatDuration(state.duration || 0)}</span> <NumberFlowDuration duration={state.duration || 0} />
</div> </div>
</div> </div>
@@ -645,8 +645,8 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
}} }}
/> />
<div className="flex justify-between text-sm text-muted-foreground mt-2"> <div className="flex justify-between text-sm text-muted-foreground mt-2">
<span>{formatDuration(state.position)}</span> <NumberFlowDuration duration={state.position} />
<span>{formatDuration(state.duration || 0)}</span> <NumberFlowDuration duration={state.duration || 0} />
</div> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@ interface CreateTaskDialogProps {
onCancel: () => void onCancel: () => void
} }
const TASK_TYPES: TaskType[] = ['play_sound', 'play_playlist'] const TASK_TYPES: TaskType[] = [/*'credit_recharge',*/ 'play_sound', 'play_playlist']
const RECURRENCE_TYPES: RecurrenceType[] = ['none', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'cron'] const RECURRENCE_TYPES: RecurrenceType[] = ['none', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'cron']
export function CreateTaskDialog({ export function CreateTaskDialog({

View File

@@ -35,7 +35,7 @@ interface SchedulersHeaderProps {
} }
const TASK_STATUSES: TaskStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled'] const TASK_STATUSES: TaskStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled']
const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist'] const TASK_TYPES: TaskType[] = [/*'credit_recharge',*/ 'play_sound', 'play_playlist']
export function SchedulersHeader({ export function SchedulersHeader({
searchQuery, searchQuery,

View File

@@ -1,4 +1,5 @@
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { type Sound } from '@/lib/api/services/sounds' import { type Sound } from '@/lib/api/services/sounds'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration' import { formatDuration } from '@/utils/format-duration'
@@ -27,6 +28,19 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
onFavoriteToggle(sound.id, !sound.is_favorited) onFavoriteToggle(sound.id, !sound.is_favorited)
} }
const getBadgeVariant = (type: Sound['type']) => {
switch (type) {
case 'SDB':
return 'default'
case 'TTS':
return 'secondary'
case 'EXT':
return 'outline'
default:
return 'default'
}
}
return ( return (
<Card <Card
onClick={handlePlaySound} onClick={handlePlaySound}
@@ -36,6 +50,14 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
)} )}
> >
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1"> <CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
{/* Type badge */}
<Badge
variant={getBadgeVariant(sound.type)}
className="absolute top-2 left-2 text-xs px-1.5 py-0.5 h-5"
>
{sound.type}
</Badge>
{/* Favorite button */} {/* Favorite button */}
<button <button
data-favorite-button data-favorite-button
@@ -43,21 +65,21 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
className={cn( className={cn(
'absolute top-2 right-2 p-1 rounded-full transition-all duration-200 hover:scale-110', 'absolute top-2 right-2 p-1 rounded-full transition-all duration-200 hover:scale-110',
'bg-background/80 hover:bg-background/90 shadow-sm', 'bg-background/80 hover:bg-background/90 shadow-sm',
sound.is_favorited sound.is_favorited
? 'text-red-500 hover:text-red-600' ? 'text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
)} )}
title={sound.is_favorited ? 'Remove from favorites' : 'Add to favorites'} title={sound.is_favorited ? 'Remove from favorites' : 'Add to favorites'}
> >
<Heart <Heart
className={cn( className={cn(
'h-3.5 w-3.5 transition-all duration-200', 'h-3.5 w-3.5 transition-all duration-200',
sound.is_favorited && 'fill-current' sound.is_favorited && 'fill-current'
)} )}
/> />
</button> </button>
<h3 className="font-medium text-s truncate pr-8">{sound.name}</h3> <h3 className="font-medium text-s truncate pl-12 pr-8">{sound.name}</h3>
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground"> <div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
<div className="flex"> <div className="flex">
<Clock className="h-3.5 w-3.5 mr-0.5" /> <Clock className="h-3.5 w-3.5 mr-0.5" />

View File

@@ -0,0 +1,279 @@
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Loader2, Mic } from 'lucide-react'
import { ttsService, type TTSProvider } from '@/lib/api/services/tts'
import { TTS_EVENTS, ttsEvents } from '@/lib/events'
import { toast } from 'sonner'
import { getSortedLanguages, getLanguageDisplayName } from '@/lib/constants/gtts-languages'
interface FormData {
text: string
provider: string
language?: string
slow?: boolean
}
interface CreateTTSDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CreateTTSDialog({ open, onOpenChange }: CreateTTSDialogProps) {
const [selectedProvider, setSelectedProvider] = useState<TTSProvider | null>(null)
const [providers, setProviders] = useState<Record<string, TTSProvider> | null>(null)
const [isLoadingProviders, setIsLoadingProviders] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [formData, setFormData] = useState<FormData>({
text: '',
provider: 'gtts',
language: 'en',
slow: false,
})
// Prepare language options for combobox
const languageOptions: ComboboxOption[] = getSortedLanguages().map((lang) => ({
value: lang.code,
label: getLanguageDisplayName(lang),
searchValue: `${lang.name} ${lang.region || ''} ${lang.code}`.toLowerCase()
}))
// Load providers when dialog opens
useEffect(() => {
if (open && !providers) {
const loadProviders = async () => {
try {
setIsLoadingProviders(true)
const data = await ttsService.getProviders()
setProviders(data)
} catch (error) {
toast.error('Failed to load TTS providers')
} finally {
setIsLoadingProviders(false)
}
}
loadProviders()
}
}, [open, providers])
const generateTTS = async (data: FormData) => {
try {
setIsGenerating(true)
const { text, provider, language, slow } = data
const response = await ttsService.generateTTS({
text,
provider,
options: {
...(language && { lang: language }),
tld: 'com', // Always use .com TLD
...(slow !== undefined && { slow }),
},
})
toast.success(response.message)
onOpenChange(false)
handleReset()
// Emit event for new TTS created
ttsEvents.emit(TTS_EVENTS.TTS_CREATED, response.tts)
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to generate TTS')
} finally {
setIsGenerating(false)
}
}
// Update selected provider when form provider changes
useEffect(() => {
if (providers && formData.provider) {
setSelectedProvider(providers[formData.provider] || null)
}
}, [formData.provider, providers])
const validateForm = (): boolean => {
const errors: Record<string, string> = {}
if (!formData.text.trim()) {
errors.text = 'Text is required'
} else if (formData.text.length > 1000) {
errors.text = 'Text must be less than 1000 characters'
}
if (!formData.provider) {
errors.provider = 'Provider is required'
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validateForm()) {
generateTTS(formData)
}
}
const handleReset = () => {
setFormData({
text: '',
provider: 'gtts',
language: 'en',
slow: false,
})
setFormErrors({})
}
const handleClose = () => {
if (!isGenerating) {
handleReset()
onOpenChange(false)
}
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mic className="h-5 w-5" />
Generate Text to Speech
</DialogTitle>
<DialogDescription>
Convert text to speech using various TTS providers. The audio will be processed and added to your soundboard.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="text">Text</Label>
<Textarea
id="text"
placeholder="Enter the text you want to convert to speech..."
className="min-h-[100px] resize-none"
value={formData.text}
onChange={(e) => setFormData(prev => ({ ...prev, text: e.target.value }))}
/>
<p className="text-sm text-muted-foreground">
Maximum 1000 characters ({formData.text?.length || 0}/1000)
</p>
{formErrors.text && (
<p className="text-sm text-destructive">{formErrors.text}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="provider">Provider</Label>
<Select
value={formData.provider}
onValueChange={(value) => setFormData(prev => ({ ...prev, provider: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a TTS provider" />
</SelectTrigger>
<SelectContent>
{isLoadingProviders ? (
<SelectItem value="loading" disabled>
Loading providers...
</SelectItem>
) : (
providers &&
Object.entries(providers).map(([key, provider]) => (
<SelectItem key={key} value={key}>
<div className="flex items-center gap-2">
<span>{provider.name.toUpperCase()}</span>
<Badge variant="outline" className="text-xs">
{provider.file_extension}
</Badge>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
{formErrors.provider && (
<p className="text-sm text-destructive">{formErrors.provider}</p>
)}
</div>
{selectedProvider && (
<div className="space-y-4 p-4 border rounded-lg bg-muted/50">
<Label className="text-sm font-medium">Provider Options</Label>
{selectedProvider.name === 'gtts' && (
<>
<div className="space-y-2">
<Label htmlFor="language">Language</Label>
<Combobox
value={formData.language}
onValueChange={(value) => setFormData(prev => ({ ...prev, language: value }))}
options={languageOptions}
placeholder="Select language..."
searchPlaceholder="Search languages..."
emptyMessage="No language found."
/>
<p className="text-sm text-muted-foreground">
Choose from {languageOptions.length} supported languages including regional variants
</p>
</div>
<div className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">Slow Speech</Label>
<p className="text-sm text-muted-foreground">
Generate speech at a slower pace
</p>
</div>
<Switch
checked={formData.slow}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, slow: checked }))}
/>
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isGenerating}
>
Cancel
</Button>
<Button type="submit" disabled={isGenerating}>
{isGenerating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Generate TTS
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,131 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { TTSSortField, TTSSortOrder } from '@/lib/api/services/tts'
import { Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
interface TTSHeaderProps {
searchQuery: string
onSearchChange: (query: string) => void
sortBy: TTSSortField
onSortByChange: (sortBy: TTSSortField) => void
sortOrder: TTSSortOrder
onSortOrderChange: (order: TTSSortOrder) => void
onRefresh: () => void
onCreateClick: () => void
loading: boolean
error: string | null
ttsCount: number
}
export function TTSHeader({
searchQuery,
onSearchChange,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
onRefresh,
onCreateClick,
loading,
error,
ttsCount,
}: TTSHeaderProps) {
return (
<>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Text to Speech</h1>
<p className="text-muted-foreground">
Generate speech from text using various TTS providers
</p>
</div>
<div className="flex items-center gap-4">
{!loading && !error && (
<div className="text-sm text-muted-foreground">
{ttsCount} generation{ttsCount !== 1 ? 's' : ''}
</div>
)}
<Button onClick={onCreateClick}>
<Plus className="h-4 w-4 mr-2" />
Generate TTS
</Button>
</div>
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by text or provider..."
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => onSearchChange('')}
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
title="Clear search"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
<div className="flex gap-2">
<Select
value={sortBy}
onValueChange={value => onSortByChange(value as TTSSortField)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at">Created Date</SelectItem>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="provider">Provider</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
>
{sortOrder === 'asc' ? (
<SortAsc className="h-4 w-4" />
) : (
<SortDesc className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={onRefresh}
disabled={loading}
title="Refresh TTS history"
>
<RefreshCw
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,239 @@
import { useState, useEffect, useCallback } from 'react'
import { ttsService, type TTSResponse } from '@/lib/api/services/tts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { TTSRow } from './TTSRow'
import { RefreshCw, Search } from 'lucide-react'
export function TTSList() {
const [ttsHistory, setTTSHistory] = useState<TTSResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState('created_at')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
const [limit, setLimit] = useState(50)
const fetchTTSHistory = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const data = await ttsService.getTTSHistory({ limit })
setTTSHistory(data.tts)
} catch (err) {
setError('Failed to load TTS history')
console.error('Failed to fetch TTS history:', err)
} finally {
setIsLoading(false)
}
}, [limit])
useEffect(() => {
fetchTTSHistory()
}, [fetchTTSHistory])
// Listen for TTS generation events to refresh the list
useEffect(() => {
const handleTTSGenerated = () => {
fetchTTSHistory()
}
window.addEventListener('tts-generated', handleTTSGenerated)
return () => {
window.removeEventListener('tts-generated', handleTTSGenerated)
}
}, [fetchTTSHistory])
const filteredHistory = ttsHistory.filter((tts) =>
tts.text.toLowerCase().includes(searchQuery.toLowerCase()) ||
tts.provider.toLowerCase().includes(searchQuery.toLowerCase())
)
const sortedHistory = [...filteredHistory].sort((a, b) => {
let aValue: any = a[sortBy as keyof TTSResponse]
let bValue: any = b[sortBy as keyof TTSResponse]
// Convert dates to timestamps for comparison
if (sortBy === 'created_at') {
aValue = new Date(aValue).getTime()
bValue = new Date(bValue).getTime()
}
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1
} else {
return aValue < bValue ? 1 : -1
}
})
if (error) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
Failed to load TTS history. Please try again.
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Search and filters */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
TTS History
<Button
variant="ghost"
size="sm"
onClick={fetchTTSHistory}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<Label htmlFor="search">Search</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="search"
placeholder="Search by text or provider..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div>
<Label htmlFor="sortBy">Sort by</Label>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger id="sortBy" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at">Created</SelectItem>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="provider">Provider</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="sortOrder">Order</Label>
<Select
value={sortOrder}
onValueChange={(value: 'asc' | 'desc') => setSortOrder(value)}
>
<SelectTrigger id="sortOrder" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">Newest</SelectItem>
<SelectItem value="asc">Oldest</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="limit">Limit</Label>
<Select
value={limit.toString()}
onValueChange={(value) => setLimit(parseInt(value))}
>
<SelectTrigger id="limit" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Results */}
<div className="space-y-2">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
{/* Badges row */}
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-20" />
</div>
{/* Text content */}
<div className="space-y-1">
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-1">
<Skeleton className="h-5 w-12" />
<Skeleton className="h-5 w-16" />
</div>
</div>
{/* Date and metadata */}
<div className="flex items-center gap-4">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
{/* Play button */}
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : sortedHistory.length === 0 ? (
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
{searchQuery
? 'No TTS generations match your search.'
: 'No TTS generations yet. Create your first one!'}
</div>
</CardContent>
</Card>
) : (
<>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{sortedHistory.length} generation{sortedHistory.length !== 1 ? 's' : ''}
</div>
</div>
<div className="space-y-2">
{sortedHistory.map((tts) => (
<TTSRow key={tts.id} tts={tts} />
))}
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AlertCircle, Mic, RefreshCw } from 'lucide-react'
export function TTSLoading() {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Text</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Options</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell className="max-w-md">
<Skeleton className="h-4 w-3/4" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-16" />
</TableCell>
<TableCell>
<div className="flex gap-1">
<Skeleton className="h-5 w-12" />
<Skeleton className="h-5 w-16" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-6 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
interface TTSErrorProps {
error: string
onRetry: () => void
}
export function TTSError({ error, onRetry }: TTSErrorProps) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<AlertCircle className="h-12 w-12 text-destructive" />
<div className="space-y-2">
<h3 className="text-lg font-semibold">Failed to load TTS generations</h3>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
<Button onClick={onRetry} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try again
</Button>
</div>
</CardContent>
</Card>
)
}
interface TTSEmptyProps {
searchQuery: string
}
export function TTSEmpty({ searchQuery }: TTSEmptyProps) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<Mic className="h-12 w-12 text-muted-foreground" />
<div className="space-y-2">
<h3 className="text-lg font-semibold">
{searchQuery ? 'No TTS generations found' : 'No TTS generations yet'}
</h3>
<p className="text-sm text-muted-foreground">
{searchQuery
? 'Try adjusting your search or create a new TTS generation.'
: 'Create your first text-to-speech generation to get started.'}
</p>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,121 @@
import { format } from 'date-fns'
import { Clock, Mic, Volume2, CheckCircle, Loader } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { type TTSResponse } from '@/lib/api/services/tts'
import { soundsService } from '@/lib/api/services/sounds'
import { toast } from 'sonner'
interface TTSRowProps {
tts: TTSResponse
}
export function TTSRow({ tts }: TTSRowProps) {
const isCompleted = tts.sound_id !== null
const isProcessing = tts.sound_id === null
const handlePlaySound = async () => {
if (!tts.sound_id) {
toast.error('This TTS is still being processed.')
return
}
try {
await soundsService.playSound(tts.sound_id)
} catch (error) {
toast.error('Failed to play the sound.')
}
}
const getProviderColor = (provider: string) => {
const colors = {
gtts: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
}
return colors[provider as keyof typeof colors] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
}
const getStatusBadge = () => {
if (isCompleted) {
return (
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3 w-3" />
Complete
</Badge>
)
} else {
return (
<Badge variant="outline" className="gap-1">
<Loader className="h-3 w-3 animate-spin" />
Processing
</Badge>
)
}
}
return (
<Card className="transition-colors hover:bg-muted/50">
<CardContent className="pt-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Badge className={getProviderColor(tts.provider)}>
<Mic className="mr-1 h-3 w-3" />
{tts.provider.toUpperCase()}
</Badge>
{getStatusBadge()}
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-relaxed">
&quot;{tts.text}&quot;
</p>
{Object.keys(tts.options).length > 0 && (
<div className="flex gap-1 flex-wrap">
{Object.entries(tts.options).map(([key, value]) => (
<Badge key={key} variant="outline" className="text-xs">
{key}: {String(value)}
</Badge>
))}
</div>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{format(new Date(tts.created_at), 'MMM dd, yyyy HH:mm')}
</div>
{tts.sound_id && (
<div className="flex items-center gap-1">
Sound ID: {tts.sound_id}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handlePlaySound}
disabled={isProcessing}
>
<Volume2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isProcessing ? 'Sound is being processed' : 'Play sound'}
</TooltipContent>
</Tooltip>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,193 @@
import { Calendar, CheckCircle, Clock, Loader, Mic, Trash2, Volume2, XCircle } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { type TTSResponse, ttsService } from '@/lib/api/services/tts'
import { soundsService } from '@/lib/api/services/sounds'
import { toast } from 'sonner'
import { formatDateDistanceToNow } from '@/utils/format-date'
interface TTSTableProps {
ttsHistory: TTSResponse[]
onTTSDeleted?: (ttsId: number) => void
}
export function TTSTable({ ttsHistory, onTTSDeleted }: TTSTableProps) {
const handlePlaySound = async (tts: TTSResponse) => {
if (!tts.sound_id) {
toast.error('This TTS is still being processed.')
return
}
try {
await soundsService.playSound(tts.sound_id)
} catch (error) {
toast.error('Failed to play the sound.')
}
}
const handleDeleteTTS = async (tts: TTSResponse) => {
if (!confirm(`Are you sure you want to delete this TTS generation?\n\n"${tts.text}"\n\nThis will also delete the associated sound file and cannot be undone.`)) {
return
}
try {
await ttsService.deleteTTS(tts.id)
toast.success('TTS generation deleted successfully')
onTTSDeleted?.(tts.id)
} catch (error) {
toast.error('Failed to delete TTS generation')
}
}
const getProviderColor = (provider: string) => {
const colors = {
gtts: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
}
return colors[provider as keyof typeof colors] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
}
const getStatusBadge = (tts: TTSResponse) => {
switch (tts.status) {
case 'completed':
return (
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3 w-3" />
Completed
</Badge>
)
case 'processing':
return (
<Badge variant="outline" className="gap-1">
<Loader className="h-3 w-3 animate-spin" />
Processing
</Badge>
)
case 'failed':
return (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Failed
</Badge>
)
case 'pending':
default:
return (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
Pending
</Badge>
)
}
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Text</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Options</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ttsHistory.map((tts) => (
<TableRow key={tts.id}>
<TableCell className="max-w-md">
<div className="truncate font-medium">
"{tts.text}"
</div>
</TableCell>
<TableCell>
<Badge className={getProviderColor(tts.provider)}>
<Mic className="mr-1 h-3 w-3" />
{tts.provider.toUpperCase()}
</Badge>
</TableCell>
<TableCell>
{Object.keys(tts.options).length > 0 ? (
<div className="flex gap-1 flex-wrap">
{Object.entries(tts.options).map(([key, value]) => (
<Badge key={key} variant="outline" className="text-xs">
{key}: {String(value)}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-sm">None</span>
)}
</TableCell>
<TableCell>
<div className="space-y-1">
{getStatusBadge(tts)}
{tts.error && (
<div
className="text-xs text-destructive max-w-48 truncate"
title={tts.error}
>
{tts.error}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatDateDistanceToNow(tts.created_at)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handlePlaySound(tts)}
disabled={!tts.sound_id}
>
<Volume2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{!tts.sound_id ? 'Sound is being processed' : 'Play sound'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTTS(tts)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Delete TTS generation
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -12,11 +12,13 @@ import {
EXTRACTION_EVENTS, EXTRACTION_EVENTS,
PLAYER_EVENTS, PLAYER_EVENTS,
SOUND_EVENTS, SOUND_EVENTS,
TTS_EVENTS,
USER_EVENTS, USER_EVENTS,
authEvents, authEvents,
extractionEvents, extractionEvents,
playerEvents, playerEvents,
soundEvents, soundEvents,
ttsEvents,
userEvents, userEvents,
} from '../lib/events' } from '../lib/events'
import { extractionsService } from '../lib/api/services/extractions' import { extractionsService } from '../lib/api/services/extractions'
@@ -128,10 +130,10 @@ export function SocketProvider({ children }: SocketProviderProps) {
// Listen for extraction status updates // Listen for extraction status updates
newSocket.on('extraction_status_update', data => { newSocket.on('extraction_status_update', data => {
const { extraction_id, status, title, error } = data const { extraction_id, status, title, error } = data
// Emit local event for other components to listen to // Emit local event for other components to listen to
extractionEvents.emit(EXTRACTION_EVENTS.EXTRACTION_STATUS_UPDATED, data) extractionEvents.emit(EXTRACTION_EVENTS.EXTRACTION_STATUS_UPDATED, data)
// Handle specific status events // Handle specific status events
switch (status) { switch (status) {
case 'processing': case 'processing':
@@ -158,6 +160,28 @@ export function SocketProvider({ children }: SocketProviderProps) {
} }
}) })
// Listen for TTS status updates
newSocket.on('tts_completed', data => {
// Emit local event for other components to listen to
ttsEvents.emit(TTS_EVENTS.TTS_COMPLETED, data)
toast.success('TTS generation completed', {
duration: 3000,
})
})
newSocket.on('tts_failed', data => {
const { error } = data
// Emit local event for other components to listen to
ttsEvents.emit(TTS_EVENTS.TTS_FAILED, data)
toast.error('TTS generation failed', {
description: error,
duration: 5000,
})
})
return newSocket return newSocket
}, [user, fetchAndShowOngoingExtractions]) }, [user, fetchAndShowOngoingExtractions])

136
src/lib/api/services/tts.ts Normal file
View File

@@ -0,0 +1,136 @@
import { apiClient } from '../client'
export interface TTSRequest {
text: string
provider?: string
options?: Record<string, any>
}
export interface TTSResponse {
id: number
text: string
provider: string
options: Record<string, any>
status: string
error: string | null
sound_id: number | null
user_id: number
created_at: string
}
export interface TTSGenerateResponse {
message: string
tts: TTSResponse
}
export interface TTSProvider {
name: string
file_extension: string
supported_languages: string[]
option_schema: Record<string, any>
}
export interface TTSProvidersResponse {
[key: string]: TTSProvider
}
export type TTSSortField = 'created_at' | 'text' | 'provider'
export type TTSSortOrder = 'asc' | 'desc'
export interface GetTTSHistoryParams {
search?: string
sort_by?: TTSSortField
sort_order?: TTSSortOrder
page?: number
limit?: number
}
export interface GetTTSHistoryResponse {
tts: TTSResponse[]
total: number
total_pages: number
current_page: number
}
export const ttsService = {
async generateTTS(request: TTSRequest): Promise<TTSGenerateResponse> {
return await apiClient.post('/api/v1/tts', request)
},
async getTTSHistory(params?: GetTTSHistoryParams): Promise<GetTTSHistoryResponse> {
const searchParams = new URLSearchParams()
// Backend currently only supports limit and offset, not page-based pagination
if (params?.limit) {
searchParams.append('limit', params.limit.toString())
}
if (params?.page && params?.limit) {
// Convert page to offset
const offset = (params.page - 1) * params.limit
searchParams.append('offset', offset.toString())
}
const url = searchParams.toString()
? `/api/v1/tts?${searchParams.toString()}`
: '/api/v1/tts'
const ttsArray: TTSResponse[] = await apiClient.get(url)
// Apply client-side filtering and sorting since backend doesn't support them yet
let filteredTTS = ttsArray
if (params?.search) {
const search = params.search.toLowerCase()
filteredTTS = filteredTTS.filter(tts =>
tts.text.toLowerCase().includes(search) ||
tts.provider.toLowerCase().includes(search)
)
}
if (params?.sort_by && params?.sort_order) {
filteredTTS.sort((a, b) => {
let aValue = a[params.sort_by as keyof TTSResponse]
let bValue = b[params.sort_by as keyof TTSResponse]
// Convert dates to timestamps for comparison
if (params.sort_by === 'created_at') {
aValue = new Date(aValue as string).getTime()
bValue = new Date(bValue as string).getTime()
}
// Handle null values
if (aValue === null && bValue === null) return 0
if (aValue === null) return 1
if (bValue === null) return -1
const comparison = aValue > bValue ? 1 : -1
return params.sort_order === 'asc' ? comparison : -comparison
})
}
// Calculate pagination info
const limit = params?.limit || 50
const currentPage = params?.page || 1
const total = filteredTTS.length
const totalPages = Math.ceil(total / limit)
return {
tts: filteredTTS,
total,
total_pages: totalPages,
current_page: currentPage,
}
},
async getProviders(): Promise<TTSProvidersResponse> {
return await apiClient.get('/api/v1/tts/providers')
},
async getProvider(name: string): Promise<TTSProvider> {
return await apiClient.get(`/api/v1/tts/providers/${name}`)
},
async deleteTTS(ttsId: number): Promise<{ message: string }> {
return await apiClient.delete(`/api/v1/tts/${ttsId}`)
},
}

View File

@@ -0,0 +1,115 @@
export interface LanguageOption {
code: string
name: string
region?: string
}
export const GTTS_LANGUAGES: LanguageOption[] = [
{ code: 'af', name: 'Afrikaans' },
{ code: 'ar', name: 'Arabic' },
{ code: 'bg', name: 'Bulgarian' },
{ code: 'bn', name: 'Bengali' },
{ code: 'bs', name: 'Bosnian' },
{ code: 'ca', name: 'Catalan' },
{ code: 'cs', name: 'Czech' },
{ code: 'cy', name: 'Welsh' },
{ code: 'da', name: 'Danish' },
{ code: 'de', name: 'German' },
{ code: 'el', name: 'Greek' },
{ code: 'en', name: 'English', region: 'United States' },
{ code: 'en-au', name: 'English', region: 'Australia' },
{ code: 'en-ca', name: 'English', region: 'Canada' },
{ code: 'en-gb', name: 'English', region: 'UK' },
{ code: 'en-ie', name: 'English', region: 'Ireland' },
{ code: 'en-in', name: 'English', region: 'India' },
{ code: 'en-ng', name: 'English', region: 'Nigeria' },
{ code: 'en-nz', name: 'English', region: 'New Zealand' },
{ code: 'en-ph', name: 'English', region: 'Philippines' },
{ code: 'en-za', name: 'English', region: 'South Africa' },
{ code: 'en-tz', name: 'English', region: 'Tanzania' },
{ code: 'en-uk', name: 'English', region: 'United Kingdom' },
{ code: 'en-us', name: 'English', region: 'United States' },
{ code: 'eo', name: 'Esperanto' },
{ code: 'es', name: 'Spanish', region: 'Spain' },
{ code: 'es-es', name: 'Spanish', region: 'Spain' },
{ code: 'es-mx', name: 'Spanish', region: 'Mexico' },
{ code: 'es-us', name: 'Spanish', region: 'United States' },
{ code: 'et', name: 'Estonian' },
{ code: 'eu', name: 'Basque' },
{ code: 'fa', name: 'Persian' },
{ code: 'fi', name: 'Finnish' },
{ code: 'fr', name: 'French', region: 'France' },
{ code: 'fr-ca', name: 'French', region: 'Canada' },
{ code: 'fr-fr', name: 'French', region: 'France' },
{ code: 'ga', name: 'Irish' },
{ code: 'gu', name: 'Gujarati' },
{ code: 'he', name: 'Hebrew' },
{ code: 'hi', name: 'Hindi' },
{ code: 'hr', name: 'Croatian' },
{ code: 'hu', name: 'Hungarian' },
{ code: 'hy', name: 'Armenian' },
{ code: 'id', name: 'Indonesian' },
{ code: 'is', name: 'Icelandic' },
{ code: 'it', name: 'Italian' },
{ code: 'ja', name: 'Japanese' },
{ code: 'jw', name: 'Javanese' },
{ code: 'ka', name: 'Georgian' },
{ code: 'kk', name: 'Kazakh' },
{ code: 'km', name: 'Khmer' },
{ code: 'kn', name: 'Kannada' },
{ code: 'ko', name: 'Korean' },
{ code: 'la', name: 'Latin' },
{ code: 'lv', name: 'Latvian' },
{ code: 'mk', name: 'Macedonian' },
{ code: 'ml', name: 'Malayalam' },
{ code: 'mr', name: 'Marathi' },
{ code: 'ms', name: 'Malay' },
{ code: 'mt', name: 'Maltese' },
{ code: 'my', name: 'Myanmar (Burmese)' },
{ code: 'ne', name: 'Nepali' },
{ code: 'nl', name: 'Dutch' },
{ code: 'no', name: 'Norwegian' },
{ code: 'pa', name: 'Punjabi' },
{ code: 'pl', name: 'Polish' },
{ code: 'pt', name: 'Portuguese', region: 'Brazil' },
{ code: 'pt-br', name: 'Portuguese', region: 'Brazil' },
{ code: 'pt-pt', name: 'Portuguese', region: 'Portugal' },
{ code: 'ro', name: 'Romanian' },
{ code: 'ru', name: 'Russian' },
{ code: 'si', name: 'Sinhala' },
{ code: 'sk', name: 'Slovak' },
{ code: 'sl', name: 'Slovenian' },
{ code: 'sq', name: 'Albanian' },
{ code: 'sr', name: 'Serbian' },
{ code: 'su', name: 'Sundanese' },
{ code: 'sv', name: 'Swedish' },
{ code: 'sw', name: 'Swahili' },
{ code: 'ta', name: 'Tamil' },
{ code: 'te', name: 'Telugu' },
{ code: 'th', name: 'Thai' },
{ code: 'tl', name: 'Filipino' },
{ code: 'tr', name: 'Turkish' },
{ code: 'uk', name: 'Ukrainian' },
{ code: 'ur', name: 'Urdu' },
{ code: 'vi', name: 'Vietnamese' },
{ code: 'yo', name: 'Yoruba' },
{ code: 'zh', name: 'Chinese (Mandarin)' },
{ code: 'zh-cn', name: 'Chinese', region: 'China' },
{ code: 'zh-tw', name: 'Chinese', region: 'Taiwan' },
{ code: 'zu', name: 'Zulu' }
]
export function getLanguageDisplayName(lang: LanguageOption): string {
if (lang.region) {
return `${lang.name} (${lang.region}) - ${lang.code}`
}
return `${lang.name} - ${lang.code}`
}
export function getSortedLanguages(): LanguageOption[] {
return [...GTTS_LANGUAGES].sort((a, b) => {
const aDisplay = getLanguageDisplayName(a)
const bDisplay = getLanguageDisplayName(b)
return aDisplay.localeCompare(bDisplay)
})
}

View File

@@ -37,6 +37,7 @@ export const playerEvents = new EventEmitter()
export const soundEvents = new EventEmitter() export const soundEvents = new EventEmitter()
export const userEvents = new EventEmitter() export const userEvents = new EventEmitter()
export const extractionEvents = new EventEmitter() export const extractionEvents = new EventEmitter()
export const ttsEvents = new EventEmitter()
// Auth event types // Auth event types
export const AUTH_EVENTS = { export const AUTH_EVENTS = {
@@ -69,3 +70,11 @@ export const EXTRACTION_EVENTS = {
EXTRACTION_COMPLETED: 'extraction_completed', EXTRACTION_COMPLETED: 'extraction_completed',
EXTRACTION_FAILED: 'extraction_failed', EXTRACTION_FAILED: 'extraction_failed',
} as const } as const
// TTS event types
export const TTS_EVENTS = {
TTS_STATUS_UPDATED: 'tts_status_updated',
TTS_CREATED: 'tts_created',
TTS_COMPLETED: 'tts_completed',
TTS_FAILED: 'tts_failed',
} as const

View File

@@ -22,6 +22,7 @@ import { SOUND_EVENTS, soundEvents } from '@/lib/events'
import { useSocket } from '@/contexts/SocketContext' import { useSocket } from '@/contexts/SocketContext'
import { import {
AlertCircle, AlertCircle,
Filter,
Heart, Heart,
RefreshCw, RefreshCw,
Search, Search,
@@ -90,6 +91,7 @@ export function SoundsPage() {
const [sortBy, setSortBy] = useState<SoundSortField>('name') const [sortBy, setSortBy] = useState<SoundSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc') const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false) const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
const [typeFilter, setTypeFilter] = useState<'all' | 'SDB' | 'TTS'>('all')
const handlePlaySound = async (sound: Sound) => { const handlePlaySound = async (sound: Sound) => {
// If WebSocket is connected, use WebSocket for immediate response // If WebSocket is connected, use WebSocket for immediate response
@@ -159,13 +161,18 @@ export function SoundsPage() {
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
const sdbSounds = await soundsService.getSDBSounds({
// Determine types to filter by
const types = typeFilter === 'all' ? ['SDB', 'TTS'] : [typeFilter]
const sounds = await soundsService.getSounds({
types,
search: debouncedSearchQuery.trim() || undefined, search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy, sort_by: sortBy,
sort_order: sortOrder, sort_order: sortOrder,
favorites_only: showFavoritesOnly, favorites_only: showFavoritesOnly,
}) })
setSounds(sdbSounds) setSounds(sounds)
} catch (err) { } catch (err) {
const errorMessage = const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch sounds' err instanceof Error ? err.message : 'Failed to fetch sounds'
@@ -189,7 +196,7 @@ export function SoundsPage() {
useEffect(() => { useEffect(() => {
fetchSounds() fetchSounds()
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly]) }, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly, typeFilter])
// Listen for sound_played events and update play_count // Listen for sound_played events and update play_count
useEffect(() => { useEffect(() => {
@@ -288,9 +295,11 @@ export function SoundsPage() {
{showFavoritesOnly ? 'No favorite sounds found' : 'No sounds found'} {showFavoritesOnly ? 'No favorite sounds found' : 'No sounds found'}
</h3> </h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{showFavoritesOnly {showFavoritesOnly
? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.' ? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.'
: 'No SDB type sounds are available in your library.' : typeFilter === 'all'
? 'No sounds are available in your library.'
: `No ${typeFilter} type sounds are available in your library.`
} }
</p> </p>
</div> </div>
@@ -362,6 +371,21 @@ export function SoundsPage() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select
value={typeFilter}
onValueChange={value => setTypeFilter(value as 'all' | 'SDB' | 'TTS')}
>
<SelectTrigger className="w-[160px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="SDB">Soundboard</SelectItem>
<SelectItem value="TTS">TTS</SelectItem>
</SelectContent>
</Select>
<Select <Select
value={sortBy} value={sortBy}
onValueChange={value => setSortBy(value as SoundSortField)} onValueChange={value => setSortBy(value as SoundSortField)}

201
src/pages/TTSPage.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { AppLayout } from '@/components/AppLayout'
import { AppPagination } from '@/components/AppPagination'
import { CreateTTSDialog } from '@/components/tts/CreateTTSDialog'
import { TTSHeader } from '@/components/tts/TTSHeader'
import {
TTSEmpty,
TTSError,
TTSLoading,
} from '@/components/tts/TTSLoadingStates'
import { TTSTable } from '@/components/tts/TTSTable'
import {
type TTSResponse,
type TTSSortField,
type TTSSortOrder,
ttsService,
} from '@/lib/api/services/tts'
import { TTS_EVENTS, ttsEvents } from '@/lib/events'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
export function TTSPage() {
const [ttsHistory, setTTSHistory] = useState<TTSResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Search and sorting state
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<TTSSortField>('created_at')
const [sortOrder, setSortOrder] = useState<TTSSortOrder>('desc')
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [totalCount, setTotalCount] = useState(0)
const [pageSize, setPageSize] = useState(10)
// Create TTS dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false)
// Debounce search query
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchTTSHistory = useCallback(async () => {
try {
setLoading(true)
setError(null)
const response = await ttsService.getTTSHistory({
search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy,
sort_order: sortOrder,
page: currentPage,
limit: pageSize,
})
setTTSHistory(response.tts)
setTotalPages(response.total_pages)
setTotalCount(response.total)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to fetch TTS history'
setError(errorMessage)
toast.error(errorMessage)
} finally {
setLoading(false)
}
}, [debouncedSearchQuery, sortBy, sortOrder, currentPage, pageSize])
useEffect(() => {
fetchTTSHistory()
}, [fetchTTSHistory])
// Reset to page 1 when filters change
useEffect(() => {
if (currentPage !== 1) {
setCurrentPage(1)
}
}, [debouncedSearchQuery, sortBy, sortOrder, pageSize])
// Listen for TTS events to refresh the list
useEffect(() => {
const handleTTSCompleted = () => {
fetchTTSHistory()
}
const handleTTSFailed = () => {
fetchTTSHistory()
}
const handleTTSCreated = () => {
fetchTTSHistory()
}
// Subscribe to TTS events
ttsEvents.on(TTS_EVENTS.TTS_COMPLETED, handleTTSCompleted)
ttsEvents.on(TTS_EVENTS.TTS_FAILED, handleTTSFailed)
ttsEvents.on(TTS_EVENTS.TTS_CREATED, handleTTSCreated)
return () => {
// Cleanup event listeners
ttsEvents.off(TTS_EVENTS.TTS_COMPLETED, handleTTSCompleted)
ttsEvents.off(TTS_EVENTS.TTS_FAILED, handleTTSFailed)
ttsEvents.off(TTS_EVENTS.TTS_CREATED, handleTTSCreated)
}
}, [fetchTTSHistory])
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
const handlePageSizeChange = (size: number) => {
setPageSize(size)
setCurrentPage(1) // Reset to first page when changing page size
}
const handleTTSDeleted = (ttsId: number) => {
// Remove the deleted TTS from the current list
setTTSHistory(prev => prev.filter(tts => tts.id !== ttsId))
// Update total count
setTotalCount(prev => prev - 1)
// If current page is now empty and not the first page, go to previous page
const remainingOnCurrentPage = ttsHistory.length - 1
if (remainingOnCurrentPage === 0 && currentPage > 1) {
setCurrentPage(currentPage - 1)
}
// Refresh the full list to ensure accuracy
fetchTTSHistory()
}
const renderContent = () => {
if (loading) {
return <TTSLoading />
}
if (error) {
return <TTSError error={error} onRetry={fetchTTSHistory} />
}
if (!ttsHistory || ttsHistory.length === 0) {
return <TTSEmpty searchQuery={searchQuery} />
}
return (
<div className="space-y-4">
<TTSTable
ttsHistory={ttsHistory}
onTTSDeleted={handleTTSDeleted}
/>
<AppPagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
itemName="TTS generations"
/>
</div>
)
}
return (
<AppLayout
breadcrumb={{
items: [{ label: 'Dashboard', href: '/' }, { label: 'Text to Speech' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<TTSHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sortBy={sortBy}
onSortByChange={setSortBy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
onRefresh={fetchTTSHistory}
onCreateClick={() => setShowCreateDialog(true)}
loading={loading}
error={error}
ttsCount={totalCount}
/>
<CreateTTSDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
/>
{renderContent()}
</div>
</AppLayout>
)
}