# 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 ```bash 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 configuration - `components/ui/` - UI primitives directory - Installs: `class-variance-authority`, `clsx`, `tailwind-merge`, `lucide-react`, `tailwindcss-animate` ### 2. Update Vite Config **app/vite.config.ts:** ```typescript 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:** ```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 ```bash # 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:** ```typescript 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( , ); ``` ### 6. Setup Zustand Stores **app/src/stores/uiStore.ts:** ```typescript 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((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:** ```typescript 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()( 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:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript 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
Loading profiles...
; } return (

Voice Profiles

{profiles?.map((profile) => ( {profile.name}

{profile.description}

{profile.language} {profile.sample_count} samples
))}
); } ``` #### ProfileForm Component **app/src/components/VoiceProfiles/ProfileForm.tsx:** ```typescript 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; export function ProfileForm() { const open = useUIStore((state) => state.profileDialogOpen); const setOpen = useUIStore((state) => state.setProfileDialogOpen); const createProfile = useCreateProfile(); const form = useForm({ 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 ( Create Voice Profile Add a new voice profile with samples
( Name )} /> ( Description