mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
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:
+3
-1
@@ -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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user