Refactor ProfileForm to support draft state management and improve file handling

- Introduced functionality to save and restore form state as a draft when creating a new voice profile.
- Added helper functions for converting files to and from base64 format to facilitate file handling.
- Updated the API types to use a more flexible LanguageCode type for language parameters.
- Enhanced the UI store to manage profile form drafts, improving user experience during profile creation.
This commit is contained in:
Jamie Pine
2026-01-29 15:45:14 -08:00
parent 341d71470c
commit 3be8980f48
6 changed files with 145 additions and 15 deletions
@@ -565,7 +565,14 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedClipId, handleSplit, handleDuplicate, handleDelete, setSelectedClipId, handlePlayPause]); }, [
selectedClipId,
handleSplit,
handleDuplicate,
handleDelete,
setSelectedClipId,
handlePlayPause,
]);
// Add global mouse listeners for trimming // Add global mouse listeners for trimming
useEffect(() => { useEffect(() => {
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Mic, Monitor, Upload } from 'lucide-react'; import { Mic, Monitor, Upload, X } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
@@ -43,7 +43,7 @@ import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription'; import { useTranscription } from '@/lib/hooks/useTranscription';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio'; import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore, type ProfileFormDraft } from '@/stores/uiStore';
import { AudioSampleRecording } from './AudioSampleRecording'; import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem'; import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload'; import { AudioSampleUpload } from './AudioSampleUpload';
@@ -75,11 +75,35 @@ const profileSchema = baseProfileSchema.refine(
type ProfileFormValues = z.infer<typeof profileSchema>; type ProfileFormValues = z.infer<typeof profileSchema>;
// Helper to convert File to base64
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Helper to convert base64 to File
function base64ToFile(base64: string, fileName: string, fileType: string): File {
const arr = base64.split(',');
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], fileName, { type: fileType });
}
export function ProfileForm() { export function ProfileForm() {
const open = useUIStore((state) => state.profileDialogOpen); const open = useUIStore((state) => state.profileDialogOpen);
const setOpen = useUIStore((state) => state.setProfileDialogOpen); const setOpen = useUIStore((state) => state.setProfileDialogOpen);
const editingProfileId = useUIStore((state) => state.editingProfileId); const editingProfileId = useUIStore((state) => state.editingProfileId);
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const profileFormDraft = useUIStore((state) => state.profileFormDraft);
const setProfileFormDraft = useUIStore((state) => state.setProfileFormDraft);
const { data: editingProfile } = useProfile(editingProfileId || ''); const { data: editingProfile } = useProfile(editingProfileId || '');
const createProfile = useCreateProfile(); const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile(); const updateProfile = useUpdateProfile();
@@ -220,6 +244,7 @@ export function ProfileForm() {
} }
}, [systemRecordingError, toast]); }, [systemRecordingError, toast]);
// Restore form state from draft or editing profile
useEffect(() => { useEffect(() => {
if (editingProfile) { if (editingProfile) {
form.reset({ form.reset({
@@ -229,7 +254,27 @@ export function ProfileForm() {
sampleFile: undefined, sampleFile: undefined,
referenceText: undefined, referenceText: undefined,
}); });
} else { } else if (profileFormDraft && open) {
// Restore from draft when opening in create mode
form.reset({
name: profileFormDraft.name,
description: profileFormDraft.description,
language: profileFormDraft.language as LanguageCode,
referenceText: profileFormDraft.referenceText,
sampleFile: undefined,
});
setSampleMode(profileFormDraft.sampleMode);
// Restore the file if we have it saved
if (profileFormDraft.sampleFileData && profileFormDraft.sampleFileName && profileFormDraft.sampleFileType) {
const file = base64ToFile(
profileFormDraft.sampleFileData,
profileFormDraft.sampleFileName,
profileFormDraft.sampleFileType
);
form.setValue('sampleFile', file);
}
} else if (!open) {
// Only reset to defaults when modal is closed and no draft
form.reset({ form.reset({
name: '', name: '',
description: '', description: '',
@@ -239,7 +284,7 @@ export function ProfileForm() {
}); });
setSampleMode('upload'); setSampleMode('upload');
} }
}, [editingProfile, form]); }, [editingProfile, profileFormDraft, open, form]);
async function handleTranscribe() { async function handleTranscribe() {
const file = form.getValues('sampleFile'); const file = form.getValues('sampleFile');
@@ -383,6 +428,8 @@ export function ProfileForm() {
} }
} }
// Clear draft and reset form on success
setProfileFormDraft(null);
form.reset(); form.reset();
setEditingProfileId(null); setEditingProfileId(null);
setOpen(false); setOpen(false);
@@ -395,12 +442,40 @@ export function ProfileForm() {
} }
} }
function handleOpenChange(open: boolean) { async function handleOpenChange(newOpen: boolean) {
setOpen(open); if (!newOpen && isCreating) {
if (!open) { // Save draft when closing the create modal
const values = form.getValues();
const hasContent = values.name || values.description || values.referenceText || values.sampleFile;
if (hasContent) {
const draft: ProfileFormDraft = {
name: values.name || '',
description: values.description || '',
language: values.language || 'en',
referenceText: values.referenceText || '',
sampleMode,
};
// Save file as base64 if present
if (values.sampleFile) {
try {
draft.sampleFileName = values.sampleFile.name;
draft.sampleFileType = values.sampleFile.type;
draft.sampleFileData = await fileToBase64(values.sampleFile);
} catch {
// If file conversion fails, just don't save the file
}
}
setProfileFormDraft(draft);
}
}
setOpen(newOpen);
if (!newOpen) {
setEditingProfileId(null); setEditingProfileId(null);
form.reset(); // Don't reset form here - let the effect handle it based on draft state
setSampleMode('upload');
if (isRecording) { if (isRecording) {
cancelRecording(); cancelRecording();
} }
@@ -421,6 +496,31 @@ export function ProfileForm() {
? 'Update your voice profile details and manage samples.' ? 'Update your voice profile details and manage samples.'
: 'Create a new voice profile with an audio sample to clone the voice.'} : 'Create a new voice profile with an audio sample to clone the voice.'}
</DialogDescription> </DialogDescription>
{isCreating && profileFormDraft && (
<div className="flex items-center gap-2 pt-2">
<span className="text-xs text-muted-foreground">Draft restored</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => {
setProfileFormDraft(null);
form.reset({
name: '',
description: '',
language: 'en',
sampleFile: undefined,
referenceText: '',
});
setSampleMode('upload');
}}
>
<X className="h-3 w-3 mr-1" />
Discard
</Button>
</div>
)}
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
+2 -1
View File
@@ -1,4 +1,5 @@
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
import type { LanguageCode } from '@/lib/constants/languages';
import type { import type {
VoiceProfileCreate, VoiceProfileCreate,
VoiceProfileResponse, VoiceProfileResponse,
@@ -254,7 +255,7 @@ class ApiClient {
} }
// Transcription // Transcription
async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> { async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (language) { if (language) {
+4 -3
View File
@@ -1,9 +1,10 @@
// API Types matching backend Pydantic models // API Types matching backend Pydantic models
import type { LanguageCode } from '@/lib/constants/languages';
export interface VoiceProfileCreate { export interface VoiceProfileCreate {
name: string; name: string;
description?: string; description?: string;
language: 'en' | 'zh'; language: LanguageCode;
} }
export interface VoiceProfileResponse { export interface VoiceProfileResponse {
@@ -29,7 +30,7 @@ export interface ProfileSampleResponse {
export interface GenerationRequest { export interface GenerationRequest {
profile_id: string; profile_id: string;
text: string; text: string;
language: 'en' | 'zh'; language: LanguageCode;
seed?: number; seed?: number;
model_size?: '1.7B' | '0.6B'; model_size?: '1.7B' | '0.6B';
} }
@@ -62,7 +63,7 @@ export interface HistoryListResponse {
} }
export interface TranscriptionRequest { export interface TranscriptionRequest {
language?: 'en' | 'zh'; language?: LanguageCode;
} }
export interface TranscriptionResponse { export interface TranscriptionResponse {
+2 -1
View File
@@ -1,9 +1,10 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { LanguageCode } from '@/lib/constants/languages';
export function useTranscription() { export function useTranscription() {
return useMutation({ return useMutation({
mutationFn: ({ file, language }: { file: File; language?: 'en' | 'zh' }) => mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
apiClient.transcribeAudio(file, language), apiClient.transcribeAudio(file, language),
}); });
} }
+20
View File
@@ -1,5 +1,18 @@
import { create } from 'zustand'; import { create } from 'zustand';
// Draft state for the create voice profile form
export interface ProfileFormDraft {
name: string;
description: string;
language: string;
referenceText: string;
sampleMode: 'upload' | 'record' | 'system';
// Note: File objects can't be persisted, so we store metadata
sampleFileName?: string;
sampleFileType?: string;
sampleFileData?: string; // Base64 encoded
}
interface UIStore { interface UIStore {
// Sidebar // Sidebar
sidebarOpen: boolean; sidebarOpen: boolean;
@@ -18,6 +31,10 @@ interface UIStore {
selectedProfileId: string | null; selectedProfileId: string | null;
setSelectedProfileId: (id: string | null) => void; setSelectedProfileId: (id: string | null) => void;
// Profile form draft (for persisting create voice modal state)
profileFormDraft: ProfileFormDraft | null;
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
// Theme // Theme
theme: 'light' | 'dark'; theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void; setTheme: (theme: 'light' | 'dark') => void;
@@ -38,6 +55,9 @@ export const useUIStore = create<UIStore>((set) => ({
selectedProfileId: null, selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }), setSelectedProfileId: (id) => set({ selectedProfileId: id }),
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
theme: 'light', theme: 'light',
setTheme: (theme) => { setTheme: (theme) => {
set({ theme }); set({ theme });