From 3be8980f4876ba0f2cb7429611e7013b0edb0a48 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Thu, 29 Jan 2026 15:45:14 -0800 Subject: [PATCH] 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. --- .../StoriesTab/StoryTrackEditor.tsx | 9 +- .../components/VoiceProfiles/ProfileForm.tsx | 118 ++++++++++++++++-- app/src/lib/api/client.ts | 3 +- app/src/lib/api/types.ts | 7 +- app/src/lib/hooks/useTranscription.ts | 3 +- app/src/stores/uiStore.ts | 20 +++ 6 files changed, 145 insertions(+), 15 deletions(-) diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index bc3758dd..74dbde25 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -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(() => { diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx index de2b3fea..76f736c0 100644 --- a/app/src/components/VoiceProfiles/ProfileForm.tsx +++ b/app/src/components/VoiceProfiles/ProfileForm.tsx @@ -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; +// Helper to convert File to base64 +async function fileToBase64(file: File): Promise { + 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.'} + {isCreating && profileFormDraft && ( +
+ Draft restored + +
+ )}
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index 3e27c6cf..7e319b43 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -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 { + async transcribeAudio(file: File, language?: LanguageCode): Promise { const formData = new FormData(); formData.append('file', file); if (language) { diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 5b31e9f3..cdd68058 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -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 { diff --git a/app/src/lib/hooks/useTranscription.ts b/app/src/lib/hooks/useTranscription.ts index 1363441c..0b80722f 100644 --- a/app/src/lib/hooks/useTranscription.ts +++ b/app/src/lib/hooks/useTranscription.ts @@ -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), }); } diff --git a/app/src/stores/uiStore.ts b/app/src/stores/uiStore.ts index 6f3a1605..3822d5f9 100644 --- a/app/src/stores/uiStore.ts +++ b/app/src/stores/uiStore.ts @@ -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((set) => ({ selectedProfileId: null, setSelectedProfileId: (id) => set({ selectedProfileId: id }), + profileFormDraft: null, + setProfileFormDraft: (draft) => set({ profileFormDraft: draft }), + theme: 'light', setTheme: (theme) => { set({ theme });