mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
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:
@@ -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}>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user