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);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedClipId, handleSplit, handleDuplicate, handleDelete, setSelectedClipId, handlePlayPause]);
}, [
selectedClipId,
handleSplit,
handleDuplicate,
handleDelete,
setSelectedClipId,
handlePlayPause,
]);
// Add global mouse listeners for trimming
useEffect(() => {
@@ -1,5 +1,5 @@
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 { useForm } from 'react-hook-form';
import * as z from 'zod';
@@ -43,7 +43,7 @@ import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { isTauri } from '@/lib/tauri';
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { useUIStore } from '@/stores/uiStore';
import { useUIStore, type ProfileFormDraft } from '@/stores/uiStore';
import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload';
@@ -75,11 +75,35 @@ const profileSchema = baseProfileSchema.refine(
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() {
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 profileFormDraft = useUIStore((state) => state.profileFormDraft);
const setProfileFormDraft = useUIStore((state) => state.setProfileFormDraft);
const { data: editingProfile } = useProfile(editingProfileId || '');
const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile();
@@ -220,6 +244,7 @@ export function ProfileForm() {
}
}, [systemRecordingError, toast]);
// Restore form state from draft or editing profile
useEffect(() => {
if (editingProfile) {
form.reset({
@@ -229,7 +254,27 @@ export function ProfileForm() {
sampleFile: 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({
name: '',
description: '',
@@ -239,7 +284,7 @@ export function ProfileForm() {
});
setSampleMode('upload');
}
}, [editingProfile, form]);
}, [editingProfile, profileFormDraft, open, form]);
async function handleTranscribe() {
const file = form.getValues('sampleFile');
@@ -383,6 +428,8 @@ export function ProfileForm() {
}
}
// Clear draft and reset form on success
setProfileFormDraft(null);
form.reset();
setEditingProfileId(null);
setOpen(false);
@@ -395,12 +442,40 @@ export function ProfileForm() {
}
}
function handleOpenChange(open: boolean) {
setOpen(open);
if (!open) {
async function handleOpenChange(newOpen: boolean) {
if (!newOpen && isCreating) {
// 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);
form.reset();
setSampleMode('upload');
// Don't reset form here - let the effect handle it based on draft state
if (isRecording) {
cancelRecording();
}
@@ -421,6 +496,31 @@ export function ProfileForm() {
? 'Update your voice profile details and manage samples.'
: 'Create a new voice profile with an audio sample to clone the voice.'}
</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>
<Form {...form}>
+2 -1
View File
@@ -1,4 +1,5 @@
import { useServerStore } from '@/stores/serverStore';
import type { LanguageCode } from '@/lib/constants/languages';
import type {
VoiceProfileCreate,
VoiceProfileResponse,
@@ -254,7 +255,7 @@ class ApiClient {
}
// Transcription
async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> {
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
const formData = new FormData();
formData.append('file', file);
if (language) {
+4 -3
View File
@@ -1,9 +1,10 @@
// API Types matching backend Pydantic models
import type { LanguageCode } from '@/lib/constants/languages';
export interface VoiceProfileCreate {
name: string;
description?: string;
language: 'en' | 'zh';
language: LanguageCode;
}
export interface VoiceProfileResponse {
@@ -29,7 +30,7 @@ export interface ProfileSampleResponse {
export interface GenerationRequest {
profile_id: string;
text: string;
language: 'en' | 'zh';
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B';
}
@@ -62,7 +63,7 @@ export interface HistoryListResponse {
}
export interface TranscriptionRequest {
language?: 'en' | 'zh';
language?: LanguageCode;
}
export interface TranscriptionResponse {
+2 -1
View File
@@ -1,9 +1,10 @@
import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { LanguageCode } from '@/lib/constants/languages';
export function useTranscription() {
return useMutation({
mutationFn: ({ file, language }: { file: File; language?: 'en' | 'zh' }) =>
mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
apiClient.transcribeAudio(file, language),
});
}
+20
View File
@@ -1,5 +1,18 @@
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 {
// Sidebar
sidebarOpen: boolean;
@@ -18,6 +31,10 @@ interface UIStore {
selectedProfileId: string | null;
setSelectedProfileId: (id: string | null) => void;
// Profile form draft (for persisting create voice modal state)
profileFormDraft: ProfileFormDraft | null;
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
// Theme
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
@@ -38,6 +55,9 @@ export const useUIStore = create<UIStore>((set) => ({
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
theme: 'light',
setTheme: (theme) => {
set({ theme });