mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
Refactor App and Sidebar components to support macOS, add TitleBarDragRegion for improved window dragging, and enhance model management with delete functionality and download progress tracking. Update audio player to handle audio resets more effectively and improve generation form with model download notifications.
This commit is contained in:
@@ -9,6 +9,7 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
export function AudioPlayer() {
|
||||
const {
|
||||
audioUrl,
|
||||
audioId,
|
||||
title,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
@@ -25,6 +26,9 @@ export function AudioPlayer() {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const previousAudioIdRef = useRef<string | null>(null);
|
||||
const previousCurrentTimeRef = useRef<number>(0);
|
||||
const hasInitializedRef = useRef(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -354,6 +358,61 @@ export function AudioPlayer() {
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
// Mark as initialized when audio is ready, reset when audioId changes
|
||||
useEffect(() => {
|
||||
if (duration > 0 && audioId) {
|
||||
hasInitializedRef.current = true;
|
||||
}
|
||||
// Reset initialization flag when audioId changes to a new audio
|
||||
if (audioId !== previousAudioIdRef.current && previousAudioIdRef.current !== null) {
|
||||
hasInitializedRef.current = false;
|
||||
}
|
||||
}, [duration, audioId]);
|
||||
|
||||
// Handle clicking the same audio again - always restart from beginning
|
||||
// When setAudio is called with the same audioId, it sets currentTime to 0 in the store
|
||||
// but WaveSurfer's actual position is still wherever it was. We detect this mismatch and reset.
|
||||
useEffect(() => {
|
||||
const wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer || !audioId || duration === 0 || !hasInitializedRef.current) {
|
||||
// Update the refs even if we don't process
|
||||
if (audioId !== null) {
|
||||
previousAudioIdRef.current = audioId;
|
||||
}
|
||||
previousCurrentTimeRef.current = currentTime;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousAudioId = previousAudioIdRef.current;
|
||||
const previousCurrentTime = previousCurrentTimeRef.current;
|
||||
|
||||
// Check if the same audio was clicked again
|
||||
// This happens when:
|
||||
// 1. audioId matches the previous one (same audio)
|
||||
// 2. currentTime was reset from a non-zero value to 0 (setAudio was called)
|
||||
// 3. WaveSurfer is not at the beginning (needs reset)
|
||||
const wasResetToZero = previousCurrentTime > 0.1 && currentTime < 0.1;
|
||||
const isSameAudio = audioId === previousAudioId;
|
||||
const wavesurferPosition = wavesurfer.getCurrentTime();
|
||||
const wavesurferNotAtStart = wavesurferPosition > 0.1;
|
||||
|
||||
// Update refs for next time
|
||||
previousAudioIdRef.current = audioId;
|
||||
previousCurrentTimeRef.current = currentTime;
|
||||
|
||||
// If same audio was clicked (reset to 0) and WaveSurfer is not at start, reset it
|
||||
if (isSameAudio && wasResetToZero && wavesurferNotAtStart) {
|
||||
// Reset to beginning and play
|
||||
console.log('Same audio clicked again, resetting to beginning');
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play().catch((error) => {
|
||||
console.error('Failed to play after reset:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}, [audioId, duration, currentTime, setIsPlaying]);
|
||||
|
||||
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||
|
||||
const handlePlayPause = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2, Mic } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,6 +26,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
@@ -47,6 +49,15 @@ export function GenerationForm() {
|
||||
const { toast } = useToast();
|
||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||
|
||||
// Use the download toast hook to show progress when model is downloading
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModelName || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
enabled: !!downloadingModelName,
|
||||
});
|
||||
|
||||
const form = useForm<GenerationFormValues>({
|
||||
resolver: zodResolver(generationSchema),
|
||||
@@ -71,6 +82,27 @@ export function GenerationForm() {
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
// Determine model name and display name
|
||||
const modelName = `qwen-tts-${data.modelSize}`;
|
||||
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
||||
|
||||
// Check if model is downloaded before starting generation
|
||||
try {
|
||||
const modelStatus = await apiClient.getModelStatus();
|
||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||
|
||||
if (model && !model.downloaded) {
|
||||
// Model is not downloaded, enable download toast
|
||||
setDownloadingModelName(modelName);
|
||||
setDownloadingDisplayName(displayName);
|
||||
}
|
||||
} catch (error) {
|
||||
// If status check fails, continue anyway - generation will handle it
|
||||
console.error('Failed to check model status:', error);
|
||||
}
|
||||
|
||||
// Proceed with generation (which will trigger download if needed)
|
||||
const result = await generation.mutateAsync({
|
||||
profile_id: selectedProfileId,
|
||||
text: data.text,
|
||||
@@ -98,6 +130,9 @@ export function GenerationForm() {
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
// Clear download state after generation completes
|
||||
setDownloadingModelName(null);
|
||||
setDownloadingDisplayName(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AudioWaveform, Download, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,6 +20,8 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS
|
||||
export function HistoryTable() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const limit = 20;
|
||||
|
||||
const { data: historyData, isLoading } = useHistory({
|
||||
@@ -34,6 +36,18 @@ export function HistoryTable() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(scrollEl.scrollTop > 0);
|
||||
};
|
||||
|
||||
scrollEl.addEventListener('scroll', handleScroll);
|
||||
return () => scrollEl.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const handlePlay = (audioId: string, text: string) => {
|
||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
||||
// If clicking the same audio that's playing, it will be handled by the player
|
||||
@@ -64,14 +78,18 @@ export function HistoryTable() {
|
||||
const hasMore = history.length === limit && (page + 1) * limit < total;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex flex-col h-full min-h-0 relative">
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground flex-1 flex items-center justify-center">
|
||||
No generation history yet. Generate your first audio to see it here.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isScrolled && (
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'flex-1 min-h-0 overflow-y-auto space-y-2',
|
||||
isPlayerVisible && 'max-h-[calc(100vh-117px)]',
|
||||
|
||||
@@ -4,14 +4,26 @@ import { apiClient } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Download, CheckCircle2 } from 'lucide-react';
|
||||
import { Loader2, Download, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { ModelProgress } from './ModelProgress';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export function ModelManagement() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [downloadingModel, setDownloadingModel] = useState<string | null>(null);
|
||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||
|
||||
const { data: modelStatus, isLoading } = useQuery({
|
||||
queryKey: ['modelStatus'],
|
||||
@@ -19,16 +31,29 @@ export function ModelManagement() {
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
});
|
||||
|
||||
// Use progress toast hook for the downloading model
|
||||
useModelDownloadToast({
|
||||
modelName: downloadingModel || '',
|
||||
displayName: downloadingDisplayName || '',
|
||||
enabled: !!downloadingModel && !!downloadingDisplayName,
|
||||
});
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [modelToDelete, setModelToDelete] = useState<{
|
||||
name: string;
|
||||
displayName: string;
|
||||
sizeMb?: number;
|
||||
} | null>(null);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: (modelName: string) => {
|
||||
setDownloadingModel(modelName);
|
||||
// Find display name from model status
|
||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||
setDownloadingDisplayName(model?.display_name || modelName);
|
||||
return apiClient.triggerModelDownload(modelName);
|
||||
},
|
||||
onSuccess: (_, modelName) => {
|
||||
toast({
|
||||
title: 'Download started',
|
||||
description: `Downloading ${modelName}...`,
|
||||
});
|
||||
onSuccess: () => {
|
||||
// Refetch status after a delay to see progress
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
@@ -36,6 +61,7 @@ export function ModelManagement() {
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: error.message,
|
||||
@@ -46,10 +72,32 @@ export function ModelManagement() {
|
||||
// Clear downloading state after a delay to allow progress to show
|
||||
setTimeout(() => {
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (modelName: string) => apiClient.deleteModel(modelName),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Model deleted',
|
||||
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
setModelToDelete(null);
|
||||
// Refetch status to update UI
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const formatSize = (sizeMb?: number): string => {
|
||||
if (!sizeMb) return 'Unknown';
|
||||
if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`;
|
||||
@@ -84,6 +132,14 @@ export function ModelManagement() {
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
||||
onDelete={() => {
|
||||
setModelToDelete({
|
||||
name: model.model_name,
|
||||
displayName: model.display_name,
|
||||
sizeMb: model.size_mb,
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
isDownloading={downloadingModel === model.model_name}
|
||||
formatSize={formatSize}
|
||||
/>
|
||||
@@ -104,6 +160,14 @@ export function ModelManagement() {
|
||||
key={model.model_name}
|
||||
model={model}
|
||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
||||
onDelete={() => {
|
||||
setModelToDelete({
|
||||
name: model.model_name,
|
||||
displayName: model.display_name,
|
||||
sizeMb: model.size_mb,
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
isDownloading={downloadingModel === model.model_name}
|
||||
formatSize={formatSize}
|
||||
/>
|
||||
@@ -129,6 +193,46 @@ export function ModelManagement() {
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{modelToDelete?.displayName}</strong>?
|
||||
{modelToDelete?.sizeMb && (
|
||||
<>
|
||||
{' '}
|
||||
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model
|
||||
will need to be re-downloaded if you want to use it again.
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (modelToDelete) {
|
||||
deleteMutation.mutate(modelToDelete.name);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -142,11 +246,18 @@ interface ModelItemProps {
|
||||
loaded: boolean;
|
||||
};
|
||||
onDownload: () => void;
|
||||
onDelete: () => void;
|
||||
isDownloading: boolean;
|
||||
formatSize: (sizeMb?: number) => string;
|
||||
}
|
||||
|
||||
function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemProps) {
|
||||
function ModelItem({
|
||||
model,
|
||||
onDownload,
|
||||
onDelete,
|
||||
isDownloading,
|
||||
formatSize,
|
||||
}: ModelItemProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
@@ -171,9 +282,21 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.downloaded ? (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Ready</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={model.loaded}
|
||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
isMacOS?: boolean;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
@@ -14,13 +15,16 @@ const tabs = [
|
||||
{ id: 'settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
||||
export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
||||
const isGenerating = useGenerationStore((state) => state.isGenerating);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6">
|
||||
<div className={cn(
|
||||
"fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6",
|
||||
isMacOS && "pt-14"
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="mb-2">
|
||||
<img src={voiceboxLogo} alt="Voicebox" className="w-12 h-12 object-contain" />
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function TitleBarDragRegion() {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="fixed top-0 left-0 right-0 h-12 z-[9999]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
Reference in New Issue
Block a user