From a3cbe7f2b60c943dc466d4f1b6ebafdd819f8436 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 27 Jan 2026 17:11:38 -0800 Subject: [PATCH] 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. --- app/src/App.tsx | 4 +- app/src/components/AppFrame/AppFrame.tsx | 4 +- app/src/components/AudioTab/AudioTab.tsx | 354 ++++++++++++------ app/src/components/History/HistoryTable.tsx | 3 +- app/src/components/MainEditor/MainEditor.tsx | 146 +++++++- .../ServerSettings/ModelManagement.tsx | 3 +- app/src/components/Sidebar.tsx | 2 +- .../components/VoiceProfiles/ProfileList.tsx | 104 +---- app/src/components/VoicesTab/VoicesTab.tsx | 69 ++-- app/src/lib/constants/ui.ts | 15 + 10 files changed, 455 insertions(+), 249 deletions(-) create mode 100644 app/src/lib/constants/ui.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index c4cd85b7..89afc281 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -21,6 +21,8 @@ import { startServer, } from '@/lib/tauri'; 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 let serverStarting = false; @@ -138,7 +140,7 @@ function App() { // Show loading screen while server is starting in Tauri if (isTauri() && !serverReady) { return ( -
+
diff --git a/app/src/components/AppFrame/AppFrame.tsx b/app/src/components/AppFrame/AppFrame.tsx index 6b72b3e3..2caaa0dc 100644 --- a/app/src/components/AppFrame/AppFrame.tsx +++ b/app/src/components/AppFrame/AppFrame.tsx @@ -1,5 +1,7 @@ import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; +import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { cn } from '@/lib/utils/cn'; interface AppFrameProps { children: React.ReactNode; @@ -7,7 +9,7 @@ interface AppFrameProps { export function AppFrame({ children }: AppFrameProps) { return ( -
+
{children} diff --git a/app/src/components/AudioTab/AudioTab.tsx b/app/src/components/AudioTab/AudioTab.tsx index 04e07978..059bc836 100644 --- a/app/src/components/AudioTab/AudioTab.tsx +++ b/app/src/components/AudioTab/AudioTab.tsx @@ -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 { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, @@ -19,10 +18,14 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import { isTauri } from '@/lib/tauri'; 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 { id: string; @@ -33,7 +36,10 @@ interface AudioDevice { export function AudioTab() { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingChannel, setEditingChannel] = useState(null); + const [selectedChannelId, setSelectedChannelId] = useState(null); const queryClient = useQueryClient(); + const audioUrl = usePlayerStore((state) => state.audioUrl); + const isPlayerVisible = !!audioUrl; const { data: channels, isLoading: channelsLoading } = useQuery({ queryKey: ['channels'], @@ -120,98 +126,219 @@ export function AudioTab() { ); } + const allChannels = channels || []; + const allDevices = devices || []; + const selectedChannel = selectedChannelId + ? allChannels.find((c) => c.id === selectedChannelId) + : null; + return (
-
-

Audio Channels

+
+

Audio Channels

-
- {channels?.map((channel) => ( - - -
- - - {channel.name} - {channel.is_default && ( - (Default) - )} - - {!channel.is_default && ( -
- - -
- )} -
-
- -
-
- -
- {channel.device_ids.length > 0 ? ( -
    - {channel.device_ids.map((deviceId) => { - const device = devices?.find((d) => d.id === deviceId); - return ( -
  • {device?.name || deviceId || 'Default Speakers'}
  • - ); - })} -
- ) : ( - Default Speakers - )} -
-
-
- - -
-
-
-
- ))} -
- -
-

Available Devices

-
- {devices && devices.length > 0 ? ( - devices.map((device) => ( -
- {device.name} - {device.is_default && ( - (default) - )} -
- )) +
+ {/* Left Column - Channels */} +
+ {allChannels.length === 0 ? ( +
+ +

+ No audio channels yet. Create your first channel to route voices to specific devices. +

+ +
) : ( -
- {isTauri() - ? 'No audio devices found' - : 'Audio device selection requires Tauri'} +
+ {allChannels.map((channel) => { + const isSelected = selectedChannelId === channel.id; + return ( + + +
+ )} +
+ + ); + })} +
+ )} +
+ + {/* Right Column - Available Devices */} +
+
+

Available Devices

+

+ {selectedChannelId + ? selectedChannel?.is_default + ? 'Default channel uses system default device' + : 'Click devices to add or remove them from the selected channel' + : 'Select a channel to assign devices'} +

+
+ {allDevices.length > 0 ? ( +
+ {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 ( + + ); + })} +
+ ) : ( +
+ +

+ {isTauri() + ? 'No audio devices found' + : 'Audio device selection requires Tauri'} +

)}
@@ -228,28 +355,31 @@ export function AudioTab() { /> {/* Edit Channel Dialog */} - {editingChannel && ( - !open && setEditingChannel(null)} - channel={channels?.find((c) => c.id === editingChannel)!} - devices={devices || []} - profiles={profiles || []} - channelVoices={channelVoices?.profile_ids || []} - onUpdate={(name, deviceIds) => { - updateChannel.mutate({ - channelId: editingChannel, - data: { name, device_ids: deviceIds }, - }); - }} - onSetVoices={(profileIds) => { - setChannelVoices.mutate({ - channelId: editingChannel, - profileIds, - }); - }} - /> - )} + {editingChannel && (() => { + const channel = channels?.find((c) => c.id === editingChannel); + return channel ? ( + !open && setEditingChannel(null)} + channel={channel} + devices={devices || []} + profiles={profiles || []} + channelVoices={channelVoices?.profile_ids || []} + onUpdate={(name, deviceIds) => { + updateChannel.mutate({ + channelId: editingChannel, + data: { name, device_ids: deviceIds }, + }); + }} + onSetVoices={(profileIds) => { + setChannelVoices.mutate({ + channelId: editingChannel, + profileIds, + }); + }} + /> + ) : null; + })()}
); } @@ -271,11 +401,15 @@ function ChannelVoicesList({ channelId }: { channelId: string }) { .filter(Boolean) || []; return ( -
+
{voiceNames.length > 0 ? ( - {voiceNames.join(', ')} + voiceNames.map((name) => ( + + {name} + + )) ) : ( - No voices assigned + No voices assigned )}
); diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index b333318f..198bf937 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -27,6 +27,7 @@ import { import { cn } from '@/lib/utils/cn'; import { formatDate, formatDuration } from '@/lib/utils/format'; 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) // This is the new alternate history view with fixed height rows @@ -177,7 +178,7 @@ export function HistoryTable() { ref={scrollRef} className={cn( 'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4', - isPlayerVisible && 'pb-32', + isPlayerVisible && BOTTOM_SAFE_AREA_PADDING, )} > {history.map((gen) => { diff --git a/app/src/components/MainEditor/MainEditor.tsx b/app/src/components/MainEditor/MainEditor.tsx index fd766265..754e3f70 100644 --- a/app/src/components/MainEditor/MainEditor.tsx +++ b/app/src/components/MainEditor/MainEditor.tsx @@ -1,25 +1,118 @@ -import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; -import { HistoryTable } from '@/components/History/HistoryTable'; +import { Sparkles, Upload } from 'lucide-react'; +import { useRef, useState } from 'react'; 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 { useUIStore } from '@/stores/uiStore'; export function MainEditor() { const audioUrl = usePlayerStore((state) => state.audioUrl); + const isPlayerVisible = !!audioUrl; + const scrollRef = useRef(null); + const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); + const importProfile = useImportProfile(); + const fileInputRef = useRef(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( // Main view: Profiles top left, Generator bottom left, History right
{/* Left Column */} -
- {/* Profiles - Top Left */} -
- +
+ {/* Scroll Mask - Always visible, behind content */} +
+ + {/* Fixed Header */} +
+
+

Voicebox

+
+ + + +
+
- {/* Generator - Bottom Left */} - {/*
- -
*/} + {/* Scrollable Content */} +
+
+ {/* Profiles - Top Left */} +
+ +
+ + {/* Generator - Bottom Left */} + {/*
+ +
*/} +
+
{/* Right Column - History */} @@ -29,6 +122,39 @@ export function MainEditor() { {/* Floating Generate Box */} + + {/* Import Dialog */} + + + + Import Profile + + Import the profile from "{selectedFile?.name}". This will create a new profile with + all samples. + + + + + + + +
); } diff --git a/app/src/components/ServerSettings/ModelManagement.tsx b/app/src/components/ServerSettings/ModelManagement.tsx index 3b160e5b..41d2143b 100644 --- a/app/src/components/ServerSettings/ModelManagement.tsx +++ b/app/src/components/ServerSettings/ModelManagement.tsx @@ -277,14 +277,13 @@ function ModelItem({ {model.downloaded ? (
- + Ready
- - -
-
-
{allProfiles.length === 0 ? ( @@ -118,38 +50,6 @@ export function ProfileList() {
- - - - - Import Profile - - Import the profile from "{selectedFile?.name}". This will create a new profile with - all samples. - - - - - - - -
); } diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx index 63a67c45..4c2b8acd 100644 --- a/app/src/components/VoicesTab/VoicesTab.tsx +++ b/app/src/components/VoicesTab/VoicesTab.tsx @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; -import { useMemo } from 'react'; +import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react'; +import { useMemo, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -22,6 +22,10 @@ import type { VoiceProfileResponse } from '@/lib/api/types'; import { useHistory } from '@/lib/hooks/useHistory'; import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles'; 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() { const { data: profiles, isLoading } = useProfiles(); @@ -30,6 +34,9 @@ export function VoicesTab() { const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const deleteProfile = useDeleteProfile(); + const scrollRef = useRef(null); + const audioUrl = usePlayerStore((state) => state.audioUrl); + const isPlayerVisible = !!audioUrl; // Get generation counts per profile const generationCounts = useMemo(() => { @@ -96,16 +103,29 @@ export function VoicesTab() { } return ( -
-
-

Voices

- +
+ {/* Scroll Mask - Always visible, behind content */} +
+ + {/* Fixed Header */} +
+
+

Voices

+ +
-
+ {/* Scrollable Content */} +
@@ -133,6 +153,8 @@ export function VoicesTab() {
+ +
); } @@ -159,19 +181,24 @@ function VoiceRow({ const { data: samples } = useProfileSamples(profile.id); return ( - + -
-
{profile.name}
- {profile.description && ( -
{profile.description}
- )} +
+
+ +
+
+
{profile.name}
+ {profile.description && ( +
{profile.description}
+ )} +
- {profile.language} - {generationCount} - {samples?.length || 0} - + e.stopPropagation()}>{profile.language} + e.stopPropagation()}>{generationCount} + e.stopPropagation()}>{samples?.length || 0} + e.stopPropagation()}> ({ value: ch.id, @@ -183,7 +210,7 @@ function VoiceRow({ className="min-w-[200px]" /> - + e.stopPropagation()}>