Initialize voicebox project with backend, frontend, and Tauri setup. Added configuration files, dependencies, and basic structure for components, hooks, and utilities. Included README and setup documentation for guidance.

This commit is contained in:
Jamie Pine
2026-01-25 02:19:06 -08:00
commit 01e3065692
166 changed files with 22764 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
import { History, Mic, Settings, Sparkles } from 'lucide-react';
import { GenerationForm } from '@/components/Generation/GenerationForm';
import { HistoryTable } from '@/components/History/HistoryTable';
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Toaster } from '@/components/ui/toaster';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
function App() {
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8 max-w-7xl">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">voicebox</h1>
<p className="text-muted-foreground">
Production-quality Qwen3-TTS voice cloning and generation
</p>
</div>
<Tabs defaultValue="profiles" className="space-y-4">
<TabsList>
<TabsTrigger value="profiles">
<Mic className="mr-2 h-4 w-4" />
Profiles
</TabsTrigger>
<TabsTrigger value="generate">
<Sparkles className="mr-2 h-4 w-4" />
Generate
</TabsTrigger>
<TabsTrigger value="history">
<History className="mr-2 h-4 w-4" />
History
</TabsTrigger>
<TabsTrigger value="settings">
<Settings className="mr-2 h-4 w-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="profiles" className="space-y-4">
<ProfileList />
</TabsContent>
<TabsContent value="generate" className="space-y-4">
<GenerationForm />
</TabsContent>
<TabsContent value="history" className="space-y-4">
<HistoryTable />
</TabsContent>
<TabsContent value="settings" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<ConnectionForm />
<ServerStatus />
</div>
</TabsContent>
</Tabs>
</div>
<Toaster />
</div>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
# Audio studio timeline editing components
+1
View File
@@ -0,0 +1 @@
# Voice generation components
@@ -0,0 +1,190 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useProfiles } from '@/lib/hooks/useProfiles';
const generationSchema = z.object({
profileId: z.string().min(1, 'Please select a voice profile'),
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(['en', 'zh']),
seed: z.number().int().optional(),
});
type GenerationFormValues = z.infer<typeof generationSchema>;
export function GenerationForm() {
const { data: profiles } = useProfiles();
const generation = useGeneration();
const { toast } = useToast();
const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema),
defaultValues: {
profileId: '',
text: '',
language: 'en',
seed: undefined,
},
});
async function onSubmit(data: GenerationFormValues) {
try {
const result = await generation.mutateAsync({
profile_id: data.profileId,
text: data.text,
language: data.language,
seed: data.seed,
});
toast({
title: 'Generation complete!',
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
form.reset();
} catch (error) {
toast({
title: 'Generation failed',
description: error instanceof Error ? error.message : 'Failed to generate audio',
variant: 'destructive',
});
}
}
return (
<Card>
<CardHeader>
<CardTitle>Generate Speech</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="profileId"
render={({ field }) => (
<FormItem>
<FormLabel>Voice Profile</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a voice" />
</SelectTrigger>
</FormControl>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>Text to Speak</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the text you want to generate..."
className="min-h-[200px]"
{...field}
/>
</FormControl>
<FormDescription>Max 5000 characters</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="seed"
render={({ field }) => (
<FormItem>
<FormLabel>Seed (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Random"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormDescription>For reproducible results</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-full" disabled={generation.isPending}>
{generation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
'Generate Speech'
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
+1
View File
@@ -0,0 +1 @@
# Generation history components
+151
View File
@@ -0,0 +1,151 @@
import { Download, Play, Trash2 } from 'lucide-react';
import { useState } from '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 { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
import { formatDate, formatDuration } from '@/lib/utils/format';
import { useServerStore } from '@/stores/serverStore';
export function HistoryTable() {
const [page, setPage] = useState(0);
const limit = 20;
const { toast } = useToast();
const _serverUrl = useServerStore((state) => state.serverUrl);
const { data: historyData, isLoading } = useHistory({
limit,
offset: page * limit,
});
const deleteGeneration = useDeleteGeneration();
const handlePlay = (audioId: string) => {
const audioUrl = apiClient.getAudioUrl(audioId);
const audio = new Audio(audioUrl);
audio.play().catch((_error) => {
toast({
title: 'Error',
description: 'Failed to play audio',
variant: 'destructive',
});
});
};
const handleDownload = (audioId: string, text: string) => {
const audioUrl = apiClient.getAudioUrl(audioId);
const filename = `${text.substring(0, 30).replace(/[^a-z0-9]/gi, '_')}.wav`;
const link = document.createElement('a');
link.href = audioUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">Loading history...</div>
</div>
);
}
const history = historyData?.items || [];
const total = historyData?.total || 0;
const hasMore = history.length === limit && (page + 1) * limit < total;
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Generation History</h2>
{history.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No generation history yet. Generate your first audio to see it here.
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Text</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Language</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.map((gen) => (
<TableRow key={gen.id}>
<TableCell className="max-w-[300px] truncate">{gen.text}</TableCell>
<TableCell>{gen.profile_name}</TableCell>
<TableCell>
<Badge variant="outline">{gen.language}</Badge>
</TableCell>
<TableCell>{formatDuration(gen.duration)}</TableCell>
<TableCell>{formatDate(gen.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handlePlay(gen.id)}
aria-label="Play audio"
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(gen.id, gen.text)}
aria-label="Download audio"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteGeneration.mutate(gen.id)}
disabled={deleteGeneration.isPending}
aria-label="Delete generation"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</Button>
<div className="text-sm text-muted-foreground flex items-center">
Page {page + 1} {total} total
</div>
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
Next
</Button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1 @@
# Server settings and connection components
@@ -0,0 +1,74 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/use-toast';
import { useServerStore } from '@/stores/serverStore';
const connectionSchema = z.object({
serverUrl: z.string().url('Please enter a valid URL'),
});
type ConnectionFormValues = z.infer<typeof connectionSchema>;
export function ConnectionForm() {
const serverUrl = useServerStore((state) => state.serverUrl);
const setServerUrl = useServerStore((state) => state.setServerUrl);
const { toast } = useToast();
const form = useForm<ConnectionFormValues>({
resolver: zodResolver(connectionSchema),
defaultValues: {
serverUrl: serverUrl,
},
});
function onSubmit(data: ConnectionFormValues) {
setServerUrl(data.serverUrl);
toast({
title: 'Server URL updated',
description: `Connected to ${data.serverUrl}`,
});
}
return (
<Card>
<CardHeader>
<CardTitle>Server Connection</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input placeholder="http://localhost:8000" {...field} />
</FormControl>
<FormDescription>Enter the URL of your voicebox backend server</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update Connection</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,54 @@
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useServerHealth } from '@/lib/hooks/useServer';
import { useServerStore } from '@/stores/serverStore';
export function ServerStatus() {
const { data: health, isLoading, error } = useServerHealth();
const serverUrl = useServerStore((state) => state.serverUrl);
return (
<Card>
<CardHeader>
<CardTitle>Server Status</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Server URL</div>
<div className="font-mono text-sm">{serverUrl}</div>
</div>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Checking connection...</span>
</div>
) : error ? (
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">Connection failed: {error.message}</span>
</div>
) : health ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-sm">Connected</span>
</div>
<div className="flex gap-2">
<Badge variant={health.model_loaded ? 'default' : 'secondary'}>
Model: {health.model_loaded ? 'Loaded' : 'Not Loaded'}
</Badge>
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
</Badge>
{health.vram_used_mb && (
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
)}
</div>
</div>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1 @@
# Voice profile management components
@@ -0,0 +1,77 @@
import { Edit, Mic, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
import { formatDate } from '@/lib/utils/format';
import { useUIStore } from '@/stores/uiStore';
import { ProfileDetail } from './ProfileDetail';
interface ProfileCardProps {
profile: VoiceProfileResponse;
}
export function ProfileCard({ profile }: ProfileCardProps) {
const [detailOpen, setDetailOpen] = useState(false);
const deleteProfile = useDeleteProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const handleEdit = () => {
setEditingProfileId(profile.id);
setProfileDialogOpen(true);
};
const handleDelete = () => {
if (
confirm(`Are you sure you want to delete "${profile.name}"? This action cannot be undone.`)
) {
deleteProfile.mutate(profile.id);
}
};
return (
<>
<Card
className="cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => setDetailOpen(true)}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Mic className="h-5 w-5" />
{profile.name}
</span>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={handleEdit} aria-label="Edit profile">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
disabled={deleteProfile.isPending}
aria-label="Delete profile"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent>
{profile.description && (
<p className="text-sm text-muted-foreground mb-2">{profile.description}</p>
)}
<div className="flex gap-2 mb-2">
<Badge variant="outline">{profile.language}</Badge>
</div>
<p className="text-xs text-muted-foreground">Created {formatDate(profile.created_at)}</p>
</CardContent>
</Card>
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
</>
);
}
@@ -0,0 +1,66 @@
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useProfile } from '@/lib/hooks/useProfiles';
import { formatDate } from '@/lib/utils/format';
import { SampleList } from './SampleList';
interface ProfileDetailProps {
profileId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProfileDetail({ profileId, open, onOpenChange }: ProfileDetailProps) {
const { data: profile, isLoading } = useProfile(profileId);
if (isLoading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<div className="text-muted-foreground">Loading profile...</div>
</DialogContent>
</Dialog>
);
}
if (!profile) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{profile.name}</DialogTitle>
<DialogDescription>Manage samples and view profile details</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{profile.description && (
<div>
<h3 className="text-sm font-medium mb-1">Description</h3>
<p className="text-sm text-muted-foreground">{profile.description}</p>
</div>
)}
<div className="flex gap-2">
<Badge variant="outline">{profile.language}</Badge>
<span className="text-xs text-muted-foreground">
Created {formatDate(profile.created_at)}
</span>
</div>
<div className="border-t pt-4">
<SampleList profileId={profileId} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,197 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useCreateProfile, useProfile, useUpdateProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(['en', 'zh']),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
export function ProfileForm() {
const open = useUIStore((state) => state.profileDialogOpen);
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
const editingProfileId = useUIStore((state) => state.editingProfileId);
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const { data: editingProfile } = useProfile(editingProfileId || '');
const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile();
const { toast } = useToast();
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: '',
description: '',
language: 'en',
},
});
useEffect(() => {
if (editingProfile) {
form.reset({
name: editingProfile.name,
description: editingProfile.description || '',
language: editingProfile.language as 'en' | 'zh',
});
} else {
form.reset({
name: '',
description: '',
language: 'en',
});
}
}, [editingProfile, form]);
async function onSubmit(data: ProfileFormValues) {
try {
if (editingProfileId) {
await updateProfile.mutateAsync({
profileId: editingProfileId,
data,
});
toast({
title: 'Profile updated',
description: `"${data.name}" has been updated successfully.`,
});
} else {
await createProfile.mutateAsync(data);
toast({
title: 'Profile created',
description: `"${data.name}" has been created successfully.`,
});
}
form.reset();
setEditingProfileId(null);
setOpen(false);
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
variant: 'destructive',
});
}
}
function handleOpenChange(open: boolean) {
setOpen(open);
if (!open) {
setEditingProfileId(null);
form.reset();
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
<DialogDescription>
{editingProfileId
? 'Update your voice profile details.'
: 'Add a new voice profile with samples.'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My Voice" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Describe this voice..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={createProfile.isPending || updateProfile.isPending}>
{createProfile.isPending || updateProfile.isPending
? 'Saving...'
: editingProfileId
? 'Update Profile'
: 'Create Profile'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,64 @@
import { Mic, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useDeleteProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import { ProfileCard } from './ProfileCard';
import { ProfileForm } from './ProfileForm';
export function ProfileList() {
const { data: profiles, isLoading, error } = useProfiles();
const _deleteProfile = useDeleteProfile();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">Loading profiles...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-destructive">Error loading profiles: {error.message}</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Voice Profiles</h2>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Profile
</Button>
</div>
{profiles && profiles.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
No voice profiles yet. Create your first profile to get started.
</p>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Profile
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{profiles?.map((profile) => (
<ProfileCard key={profile.id} profile={profile} />
))}
</div>
)}
<ProfileForm />
</div>
);
}
@@ -0,0 +1,88 @@
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
import { useServerStore } from '@/stores/serverStore';
import { SampleUpload } from './SampleUpload';
interface SampleListProps {
profileId: string;
}
export function SampleList({ profileId }: SampleListProps) {
const { data: samples, isLoading } = useProfileSamples(profileId);
const deleteSample = useDeleteSample();
const [uploadOpen, setUploadOpen] = useState(false);
const { toast } = useToast();
const serverUrl = useServerStore((state) => state.serverUrl);
const handleDelete = (sampleId: string) => {
if (confirm('Are you sure you want to delete this sample?')) {
deleteSample.mutate(sampleId);
}
};
const handlePlay = (audioPath: string) => {
const audioUrl = `${serverUrl}${audioPath}`;
const audio = new Audio(audioUrl);
audio.play().catch((_error) => {
toast({
title: 'Error',
description: 'Failed to play audio',
variant: 'destructive',
});
});
};
if (isLoading) {
return <div className="text-sm text-muted-foreground">Loading samples...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Audio Samples</h3>
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Sample
</Button>
</div>
{samples && samples.length === 0 ? (
<div className="text-sm text-muted-foreground py-4">
No samples yet. Add your first audio sample.
</div>
) : (
<div className="space-y-2">
{samples?.map((sample) => (
<div
key={sample.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<p className="text-sm font-medium">{sample.reference_text}</p>
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => handlePlay(sample.audio_path)}>
Play
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(sample.id)}
disabled={deleteSample.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
</div>
);
}
@@ -0,0 +1,150 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useAddSample } from '@/lib/hooks/useProfiles';
const sampleSchema = z.object({
file: z.instanceof(File, { message: 'Please select an audio file' }),
referenceText: z
.string()
.min(1, 'Reference text is required')
.max(1000, 'Reference text must be less than 1000 characters'),
});
type SampleFormValues = z.infer<typeof sampleSchema>;
interface SampleUploadProps {
profileId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
const addSample = useAddSample();
const { toast } = useToast();
const form = useForm<SampleFormValues>({
resolver: zodResolver(sampleSchema),
defaultValues: {
referenceText: '',
},
});
async function onSubmit(data: SampleFormValues) {
try {
await addSample.mutateAsync({
profileId,
file: data.file,
referenceText: data.referenceText,
});
toast({
title: 'Sample added',
description: 'Audio sample has been added successfully.',
});
form.reset();
onOpenChange(false);
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to add sample',
variant: 'destructive',
});
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Audio Sample</DialogTitle>
<DialogDescription>
Upload an audio file and provide the reference text that matches the audio.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel>Audio File</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
type="file"
accept="audio/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
{...field}
/>
</div>
</FormControl>
<FormDescription>Supported formats: WAV, MP3, M4A</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="referenceText"
render={({ field }) => (
<FormItem>
<FormLabel>Reference Text</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the exact text spoken in the audio..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormDescription>
This should match exactly what is spoken in the audio file.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={addSample.isPending}>
{addSample.isPending ? 'Uploading...' : 'Add Sample'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { cva, type VariantProps } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils/cn';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+48
View File
@@ -0,0 +1,48 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+55
View File
@@ -0,0 +1,55 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+101
View File
@@ -0,0 +1,101 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+166
View File
@@ -0,0 +1,166 @@
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@/lib/utils/cn';
import { Label } from './label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
+18
View File
@@ -0,0 +1,18 @@
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+22
View File
@@ -0,0 +1,22 @@
import * as ProgressPrimitive from '@radix-ui/react-progress';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
+150
View File
@@ -0,0 +1,150 @@
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+23
View File
@@ -0,0 +1,23 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+90
View File
@@ -0,0 +1,90 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
),
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
));
TableCaption.displayName = 'TableCaption';
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+52
View File
@@ -0,0 +1,52 @@
import * as TabsPrimitive from '@radix-ui/react-tabs';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };
+121
View File
@@ -0,0 +1,121 @@
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
+29
View File
@@ -0,0 +1,29 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from './toast';
import { useToast } from './use-toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
);
}
+183
View File
@@ -0,0 +1,183 @@
import * as React from 'react';
import type { ToastActionElement, ToastProps } from './toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, []);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };
+68
View File
@@ -0,0 +1,68 @@
@import "tailwindcss";
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
}
+1
View File
@@ -0,0 +1 @@
# React Query hooks will be placed here
+15
View File
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { GenerationRequest } from '@/lib/api/types';
export function useGeneration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: GenerationRequest) => apiClient.generateSpeech(data),
onSuccess: () => {
// Invalidate history to show new generation
queryClient.invalidateQueries({ queryKey: ['history'] });
},
});
}
+29
View File
@@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { HistoryQuery } from '@/lib/api/types';
export function useHistory(query?: HistoryQuery) {
return useQuery({
queryKey: ['history', query],
queryFn: () => apiClient.listHistory(query),
});
}
export function useGenerationDetail(generationId: string) {
return useQuery({
queryKey: ['history', generationId],
queryFn: () => apiClient.getGeneration(generationId),
enabled: !!generationId,
});
}
export function useDeleteGeneration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (generationId: string) => apiClient.deleteGeneration(generationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['history'] });
},
});
}
+98
View File
@@ -0,0 +1,98 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { VoiceProfileCreate } from '@/lib/api/types';
export function useProfiles() {
return useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
}
export function useProfile(profileId: string) {
return useQuery({
queryKey: ['profiles', profileId],
queryFn: () => apiClient.getProfile(profileId),
enabled: !!profileId,
});
}
export function useCreateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: VoiceProfileCreate) => apiClient.createProfile(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
},
});
}
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ profileId, data }: { profileId: string; data: VoiceProfileCreate }) =>
apiClient.updateProfile(profileId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
queryClient.invalidateQueries({
queryKey: ['profiles', variables.profileId],
});
},
});
}
export function useDeleteProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (profileId: string) => apiClient.deleteProfile(profileId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
},
});
}
export function useProfileSamples(profileId: string) {
return useQuery({
queryKey: ['profiles', profileId, 'samples'],
queryFn: () => apiClient.listProfileSamples(profileId),
enabled: !!profileId,
});
}
export function useAddSample() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
profileId,
file,
referenceText,
}: {
profileId: string;
file: File;
referenceText: string;
}) => apiClient.addProfileSample(profileId, file, referenceText),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['profiles', variables.profileId, 'samples'],
});
queryClient.invalidateQueries({
queryKey: ['profiles', variables.profileId],
});
},
});
}
export function useDeleteSample() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sampleId: string) => apiClient.deleteProfileSample(sampleId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
},
});
}
+14
View File
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import { useServerStore } from '@/stores/serverStore';
export function useServerHealth() {
const serverUrl = useServerStore((state) => state.serverUrl);
return useQuery({
queryKey: ['server', 'health', serverUrl],
queryFn: () => apiClient.getHealth(),
refetchInterval: 30000, // Check every 30 seconds
retry: 1,
});
}
+9
View File
@@ -0,0 +1,9 @@
import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
export function useTranscription() {
return useMutation({
mutationFn: ({ file, language }: { file: File; language?: 'en' | 'zh' }) =>
apiClient.transcribeAudio(file, language),
});
}
+1
View File
@@ -0,0 +1 @@
# Utility functions will be placed here
+18
View File
@@ -0,0 +1,18 @@
export function createAudioUrl(audioId: string, serverUrl: string): string {
return `${serverUrl}/audio/${audioId}`;
}
export function downloadAudio(url: string, filename: string): void {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
export function formatAudioDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+19
View File
@@ -0,0 +1,19 @@
import { formatDistance } from 'date-fns';
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export function formatDate(date: string | Date): string {
return formatDistance(new Date(date), new Date(), { addSuffix: true });
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
}
+26
View File
@@ -0,0 +1,26 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>,
);
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
interface PlayerState {
currentAudioId: string | null;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
setCurrentAudio: (audioId: string | null) => void;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
setVolume: (volume: number) => void;
reset: () => void;
}
export const usePlayerStore = create<PlayerState>((set) => ({
currentAudioId: null,
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
setCurrentAudio: (audioId) => set({ currentAudioId: audioId, currentTime: 0, isPlaying: false }),
setIsPlaying: (playing) => set({ isPlaying: playing }),
setCurrentTime: (time) => set({ currentTime: time }),
setDuration: (duration) => set({ duration }),
setVolume: (volume) => set({ volume }),
reset: () =>
set({
currentAudioId: null,
isPlaying: false,
currentTime: 0,
duration: 0,
}),
}));
+31
View File
@@ -0,0 +1,31 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ServerStore {
serverUrl: string;
setServerUrl: (url: string) => void;
isConnected: boolean;
setIsConnected: (connected: boolean) => void;
mode: 'local' | 'remote';
setMode: (mode: 'local' | 'remote') => void;
}
export const useServerStore = create<ServerStore>()(
persist(
(set) => ({
serverUrl: 'http://localhost:8000',
setServerUrl: (url) => set({ serverUrl: url }),
isConnected: false,
setIsConnected: (connected) => set({ isConnected: connected }),
mode: 'local',
setMode: (mode) => set({ mode }),
}),
{
name: 'voicebox-server',
},
),
);
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand';
interface UIStore {
// Sidebar
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
// Modals
profileDialogOpen: boolean;
setProfileDialogOpen: (open: boolean) => void;
editingProfileId: string | null;
setEditingProfileId: (id: string | null) => void;
generationDialogOpen: boolean;
setGenerationDialogOpen: (open: boolean) => void;
// Theme
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
profileDialogOpen: false,
setProfileDialogOpen: (open) => set({ profileDialogOpen: open }),
editingProfileId: null,
setEditingProfileId: (id) => set({ editingProfileId: id }),
generationDialogOpen: false,
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
theme: 'light',
setTheme: (theme) => {
set({ theme });
document.documentElement.classList.toggle('dark', theme === 'dark');
},
}));
+27
View File
@@ -0,0 +1,27 @@
// Shared TypeScript types for the voicebox application
export interface VoiceProfile {
id: string;
name: string;
description?: string;
language: string;
createdAt: string;
updatedAt: string;
}
export interface Generation {
id: string;
profileId: string;
text: string;
language: string;
audioPath: string;
duration: number;
seed?: number;
createdAt: string;
}
export interface ServerConfig {
url: string;
isRemote: boolean;
isRunning: boolean;
}