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.
This commit is contained in:
JSC
2025-09-20 23:11:21 +02:00
parent da4566c789
commit 6f477a1aa7
10 changed files with 1344 additions and 0 deletions

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>
)
}