'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { CheckCircle2, Loader2, UploadCloud, XCircle } from 'lucide-react'; import { toast } from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { isTrialStorageError, toastApiError } from '@/lib/client/api-error'; import { cleanupPendingProjectUpload, getDefaultTitleFromFile, isVideoFile, uploadProjectVideo, type ActiveTusUpload, type PendingProjectUploadCleanup, } from '@/lib/client/project-video-upload'; import type { DirectUploadProvider } from '@/components/video-page/types'; type ProjectOption = { id: string; name: string; description?: string | null; }; type QueueItemStatus = 'pending' | 'uploading' | 'done' | 'error' | 'cancelled'; type QueueItem = { id: string; file: File; status: QueueItemStatus; progress: number; error?: string; /** The failure was the trial ceiling, so the row shows the way out. */ errorIsTrialLimit?: boolean; }; interface VideoDragDropUploaderProps { fixedProjectId?: string; fixedProjectName?: string; workspaceId?: string; projectOptions?: ProjectOption[]; canUpload?: boolean; directUploadProvider?: DirectUploadProvider; } function hasFileData(dataTransfer: DataTransfer | null): boolean { if (!dataTransfer) return false; return Array.from(dataTransfer.types || []).includes('Files'); } function createQueueItem(file: File): QueueItem { return { id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`, file, status: 'pending', progress: 0, }; } export function VideoDragDropUploader({ fixedProjectId, fixedProjectName, workspaceId, projectOptions, canUpload = false, directUploadProvider = 'bunny', }: VideoDragDropUploaderProps) { const router = useRouter(); const [projects, setProjects] = useState(projectOptions ?? []); const [isLoadingProjects, setIsLoadingProjects] = useState(false); const [isDragActive, setIsDragActive] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [queue, setQueue] = useState([]); const [isUploading, setIsUploading] = useState(false); const [uploadStatus, setUploadStatus] = useState(''); const [uploadProgress, setUploadProgress] = useState(0); const [showCancelUploadDialog, setShowCancelUploadDialog] = useState(false); const [selectedProjectId, setSelectedProjectId] = useState(fixedProjectId ?? null); const [selectedProjectName, setSelectedProjectName] = useState( fixedProjectName ?? null ); const activeTusUploadRef = useRef(null); const pendingUploadRef = useRef<(PendingProjectUploadCleanup & { projectId: string }) | null>( null ); const cancelRequestedRef = useRef(false); const dragDepthRef = useRef(0); const hasLoadedProjectsRef = useRef(false); const needsProjectSelection = !fixedProjectId; const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []); const projectsById = useMemo(() => { return new Map(projects.map((project) => [project.id, project.name])); }, [projects]); const pendingCount = queue.filter((item) => item.status === 'pending').length; const doneCount = queue.filter((item) => item.status === 'done').length; const errorCount = queue.filter((item) => item.status === 'error').length; const totalCount = queue.length; const hasQueue = totalCount > 0; const ensureProjectsLoaded = useCallback(async () => { if (!canUpload) return; if (!needsProjectSelection) return; if (hasLoadedProjectsRef.current) return; if (projectOptions && projectOptions.length > 0) { setProjects(projectOptions); hasLoadedProjectsRef.current = true; return; } setIsLoadingProjects(true); try { const pageSize = 100; let page = 1; let totalPages = 1; const collected = new Map(); while (page <= totalPages) { const query = new URLSearchParams({ limit: pageSize.toString(), page: page.toString(), }); if (workspaceId) { query.set('workspaceId', workspaceId); } const response = await fetch(`/api/projects?${query.toString()}`, { cache: 'no-store', }); const payload = (await response.json().catch(() => null)) as { data?: { projects?: Array<{ id: string; name: string; description?: string | null }> }; meta?: { totalPages?: number }; error?: string; } | null; if (!response.ok) { throw new Error(payload?.error || 'Failed to load projects'); } const pageProjects = payload?.data?.projects || []; for (const project of pageProjects) { collected.set(project.id, { id: project.id, name: project.name, description: project.description ?? null, }); } totalPages = Math.max(payload?.meta?.totalPages ?? page, page); page += 1; } setProjects(Array.from(collected.values())); hasLoadedProjectsRef.current = true; } catch (error) { console.error('Failed to load projects for upload:', error); toast.error('Failed to load projects for upload'); } finally { setIsLoadingProjects(false); } }, [canUpload, needsProjectSelection, projectOptions, workspaceId]); useEffect(() => { if (!canUpload) { setDialogOpen(false); setQueue([]); } }, [canUpload]); useEffect(() => { hasLoadedProjectsRef.current = false; if (projectOptions && projectOptions.length > 0) { setProjects(projectOptions); } else if (needsProjectSelection) { setProjects([]); } }, [needsProjectSelection, projectOptions, workspaceId]); const resetUploadState = useCallback(() => { activeTusUploadRef.current = null; pendingUploadRef.current = null; setIsUploading(false); setUploadStatus(''); setUploadProgress(0); }, []); const cancelPendingUpload = useCallback(async () => { if (!isUploading) return; cancelRequestedRef.current = true; if (activeTusUploadRef.current) { try { await Promise.resolve(activeTusUploadRef.current.abort(false)); } catch { // Ignore abort failures and continue cleanup. } finally { activeTusUploadRef.current = null; } } const pending = pendingUploadRef.current; if (pending) { await cleanupPendingProjectUpload(pending.projectId, pending); } setQueue((prev) => prev.map((item) => item.status === 'uploading' || item.status === 'pending' ? { ...item, status: 'cancelled' as const } : item ) ); resetUploadState(); setShowCancelUploadDialog(false); toast.info('Upload cancelled'); }, [isUploading, resetUploadState]); const uploadQueueToProject = useCallback( async (files: File[], projectId: string, projectName?: string) => { if (files.length === 0) return; setDialogOpen(true); cancelRequestedRef.current = false; setIsUploading(true); setSelectedProjectId(projectId); setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null); const initialQueue = files.map(createQueueItem); setQueue(initialQueue); let successCount = 0; let failCount = 0; for (let index = 0; index < initialQueue.length; index++) { if (cancelRequestedRef.current) break; const item = initialQueue[index]; setUploadProgress(0); setUploadStatus(`Uploading ${index + 1} of ${initialQueue.length}: ${item.file.name}`); setQueue((prev) => prev.map((entry) => entry.id === item.id ? { ...entry, status: 'uploading', progress: 0, error: undefined } : entry ) ); try { await uploadProjectVideo(projectId, item.file, { provider: directUploadProvider, bunnyCdnHostname, onProgress: (progress) => { setUploadProgress(progress); setQueue((prev) => prev.map((entry) => (entry.id === item.id ? { ...entry, progress } : entry)) ); }, onStatus: (status) => { setUploadStatus(`Uploading ${index + 1} of ${initialQueue.length}: ${status}`); }, onTusUploadReady: (upload) => { activeTusUploadRef.current = upload; }, onPendingUpload: (pending) => { pendingUploadRef.current = { ...pending, projectId }; }, isCancelled: () => cancelRequestedRef.current, }); if (cancelRequestedRef.current) break; pendingUploadRef.current = null; activeTusUploadRef.current = null; successCount += 1; setQueue((prev) => prev.map((entry) => entry.id === item.id ? { ...entry, status: 'done', progress: 100 } : entry ) ); } catch (error) { if (cancelRequestedRef.current) break; pendingUploadRef.current = null; activeTusUploadRef.current = null; failCount += 1; const message = error instanceof Error ? error.message : 'Failed to upload video'; const isTrialLimit = isTrialStorageError(error); setQueue((prev) => prev.map((entry) => entry.id === item.id ? { ...entry, status: 'error', error: message, errorIsTrialLimit: isTrialLimit } : entry ) ); // Keeps the error code alive to the toast: a trial account that has run // out of room is shown the plan rather than just told the upload failed. toastApiError(error, 'Failed to upload video', { prefix: item.file.name }); } } resetUploadState(); if (cancelRequestedRef.current) { return; } if (successCount > 0) { router.refresh(); } if (successCount > 0 && failCount === 0) { toast.success( successCount === 1 ? `Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}` : `${successCount} videos uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}` ); if (fixedProjectId) { setDialogOpen(false); setQueue([]); } } else if (successCount > 0 && failCount > 0) { toast.warning(`${successCount} uploaded, ${failCount} failed`); } else if (failCount > 0) { toast.error('All uploads failed'); } }, [bunnyCdnHostname, directUploadProvider, fixedProjectId, projectsById, resetUploadState, router] ); const handleDropFiles = useCallback( (files: File[]) => { if (!canUpload) { toast.error('You do not have permission to upload videos here'); return; } const videoFiles = files.filter(isVideoFile); const invalidCount = files.length - videoFiles.length; if (videoFiles.length === 0) { toast.error('Please drop valid video files'); return; } if (invalidCount > 0) { toast.error(`${invalidCount} file${invalidCount === 1 ? '' : 's'} skipped (not a video)`); } if (fixedProjectId) { void uploadQueueToProject(videoFiles, fixedProjectId, fixedProjectName); return; } setQueue(videoFiles.map(createQueueItem)); setDialogOpen(true); void ensureProjectsLoaded(); }, [canUpload, ensureProjectsLoaded, fixedProjectId, fixedProjectName, uploadQueueToProject] ); useEffect(() => { const handleDragEnter = (event: DragEvent) => { if (!hasFileData(event.dataTransfer)) return; event.preventDefault(); dragDepthRef.current += 1; setIsDragActive(true); }; const handleDragOver = (event: DragEvent) => { if (!hasFileData(event.dataTransfer)) return; event.preventDefault(); if (event.dataTransfer) { event.dataTransfer.dropEffect = 'copy'; } }; const handleDragLeave = (event: DragEvent) => { if (!hasFileData(event.dataTransfer)) return; event.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsDragActive(false); } }; const handleDrop = (event: DragEvent) => { if (!hasFileData(event.dataTransfer)) return; event.preventDefault(); dragDepthRef.current = 0; setIsDragActive(false); const allFiles = Array.from(event.dataTransfer?.files ?? []); if (allFiles.length === 0) return; handleDropFiles(allFiles); }; window.addEventListener('dragenter', handleDragEnter); window.addEventListener('dragover', handleDragOver); window.addEventListener('dragleave', handleDragLeave); window.addEventListener('drop', handleDrop); return () => { window.removeEventListener('dragenter', handleDragEnter); window.removeEventListener('dragover', handleDragOver); window.removeEventListener('dragleave', handleDragLeave); window.removeEventListener('drop', handleDrop); }; }, [handleDropFiles]); const closeDialog = useCallback(() => { setQueue([]); setUploadStatus(''); setUploadProgress(0); setSelectedProjectId(fixedProjectId ?? null); setSelectedProjectName(fixedProjectName ?? null); }, [fixedProjectId, fixedProjectName]); return ( <> {isDragActive && (

Drop videos to upload

{fixedProjectId ? `Upload multiple videos to ${fixedProjectName ?? 'current project'}` : 'Drop multiple videos, then choose a project.'}

)} { if (!open) { if (isUploading) { setShowCancelUploadDialog(true); return; } closeDialog(); } setDialogOpen(open); }} > {needsProjectSelection ? 'Choose a project' : 'Uploading videos'} {hasQueue ? totalCount === 1 ? `Upload 1 video${needsProjectSelection ? ' to:' : ''}` : `Upload ${totalCount} videos${needsProjectSelection ? ' to:' : ''}` : 'Drop video files anywhere on this page to start.'}
{hasQueue && (
{queue.map((item) => (
{item.status === 'done' ? ( ) : item.status === 'error' ? ( ) : item.status === 'uploading' ? ( ) : item.status === 'cancelled' ? ( ) : (
)}

{item.file.name}

{item.error ? (

{item.error} {item.errorIsTrialLimit && ( Upgrade )}

) : (

{getDefaultTitleFromFile(item.file)} {item.status === 'uploading' && item.progress > 0 ? ` · ${item.progress}%` : ''}

)}
))}
)} {!fixedProjectId && (
{isLoadingProjects ? (

Loading projects...

) : projects.length > 0 ? (
{projects.map((project) => ( ))}
) : (

No projects available

)}
)} {(selectedProjectId || fixedProjectId) && (

Target:{' '} {selectedProjectName ?? fixedProjectName ?? projectsById.get(selectedProjectId ?? '')}

)} {isUploading && (

{uploadStatus || 'Uploading...'}

{uploadProgress > 0 && uploadProgress < 100 && (
)} {totalCount > 1 && (

{doneCount} of {totalCount} complete {errorCount > 0 ? ` · ${errorCount} failed` : ''}

)}
)} {!fixedProjectId && !isUploading && hasQueue && pendingCount > 0 && (

Click a project card to start uploading {pendingCount} video {pendingCount === 1 ? '' : 's'}.

)} {!isUploading && hasQueue && (doneCount > 0 || errorCount > 0) && (

{doneCount > 0 ? `${doneCount} uploaded` : ''} {doneCount > 0 && errorCount > 0 ? ', ' : ''} {errorCount > 0 ? `${errorCount} failed` : ''}

)}
Cancel upload? {totalCount > 1 ? 'Video uploads are in progress. If you cancel now, the current upload and any remaining queued files will be discarded.' : 'A video upload is in progress. If you cancel now, the current upload will be discarded.'} Keep uploading { void cancelPendingUpload(); }} > Cancel upload ); }