35 KiB
Frontend Implementation Plan
Complete plan for building the voicebox frontend with modern React, TypeScript, shadcn/ui, and full type safety.
Technology Stack
Core
- React 18 - UI framework with concurrent features
- TypeScript (strict mode) - Full type safety
- Vite - Fast build tool and dev server
- Bun - Package manager
UI & Styling
- shadcn/ui - Headless component primitives (new-york style)
- Tailwind CSS v4 - Utility-first styling
- Radix UI - Accessible primitives (via shadcn/ui)
- Lucide React - Icon system
- class-variance-authority (cva) - Component variants
- tailwind-merge - Smart class merging
State Management
- React Query v5 - Server state (API calls, caching)
- Zustand - Client state (UI state, modals, selections)
- React Hook Form - Form state and validation
- Zod - Runtime schema validation
Audio
- WaveSurfer.js - Audio waveform visualization
- Web Audio API - Audio recording/playback
- MediaRecorder API - Voice recording
Type Safety
- OpenAPI TypeScript Codegen - Generate API client from FastAPI schema
- Zod - Runtime validation matching backend Pydantic models
- TypeScript strict mode - Compiler enforcement
Architecture Overview
app/src/
├── components/
│ ├── ui/ # shadcn/ui primitives (auto-generated)
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── select.tsx
│ │ ├── slider.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── card.tsx
│ │ ├── badge.tsx
│ │ ├── toast.tsx
│ │ └── ...
│ │
│ ├── VoiceProfiles/ # Voice profile management
│ │ ├── ProfileList.tsx
│ │ ├── ProfileCard.tsx
│ │ ├── ProfileForm.tsx
│ │ ├── SampleUpload.tsx
│ │ └── SampleList.tsx
│ │
│ ├── Generation/ # Voice generation
│ │ ├── GenerationForm.tsx
│ │ ├── GenerationPreview.tsx
│ │ └── GenerationSettings.tsx
│ │
│ ├── History/ # Generation history
│ │ ├── HistoryTable.tsx
│ │ ├── HistoryFilter.tsx
│ │ └── HistoryPlayer.tsx
│ │
│ ├── AudioStudio/ # Audio editing (Phase 3)
│ │ ├── Timeline.tsx
│ │ ├── Waveform.tsx
│ │ ├── Controls.tsx
│ │ └── TrackList.tsx
│ │
│ └── ServerSettings/ # Server connection
│ ├── ConnectionForm.tsx
│ ├── ServerStatus.tsx
│ └── LocalServerToggle.tsx
│
├── lib/
│ ├── api/ # Generated OpenAPI client
│ │ ├── core/
│ │ ├── models/
│ │ └── services/
│ │ ├── ProfilesService.ts
│ │ ├── GenerationService.ts
│ │ └── HistoryService.ts
│ │
│ ├── hooks/ # React Query hooks
│ │ ├── useProfiles.ts
│ │ ├── useGeneration.ts
│ │ ├── useHistory.ts
│ │ ├── useTranscription.ts
│ │ └── useServer.ts
│ │
│ ├── schemas/ # Zod schemas (match backend)
│ │ ├── profile.ts
│ │ ├── generation.ts
│ │ └── history.ts
│ │
│ └── utils/
│ ├── cn.ts # Class name utility (shadcn)
│ ├── audio.ts # Audio utilities
│ └── format.ts # Formatting helpers
│
├── stores/ # Zustand stores
│ ├── uiStore.ts # UI state (modals, sidebar)
│ ├── playerStore.ts # Audio player state
│ └── serverStore.ts # Server connection state
│
├── types/ # TypeScript types
│ ├── index.ts
│ ├── api.ts # Augment generated types
│ └── tauri.ts # Tauri-specific types
│
├── App.tsx # Main app component
├── main.tsx # Entry point
└── index.css # Global styles + Tailwind
Setup Steps
1. Install shadcn/ui and Dependencies
cd app
# Core dependencies (if not installed)
bun add @tanstack/react-query zustand react-hook-form zod @hookform/resolvers wavesurfer.js
# shadcn/ui setup
bunx --bun shadcn-ui@latest init
# Select:
# - Style: new-york
# - Base color: slate (or zinc for darker theme)
# - CSS variables: yes
This creates:
components.json- shadcn/ui configurationcomponents/ui/- UI primitives directory- Installs:
class-variance-authority,clsx,tailwind-merge,lucide-react,tailwindcss-animate
2. Update Vite Config
app/vite.config.ts:
import path from 'node:path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
3. Update TypeScript Config
app/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
4. Add Essential shadcn/ui Components
# Forms and inputs
bunx --bun shadcn-ui@latest add button
bunx --bun shadcn-ui@latest add input
bunx --bun shadcn-ui@latest add form
bunx --bun shadcn-ui@latest add label
bunx --bun shadcn-ui@latest add select
bunx --bun shadcn-ui@latest add textarea
bunx --bun shadcn-ui@latest add slider
# Layout and display
bunx --bun shadcn-ui@latest add card
bunx --bun shadcn-ui@latest add tabs
bunx --bun shadcn-ui@latest add separator
bunx --bun shadcn-ui@latest add badge
bunx --bun shadcn-ui@latest add avatar
# Feedback
bunx --bun shadcn-ui@latest add toast
bunx --bun shadcn-ui@latest add alert
bunx --bun shadcn-ui@latest add progress
# Overlays
bunx --bun shadcn-ui@latest add dialog
bunx --bun shadcn-ui@latest add dropdown-menu
bunx --bun shadcn-ui@latest add popover
# Data display
bunx --bun shadcn-ui@latest add table
bunx --bun shadcn-ui@latest add scroll-area
5. Setup Providers
app/src/main.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
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>,
);
6. Setup Zustand Stores
app/src/stores/uiStore.ts:
import { create } from 'zustand';
interface UIStore {
// Sidebar
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
// Modals
profileDialogOpen: boolean;
setProfileDialogOpen: (open: boolean) => 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 }),
generationDialogOpen: false,
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
theme: 'light',
setTheme: (theme) => set({ theme }),
}));
app/src/stores/serverStore.ts:
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',
},
),
);
React Query Hooks
useProfiles Hook
app/src/lib/hooks/useProfiles.ts:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ProfilesService } from '@/lib/api/services/ProfilesService';
import type { VoiceProfileCreate, VoiceProfileResponse } from '@/lib/api/models';
export function useProfiles() {
return useQuery({
queryKey: ['profiles'],
queryFn: () => ProfilesService.listProfiles(),
});
}
export function useProfile(profileId: string) {
return useQuery({
queryKey: ['profiles', profileId],
queryFn: () => ProfilesService.getProfile({ profileId }),
enabled: !!profileId,
});
}
export function useCreateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: VoiceProfileCreate) =>
ProfilesService.createProfile({ data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
},
});
}
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ profileId, data }: { profileId: string; data: VoiceProfileCreate }) =>
ProfilesService.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) =>
ProfilesService.deleteProfile({ profileId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
},
});
}
export function useAddSample() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
profileId,
file,
referenceText
}: {
profileId: string;
file: File;
referenceText: string;
}) =>
ProfilesService.addProfileSample({
profileId,
file,
referenceText,
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['profiles', variables.profileId, 'samples']
});
},
});
}
useGeneration Hook
app/src/lib/hooks/useGeneration.ts:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GenerationService } from '@/lib/api/services/GenerationService';
import type { GenerationRequest } from '@/lib/api/models';
export function useGeneration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: GenerationRequest) =>
GenerationService.generateSpeech({ data }),
onSuccess: () => {
// Invalidate history to show new generation
queryClient.invalidateQueries({ queryKey: ['history'] });
},
});
}
useHistory Hook
app/src/lib/hooks/useHistory.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { HistoryService } from '@/lib/api/services/HistoryService';
import type { HistoryQuery } from '@/lib/api/models';
export function useHistory(query?: HistoryQuery) {
return useQuery({
queryKey: ['history', query],
queryFn: () => HistoryService.listHistory(query),
});
}
export function useGeneration(generationId: string) {
return useQuery({
queryKey: ['history', generationId],
queryFn: () => HistoryService.getGeneration({ generationId }),
enabled: !!generationId,
});
}
export function useDeleteGeneration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (generationId: string) =>
HistoryService.deleteGeneration({ generationId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['history'] });
},
});
}
Component Implementation
Phase 1: Voice Profiles (Week 1)
ProfileList Component
app/src/components/VoiceProfiles/ProfileList.tsx:
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Mic, Plus, Trash2 } from 'lucide-react';
import { useProfiles, useDeleteProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import { ProfileForm } from './ProfileForm';
export function ProfileList() {
const { data: profiles, isLoading } = useProfiles();
const deleteProfile = useDeleteProfile();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
if (isLoading) {
return <div>Loading profiles...</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>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{profiles?.map((profile) => (
<Card key={profile.id}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Mic className="h-5 w-5" />
{profile.name}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => deleteProfile.mutate(profile.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{profile.description}
</p>
<div className="mt-2 flex gap-2">
<Badge variant="outline">{profile.language}</Badge>
<Badge variant="secondary">
{profile.sample_count} samples
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
<ProfileForm />
</div>
);
}
ProfileForm Component
app/src/components/VoiceProfiles/ProfileForm.tsx:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
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 { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { useCreateProfile } 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().optional(),
language: z.enum(['en', 'zh']),
tags: z.string().optional(),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
export function ProfileForm() {
const open = useUIStore((state) => state.profileDialogOpen);
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
const createProfile = useCreateProfile();
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: '',
description: '',
language: 'en',
tags: '',
},
});
async function onSubmit(data: ProfileFormValues) {
const tags = data.tags ? data.tags.split(',').map((t) => t.trim()) : [];
await createProfile.mutateAsync({
...data,
tags,
});
form.reset();
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Voice Profile</DialogTitle>
<DialogDescription>
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>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input
placeholder="tag1, tag2, tag3"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={createProfile.isPending}
>
{createProfile.isPending ? 'Creating...' : 'Create Profile'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
Phase 2: Generation (Week 2)
GenerationForm Component
app/src/components/Generation/GenerationForm.tsx:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2 } from 'lucide-react';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { useToast } from '@/hooks/use-toast';
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.message,
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) : 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>
);
}
Phase 3: History (Week 2)
HistoryTable Component
app/src/components/History/HistoryTable.tsx:
import { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Play, Download, Trash2 } from 'lucide-react';
import { useHistory, useDeleteGeneration } from '@/lib/hooks/useHistory';
import { formatDistance } from 'date-fns';
export function HistoryTable() {
const [page, setPage] = useState(0);
const limit = 20;
const { data: history, isLoading } = useHistory({
limit,
offset: page * limit,
});
const deleteGeneration = useDeleteGeneration();
if (isLoading) {
return <div>Loading history...</div>;
}
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Generation History</h2>
<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>{gen.duration.toFixed(2)}s</TableCell>
<TableCell>
{formatDistance(new Date(gen.created_at), new Date(), {
addSuffix: true,
})}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon">
<Play className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon">
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteGeneration.mutate(gen.id)}
>
<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>
<Button
variant="outline"
onClick={() => setPage((p) => p + 1)}
disabled={!history || history.length < limit}
>
Next
</Button>
</div>
</div>
);
}
Implementation Phases
Phase 1: Foundation (Week 1)
Goal: Setup, OpenAPI client, basic UI
-
Day 1-2: Setup
- ✅ Install shadcn/ui and dependencies
- ✅ Generate OpenAPI client
- ✅ Setup providers (React Query, Toaster)
- ✅ Create Zustand stores
- ✅ Setup routing (if needed)
-
Day 3-4: Voice Profiles
- ProfileList component
- ProfileCard component
- ProfileForm (create/edit)
- Profile deletion
- Sample upload UI
-
Day 5-7: Testing & Polish
- Test all CRUD operations
- Error handling
- Loading states
- Empty states
Phase 2: Generation & History (Week 2)
Goal: Core voice generation functionality
-
Day 1-3: Generation
- GenerationForm component
- Real-time generation
- Progress indication
- Audio preview player
- Download functionality
-
Day 4-7: History
- HistoryTable component
- Filtering and search
- Pagination
- Audio playback
- Export functionality
Phase 3: Audio Studio (Week 3-4)
Goal: Timeline editing and advanced features
-
Week 3: Basic Studio
- Waveform visualization (WaveSurfer.js)
- Timeline component
- Playback controls
- Multiple tracks
-
Week 4: Advanced Features
- Word-level timestamps
- Editing (trim, split)
- Audio effects
- Project save/load
Phase 4: Polish & Features (Week 5+)
Goal: Production polish
-
Server Settings
- Connection management
- Local server toggle
- Health monitoring
-
UX Improvements
- Keyboard shortcuts
- Drag and drop
- Batch operations
- Export options
Type Safety Best Practices
1. OpenAPI Generated Types
// Use generated types from @/lib/api/models
import type {
VoiceProfileResponse,
GenerationRequest,
HistoryQuery,
} from '@/lib/api/models';
// Never use `any`
function handleProfile(profile: VoiceProfileResponse) {
// Fully typed
}
2. Zod Schemas Match Backend
// app/src/lib/schemas/profile.ts
import { z } from 'zod';
// Match backend Pydantic model
export const profileCreateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
language: z.enum(['en', 'zh']),
tags: z.array(z.string()).optional(),
});
export type ProfileCreate = z.infer<typeof profileCreateSchema>;
3. React Hook Form with Zod
const form = useForm<ProfileCreate>({
resolver: zodResolver(profileCreateSchema),
});
// Fully typed, runtime validated
4. Type-Safe Event Handlers
// Good
function handleSubmit(data: ProfileCreate) {
// data is typed
}
// Bad
function handleSubmit(data: any) {
// No type safety
}
Performance Optimizations
1. React Query Configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // Don't refetch for 5 minutes
gcTime: 1000 * 60 * 10, // Keep unused data for 10 minutes
retry: 1, // Only retry once
},
},
});
2. Component Code Splitting
import { lazy } from 'react';
const AudioStudio = lazy(() => import('@/components/AudioStudio'));
// Lazy load heavy components
3. Virtualized Lists
For large history tables:
bunx --bun shadcn-ui@latest add table
Use with TanStack Virtual for performance.
4. Debounced Search
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search, 300);
// Use debouncedSearch in query
useHistory({ search: debouncedSearch });
Testing Strategy
Unit Tests (Vitest)
bun add -D vitest @testing-library/react @testing-library/jest-dom
app/src/components/VoiceProfiles/ProfileCard.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ProfileCard } from './ProfileCard';
describe('ProfileCard', () => {
it('renders profile name', () => {
render(<ProfileCard profile={{ name: 'Test Voice' }} />);
expect(screen.getByText('Test Voice')).toBeInTheDocument();
});
});
Component Tests (Storybook - Optional)
bunx storybook@latest init
Accessibility
shadcn/ui components are built on Radix UI with full accessibility:
- ✅ Keyboard navigation
- ✅ Screen reader support
- ✅ ARIA attributes
- ✅ Focus management
Additional improvements:
- Add
aria-labelto icon buttons - Use semantic HTML (
<main>,<nav>,<section>) - Ensure color contrast meets WCAG AA
- Add loading announcements for screen readers
Summary
Tech Stack:
- React 18 + TypeScript (strict)
- shadcn/ui + Radix UI
- React Query + Zustand
- React Hook Form + Zod
- Tailwind CSS v4
- WaveSurfer.js
Type Safety:
- ✅ OpenAPI generated client
- ✅ Zod runtime validation
- ✅ TypeScript strict mode
- ✅ No
anytypes
Implementation:
- Phase 1: Voice Profiles (Week 1)
- Phase 2: Generation & History (Week 2)
- Phase 3: Audio Studio (Week 3-4)
- Phase 4: Polish (Week 5+)
Next Steps:
- Run
bunx --bun shadcn-ui@latest init - Generate OpenAPI client (
bun run generate:api) - Add essential shadcn/ui components
- Build ProfileList component
- Test with backend
Ready to build a production-quality, type-safe voice cloning app! 🚀