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:
@@ -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>
|
||||
)
|
||||
}
|
||||
127
src/components/tts/TTSHeader.tsx
Normal file
127
src/components/tts/TTSHeader.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
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 { Plus, RefreshCw, Search } from 'lucide-react'
|
||||
|
||||
interface TTSHeaderProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
sortBy: string
|
||||
onSortByChange: (sortBy: string) => void
|
||||
sortOrder: 'asc' | 'desc'
|
||||
onSortOrderChange: (order: 'asc' | 'desc') => 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 (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Text to Speech</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Generate speech from text using various TTS providers
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onCreateClick}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Generate TTS
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<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) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<Label htmlFor="sortBy">Sort by</Label>
|
||||
<Select value={sortBy} onValueChange={onSortByChange}>
|
||||
<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') => onSortOrderChange(value)}
|
||||
>
|
||||
<SelectTrigger id="sortOrder" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="desc">Newest</SelectItem>
|
||||
<SelectItem value="asc">Oldest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{ttsCount} generation{ttsCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="text-destructive">Error: {error}</span>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user