feat: enable global audio upload from any page and add dialog blur

- Created GlobalUploadContext for global upload functionality
- Header now uses context when props not provided
- Upload from Settings/AudioDetail shows toast notifications
- Upload from Dashboard shows progress bar
- All add button options (Quick Transcribe, YouTube, Record, etc.) work everywhere
- Added backdrop-blur-sm to Dialog and AlertDialog overlays
- Fixed dialog flash bug by setting modal default to true
This commit is contained in:
rishikanthc
2025-12-14 12:44:09 -08:00
parent ffeaf766cd
commit 79800e2320
8 changed files with 327 additions and 169 deletions

View File

@@ -15,6 +15,7 @@ import { YouTubeDownloadDialog } from "@/features/transcription/components/YouTu
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/features/auth/hooks/useAuth";
import { isVideoFile, isAudioFile } from "../utils/fileProcessor";
import { useGlobalUpload } from "@/contexts/GlobalUploadContext";
interface FileWithType {
file: File;
@@ -22,7 +23,7 @@ interface FileWithType {
}
interface HeaderProps {
onFileSelect: (files: File | File[] | FileWithType | FileWithType[]) => void;
onFileSelect?: (files: File | File[] | FileWithType | FileWithType[]) => void;
onMultiTrackClick?: () => void;
onDownloadComplete?: () => void;
}
@@ -36,6 +37,14 @@ export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }:
const [isQuickTranscriptionOpen, setIsQuickTranscriptionOpen] = useState(false);
const [isYouTubeDialogOpen, setIsYouTubeDialogOpen] = useState(false);
// Use global upload context as fallback when props are not provided
const globalUpload = useGlobalUpload();
// Determine which handlers to use (prop or global context)
const effectiveFileSelect = onFileSelect ?? globalUpload.handleFileSelect;
const effectiveMultiTrackClick = onMultiTrackClick ?? globalUpload.openMultiTrackDialog;
const effectiveRecordingComplete = globalUpload.handleRecordingComplete;
const handleUploadClick = () => {
fileInputRef.current?.click();
};
@@ -57,7 +66,7 @@ export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }:
};
const handleMultiTrackClick = () => {
onMultiTrackClick?.();
effectiveMultiTrackClick();
};
const handleSettingsClick = () => {
@@ -88,7 +97,7 @@ export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }:
// Filter to only audio files
const audioFiles = fileArray.filter(file => isAudioFile(file));
if (audioFiles.length > 0) {
onFileSelect(audioFiles.length === 1 ? audioFiles[0] : audioFiles);
effectiveFileSelect(audioFiles.length === 1 ? audioFiles[0] : audioFiles);
// Reset the input so the same files can be selected again
event.target.value = "";
} else {
@@ -107,7 +116,7 @@ export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }:
if (videoFiles.length > 0) {
// Pass video files with type marker
const filesWithType: FileWithType[] = videoFiles.map(file => ({ file, isVideo: true }));
onFileSelect(filesWithType.length === 1 ? filesWithType[0] : filesWithType);
effectiveFileSelect(filesWithType.length === 1 ? filesWithType[0] : filesWithType);
// Reset the input so the same files can be selected again
event.target.value = "";
}
@@ -115,11 +124,11 @@ export function Header({ onFileSelect, onMultiTrackClick, onDownloadComplete }:
};
const handleRecordingComplete = async (blob: Blob, title: string) => {
// Convert blob to file and use existing upload logic
const file = new File([blob], `${title}.webm`, { type: blob.type });
onFileSelect(file);
// Use global recording complete handler
await effectiveRecordingComplete(blob, title);
};
return (
<header className="sticky top-4 sm:top-6 z-50 glass rounded-[var(--radius-card)] px-4 py-3 sm:px-6 sm:py-4 transition-all duration-500 shadow-[var(--shadow-float)] border border-[var(--border-subtle)]">
<div className="flex items-center justify-between">

View File

@@ -34,7 +34,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-carbon-950/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-carbon-950/50 backdrop-blur-sm",
className
)}
{...props}

View File

@@ -5,7 +5,7 @@ import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
modal = false,
modal = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" modal={modal} {...props} />
@@ -37,7 +37,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-carbon-950/60",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-carbon-950/60 backdrop-blur-sm",
className
)}
{...props}

View File

@@ -0,0 +1,286 @@
import {
createContext,
useContext,
useState,
useCallback,
type PropsWithChildren,
} from "react";
import { useLocation } from "react-router-dom";
import { useAudioUpload, useMultiTrackUpload } from "@/features/transcription/hooks/useAudioFiles";
import { useToast } from "@/components/ui/toast";
import { MultiTrackUploadDialog } from "@/features/transcription/components/MultiTrackUploadDialog";
// Types
interface FileWithType {
file: File;
isVideo: boolean;
}
interface UploadProgress {
fileName: string;
status: "uploading" | "success" | "error";
error?: string;
}
interface GlobalUploadContextValue {
// File upload
handleFileSelect: (
files: File | File[] | FileWithType | FileWithType[]
) => Promise<void>;
// Multi-track
handleMultiTrackUpload: (
files: File[],
aupFile: File,
title: string
) => Promise<void>;
openMultiTrackDialog: () => void;
// Recording completion
handleRecordingComplete: (blob: Blob, title: string) => Promise<void>;
// State
isUploading: boolean;
uploadProgress: UploadProgress[];
// For Dashboard to render its own progress bar
isOnDashboard: boolean;
}
const GlobalUploadContext = createContext<GlobalUploadContextValue | null>(
null
);
export function GlobalUploadProvider({ children }: PropsWithChildren) {
const { mutateAsync: uploadFile } = useAudioUpload();
const { mutateAsync: uploadMultiTrack } = useMultiTrackUpload();
const { toast } = useToast();
const location = useLocation();
// Check if we're on the dashboard (home page)
const isOnDashboard = location.pathname === "/" || location.pathname === "";
// Upload state
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
const [isUploading, setIsUploading] = useState(false);
// Multi-track dialog state
const [isMultiTrackDialogOpen, setIsMultiTrackDialogOpen] = useState(false);
const [multiTrackPreview, setMultiTrackPreview] = useState<{
audioFiles: File[];
aupFile: File;
title: string;
} | null>(null);
const handleFileSelect = useCallback(
async (files: File | File[] | FileWithType | FileWithType[]) => {
// Normalize input to an array of FileWithType objects
const fileArray = Array.isArray(files) ? files : [files];
const processedFiles = fileArray.map((item) => {
if ("file" in item && "isVideo" in item) {
return item;
} else {
return { file: item as File, isVideo: false };
}
});
if (processedFiles.length === 0) return;
setIsUploading(true);
// If on dashboard, use progress bar; otherwise use toasts
if (isOnDashboard) {
setUploadProgress(
processedFiles.map((item) => ({
fileName: item.file.name,
status: "uploading",
}))
);
} else {
toast({
title: "Uploading...",
description: `Uploading ${processedFiles.length} file(s)`,
});
}
let successCount = 0;
// Upload files sequentially
for (let i = 0; i < processedFiles.length; i++) {
const fileItem = processedFiles[i];
const file = fileItem.file;
const isVideo = fileItem.isVideo;
try {
await uploadFile({ file, isVideo });
if (isOnDashboard) {
setUploadProgress((prev) =>
prev.map((item, index) =>
index === i
? { ...item, status: "success", error: undefined }
: item
)
);
}
successCount++;
} catch (error) {
if (isOnDashboard) {
setUploadProgress((prev) =>
prev.map((item, index) =>
index === i
? {
...item,
status: "error",
error:
error instanceof Error
? error.message
: "Upload failed",
}
: item
)
);
} else {
toast({
title: "Upload Failed",
description: `Failed to upload ${file.name}`,
});
}
}
}
setIsUploading(false);
// Show success toast if not on dashboard
if (!isOnDashboard && successCount > 0) {
toast({
title: "Upload Complete",
description: `Successfully uploaded ${successCount} file(s)`,
});
}
// Auto-hide progress after 3 seconds if all succeeded (for dashboard)
if (isOnDashboard && successCount === fileArray.length) {
setTimeout(() => setUploadProgress([]), 3000);
}
},
[isOnDashboard, uploadFile, toast]
);
const handleMultiTrackUpload = useCallback(
async (files: File[], aupFile: File, title: string) => {
setIsUploading(true);
if (isOnDashboard) {
setUploadProgress([
{
fileName: `${title} (${files.length} tracks)`,
status: "uploading",
},
]);
} else {
toast({
title: "Uploading Multi-Track...",
description: `Uploading ${title} with ${files.length} tracks`,
});
}
try {
await uploadMultiTrack({ files, aupFile, title });
if (isOnDashboard) {
setUploadProgress([
{
fileName: `${title} (${files.length} tracks)`,
status: "success",
},
]);
setTimeout(() => setUploadProgress([]), 3000);
} else {
toast({
title: "Upload Complete",
description: `Successfully uploaded ${title}`,
});
}
} catch (error) {
if (isOnDashboard) {
setUploadProgress([
{
fileName: `${title} (${files.length} tracks)`,
status: "error",
error:
error instanceof Error ? error.message : "Upload failed",
},
]);
} else {
toast({
title: "Upload Failed",
description: `Failed to upload ${title}`,
});
}
} finally {
setIsUploading(false);
}
},
[isOnDashboard, uploadMultiTrack, toast]
);
const openMultiTrackDialog = useCallback(() => {
setMultiTrackPreview(null);
setIsMultiTrackDialogOpen(true);
}, []);
const handleRecordingComplete = useCallback(
async (blob: Blob, title: string) => {
const file = new File([blob], `${title}.webm`, { type: blob.type });
await handleFileSelect(file);
},
[handleFileSelect]
);
const handleMultiTrackDialogClose = useCallback(() => {
setIsMultiTrackDialogOpen(false);
setMultiTrackPreview(null);
}, []);
const handleMultiTrackConfirm = useCallback(
async (files: File[], aupFile: File, title: string) => {
await handleMultiTrackUpload(files, aupFile, title);
handleMultiTrackDialogClose();
},
[handleMultiTrackUpload, handleMultiTrackDialogClose]
);
const value: GlobalUploadContextValue = {
handleFileSelect,
handleMultiTrackUpload,
openMultiTrackDialog,
handleRecordingComplete,
isUploading,
uploadProgress,
isOnDashboard,
};
return (
<GlobalUploadContext.Provider value={value}>
{children}
{/* Multi-track Upload Dialog (global) */}
<MultiTrackUploadDialog
open={isMultiTrackDialogOpen}
onOpenChange={handleMultiTrackDialogClose}
onMultiTrackUpload={handleMultiTrackConfirm}
prePopulatedFiles={multiTrackPreview?.audioFiles}
prePopulatedAupFile={multiTrackPreview?.aupFile}
prePopulatedTitle={multiTrackPreview?.title}
/>
</GlobalUploadContext.Provider>
);
}
export function useGlobalUpload() {
const ctx = useContext(GlobalUploadContext);
if (!ctx) {
throw new Error(
"useGlobalUpload must be used within GlobalUploadProvider"
);
}
return ctx;
}

View File

@@ -47,15 +47,9 @@ export function Settings() {
fetchLLM();
}, [activeTab, getAuthHeaders]);
// Dummy function for file select (Settings page doesn't upload files)
const handleFileSelect = () => {
// No file upload in settings
};
return (
<MainLayout
header={<Header onFileSelect={handleFileSelect} />}
header={<Header />}
>
{/* Main Content Container with same styling as Homepage */}
<div className="bg-[var(--bg-card)] border border-[var(--border-subtle)] shadow-[var(--shadow-card)] rounded-[var(--radius-card)] p-2 sm:p-6 mt-8">

View File

@@ -186,7 +186,7 @@ export const AudioDetailView = function AudioDetailView({ audioId: propAudioId }
<div className="flex-1 overflow-y-auto scrollbar-thin">
<div className="mx-auto w-full max-w-[960px] px-4 sm:px-6 py-6 pb-32">
<div className="mb-6 pb-6">
<Header onFileSelect={() => { }} />
<Header />
</div>
<div className="space-y-6 sm:space-y-8">
{/* Title & Metadata */}

View File

@@ -1,10 +1,8 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useRef, useEffect } from "react";
import { Header } from "@/components/Header";
import { MainLayout } from "@/components/layout/MainLayout";
import { AudioFilesTable } from "./AudioFilesTable";
import { DragDropOverlay } from "@/components/DragDropOverlay";
import { MultiTrackUploadDialog } from "./MultiTrackUploadDialog";
import { useAudioUpload, useMultiTrackUpload } from "@/features/transcription/hooks/useAudioFiles";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { X, CheckCircle, AlertCircle } from "lucide-react";
@@ -16,134 +14,30 @@ import {
getFileDescription,
validateMultiTrackFiles
} from "@/utils/fileProcessor";
interface FileWithType {
file: File;
isVideo: boolean;
}
interface UploadProgress {
fileName: string;
status: 'uploading' | 'success' | 'error';
error?: string;
}
import { useGlobalUpload } from "@/contexts/GlobalUploadContext";
export function Dashboard() {
const { mutateAsync: uploadFile } = useAudioUpload();
const { mutateAsync: uploadMultiTrack } = useMultiTrackUpload();
// Get upload functionality from global context
const {
handleFileSelect,
openMultiTrackDialog,
isUploading,
uploadProgress,
} = useGlobalUpload();
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
const [isUploading, setIsUploading] = useState(false);
// Drag and drop state
// Drag and drop state (dashboard-specific UI)
const [isDragging, setIsDragging] = useState(false);
const [dragCount, setDragCount] = useState(0);
const [draggedFileGroup, setDraggedFileGroup] = useState<ReturnType<typeof groupFiles> | null>(null);
const [isMultiTrackDialogOpen, setIsMultiTrackDialogOpen] = useState(false);
const [multiTrackPreview, setMultiTrackPreview] = useState<{ audioFiles: File[], aupFile: File, title: string } | null>(null);
const dragCounter = useRef(0);
const handleFileSelect = async (files: File | File[] | FileWithType | FileWithType[]) => {
// Normalize input to an array of FileWithType objects
const fileArray = Array.isArray(files) ? files : [files];
const processedFiles = fileArray.map(item => {
if ('file' in item && 'isVideo' in item) {
// It's already a FileWithType
return item;
} else {
// It's a regular File, treat as audio
return { file: item as File, isVideo: false };
}
});
if (processedFiles.length === 0) return;
setIsUploading(true);
setUploadProgress(processedFiles.map(item => ({
fileName: item.file.name,
status: 'uploading'
})));
let successCount = 0;
// Upload files sequentially to avoid overwhelming the server
for (let i = 0; i < processedFiles.length; i++) {
const fileItem = processedFiles[i];
const file = fileItem.file;
const isVideo = fileItem.isVideo;
try {
await uploadFile({ file, isVideo });
setUploadProgress(prev => prev.map((item, index) =>
index === i ? {
...item,
status: 'success',
error: undefined
} : item
));
successCount++;
} catch (error) {
setUploadProgress(prev => prev.map((item, index) =>
index === i ? {
...item,
status: 'error',
error: error instanceof Error ? error.message : 'Upload failed'
} : item
));
}
}
setIsUploading(false);
// Auto-hide progress after 3 seconds if all succeeded
if (successCount === fileArray.length) {
setTimeout(() => setUploadProgress([]), 3000);
}
};
const handleTranscribe = () => {
// Table auto-refreshes when transcription starts via query invalidation
};
const dismissProgress = () => {
setUploadProgress([]);
};
const handleMultiTrackUpload = async (files: File[], aupFile: File, title: string) => {
setIsUploading(true);
// Create progress entry for multi-track upload
const multiTrackProgress = {
fileName: `${title} (${files.length} tracks)`,
status: 'uploading' as const
};
setUploadProgress([multiTrackProgress]);
try {
await uploadMultiTrack({ files, aupFile, title });
setUploadProgress([{
...multiTrackProgress,
status: 'success'
}]);
// Auto-hide progress after 3 seconds
setTimeout(() => setUploadProgress([]), 3000);
} catch (error) {
setUploadProgress([{
...multiTrackProgress,
status: 'error',
error: error instanceof Error ? error.message : 'Upload failed'
}]);
} finally {
setIsUploading(false);
}
// Progress is managed by global context, but we can trigger a refresh
// by updating dependencies. For now, progress auto-dismisses.
};
// Global drag and drop handlers
@@ -213,8 +107,8 @@ export function Dashboard() {
if (fileGroup.type === 'multitrack') {
const multiTrackFiles = prepareMultiTrackFiles(fileGroup);
if (multiTrackFiles) {
setMultiTrackPreview(multiTrackFiles);
setIsMultiTrackDialogOpen(true);
// Open the global multi-track dialog
openMultiTrackDialog();
}
} else if (fileGroup.type === 'video') {
const filesWithType = convertToFileWithType(fileGroup.files, true);
@@ -236,30 +130,12 @@ export function Dashboard() {
window.removeEventListener('dragover', handleWindowDragOver);
window.removeEventListener('drop', handleWindowDrop);
};
}, []); // Empty dependency array as handlers use refs or stable functions
const handleMultiTrackDialogClose = useCallback(() => {
setIsMultiTrackDialogOpen(false);
setMultiTrackPreview(null);
}, []);
const handleMultiTrackConfirm = useCallback(async (files: File[], aupFile: File, title: string) => {
await handleMultiTrackUpload(files, aupFile, title);
handleMultiTrackDialogClose();
}, []);
}, [handleFileSelect, openMultiTrackDialog]);
return (
<MainLayout
className="min-h-screen bg-[var(--bg-main)]"
header={
<Header
onFileSelect={handleFileSelect}
onMultiTrackClick={() => setIsMultiTrackDialogOpen(true)}
onDownloadComplete={() => {
// Table auto-refreshes due to query invalidation
}}
/>
}
header={<Header />}
>
{/* Upload Progress */}
@@ -344,16 +220,6 @@ export function Dashboard() {
: "No supported files found")
: undefined}
/>
{/* Multi-track Upload Dialog with pre-populated data */}
<MultiTrackUploadDialog
open={isMultiTrackDialogOpen}
onOpenChange={handleMultiTrackDialogClose}
onMultiTrackUpload={handleMultiTrackConfirm}
prePopulatedFiles={multiTrackPreview?.audioFiles}
prePopulatedAupFile={multiTrackPreview?.aupFile}
prePopulatedTitle={multiTrackPreview?.title}
/>
</MainLayout>
);
}

View File

@@ -11,6 +11,7 @@ import { ProtectedRoute } from './components/ProtectedRoute'
import { TooltipProvider } from '@/components/ui/tooltip'
import { ToastProvider } from '@/components/ui/toast'
import { ChatEventsProvider } from './contexts/ChatEventsContext'
import { GlobalUploadProvider } from './contexts/GlobalUploadContext'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
@@ -24,7 +25,9 @@ createRoot(document.getElementById('root')!).render(
<ToastProvider>
<ChatEventsProvider>
<ProtectedRoute>
<App />
<GlobalUploadProvider>
<App />
</GlobalUploadProvider>
</ProtectedRoute>
</ChatEventsProvider>
</ToastProvider>