Refactor UI layout and introduce safe area constants for improved responsiveness

- Updated App, AppFrame, and AudioTab components to utilize new TOP_SAFE_AREA_PADDING and BOTTOM_SAFE_AREA_PADDING constants for consistent layout adjustments.
- Enhanced Sidebar and MainEditor components for better organization and user experience.
- Improved VoicesTab and ProfileList components by integrating new layout features and removing redundant import functionality.
- Streamlined HistoryTable and ModelManagement components for better visual consistency and interaction.
This commit is contained in:
Jamie Pine
2026-01-27 17:11:38 -08:00
parent d8d9eeaa6a
commit a3cbe7f2b6
10 changed files with 455 additions and 249 deletions
+3 -1
View File
@@ -21,6 +21,8 @@ import {
startServer, startServer,
} from '@/lib/tauri'; } from '@/lib/tauri';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
// Track if server is starting to prevent duplicate starts // Track if server is starting to prevent duplicate starts
let serverStarting = false; let serverStarting = false;
@@ -138,7 +140,7 @@ function App() {
// Show loading screen while server is starting in Tauri // Show loading screen while server is starting in Tauri
if (isTauri() && !serverReady) { if (isTauri() && !serverReady) {
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center pt-12"> <div className={cn('min-h-screen bg-background flex items-center justify-center', TOP_SAFE_AREA_PADDING)}>
<TitleBarDragRegion /> <TitleBarDragRegion />
<div className="text-center space-y-6"> <div className="text-center space-y-6">
<div className="flex justify-center relative"> <div className="flex justify-center relative">
+3 -1
View File
@@ -1,5 +1,7 @@
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
interface AppFrameProps { interface AppFrameProps {
children: React.ReactNode; children: React.ReactNode;
@@ -7,7 +9,7 @@ interface AppFrameProps {
export function AppFrame({ children }: AppFrameProps) { export function AppFrame({ children }: AppFrameProps) {
return ( return (
<div className="h-screen bg-background flex flex-col overflow-hidden pt-12"> <div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
<TitleBarDragRegion /> <TitleBarDragRegion />
{children} {children}
<AudioPlayer /> <AudioPlayer />
+195 -61
View File
@@ -1,7 +1,6 @@
import { Edit, Plus, Trash2, Speaker } from 'lucide-react'; import { Edit, Plus, Trash2, Speaker, CheckCircle2, Check } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,10 +18,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '@/stores/playerStore';
import { cn } from '@/lib/utils/cn';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
interface AudioDevice { interface AudioDevice {
id: string; id: string;
@@ -33,7 +36,10 @@ interface AudioDevice {
export function AudioTab() { export function AudioTab() {
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null); const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const { data: channels, isLoading: channelsLoading } = useQuery({ const { data: channels, isLoading: channelsLoading } = useQuery({
queryKey: ['channels'], queryKey: ['channels'],
@@ -120,41 +126,121 @@ export function AudioTab() {
); );
} }
const allChannels = channels || [];
const allDevices = devices || [];
const selectedChannel = selectedChannelId
? allChannels.find((c) => c.id === selectedChannelId)
: null;
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6 shrink-0">
<h1 className="text-2xl font-bold">Audio Channels</h1> <h2 className="text-2xl font-bold">Audio Channels</h2>
<Button onClick={() => setCreateDialogOpen(true)}> <Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
New Channel New Channel
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-auto space-y-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
{channels?.map((channel) => ( {/* Left Column - Channels */}
<Card key={channel.id}> <div className={cn('flex flex-col min-h-0 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<CardHeader> {allChannels.length === 0 ? (
<div className="flex items-center justify-between"> <div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<CardTitle className="flex items-center gap-2"> <Speaker className="h-12 w-12 text-muted-foreground mb-4" />
<Speaker className="h-5 w-5" /> <p className="text-muted-foreground mb-4">
{channel.name} No audio channels yet. Create your first channel to route voices to specific devices.
{channel.is_default && ( </p>
<span className="text-xs text-muted-foreground">(Default)</span> <Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Channel
</Button>
</div>
) : (
<div className="space-y-3 p-2">
{allChannels.map((channel) => {
const isSelected = selectedChannelId === channel.id;
return (
<button
key={channel.id}
type="button"
className={cn(
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
)} )}
</CardTitle> onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-3">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Speaker className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
</div>
</div>
<div className="space-y-2.5 ml-10">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
Output Devices
</div>
<div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0 ? (
channel.device_ids.map((deviceId) => {
const device = allDevices.find((d) => d.id === deviceId);
return (
<Badge
key={deviceId}
variant="outline"
className="text-xs font-normal"
>
{device?.name || deviceId}
</Badge>
);
})
) : (
(() => {
const defaultDevice = allDevices.find((d) => d.is_default);
return defaultDevice ? (
<Badge variant="outline" className="text-xs font-normal">
{defaultDevice.name}
</Badge>
) : null;
})()
)}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
Assigned Voices
</div>
<ChannelVoicesList channelId={channel.id} />
</div>
</div>
</div>
{!channel.is_default && ( {!channel.is_default && (
<div className="flex gap-2"> <div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setEditingChannel(channel.id)} className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel.id);
}}
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this channel?')) { if (confirm('Delete this channel?')) {
deleteChannel.mutate(channel.id); deleteChannel.mutate(channel.id);
} }
@@ -165,53 +251,94 @@ export function AudioTab() {
</div> </div>
)} )}
</div> </div>
</CardHeader> </button>
<CardContent>
<div className="space-y-2">
<div>
<Label className="text-sm font-medium">Output Devices:</Label>
<div className="mt-1 text-sm text-muted-foreground">
{channel.device_ids.length > 0 ? (
<ul className="list-disc list-inside">
{channel.device_ids.map((deviceId) => {
const device = devices?.find((d) => d.id === deviceId);
return (
<li key={deviceId}>{device?.name || deviceId || 'Default Speakers'}</li>
); );
})} })}
</ul> </div>
) : (
<span>Default Speakers</span>
)} )}
</div> </div>
</div>
<div>
<Label className="text-sm font-medium">Assigned Voices:</Label>
<ChannelVoicesList channelId={channel.id} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
<div className="mt-8 pt-6 border-t"> {/* Right Column - Available Devices */}
<h2 className="text-lg font-semibold mb-4">Available Devices</h2> <div className={cn('flex flex-col min-h-0 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<div className="space-y-2"> <div className="shrink-0 mb-4">
{devices && devices.length > 0 ? ( <h3 className="text-lg font-semibold">Available Devices</h3>
devices.map((device) => ( <p className="text-sm text-muted-foreground mt-1">
<div key={device.id} className="text-sm"> {selectedChannelId
<span className="font-medium">{device.name}</span> ? selectedChannel?.is_default
{device.is_default && ( ? 'Default channel uses system default device'
<span className="text-muted-foreground ml-2">(default)</span> : 'Click devices to add or remove them from the selected channel'
)} : 'Select a channel to assign devices'}
</p>
</div>
{allDevices.length > 0 ? (
<div className="space-y-2">
{allDevices.map((device) => {
const isConnected =
selectedChannelId &&
selectedChannel &&
(selectedChannel.device_ids.length === 0
? device.is_default
: selectedChannel.device_ids.includes(device.id));
const canToggle = selectedChannelId && selectedChannel && !selectedChannel.is_default;
const handleDeviceClick = () => {
if (!canToggle || !selectedChannel) return;
const currentDeviceIds = selectedChannel.device_ids;
const newDeviceIds = isConnected
? currentDeviceIds.filter((id) => id !== device.id)
: [...currentDeviceIds, device.id];
updateChannel.mutate({
channelId: selectedChannelId,
data: { device_ids: newDeviceIds },
});
};
return (
<button
key={device.id}
type="button"
onClick={handleDeviceClick}
disabled={!canToggle}
className={cn(
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
isConnected
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
: 'hover:bg-muted/50',
!canToggle && 'cursor-default opacity-60',
canToggle && 'cursor-pointer',
)}
>
{canToggle ? (
<div
className={cn(
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
isConnected
? 'bg-accent border-accent'
: 'border-muted-foreground/30',
)}
>
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
</div>
) : device.is_default ? (
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
) : null}
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
{device.name}
</span>
</button>
);
})}
</div> </div>
))
) : ( ) : (
<div className="text-sm text-muted-foreground"> <div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
{isTauri() {isTauri()
? 'No audio devices found' ? 'No audio devices found'
: 'Audio device selection requires Tauri'} : 'Audio device selection requires Tauri'}
</p>
</div> </div>
)} )}
</div> </div>
@@ -228,11 +355,13 @@ export function AudioTab() {
/> />
{/* Edit Channel Dialog */} {/* Edit Channel Dialog */}
{editingChannel && ( {editingChannel && (() => {
const channel = channels?.find((c) => c.id === editingChannel);
return channel ? (
<EditChannelDialog <EditChannelDialog
open={!!editingChannel} open={!!editingChannel}
onOpenChange={(open) => !open && setEditingChannel(null)} onOpenChange={(open) => !open && setEditingChannel(null)}
channel={channels?.find((c) => c.id === editingChannel)!} channel={channel}
devices={devices || []} devices={devices || []}
profiles={profiles || []} profiles={profiles || []}
channelVoices={channelVoices?.profile_ids || []} channelVoices={channelVoices?.profile_ids || []}
@@ -249,7 +378,8 @@ export function AudioTab() {
}); });
}} }}
/> />
)} ) : null;
})()}
</div> </div>
); );
} }
@@ -271,11 +401,15 @@ function ChannelVoicesList({ channelId }: { channelId: string }) {
.filter(Boolean) || []; .filter(Boolean) || [];
return ( return (
<div className="mt-1 text-sm text-muted-foreground"> <div className="flex flex-wrap gap-1.5">
{voiceNames.length > 0 ? ( {voiceNames.length > 0 ? (
<span>{voiceNames.join(', ')}</span> voiceNames.map((name) => (
<Badge key={name} variant="outline" className="text-xs font-normal">
{name}
</Badge>
))
) : ( ) : (
<span>No voices assigned</span> <span className="text-sm text-muted-foreground">No voices assigned</span>
)} )}
</div> </div>
); );
+2 -1
View File
@@ -27,6 +27,7 @@ import {
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { formatDate, formatDuration } from '@/lib/utils/format'; import { formatDate, formatDuration } from '@/lib/utils/format';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) // OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
// This is the new alternate history view with fixed height rows // This is the new alternate history view with fixed height rows
@@ -177,7 +178,7 @@ export function HistoryTable() {
ref={scrollRef} ref={scrollRef}
className={cn( className={cn(
'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4', 'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4',
isPlayerVisible && 'pb-32', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)} )}
> >
{history.map((gen) => { {history.map((gen) => {
+129 -3
View File
@@ -1,16 +1,107 @@
import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { Sparkles, Upload } from 'lucide-react';
import { HistoryTable } from '@/components/History/HistoryTable'; import { useRef, useState } from 'react';
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox'; import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
import { HistoryTable } from '@/components/History/HistoryTable';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useImportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useUIStore } from '@/stores/uiStore';
export function MainEditor() { export function MainEditor() {
const audioUrl = usePlayerStore((state) => state.audioUrl); const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const scrollRef = useRef<HTMLDivElement>(null);
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) {
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}`);
},
});
}
};
return ( return (
// Main view: Profiles top left, Generator bottom left, History right // Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
{/* Left Column */} {/* Left Column */}
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32"> <div className="flex flex-col min-h-0 overflow-hidden relative">
{/* Scroll Mask - Always visible, behind content */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-10">
<div className="flex items-center justify-between mb-4 px-1">
<h2 className="text-2xl font-bold">Voicebox</h2>
<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>
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn(
'flex-1 min-h-0 overflow-y-auto pt-14',
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
)}
>
<div className="flex flex-col gap-6">
{/* Profiles - Top Left */} {/* Profiles - Top Left */}
<div className="shrink-0 flex flex-col"> <div className="shrink-0 flex flex-col">
<ProfileList /> <ProfileList />
@@ -21,6 +112,8 @@ export function MainEditor() {
<GenerationForm /> <GenerationForm />
</div> */} </div> */}
</div> </div>
</div>
</div>
{/* Right Column - History */} {/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden"> <div className="flex flex-col min-h-0 overflow-hidden">
@@ -29,6 +122,39 @@ export function MainEditor() {
{/* Floating Generate Box */} {/* Floating Generate Box */}
<FloatingGenerateBox isPlayerOpen={!!audioUrl} /> <FloatingGenerateBox isPlayerOpen={!!audioUrl} />
{/* Import Dialog */}
<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> </div>
); );
} }
@@ -277,14 +277,13 @@ function ModelItem({
{model.downloaded ? ( {model.downloaded ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-muted-foreground"> <div className="flex items-center gap-1 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-500" /> <CheckCircle2 className="h-4 w-4 text-accent" />
<span>Ready</span> <span>Ready</span>
</div> </div>
<Button <Button
size="sm" size="sm"
onClick={onDelete} onClick={onDelete}
variant="outline" variant="outline"
className="text-destructive hover:text-destructive"
disabled={model.loaded} disabled={model.loaded}
title={model.loaded ? 'Unload model before deleting' : 'Delete model'} title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
> >
+1 -1
View File
@@ -1,4 +1,4 @@
import { Loader2, Volume2, Mic, Speaker, Server, Box } from 'lucide-react'; import { Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png'; import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore'; import { useGenerationStore } from '@/stores/generationStore';
@@ -1,16 +1,7 @@
import { Mic, Sparkles, Upload } from 'lucide-react'; import { Mic, Sparkles } from 'lucide-react';
import { useRef, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import { useProfiles } from '@/lib/hooks/useProfiles';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useImportProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { ProfileCard } from './ProfileCard'; import { ProfileCard } from './ProfileCard';
import { ProfileForm } from './ProfileForm'; import { ProfileForm } from './ProfileForm';
@@ -18,44 +9,6 @@ import { ProfileForm } from './ProfileForm';
export function ProfileList() { export function ProfileList() {
const { data: profiles, isLoading, error } = useProfiles(); const { data: profiles, isLoading, error } = useProfiles();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); 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) { if (isLoading) {
return null; return null;
@@ -73,27 +26,6 @@ export function ProfileList() {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center justify-between mb-4 shrink-0">
<h2 className="text-2xl font-bold">Voicebox</h2>
<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"> <div className="shrink-0">
{allProfiles.length === 0 ? ( {allProfiles.length === 0 ? (
<Card> <Card>
@@ -118,38 +50,6 @@ export function ProfileList() {
</div> </div>
<ProfileForm /> <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> </div>
); );
} }
+37 -10
View File
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -22,6 +22,10 @@ import type { VoiceProfileResponse } from '@/lib/api/types';
import { useHistory } from '@/lib/hooks/useHistory'; import { useHistory } from '@/lib/hooks/useHistory';
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles'; import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
export function VoicesTab() { export function VoicesTab() {
const { data: profiles, isLoading } = useProfiles(); const { data: profiles, isLoading } = useProfiles();
@@ -30,6 +34,9 @@ export function VoicesTab() {
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const deleteProfile = useDeleteProfile(); const deleteProfile = useDeleteProfile();
const scrollRef = useRef<HTMLDivElement>(null);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
// Get generation counts per profile // Get generation counts per profile
const generationCounts = useMemo(() => { const generationCounts = useMemo(() => {
@@ -96,7 +103,12 @@ export function VoicesTab() {
} }
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col relative overflow-hidden">
{/* Scroll Mask - Always visible, behind content */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Voices</h1> <h1 className="text-2xl font-bold">Voices</h1>
<Button onClick={() => setDialogOpen(true)}> <Button onClick={() => setDialogOpen(true)}>
@@ -104,8 +116,16 @@ export function VoicesTab() {
New Voice New Voice
</Button> </Button>
</div> </div>
</div>
<div className="flex-1 overflow-auto"> {/* Scrollable Content */}
<div
ref={scrollRef}
className={cn(
'flex-1 overflow-y-auto pt-16 relative z-0',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -133,6 +153,8 @@ export function VoicesTab() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<ProfileForm />
</div> </div>
); );
} }
@@ -159,19 +181,24 @@ function VoiceRow({
const { data: samples } = useProfileSamples(profile.id); const { data: samples } = useProfileSamples(profile.id);
return ( return (
<TableRow> <TableRow className="cursor-pointer" onClick={onEdit}>
<TableCell> <TableCell>
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Mic className="h-4 w-4 text-muted-foreground" />
</div>
<div> <div>
<div className="font-medium">{profile.name}</div> <div className="font-medium">{profile.name}</div>
{profile.description && ( {profile.description && (
<div className="text-sm text-muted-foreground">{profile.description}</div> <div className="text-sm text-muted-foreground">{profile.description}</div>
)} )}
</div> </div>
</div>
</TableCell> </TableCell>
<TableCell>{profile.language}</TableCell> <TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
<TableCell>{generationCount}</TableCell> <TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
<TableCell>{samples?.length || 0}</TableCell> <TableCell onClick={(e) => e.stopPropagation()}>{samples?.length || 0}</TableCell>
<TableCell> <TableCell onClick={(e) => e.stopPropagation()}>
<MultiSelect <MultiSelect
options={channels.map((ch) => ({ options={channels.map((ch) => ({
value: ch.id, value: ch.id,
@@ -183,7 +210,7 @@ function VoiceRow({
className="min-w-[200px]" className="min-w-[200px]"
/> />
</TableCell> </TableCell>
<TableCell> <TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
+15
View File
@@ -0,0 +1,15 @@
/**
* UI layout constants for safe area padding
*/
/**
* Top safe area padding - height of the drag region bar
* Corresponds to Tailwind's pt-12 (3rem / 48px)
*/
export const TOP_SAFE_AREA_PADDING = 'pt-12';
/**
* Bottom safe area padding - height of the audio player
* Corresponds to Tailwind's pb-32 (8rem / 128px)
*/
export const BOTTOM_SAFE_AREA_PADDING = 'pb-32';