diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index bfb27edd..38c4361e 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -1,5 +1,6 @@ -import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react'; +import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import type { HistoryResponse } from '@/lib/api/types'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -33,18 +34,23 @@ import { usePlayerStore } from '@/stores/playerStore'; // OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) // This is the new alternate history view with fixed height rows -// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS +// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL export function HistoryTable() { - const [page, _setPage] = useState(0); + const [page, setPage] = useState(0); + const [allHistory, setAllHistory] = useState([]); + const [total, setTotal] = useState(0); const [isScrolled, setIsScrolled] = useState(false); const scrollRef = useRef(null); + const loadMoreRef = useRef(null); const fileInputRef = useRef(null); const [importDialogOpen, setImportDialogOpen] = useState(false); const [selectedFile, setSelectedFile] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null); const limit = 20; const { toast } = useToast(); - const { data: historyData, isLoading } = useHistory({ + const { data: historyData, isLoading, isFetching } = useHistory({ limit, offset: page * limit, }); @@ -60,6 +66,56 @@ export function HistoryTable() { const audioUrl = usePlayerStore((state) => state.audioUrl); const isPlayerVisible = !!audioUrl; + // Update accumulated history when new data arrives + useEffect(() => { + if (historyData?.items) { + setTotal(historyData.total); + if (page === 0) { + // Reset to first page + setAllHistory(historyData.items); + } else { + // Append new items, avoiding duplicates + setAllHistory((prev) => { + const existingIds = new Set(prev.map((item) => item.id)); + const newItems = historyData.items.filter((item) => !existingIds.has(item.id)); + return [...prev, ...newItems]; + }); + } + } + }, [historyData, page]); + + // Reset to page 0 when deletions or imports occur + useEffect(() => { + if (deleteGeneration.isSuccess || importGeneration.isSuccess) { + setPage(0); + setAllHistory([]); + } + }, [deleteGeneration.isSuccess, importGeneration.isSuccess]); + + // Intersection Observer for infinite scroll + useEffect(() => { + const loadMoreEl = loadMoreRef.current; + if (!loadMoreEl) return; + + const observer = new IntersectionObserver( + (entries) => { + const target = entries[0]; + if (target.isIntersecting && !isFetching && allHistory.length < total) { + setPage((prev) => prev + 1); + } + }, + { + root: scrollRef.current, + rootMargin: '100px', + threshold: 0.1, + }, + ); + + observer.observe(loadMoreEl); + return () => observer.disconnect(); + }, [isFetching, allHistory.length, total]); + + // Track scroll position for gradient effect useEffect(() => { const scrollEl = scrollRef.current; if (!scrollEl) return; @@ -113,24 +169,16 @@ export function HistoryTable() { ); }; - const _handleImportClick = () => { - file_handleImportClickk.click(); + const handleDeleteClick = (generationId: string, profileName: string) => { + setGenerationToDelete({ id: generationId, name: profileName }); + setDeleteDialogOpen(true); }; - const _handleFileChange = (_e: React.ChangeEvent) => { - cons_handleFileChangeet.files?.[0]; - if (file) { - // Validate file extension - if (!file.name.endsWith('.voicebox.zip')) { - toast({ - title: 'Invalid file type', - description: 'Please select a valid .voicebox.zip file', - variant: 'destructive', - }); - return; - } - setSelectedFile(file); - setImportDialogOpen(true); + const handleDeleteConfirm = () => { + if (generationToDelete) { + deleteGeneration.mutate(generationToDelete.id); + setDeleteDialogOpen(false); + setGenerationToDelete(null); } }; @@ -159,13 +207,16 @@ export function HistoryTable() { } }; - if (isLoading) { - return null; + if (isLoading && page === 0) { + return ( +
+ +
+ ); } - const history = historyData?.items || []; - const total = historyData?.total || 0; - const _hasMore = history.length === limit && (page + 1) * limit < total; + const history = allHistory; + const hasMore = allHistory.length < total; return (
@@ -271,7 +322,7 @@ export function HistoryTable() { Export Package deleteGeneration.mutate(gen.id)} + onClick={() => handleDeleteClick(gen.id, gen.profile_name)} disabled={deleteGeneration.isPending} className="text-destructive focus:text-destructive" > @@ -284,10 +335,53 @@ export function HistoryTable() {
); })} + + {/* Load more trigger element */} + {hasMore && ( +
+ {isFetching && } +
+ )} + + {/* End of list indicator */} + {!hasMore && history.length > 0 && ( +
+ You've reached the end +
+ )} )} + + + + Delete Generation + + Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone. + + + + + + + + + diff --git a/app/src/components/VoiceProfiles/SampleList.tsx b/app/src/components/VoiceProfiles/SampleList.tsx index de848737..19aa1ca8 100644 --- a/app/src/components/VoiceProfiles/SampleList.tsx +++ b/app/src/components/VoiceProfiles/SampleList.tsx @@ -1,9 +1,17 @@ -import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react'; +import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { CircleButton } from '@/components/ui/circle-button'; -import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Slider } from '@/components/ui/slider'; +import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles'; @@ -140,10 +148,19 @@ export function SampleList({ profileId }: SampleListProps) { const [uploadOpen, setUploadOpen] = useState(false); const [editingSampleId, setEditingSampleId] = useState(null); const [editedText, setEditedText] = useState(''); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [sampleToDelete, setSampleToDelete] = useState(null); - const handleDelete = (sampleId: string) => { - if (confirm('Are you sure you want to delete this sample?')) { - deleteSample.mutate(sampleId); + const handleDeleteClick = (sampleId: string) => { + setSampleToDelete(sampleId); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = () => { + if (sampleToDelete) { + deleteSample.mutate(sampleToDelete); + setDeleteDialogOpen(false); + setSampleToDelete(null); } }; @@ -194,7 +211,9 @@ export function SampleList({ profileId }: SampleListProps) {

No samples yet

-

Add your first audio sample to get started

+

+ Add your first audio sample to get started +

) : (
@@ -206,7 +225,7 @@ export function SampleList({ profileId }: SampleListProps) { key={sample.id} className={cn( 'group relative rounded-lg border bg-card transition-all duration-200', - isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30' + isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30', )} > {isEditing ? ( @@ -266,7 +285,7 @@ export function SampleList({ profileId }: SampleListProps) { handleDelete(sample.id)} + onClick={() => handleDeleteClick(sample.id)} disabled={deleteSample.isPending} />
@@ -287,12 +306,52 @@ export function SampleList({ profileId }: SampleListProps) { )} - +

+ Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple + samples. In a future update samples might be interchangeable and tagged for varying styles + of the same voice. +

+ + + + + + Delete Sample + + Are you sure you want to delete this audio sample? This action cannot be undone. + + + + + + + + ); } diff --git a/app/src/components/ui/circle-button.tsx b/app/src/components/ui/circle-button.tsx index 268843ed..88394399 100644 --- a/app/src/components/ui/circle-button.tsx +++ b/app/src/components/ui/circle-button.tsx @@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes( - ({ className, icon: Icon, ...props }, ref) => { + ({ className, icon: Icon, type = 'button', ...props }, ref) => { return (