Compare commits

..

2 Commits

Author SHA1 Message Date
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
10 changed files with 1348 additions and 0 deletions

View File

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

View File

@@ -16,6 +16,7 @@ import {
Settings,
Users,
AudioLines,
Mic,
} from 'lucide-react'
import { CreditsNav } from './nav/CreditsNav'
import { NavGroup } from './nav/NavGroup'
@@ -50,6 +51,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
<NavItem href="/sounds" icon={Music} title="Sounds" />
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
<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="/sequencer" icon={AudioLines} title="Sequencer (WIP)" />
</NavGroup>

View File

@@ -0,0 +1,299 @@
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 { 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 { toast } from 'sonner'
interface FormData {
text: string
provider: string
language?: string
tld?: 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',
tld: 'com',
slow: false,
})
// 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, tld, slow } = data
const response = await ttsService.generateTTS({
text,
provider,
options: {
...(language && { lang: language }),
...(tld && { tld }),
...(slow !== undefined && { slow }),
},
})
toast.success(response.message)
onOpenChange(false)
handleReset()
// Trigger refresh of parent list if needed
window.dispatchEvent(new CustomEvent('tts-generated'))
} 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',
tld: 'com',
slow: false,
})
setFormErrors({})
setSelectedProvider(null)
}
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>
<Select
value={formData.language}
onValueChange={(value) => setFormData(prev => ({ ...prev, language: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent className="max-h-60">
{selectedProvider.supported_languages.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tld">Top-level Domain</Label>
<Select
value={formData.tld}
onValueChange={(value) => setFormData(prev => ({ ...prev, tld: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select TLD" />
</SelectTrigger>
<SelectContent>
{['com', 'co.uk', 'com.au', 'ca', 'co.in', 'ie', 'co.za'].map((tld) => (
<SelectItem key={tld} value={tld}>
{tld}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Different domains may have different voice characteristics
</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,212 @@
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 { Badge } from '@/components/ui/badge'
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)
} 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">
<Skeleton className="h-20 w-full" />
</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,83 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, Mic, RefreshCw } from 'lucide-react'
export function TTSLoading() {
return (
<div className="space-y-4">
{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">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-1">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-16" />
</div>
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-8 w-8" />
</div>
</CardContent>
</Card>
))}
</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,177 @@
import { format } from 'date-fns'
import { CheckCircle, Clock, Loader, Mic, Trash2, Volume2 } 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'
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) => {
const isCompleted = tts.sound_id !== null
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 (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Text</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Status</TableHead>
<TableHead>Options</TableHead>
<TableHead>Created</TableHead>
<TableHead>Sound ID</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>
{getStatusBadge(tts)}
</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="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3 w-3" />
{format(new Date(tts.created_at), 'MMM dd, yyyy HH:mm')}
</div>
</TableCell>
<TableCell>
{tts.sound_id ? (
<span className="text-sm font-mono">{tts.sound_id}</span>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</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>
)
}

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

@@ -0,0 +1,129 @@
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>
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/generate', 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/history?${searchParams.toString()}`
: '/api/v1/tts/history'
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()
}
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}`)
},
}

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

@@ -0,0 +1,185 @@
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 { 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 generation events to refresh the list
useEffect(() => {
const handleTTSGenerated = () => {
fetchTTSHistory()
}
window.addEventListener('tts-generated', handleTTSGenerated)
return () => {
window.removeEventListener('tts-generated', handleTTSGenerated)
}
}, [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>
)
}