Compare commits
2 Commits
da4566c789
...
620418c405
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
620418c405 | ||
|
|
6f477a1aa7 |
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
299
src/components/tts/CreateTTSDialog.tsx
Normal file
299
src/components/tts/CreateTTSDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
src/components/tts/TTSHeader.tsx
Normal file
131
src/components/tts/TTSHeader.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
212
src/components/tts/TTSList.tsx
Normal file
212
src/components/tts/TTSList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/components/tts/TTSLoadingStates.tsx
Normal file
83
src/components/tts/TTSLoadingStates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
src/components/tts/TTSRow.tsx
Normal file
121
src/components/tts/TTSRow.tsx
Normal 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">
|
||||
"{tts.text}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
177
src/components/tts/TTSTable.tsx
Normal file
177
src/components/tts/TTSTable.tsx
Normal 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
129
src/lib/api/services/tts.ts
Normal 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
185
src/pages/TTSPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user