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