Enhance history management with export and import functionalities

- Added endpoints for exporting generations as ZIP archives and audio files.
- Implemented import functionality for generations from ZIP archives with validation.
- Updated HistoryTable component to support new export and import features.
- Improved error handling and user notifications for export/import processes.
- Refactored related hooks and API client methods to accommodate new functionalities.
This commit is contained in:
Jamie Pine
2026-01-26 00:30:37 -08:00
parent 333cb262e0
commit 551abc9856
14 changed files with 653 additions and 45 deletions
+152 -29
View File
@@ -1,6 +1,14 @@
import { AudioWaveform, Download, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,7 +17,13 @@ import {
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import { apiClient } from '@/lib/api/client';
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
import {
useDeleteGeneration,
useExportGeneration,
useExportGenerationAudio,
useHistory,
useImportGeneration,
} from '@/lib/hooks/useHistory';
import { cn } from '@/lib/utils/cn';
import { formatDate, formatDuration } from '@/lib/utils/format';
import { usePlayerStore } from '@/stores/playerStore';
@@ -22,6 +36,9 @@ export function HistoryTable() {
const [page, setPage] = useState(0);
const [isScrolled, setIsScrolled] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const limit = 20;
const { data: historyData, isLoading } = useHistory({
@@ -30,6 +47,9 @@ export function HistoryTable() {
});
const deleteGeneration = useDeleteGeneration();
const exportGeneration = useExportGeneration();
const exportGenerationAudio = useExportGenerationAudio();
const importGeneration = useImportGeneration();
const setAudio = usePlayerStore((state) => state.setAudio);
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
@@ -60,23 +80,65 @@ export function HistoryTable() {
}
};
const handleDownload = (audioId: string, text: string) => {
const audioUrl = apiClient.getAudioUrl(audioId);
const filename = `${text.substring(0, 30).replace(/[^a-z0-9]/gi, '_')}.wav`;
const link = document.createElement('a');
link.href = audioUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const handleDownloadAudio = (generationId: string, text: string) => {
exportGenerationAudio.mutate(
{ generationId, text },
{
onError: (error) => {
alert(`Failed to download audio: ${error.message}`);
},
},
);
};
const handleExportPackage = (generationId: string, text: string) => {
exportGeneration.mutate(
{ generationId, text },
{
onError: (error) => {
alert(`Failed to export generation: ${error.message}`);
},
},
);
};
const _handleImportClick = () => {
file_handleImportClickk.click();
};
const _handleFileChange = (_e: React.ChangeEvent<HTMLInputElement>) => {
cons_handleFileChangeet.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) {
importGeneration.mutate(selectedFile, {
onSuccess: (data) => {
setImportDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
alert(data.message || 'Generation imported successfully');
},
onError: (error) => {
alert(`Failed to import generation: ${error.message}`);
},
});
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">Loading history...</div>
</div>
);
return null;
}
const history = historyData?.items || [];
@@ -85,8 +147,25 @@ export function HistoryTable() {
return (
<div className="flex flex-col h-full min-h-0 relative">
{/* <div className="flex justify-between items-center mb-4 shrink-0">
<h2 className="text-2xl font-bold">History</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleImportClick}>
<Upload className="mr-2 h-4 w-4" />
Import Generation
</Button>
<input
ref={fileInputRef}
type="file"
accept=".voicebox.zip"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div> */}
{history.length === 0 ? (
<div className="text-center py-12 text-muted-foreground flex-1 flex items-center justify-center">
<div className="text-center py-12 px-5 border-2 border-dashed border-gray-200 rounded-md text-muted-foreground flex-1 flex items-center justify-center">
No generation history yet. Generate your first audio to see it here.
</div>
) : (
@@ -104,15 +183,20 @@ export function HistoryTable() {
{history.map((gen) => {
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
return (
<button
<div
key={gen.id}
type="button"
className={cn(
'flex items-stretch gap-4 h-24 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors cursor-pointer text-left w-full',
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
isCurrentlyPlaying && 'bg-muted/70',
)}
onClick={() => handlePlay(gen.id, gen.text)}
aria-label={`Play audio: ${gen.text.substring(0, 50)}`}
onMouseDown={(e) => {
// Don't trigger play if clicking on textarea or if text is selected
const target = e.target as HTMLElement;
if (target.closest('textarea') || window.getSelection()?.toString()) {
return;
}
handlePlay(gen.id, gen.text);
}}
>
{/* Waveform icon */}
<div className="flex items-center shrink-0">
@@ -138,10 +222,8 @@ export function HistoryTable() {
{/* Right side - Transcript textarea */}
<div className="flex-1 min-w-0 flex">
<Textarea
readOnly
value={gen.text}
className="flex-1 resize-none text-sm text-muted-foreground"
onClick={(e) => e.stopPropagation()}
className="flex-1 resize-none text-sm text-muted-foreground select-text"
/>
</div>
@@ -164,9 +246,19 @@ export function HistoryTable() {
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDownload(gen.id, gen.text)}>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Download
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteGeneration.mutate(gen.id)}
@@ -179,7 +271,7 @@ export function HistoryTable() {
</DropdownMenuContent>
</DropdownMenu>
</div>
</button>
</div>
);
})}
</div>
@@ -203,6 +295,37 @@ export function HistoryTable() {
)}
</>
)}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Generation</DialogTitle>
<DialogDescription>
Import the generation from "{selectedFile?.name}". This will add it to your history.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setImportDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
>
Cancel
</Button>
<Button
onClick={handleImportConfirm}
disabled={importGeneration.isPending || !selectedFile}
>
{importGeneration.isPending ? 'Importing...' : 'Import'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+7 -5
View File
@@ -1,4 +1,4 @@
import { Volume2, Loader2, Settings } from 'lucide-react';
import { Loader2, Settings, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
@@ -21,10 +21,12 @@ export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
const isPlayerVisible = !!audioUrl;
return (
<div className={cn(
"fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6",
isMacOS && "pt-14"
)}>
<div
className={cn(
'fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6',
isMacOS && 'pt-14',
)}
>
{/* Logo */}
<div className="mb-2">
<img src={voiceboxLogo} alt="Voicebox" className="w-12 h-12 object-contain" />
@@ -58,11 +58,7 @@ export function ProfileList() {
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">Loading profiles...</div>
</div>
);
return null;
}
if (error) {