Add profile export and import functionality

- Implemented API endpoints for exporting and importing voice profiles as ZIP archives.
- Enhanced the frontend with new hooks and components for profile export and import, including file handling and user dialogs.
- Integrated Tauri plugins for file system access and dialog interactions to facilitate seamless user experience.
- Updated ProfileCard and ProfileList components to support new export and import features, improving overall functionality.
- Added necessary error handling and validation for file operations to ensure robustness.
This commit is contained in:
Jamie Pine
2026-01-25 19:00:16 -08:00
parent c62f615162
commit 9125a4abe0
19 changed files with 7794 additions and 30 deletions
+2
View File
@@ -31,6 +31,8 @@
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.0",
+17 -15
View File
@@ -74,7 +74,7 @@ export function HistoryTable() {
<div
className={cn(
'flex-1 min-h-0 overflow-y-auto space-y-2',
isPlayerVisible && 'max-h-[calc(100vh-220px)]',
isPlayerVisible && 'max-h-[calc(100vh-117px)]',
)}
>
{history.map((gen) => {
@@ -169,21 +169,23 @@ export function HistoryTable() {
})}
</div>
<div className="flex justify-between items-center mt-4 shrink-0">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</Button>
<div className="text-sm text-muted-foreground">
Page {page + 1} • {total} total
{(total > limit || page > 0) && (
<div className="flex justify-between items-center mt-4 shrink-0">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</Button>
<div className="text-sm text-muted-foreground">
Page {page + 1} • {total} total
</div>
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
Next
</Button>
</div>
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
Next
</Button>
</div>
)}
</>
)}
</div>
@@ -1,4 +1,4 @@
import { Edit, Eye, Mic, Trash2 } from 'lucide-react';
import { Download, Edit, Eye, Mic, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -13,7 +13,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { useUIStore } from '@/stores/uiStore';
import { ProfileDetail } from './ProfileDetail';
@@ -26,6 +26,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
const [detailOpen, setDetailOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
@@ -52,6 +53,11 @@ export function ProfileCard({ profile }: ProfileCardProps) {
setDeleteDialogOpen(false);
};
const handleExport = (e: React.MouseEvent) => {
e.stopPropagation();
exportProfile.mutate(profile.id);
};
return (
<>
<Card
@@ -87,6 +93,12 @@ export function ProfileCard({ profile }: ProfileCardProps) {
}}
aria-label="View details"
/>
<CircleButton
icon={Download}
onClick={handleExport}
disabled={exportProfile.isPending}
aria-label="Export profile"
/>
<CircleButton
icon={Edit}
onClick={(e) => {
@@ -1,7 +1,16 @@
import { Mic, Sparkles } from 'lucide-react';
import { Mic, Sparkles, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useProfiles } from '@/lib/hooks/useProfiles';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useProfiles, useImportProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import { ProfileCard } from './ProfileCard';
import { ProfileForm } from './ProfileForm';
@@ -9,6 +18,44 @@ import { ProfileForm } from './ProfileForm';
export function ProfileList() {
const { data: profiles, isLoading, error } = useProfiles();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const importProfile = useImportProfile();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file extension
if (!file.name.endsWith('.voicebox.zip')) {
alert('Please select a valid .voicebox.zip file');
return;
}
setSelectedFile(file);
setImportDialogOpen(true);
}
};
const handleImportConfirm = () => {
if (selectedFile) {
importProfile.mutate(selectedFile, {
onSuccess: () => {
setImportDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
onError: (error) => {
alert(`Failed to import profile: ${error.message}`);
},
});
}
};
if (isLoading) {
return (
@@ -32,10 +79,23 @@ export function ProfileList() {
<div className="flex flex-col">
<div className="flex items-center justify-between mb-4 shrink-0">
<h2 className="text-2xl font-bold">Voicebox</h2>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
New Profile
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleImportClick}>
<Upload className="mr-2 h-4 w-4" />
Import Voice
</Button>
<input
ref={fileInputRef}
type="file"
accept=".voicebox.zip"
onChange={handleFileChange}
className="hidden"
/>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Create Voice
</Button>
</div>
</div>
<div className="shrink-0">
@@ -48,7 +108,7 @@ export function ProfileList() {
</p>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Create Profile
Create Voice
</Button>
</CardContent>
</Card>
@@ -62,6 +122,37 @@ export function ProfileList() {
</div>
<ProfileForm />
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Profile</DialogTitle>
<DialogDescription>
Import the profile from "{selectedFile?.name}". This will create a new profile with all samples.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setImportDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
>
Cancel
</Button>
<Button
onClick={handleImportConfirm}
disabled={importProfile.isPending || !selectedFile}
>
{importProfile.isPending ? 'Importing...' : 'Import'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+34
View File
@@ -109,6 +109,40 @@ class ApiClient {
});
}
async exportProfile(profileId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
async importProfile(file: File): Promise<VoiceProfileResponse> {
const url = `${this.getBaseUrl()}/profiles/import`;
const formData = new FormData();
formData.append('file', file);
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.json();
}
// Generation
async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
return this.request<GenerationResponse>('/generate', {
+71
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { VoiceProfileCreate } from '@/lib/api/types';
import { isTauri } from '@/lib/tauri';
export function useProfiles() {
return useQuery({
@@ -96,3 +97,73 @@ export function useDeleteSample() {
},
});
}
export function useExportProfile() {
return useMutation({
mutationFn: async (profileId: string) => {
const blob = await apiClient.exportProfile(profileId);
// Get profile name for filename
const profile = await apiClient.getProfile(profileId);
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
const filename = `profile-${safeName}.voicebox.zip`;
if (isTauri()) {
// Use Tauri's native save dialog
try {
const { save } = await import('@tauri-apps/plugin-dialog');
const filePath = await save({
defaultPath: filename,
filters: [
{
name: 'Voicebox Profile',
extensions: ['voicebox.zip', 'zip'],
},
],
});
if (filePath) {
// Write file using Tauri's filesystem API
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
const arrayBuffer = await blob.arrayBuffer();
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
}
} catch (error) {
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
// Fall back to browser download if Tauri dialog fails
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
} else {
// Browser: trigger download
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
return blob;
},
});
}
export function useImportProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => apiClient.importProfile(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
},
});
}