Merge branch 'tts'
This commit is contained in:
@@ -15,6 +15,7 @@ import { RegisterPage } from './pages/RegisterPage'
|
|||||||
import { SchedulersPage } from './pages/SchedulersPage'
|
import { SchedulersPage } from './pages/SchedulersPage'
|
||||||
import { SequencerPage } from './pages/SequencerPage'
|
import { SequencerPage } from './pages/SequencerPage'
|
||||||
import { SoundsPage } from './pages/SoundsPage'
|
import { SoundsPage } from './pages/SoundsPage'
|
||||||
|
import { TTSPage } from './pages/TTSPage'
|
||||||
import { SettingsPage } from './pages/admin/SettingsPage'
|
import { SettingsPage } from './pages/admin/SettingsPage'
|
||||||
import { UsersPage } from './pages/admin/UsersPage'
|
import { UsersPage } from './pages/admin/UsersPage'
|
||||||
|
|
||||||
@@ -112,6 +113,14 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tts"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TTSPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/sequencer"
|
path="/sequencer"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
|
Mic,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { CreditsNav } from './nav/CreditsNav'
|
import { CreditsNav } from './nav/CreditsNav'
|
||||||
import { NavGroup } from './nav/NavGroup'
|
import { NavGroup } from './nav/NavGroup'
|
||||||
@@ -50,6 +51,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
|||||||
<NavItem href="/sounds" icon={Music} title="Sounds" />
|
<NavItem href="/sounds" icon={Music} title="Sounds" />
|
||||||
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
|
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
|
||||||
<NavItem href="/extractions" icon={Download} title="Extractions" />
|
<NavItem href="/extractions" icon={Download} title="Extractions" />
|
||||||
|
<NavItem href="/tts" icon={Mic} title="Text to Speech" />
|
||||||
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
|
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
|
||||||
<NavItem href="/sequencer" icon={AudioLines} title="Sequencer (WIP)" />
|
<NavItem href="/sequencer" icon={AudioLines} title="Sequencer (WIP)" />
|
||||||
</NavGroup>
|
</NavGroup>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import { soundsService } from '@/lib/api/services/sounds'
|
import { soundsService } from '@/lib/api/services/sounds'
|
||||||
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
import { PLAYER_EVENTS, playerEvents } from '@/lib/events'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { formatDuration } from '@/utils/format-duration'
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowRightToLine,
|
ArrowRightToLine,
|
||||||
@@ -44,6 +43,7 @@ import {
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Playlist } from './Playlist'
|
import { Playlist } from './Playlist'
|
||||||
|
import { NumberFlowDuration } from '../ui/number-flow-duration'
|
||||||
|
|
||||||
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
|
export type PlayerDisplayMode = 'normal' | 'minimized' | 'maximized' | 'sidebar'
|
||||||
|
|
||||||
@@ -422,8 +422,8 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||||
<span>{formatDuration(state.position)}</span>
|
<NumberFlowDuration duration={state.position} />
|
||||||
<span>{formatDuration(state.duration || 0)}</span>
|
<NumberFlowDuration duration={state.duration || 0} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -645,8 +645,8 @@ export function Player({ className, onPlayerModeChange }: PlayerProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-sm text-muted-foreground mt-2">
|
<div className="flex justify-between text-sm text-muted-foreground mt-2">
|
||||||
<span>{formatDuration(state.position)}</span>
|
<NumberFlowDuration duration={state.position} />
|
||||||
<span>{formatDuration(state.duration || 0)}</span>
|
<NumberFlowDuration duration={state.duration || 0} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface CreateTaskDialogProps {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TASK_TYPES: TaskType[] = ['play_sound', 'play_playlist']
|
const TASK_TYPES: TaskType[] = [/*'credit_recharge',*/ 'play_sound', 'play_playlist']
|
||||||
const RECURRENCE_TYPES: RecurrenceType[] = ['none', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'cron']
|
const RECURRENCE_TYPES: RecurrenceType[] = ['none', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'cron']
|
||||||
|
|
||||||
export function CreateTaskDialog({
|
export function CreateTaskDialog({
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ interface SchedulersHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TASK_STATUSES: TaskStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled']
|
const TASK_STATUSES: TaskStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled']
|
||||||
const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist']
|
const TASK_TYPES: TaskType[] = [/*'credit_recharge',*/ 'play_sound', 'play_playlist']
|
||||||
|
|
||||||
export function SchedulersHeader({
|
export function SchedulersHeader({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { type Sound } from '@/lib/api/services/sounds'
|
import { type Sound } from '@/lib/api/services/sounds'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { formatDuration } from '@/utils/format-duration'
|
import { formatDuration } from '@/utils/format-duration'
|
||||||
@@ -27,6 +28,19 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
|
|||||||
onFavoriteToggle(sound.id, !sound.is_favorited)
|
onFavoriteToggle(sound.id, !sound.is_favorited)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getBadgeVariant = (type: Sound['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'SDB':
|
||||||
|
return 'default'
|
||||||
|
case 'TTS':
|
||||||
|
return 'secondary'
|
||||||
|
case 'EXT':
|
||||||
|
return 'outline'
|
||||||
|
default:
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={handlePlaySound}
|
onClick={handlePlaySound}
|
||||||
@@ -36,6 +50,14 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
|
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
|
||||||
|
{/* Type badge */}
|
||||||
|
<Badge
|
||||||
|
variant={getBadgeVariant(sound.type)}
|
||||||
|
className="absolute top-2 left-2 text-xs px-1.5 py-0.5 h-5"
|
||||||
|
>
|
||||||
|
{sound.type}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
{/* Favorite button */}
|
{/* Favorite button */}
|
||||||
<button
|
<button
|
||||||
data-favorite-button
|
data-favorite-button
|
||||||
@@ -57,7 +79,7 @@ export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }:
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h3 className="font-medium text-s truncate pr-8">{sound.name}</h3>
|
<h3 className="font-medium text-s truncate pl-12 pr-8">{sound.name}</h3>
|
||||||
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
|
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Clock className="h-3.5 w-3.5 mr-0.5" />
|
<Clock className="h-3.5 w-3.5 mr-0.5" />
|
||||||
|
|||||||
279
src/components/tts/CreateTTSDialog.tsx
Normal file
279
src/components/tts/CreateTTSDialog.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Loader2, Mic } from 'lucide-react'
|
||||||
|
import { ttsService, type TTSProvider } from '@/lib/api/services/tts'
|
||||||
|
import { TTS_EVENTS, ttsEvents } from '@/lib/events'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { getSortedLanguages, getLanguageDisplayName } from '@/lib/constants/gtts-languages'
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
text: string
|
||||||
|
provider: string
|
||||||
|
language?: string
|
||||||
|
slow?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTTSDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateTTSDialog({ open, onOpenChange }: CreateTTSDialogProps) {
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<TTSProvider | null>(null)
|
||||||
|
const [providers, setProviders] = useState<Record<string, TTSProvider> | null>(null)
|
||||||
|
const [isLoadingProviders, setIsLoadingProviders] = useState(false)
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
text: '',
|
||||||
|
provider: 'gtts',
|
||||||
|
language: 'en',
|
||||||
|
slow: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prepare language options for combobox
|
||||||
|
const languageOptions: ComboboxOption[] = getSortedLanguages().map((lang) => ({
|
||||||
|
value: lang.code,
|
||||||
|
label: getLanguageDisplayName(lang),
|
||||||
|
searchValue: `${lang.name} ${lang.region || ''} ${lang.code}`.toLowerCase()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Load providers when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !providers) {
|
||||||
|
const loadProviders = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingProviders(true)
|
||||||
|
const data = await ttsService.getProviders()
|
||||||
|
setProviders(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load TTS providers')
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProviders(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadProviders()
|
||||||
|
}
|
||||||
|
}, [open, providers])
|
||||||
|
|
||||||
|
const generateTTS = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
setIsGenerating(true)
|
||||||
|
const { text, provider, language, slow } = data
|
||||||
|
const response = await ttsService.generateTTS({
|
||||||
|
text,
|
||||||
|
provider,
|
||||||
|
options: {
|
||||||
|
...(language && { lang: language }),
|
||||||
|
tld: 'com', // Always use .com TLD
|
||||||
|
...(slow !== undefined && { slow }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.success(response.message)
|
||||||
|
onOpenChange(false)
|
||||||
|
handleReset()
|
||||||
|
|
||||||
|
// Emit event for new TTS created
|
||||||
|
ttsEvents.emit(TTS_EVENTS.TTS_CREATED, response.tts)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to generate TTS')
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected provider when form provider changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (providers && formData.provider) {
|
||||||
|
setSelectedProvider(providers[formData.provider] || null)
|
||||||
|
}
|
||||||
|
}, [formData.provider, providers])
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (!formData.text.trim()) {
|
||||||
|
errors.text = 'Text is required'
|
||||||
|
} else if (formData.text.length > 1000) {
|
||||||
|
errors.text = 'Text must be less than 1000 characters'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.provider) {
|
||||||
|
errors.provider = 'Provider is required'
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors)
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (validateForm()) {
|
||||||
|
generateTTS(formData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFormData({
|
||||||
|
text: '',
|
||||||
|
provider: 'gtts',
|
||||||
|
language: 'en',
|
||||||
|
slow: false,
|
||||||
|
})
|
||||||
|
setFormErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!isGenerating) {
|
||||||
|
handleReset()
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Mic className="h-5 w-5" />
|
||||||
|
Generate Text to Speech
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Convert text to speech using various TTS providers. The audio will be processed and added to your soundboard.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="text">Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="text"
|
||||||
|
placeholder="Enter the text you want to convert to speech..."
|
||||||
|
className="min-h-[100px] resize-none"
|
||||||
|
value={formData.text}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, text: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Maximum 1000 characters ({formData.text?.length || 0}/1000)
|
||||||
|
</p>
|
||||||
|
{formErrors.text && (
|
||||||
|
<p className="text-sm text-destructive">{formErrors.text}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider">Provider</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.provider}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, provider: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a TTS provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{isLoadingProviders ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
Loading providers...
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
providers &&
|
||||||
|
Object.entries(providers).map(([key, provider]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{provider.name.toUpperCase()}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{provider.file_extension}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formErrors.provider && (
|
||||||
|
<p className="text-sm text-destructive">{formErrors.provider}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProvider && (
|
||||||
|
<div className="space-y-4 p-4 border rounded-lg bg-muted/50">
|
||||||
|
<Label className="text-sm font-medium">Provider Options</Label>
|
||||||
|
|
||||||
|
{selectedProvider.name === 'gtts' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language">Language</Label>
|
||||||
|
<Combobox
|
||||||
|
value={formData.language}
|
||||||
|
onValueChange={(value) => setFormData(prev => ({ ...prev, language: value }))}
|
||||||
|
options={languageOptions}
|
||||||
|
placeholder="Select language..."
|
||||||
|
searchPlaceholder="Search languages..."
|
||||||
|
emptyMessage="No language found."
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose from {languageOptions.length} supported languages including regional variants
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-base">Slow Speech</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Generate speech at a slower pace
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.slow}
|
||||||
|
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, slow: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isGenerating}>
|
||||||
|
{isGenerating && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Generate TTS
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
239
src/components/tts/TTSList.tsx
Normal file
239
src/components/tts/TTSList.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
import { ttsService, type TTSResponse } from '@/lib/api/services/tts'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { TTSRow } from './TTSRow'
|
||||||
|
import { RefreshCw, Search } from 'lucide-react'
|
||||||
|
|
||||||
|
export function TTSList() {
|
||||||
|
const [ttsHistory, setTTSHistory] = useState<TTSResponse[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState('created_at')
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||||
|
const [limit, setLimit] = useState(50)
|
||||||
|
|
||||||
|
const fetchTTSHistory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const data = await ttsService.getTTSHistory({ limit })
|
||||||
|
setTTSHistory(data.tts)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load TTS history')
|
||||||
|
console.error('Failed to fetch TTS history:', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [limit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTTSHistory()
|
||||||
|
}, [fetchTTSHistory])
|
||||||
|
|
||||||
|
// Listen for TTS generation events to refresh the list
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTTSGenerated = () => {
|
||||||
|
fetchTTSHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('tts-generated', handleTTSGenerated)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('tts-generated', handleTTSGenerated)
|
||||||
|
}
|
||||||
|
}, [fetchTTSHistory])
|
||||||
|
|
||||||
|
const filteredHistory = ttsHistory.filter((tts) =>
|
||||||
|
tts.text.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
tts.provider.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedHistory = [...filteredHistory].sort((a, b) => {
|
||||||
|
let aValue: any = a[sortBy as keyof TTSResponse]
|
||||||
|
let bValue: any = b[sortBy as keyof TTSResponse]
|
||||||
|
|
||||||
|
// Convert dates to timestamps for comparison
|
||||||
|
if (sortBy === 'created_at') {
|
||||||
|
aValue = new Date(aValue).getTime()
|
||||||
|
bValue = new Date(bValue).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
Failed to load TTS history. Please try again.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search and filters */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
TTS History
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchTTSHistory}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="search">Search</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="search"
|
||||||
|
placeholder="Search by text or provider..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sortBy">Sort by</Label>
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger id="sortBy" className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="created_at">Created</SelectItem>
|
||||||
|
<SelectItem value="text">Text</SelectItem>
|
||||||
|
<SelectItem value="provider">Provider</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sortOrder">Order</Label>
|
||||||
|
<Select
|
||||||
|
value={sortOrder}
|
||||||
|
onValueChange={(value: 'asc' | 'desc') => setSortOrder(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="sortOrder" className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="desc">Newest</SelectItem>
|
||||||
|
<SelectItem value="asc">Oldest</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="limit">Limit</Label>
|
||||||
|
<Select
|
||||||
|
value={limit.toString()}
|
||||||
|
onValueChange={(value) => setLimit(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="limit" className="w-20">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="25">25</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
<SelectItem value="100">100</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{/* Badges row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Skeleton className="h-5 w-12" />
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date and metadata */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Play button */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-8 w-8 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sortedHistory.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
{searchQuery
|
||||||
|
? 'No TTS generations match your search.'
|
||||||
|
: 'No TTS generations yet. Create your first one!'}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{sortedHistory.length} generation{sortedHistory.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedHistory.map((tts) => (
|
||||||
|
<TTSRow key={tts.id} tts={tts} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
src/components/tts/TTSLoadingStates.tsx
Normal file
112
src/components/tts/TTSLoadingStates.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { AlertCircle, Mic, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
export function TTSLoading() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Text</TableHead>
|
||||||
|
<TableHead>Provider</TableHead>
|
||||||
|
<TableHead>Options</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="max-w-md">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Skeleton className="h-5 w-12" />
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-6 w-20" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TTSErrorProps {
|
||||||
|
error: string
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TTSError({ error, onRetry }: TTSErrorProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Failed to load TTS generations</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onRetry} variant="outline">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TTSEmptyProps {
|
||||||
|
searchQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TTSEmpty({ searchQuery }: TTSEmptyProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||||
|
<Mic className="h-12 w-12 text-muted-foreground" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{searchQuery ? 'No TTS generations found' : 'No TTS generations yet'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{searchQuery
|
||||||
|
? 'Try adjusting your search or create a new TTS generation.'
|
||||||
|
: 'Create your first text-to-speech generation to get started.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
193
src/components/tts/TTSTable.tsx
Normal file
193
src/components/tts/TTSTable.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Calendar, CheckCircle, Clock, Loader, Mic, Trash2, Volume2, XCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { type TTSResponse, ttsService } from '@/lib/api/services/tts'
|
||||||
|
import { soundsService } from '@/lib/api/services/sounds'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { formatDateDistanceToNow } from '@/utils/format-date'
|
||||||
|
|
||||||
|
interface TTSTableProps {
|
||||||
|
ttsHistory: TTSResponse[]
|
||||||
|
onTTSDeleted?: (ttsId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TTSTable({ ttsHistory, onTTSDeleted }: TTSTableProps) {
|
||||||
|
const handlePlaySound = async (tts: TTSResponse) => {
|
||||||
|
if (!tts.sound_id) {
|
||||||
|
toast.error('This TTS is still being processed.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await soundsService.playSound(tts.sound_id)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to play the sound.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTTS = async (tts: TTSResponse) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete this TTS generation?\n\n"${tts.text}"\n\nThis will also delete the associated sound file and cannot be undone.`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ttsService.deleteTTS(tts.id)
|
||||||
|
toast.success('TTS generation deleted successfully')
|
||||||
|
onTTSDeleted?.(tts.id)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete TTS generation')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProviderColor = (provider: string) => {
|
||||||
|
const colors = {
|
||||||
|
gtts: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
}
|
||||||
|
return colors[provider as keyof typeof colors] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (tts: TTSResponse) => {
|
||||||
|
switch (tts.status) {
|
||||||
|
case 'completed':
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'processing':
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Loader className="h-3 w-3 animate-spin" />
|
||||||
|
Processing
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'failed':
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Failed
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'pending':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Text</TableHead>
|
||||||
|
<TableHead>Provider</TableHead>
|
||||||
|
<TableHead>Options</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[120px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{ttsHistory.map((tts) => (
|
||||||
|
<TableRow key={tts.id}>
|
||||||
|
<TableCell className="max-w-md">
|
||||||
|
<div className="truncate font-medium">
|
||||||
|
"{tts.text}"
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={getProviderColor(tts.provider)}>
|
||||||
|
<Mic className="mr-1 h-3 w-3" />
|
||||||
|
{tts.provider.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{Object.keys(tts.options).length > 0 ? (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{Object.entries(tts.options).map(([key, value]) => (
|
||||||
|
<Badge key={key} variant="outline" className="text-xs">
|
||||||
|
{key}: {String(value)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">None</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{getStatusBadge(tts)}
|
||||||
|
{tts.error && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-destructive max-w-48 truncate"
|
||||||
|
title={tts.error}
|
||||||
|
>
|
||||||
|
{tts.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatDateDistanceToNow(tts.created_at)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePlaySound(tts)}
|
||||||
|
disabled={!tts.sound_id}
|
||||||
|
>
|
||||||
|
<Volume2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{!tts.sound_id ? 'Sound is being processed' : 'Play sound'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteTTS(tts)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Delete TTS generation
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,11 +12,13 @@ import {
|
|||||||
EXTRACTION_EVENTS,
|
EXTRACTION_EVENTS,
|
||||||
PLAYER_EVENTS,
|
PLAYER_EVENTS,
|
||||||
SOUND_EVENTS,
|
SOUND_EVENTS,
|
||||||
|
TTS_EVENTS,
|
||||||
USER_EVENTS,
|
USER_EVENTS,
|
||||||
authEvents,
|
authEvents,
|
||||||
extractionEvents,
|
extractionEvents,
|
||||||
playerEvents,
|
playerEvents,
|
||||||
soundEvents,
|
soundEvents,
|
||||||
|
ttsEvents,
|
||||||
userEvents,
|
userEvents,
|
||||||
} from '../lib/events'
|
} from '../lib/events'
|
||||||
import { extractionsService } from '../lib/api/services/extractions'
|
import { extractionsService } from '../lib/api/services/extractions'
|
||||||
@@ -158,6 +160,28 @@ export function SocketProvider({ children }: SocketProviderProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Listen for TTS status updates
|
||||||
|
newSocket.on('tts_completed', data => {
|
||||||
|
// Emit local event for other components to listen to
|
||||||
|
ttsEvents.emit(TTS_EVENTS.TTS_COMPLETED, data)
|
||||||
|
|
||||||
|
toast.success('TTS generation completed', {
|
||||||
|
duration: 3000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
newSocket.on('tts_failed', data => {
|
||||||
|
const { error } = data
|
||||||
|
|
||||||
|
// Emit local event for other components to listen to
|
||||||
|
ttsEvents.emit(TTS_EVENTS.TTS_FAILED, data)
|
||||||
|
|
||||||
|
toast.error('TTS generation failed', {
|
||||||
|
description: error,
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return newSocket
|
return newSocket
|
||||||
}, [user, fetchAndShowOngoingExtractions])
|
}, [user, fetchAndShowOngoingExtractions])
|
||||||
|
|
||||||
|
|||||||
136
src/lib/api/services/tts.ts
Normal file
136
src/lib/api/services/tts.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { apiClient } from '../client'
|
||||||
|
|
||||||
|
export interface TTSRequest {
|
||||||
|
text: string
|
||||||
|
provider?: string
|
||||||
|
options?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSResponse {
|
||||||
|
id: number
|
||||||
|
text: string
|
||||||
|
provider: string
|
||||||
|
options: Record<string, any>
|
||||||
|
status: string
|
||||||
|
error: string | null
|
||||||
|
sound_id: number | null
|
||||||
|
user_id: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSGenerateResponse {
|
||||||
|
message: string
|
||||||
|
tts: TTSResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSProvider {
|
||||||
|
name: string
|
||||||
|
file_extension: string
|
||||||
|
supported_languages: string[]
|
||||||
|
option_schema: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSProvidersResponse {
|
||||||
|
[key: string]: TTSProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TTSSortField = 'created_at' | 'text' | 'provider'
|
||||||
|
export type TTSSortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export interface GetTTSHistoryParams {
|
||||||
|
search?: string
|
||||||
|
sort_by?: TTSSortField
|
||||||
|
sort_order?: TTSSortOrder
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTTSHistoryResponse {
|
||||||
|
tts: TTSResponse[]
|
||||||
|
total: number
|
||||||
|
total_pages: number
|
||||||
|
current_page: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ttsService = {
|
||||||
|
async generateTTS(request: TTSRequest): Promise<TTSGenerateResponse> {
|
||||||
|
return await apiClient.post('/api/v1/tts', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTTSHistory(params?: GetTTSHistoryParams): Promise<GetTTSHistoryResponse> {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
|
// Backend currently only supports limit and offset, not page-based pagination
|
||||||
|
if (params?.limit) {
|
||||||
|
searchParams.append('limit', params.limit.toString())
|
||||||
|
}
|
||||||
|
if (params?.page && params?.limit) {
|
||||||
|
// Convert page to offset
|
||||||
|
const offset = (params.page - 1) * params.limit
|
||||||
|
searchParams.append('offset', offset.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = searchParams.toString()
|
||||||
|
? `/api/v1/tts?${searchParams.toString()}`
|
||||||
|
: '/api/v1/tts'
|
||||||
|
|
||||||
|
const ttsArray: TTSResponse[] = await apiClient.get(url)
|
||||||
|
|
||||||
|
// Apply client-side filtering and sorting since backend doesn't support them yet
|
||||||
|
let filteredTTS = ttsArray
|
||||||
|
|
||||||
|
if (params?.search) {
|
||||||
|
const search = params.search.toLowerCase()
|
||||||
|
filteredTTS = filteredTTS.filter(tts =>
|
||||||
|
tts.text.toLowerCase().includes(search) ||
|
||||||
|
tts.provider.toLowerCase().includes(search)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.sort_by && params?.sort_order) {
|
||||||
|
filteredTTS.sort((a, b) => {
|
||||||
|
let aValue = a[params.sort_by as keyof TTSResponse]
|
||||||
|
let bValue = b[params.sort_by as keyof TTSResponse]
|
||||||
|
|
||||||
|
// Convert dates to timestamps for comparison
|
||||||
|
if (params.sort_by === 'created_at') {
|
||||||
|
aValue = new Date(aValue as string).getTime()
|
||||||
|
bValue = new Date(bValue as string).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle null values
|
||||||
|
if (aValue === null && bValue === null) return 0
|
||||||
|
if (aValue === null) return 1
|
||||||
|
if (bValue === null) return -1
|
||||||
|
|
||||||
|
const comparison = aValue > bValue ? 1 : -1
|
||||||
|
return params.sort_order === 'asc' ? comparison : -comparison
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pagination info
|
||||||
|
const limit = params?.limit || 50
|
||||||
|
const currentPage = params?.page || 1
|
||||||
|
const total = filteredTTS.length
|
||||||
|
const totalPages = Math.ceil(total / limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tts: filteredTTS,
|
||||||
|
total,
|
||||||
|
total_pages: totalPages,
|
||||||
|
current_page: currentPage,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProviders(): Promise<TTSProvidersResponse> {
|
||||||
|
return await apiClient.get('/api/v1/tts/providers')
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProvider(name: string): Promise<TTSProvider> {
|
||||||
|
return await apiClient.get(`/api/v1/tts/providers/${name}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTTS(ttsId: number): Promise<{ message: string }> {
|
||||||
|
return await apiClient.delete(`/api/v1/tts/${ttsId}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
115
src/lib/constants/gtts-languages.ts
Normal file
115
src/lib/constants/gtts-languages.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
export interface LanguageOption {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
region?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GTTS_LANGUAGES: LanguageOption[] = [
|
||||||
|
{ code: 'af', name: 'Afrikaans' },
|
||||||
|
{ code: 'ar', name: 'Arabic' },
|
||||||
|
{ code: 'bg', name: 'Bulgarian' },
|
||||||
|
{ code: 'bn', name: 'Bengali' },
|
||||||
|
{ code: 'bs', name: 'Bosnian' },
|
||||||
|
{ code: 'ca', name: 'Catalan' },
|
||||||
|
{ code: 'cs', name: 'Czech' },
|
||||||
|
{ code: 'cy', name: 'Welsh' },
|
||||||
|
{ code: 'da', name: 'Danish' },
|
||||||
|
{ code: 'de', name: 'German' },
|
||||||
|
{ code: 'el', name: 'Greek' },
|
||||||
|
{ code: 'en', name: 'English', region: 'United States' },
|
||||||
|
{ code: 'en-au', name: 'English', region: 'Australia' },
|
||||||
|
{ code: 'en-ca', name: 'English', region: 'Canada' },
|
||||||
|
{ code: 'en-gb', name: 'English', region: 'UK' },
|
||||||
|
{ code: 'en-ie', name: 'English', region: 'Ireland' },
|
||||||
|
{ code: 'en-in', name: 'English', region: 'India' },
|
||||||
|
{ code: 'en-ng', name: 'English', region: 'Nigeria' },
|
||||||
|
{ code: 'en-nz', name: 'English', region: 'New Zealand' },
|
||||||
|
{ code: 'en-ph', name: 'English', region: 'Philippines' },
|
||||||
|
{ code: 'en-za', name: 'English', region: 'South Africa' },
|
||||||
|
{ code: 'en-tz', name: 'English', region: 'Tanzania' },
|
||||||
|
{ code: 'en-uk', name: 'English', region: 'United Kingdom' },
|
||||||
|
{ code: 'en-us', name: 'English', region: 'United States' },
|
||||||
|
{ code: 'eo', name: 'Esperanto' },
|
||||||
|
{ code: 'es', name: 'Spanish', region: 'Spain' },
|
||||||
|
{ code: 'es-es', name: 'Spanish', region: 'Spain' },
|
||||||
|
{ code: 'es-mx', name: 'Spanish', region: 'Mexico' },
|
||||||
|
{ code: 'es-us', name: 'Spanish', region: 'United States' },
|
||||||
|
{ code: 'et', name: 'Estonian' },
|
||||||
|
{ code: 'eu', name: 'Basque' },
|
||||||
|
{ code: 'fa', name: 'Persian' },
|
||||||
|
{ code: 'fi', name: 'Finnish' },
|
||||||
|
{ code: 'fr', name: 'French', region: 'France' },
|
||||||
|
{ code: 'fr-ca', name: 'French', region: 'Canada' },
|
||||||
|
{ code: 'fr-fr', name: 'French', region: 'France' },
|
||||||
|
{ code: 'ga', name: 'Irish' },
|
||||||
|
{ code: 'gu', name: 'Gujarati' },
|
||||||
|
{ code: 'he', name: 'Hebrew' },
|
||||||
|
{ code: 'hi', name: 'Hindi' },
|
||||||
|
{ code: 'hr', name: 'Croatian' },
|
||||||
|
{ code: 'hu', name: 'Hungarian' },
|
||||||
|
{ code: 'hy', name: 'Armenian' },
|
||||||
|
{ code: 'id', name: 'Indonesian' },
|
||||||
|
{ code: 'is', name: 'Icelandic' },
|
||||||
|
{ code: 'it', name: 'Italian' },
|
||||||
|
{ code: 'ja', name: 'Japanese' },
|
||||||
|
{ code: 'jw', name: 'Javanese' },
|
||||||
|
{ code: 'ka', name: 'Georgian' },
|
||||||
|
{ code: 'kk', name: 'Kazakh' },
|
||||||
|
{ code: 'km', name: 'Khmer' },
|
||||||
|
{ code: 'kn', name: 'Kannada' },
|
||||||
|
{ code: 'ko', name: 'Korean' },
|
||||||
|
{ code: 'la', name: 'Latin' },
|
||||||
|
{ code: 'lv', name: 'Latvian' },
|
||||||
|
{ code: 'mk', name: 'Macedonian' },
|
||||||
|
{ code: 'ml', name: 'Malayalam' },
|
||||||
|
{ code: 'mr', name: 'Marathi' },
|
||||||
|
{ code: 'ms', name: 'Malay' },
|
||||||
|
{ code: 'mt', name: 'Maltese' },
|
||||||
|
{ code: 'my', name: 'Myanmar (Burmese)' },
|
||||||
|
{ code: 'ne', name: 'Nepali' },
|
||||||
|
{ code: 'nl', name: 'Dutch' },
|
||||||
|
{ code: 'no', name: 'Norwegian' },
|
||||||
|
{ code: 'pa', name: 'Punjabi' },
|
||||||
|
{ code: 'pl', name: 'Polish' },
|
||||||
|
{ code: 'pt', name: 'Portuguese', region: 'Brazil' },
|
||||||
|
{ code: 'pt-br', name: 'Portuguese', region: 'Brazil' },
|
||||||
|
{ code: 'pt-pt', name: 'Portuguese', region: 'Portugal' },
|
||||||
|
{ code: 'ro', name: 'Romanian' },
|
||||||
|
{ code: 'ru', name: 'Russian' },
|
||||||
|
{ code: 'si', name: 'Sinhala' },
|
||||||
|
{ code: 'sk', name: 'Slovak' },
|
||||||
|
{ code: 'sl', name: 'Slovenian' },
|
||||||
|
{ code: 'sq', name: 'Albanian' },
|
||||||
|
{ code: 'sr', name: 'Serbian' },
|
||||||
|
{ code: 'su', name: 'Sundanese' },
|
||||||
|
{ code: 'sv', name: 'Swedish' },
|
||||||
|
{ code: 'sw', name: 'Swahili' },
|
||||||
|
{ code: 'ta', name: 'Tamil' },
|
||||||
|
{ code: 'te', name: 'Telugu' },
|
||||||
|
{ code: 'th', name: 'Thai' },
|
||||||
|
{ code: 'tl', name: 'Filipino' },
|
||||||
|
{ code: 'tr', name: 'Turkish' },
|
||||||
|
{ code: 'uk', name: 'Ukrainian' },
|
||||||
|
{ code: 'ur', name: 'Urdu' },
|
||||||
|
{ code: 'vi', name: 'Vietnamese' },
|
||||||
|
{ code: 'yo', name: 'Yoruba' },
|
||||||
|
{ code: 'zh', name: 'Chinese (Mandarin)' },
|
||||||
|
{ code: 'zh-cn', name: 'Chinese', region: 'China' },
|
||||||
|
{ code: 'zh-tw', name: 'Chinese', region: 'Taiwan' },
|
||||||
|
{ code: 'zu', name: 'Zulu' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getLanguageDisplayName(lang: LanguageOption): string {
|
||||||
|
if (lang.region) {
|
||||||
|
return `${lang.name} (${lang.region}) - ${lang.code}`
|
||||||
|
}
|
||||||
|
return `${lang.name} - ${lang.code}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSortedLanguages(): LanguageOption[] {
|
||||||
|
return [...GTTS_LANGUAGES].sort((a, b) => {
|
||||||
|
const aDisplay = getLanguageDisplayName(a)
|
||||||
|
const bDisplay = getLanguageDisplayName(b)
|
||||||
|
return aDisplay.localeCompare(bDisplay)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export const playerEvents = new EventEmitter()
|
|||||||
export const soundEvents = new EventEmitter()
|
export const soundEvents = new EventEmitter()
|
||||||
export const userEvents = new EventEmitter()
|
export const userEvents = new EventEmitter()
|
||||||
export const extractionEvents = new EventEmitter()
|
export const extractionEvents = new EventEmitter()
|
||||||
|
export const ttsEvents = new EventEmitter()
|
||||||
|
|
||||||
// Auth event types
|
// Auth event types
|
||||||
export const AUTH_EVENTS = {
|
export const AUTH_EVENTS = {
|
||||||
@@ -69,3 +70,11 @@ export const EXTRACTION_EVENTS = {
|
|||||||
EXTRACTION_COMPLETED: 'extraction_completed',
|
EXTRACTION_COMPLETED: 'extraction_completed',
|
||||||
EXTRACTION_FAILED: 'extraction_failed',
|
EXTRACTION_FAILED: 'extraction_failed',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// TTS event types
|
||||||
|
export const TTS_EVENTS = {
|
||||||
|
TTS_STATUS_UPDATED: 'tts_status_updated',
|
||||||
|
TTS_CREATED: 'tts_created',
|
||||||
|
TTS_COMPLETED: 'tts_completed',
|
||||||
|
TTS_FAILED: 'tts_failed',
|
||||||
|
} as const
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { SOUND_EVENTS, soundEvents } from '@/lib/events'
|
|||||||
import { useSocket } from '@/contexts/SocketContext'
|
import { useSocket } from '@/contexts/SocketContext'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Filter,
|
||||||
Heart,
|
Heart,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
@@ -90,6 +91,7 @@ export function SoundsPage() {
|
|||||||
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
const [sortBy, setSortBy] = useState<SoundSortField>('name')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
|
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
|
||||||
|
const [typeFilter, setTypeFilter] = useState<'all' | 'SDB' | 'TTS'>('all')
|
||||||
|
|
||||||
const handlePlaySound = async (sound: Sound) => {
|
const handlePlaySound = async (sound: Sound) => {
|
||||||
// If WebSocket is connected, use WebSocket for immediate response
|
// If WebSocket is connected, use WebSocket for immediate response
|
||||||
@@ -159,13 +161,18 @@ export function SoundsPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const sdbSounds = await soundsService.getSDBSounds({
|
|
||||||
|
// Determine types to filter by
|
||||||
|
const types = typeFilter === 'all' ? ['SDB', 'TTS'] : [typeFilter]
|
||||||
|
|
||||||
|
const sounds = await soundsService.getSounds({
|
||||||
|
types,
|
||||||
search: debouncedSearchQuery.trim() || undefined,
|
search: debouncedSearchQuery.trim() || undefined,
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
favorites_only: showFavoritesOnly,
|
favorites_only: showFavoritesOnly,
|
||||||
})
|
})
|
||||||
setSounds(sdbSounds)
|
setSounds(sounds)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err instanceof Error ? err.message : 'Failed to fetch sounds'
|
err instanceof Error ? err.message : 'Failed to fetch sounds'
|
||||||
@@ -189,7 +196,7 @@ export function SoundsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSounds()
|
fetchSounds()
|
||||||
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly])
|
}, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly, typeFilter])
|
||||||
|
|
||||||
// Listen for sound_played events and update play_count
|
// Listen for sound_played events and update play_count
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -290,7 +297,9 @@ export function SoundsPage() {
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{showFavoritesOnly
|
{showFavoritesOnly
|
||||||
? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.'
|
? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.'
|
||||||
: 'No SDB type sounds are available in your library.'
|
: typeFilter === 'all'
|
||||||
|
? 'No sounds are available in your library.'
|
||||||
|
: `No ${typeFilter} type sounds are available in your library.`
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,6 +371,21 @@ export function SoundsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
value={typeFilter}
|
||||||
|
onValueChange={value => setTypeFilter(value as 'all' | 'SDB' | 'TTS')}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue placeholder="Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
|
<SelectItem value="SDB">Soundboard</SelectItem>
|
||||||
|
<SelectItem value="TTS">TTS</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onValueChange={value => setSortBy(value as SoundSortField)}
|
onValueChange={value => setSortBy(value as SoundSortField)}
|
||||||
|
|||||||
201
src/pages/TTSPage.tsx
Normal file
201
src/pages/TTSPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
|
import { AppPagination } from '@/components/AppPagination'
|
||||||
|
import { CreateTTSDialog } from '@/components/tts/CreateTTSDialog'
|
||||||
|
import { TTSHeader } from '@/components/tts/TTSHeader'
|
||||||
|
import {
|
||||||
|
TTSEmpty,
|
||||||
|
TTSError,
|
||||||
|
TTSLoading,
|
||||||
|
} from '@/components/tts/TTSLoadingStates'
|
||||||
|
import { TTSTable } from '@/components/tts/TTSTable'
|
||||||
|
import {
|
||||||
|
type TTSResponse,
|
||||||
|
type TTSSortField,
|
||||||
|
type TTSSortOrder,
|
||||||
|
ttsService,
|
||||||
|
} from '@/lib/api/services/tts'
|
||||||
|
import { TTS_EVENTS, ttsEvents } from '@/lib/events'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export function TTSPage() {
|
||||||
|
const [ttsHistory, setTTSHistory] = useState<TTSResponse[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Search and sorting state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState<TTSSortField>('created_at')
|
||||||
|
const [sortOrder, setSortOrder] = useState<TTSSortOrder>('desc')
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
|
||||||
|
// Create TTS dialog state
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||||
|
|
||||||
|
// Debounce search query
|
||||||
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedSearchQuery(searchQuery)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => clearTimeout(handler)
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
const fetchTTSHistory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const response = await ttsService.getTTSHistory({
|
||||||
|
search: debouncedSearchQuery.trim() || undefined,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
|
})
|
||||||
|
setTTSHistory(response.tts)
|
||||||
|
setTotalPages(response.total_pages)
|
||||||
|
setTotalCount(response.total)
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Failed to fetch TTS history'
|
||||||
|
setError(errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [debouncedSearchQuery, sortBy, sortOrder, currentPage, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTTSHistory()
|
||||||
|
}, [fetchTTSHistory])
|
||||||
|
|
||||||
|
// Reset to page 1 when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage !== 1) {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}
|
||||||
|
}, [debouncedSearchQuery, sortBy, sortOrder, pageSize])
|
||||||
|
|
||||||
|
// Listen for TTS events to refresh the list
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTTSCompleted = () => {
|
||||||
|
fetchTTSHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTTSFailed = () => {
|
||||||
|
fetchTTSHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTTSCreated = () => {
|
||||||
|
fetchTTSHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to TTS events
|
||||||
|
ttsEvents.on(TTS_EVENTS.TTS_COMPLETED, handleTTSCompleted)
|
||||||
|
ttsEvents.on(TTS_EVENTS.TTS_FAILED, handleTTSFailed)
|
||||||
|
ttsEvents.on(TTS_EVENTS.TTS_CREATED, handleTTSCreated)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup event listeners
|
||||||
|
ttsEvents.off(TTS_EVENTS.TTS_COMPLETED, handleTTSCompleted)
|
||||||
|
ttsEvents.off(TTS_EVENTS.TTS_FAILED, handleTTSFailed)
|
||||||
|
ttsEvents.off(TTS_EVENTS.TTS_CREATED, handleTTSCreated)
|
||||||
|
}
|
||||||
|
}, [fetchTTSHistory])
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (size: number) => {
|
||||||
|
setPageSize(size)
|
||||||
|
setCurrentPage(1) // Reset to first page when changing page size
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTTSDeleted = (ttsId: number) => {
|
||||||
|
// Remove the deleted TTS from the current list
|
||||||
|
setTTSHistory(prev => prev.filter(tts => tts.id !== ttsId))
|
||||||
|
|
||||||
|
// Update total count
|
||||||
|
setTotalCount(prev => prev - 1)
|
||||||
|
|
||||||
|
// If current page is now empty and not the first page, go to previous page
|
||||||
|
const remainingOnCurrentPage = ttsHistory.length - 1
|
||||||
|
if (remainingOnCurrentPage === 0 && currentPage > 1) {
|
||||||
|
setCurrentPage(currentPage - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the full list to ensure accuracy
|
||||||
|
fetchTTSHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) {
|
||||||
|
return <TTSLoading />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <TTSError error={error} onRetry={fetchTTSHistory} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ttsHistory || ttsHistory.length === 0) {
|
||||||
|
return <TTSEmpty searchQuery={searchQuery} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<TTSTable
|
||||||
|
ttsHistory={ttsHistory}
|
||||||
|
onTTSDeleted={handleTTSDeleted}
|
||||||
|
/>
|
||||||
|
<AppPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
itemName="TTS generations"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout
|
||||||
|
breadcrumb={{
|
||||||
|
items: [{ label: 'Dashboard', href: '/' }, { label: 'Text to Speech' }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
||||||
|
<TTSHeader
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSortByChange={setSortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortOrderChange={setSortOrder}
|
||||||
|
onRefresh={fetchTTSHistory}
|
||||||
|
onCreateClick={() => setShowCreateDialog(true)}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
ttsCount={totalCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateTTSDialog
|
||||||
|
open={showCreateDialog}
|
||||||
|
onOpenChange={setShowCreateDialog}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user