diff --git a/app/(dashboard)/dashboard/dashboard-client.tsx b/app/(dashboard)/dashboard/dashboard-client.tsx index 0aeeefe..90d7de5 100644 --- a/app/(dashboard)/dashboard/dashboard-client.tsx +++ b/app/(dashboard)/dashboard/dashboard-client.tsx @@ -1,6 +1,7 @@ 'use client'; import { ProjectFilter } from './project-filter'; +import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader'; interface SerializedProject { id: string; @@ -19,11 +20,19 @@ interface DashboardClientProps { workspaces: { id: string; name: string }[]; totalPages: number; canCreateProjects: boolean; + canUploadVideos: boolean; } -export function DashboardClient({ serializedProjects, workspaces, totalPages, canCreateProjects }: DashboardClientProps) { +export function DashboardClient({ + serializedProjects, + workspaces, + totalPages, + canCreateProjects, + canUploadVideos, +}: DashboardClientProps) { return (
+ 0; + const canUploadVideos = Boolean(editableProject); const workspaceMap = new Map(); for (const project of accessibleProjects) { @@ -116,6 +136,7 @@ export default async function DashboardPage({ workspaces={workspaces} totalPages={totalPages} canCreateProjects={canCreateProjects} + canUploadVideos={canUploadVideos} /> ); } diff --git a/app/(dashboard)/projects/[projectId]/project-content-client.tsx b/app/(dashboard)/projects/[projectId]/project-content-client.tsx index 9695b69..ef56c0d 100644 --- a/app/(dashboard)/projects/[projectId]/project-content-client.tsx +++ b/app/(dashboard)/projects/[projectId]/project-content-client.tsx @@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { VideoCard } from '@/components/video-card'; +import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader'; interface SerializedVideo { id: string; @@ -93,6 +94,12 @@ export function ProjectContentClient({ return ( <> + + {/* Project Header */}
diff --git a/app/(dashboard)/workspaces/[workspaceId]/page.tsx b/app/(dashboard)/workspaces/[workspaceId]/page.tsx index 1dcc2a9..ba91b4f 100644 --- a/app/(dashboard)/workspaces/[workspaceId]/page.tsx +++ b/app/(dashboard)/workspaces/[workspaceId]/page.tsx @@ -16,6 +16,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; +import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader'; function VisibilityIcon({ visibility }: { visibility: string }) { switch (visibility) { @@ -102,6 +103,10 @@ export default async function WorkspacePage({ params, searchParams }: WorkspaceP return (
+ 0} + /> {/* Back & Header */}
)} - {video.duration}
@@ -231,6 +230,7 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP v{video.currentVersion} + {video.duration} diff --git a/components/video-drag-drop-uploader.tsx b/components/video-drag-drop-uploader.tsx new file mode 100644 index 0000000..eb2a03b --- /dev/null +++ b/components/video-drag-drop-uploader.tsx @@ -0,0 +1,554 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2, UploadCloud } 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'; + +type ProjectOption = { + id: string; + name: string; + description?: string | null; +}; + +interface VideoDragDropUploaderProps { + fixedProjectId?: string; + fixedProjectName?: string; + workspaceId?: string; + projectOptions?: ProjectOption[]; + canUpload?: boolean; +} + +const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv']; +type ActiveTusUpload = { abort: (shouldTerminate?: boolean) => Promise | void }; + +function isVideoFile(file: File): boolean { + if (file.type.startsWith('video/')) return true; + const ext = file.name.split('.').pop()?.toLowerCase(); + return !!ext && VIDEO_FILE_EXTENSIONS.includes(ext); +} + +function extractVideoFile(dataTransfer: DataTransfer | null): File | null { + if (!dataTransfer?.files?.length) return null; + const files = Array.from(dataTransfer.files); + return files.find(isVideoFile) ?? null; +} + +function hasFileData(dataTransfer: DataTransfer | null): boolean { + if (!dataTransfer) return false; + return Array.from(dataTransfer.types || []).includes('Files'); +} + +function getDefaultTitleFromFile(file: File): string { + const withoutExt = file.name.replace(/\.[^/.]+$/, '').trim(); + return withoutExt || file.name; +} + +export function VideoDragDropUploader({ + fixedProjectId, + fixedProjectName, + workspaceId, + projectOptions, + canUpload = false, +}: 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 [droppedFile, setDroppedFile] = useState(null); + 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<{ projectId: string; videoId: string; uploadToken: string } | null>(null); + const cancelRequestedRef = useRef(false); + const dragDepthRef = useRef(0); + const hasLoadedProjectsRef = useRef(false); + + const needsProjectSelection = !fixedProjectId; + + const projectsById = useMemo(() => { + return new Map(projects.map((project) => [project.id, project.name])); + }, [projects]); + + 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(() => { + hasLoadedProjectsRef.current = false; + if (projectOptions && projectOptions.length > 0) { + setProjects(projectOptions); + } else if (needsProjectSelection) { + setProjects([]); + } + }, [needsProjectSelection, projectOptions, workspaceId]); + + const cleanupUploadState = useCallback(() => { + activeTusUploadRef.current = null; + pendingUploadRef.current = null; + setIsUploading(false); + setUploadStatus(''); + setUploadProgress(0); + }, []); + + const cancelPendingUpload = useCallback(async () => { + if (!isUploading) return; + cancelRequestedRef.current = true; + const pending = pendingUploadRef.current; + + if (activeTusUploadRef.current) { + try { + await Promise.resolve(activeTusUploadRef.current.abort(false)); + } catch { + // Ignore abort failures and continue cleanup. + } finally { + activeTusUploadRef.current = null; + } + } + + if (pending) { + try { + await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }), + }); + } catch (error) { + console.error('Failed to cleanup cancelled upload:', error); + } + } + + cleanupUploadState(); + setDroppedFile(null); + setDialogOpen(false); + setShowCancelUploadDialog(false); + toast.info('Upload cancelled'); + }, [cleanupUploadState, isUploading]); + + const uploadFileToProject = useCallback(async (file: File, projectId: string, projectName?: string) => { + setDialogOpen(true); + cancelRequestedRef.current = false; + setIsUploading(true); + setUploadStatus('Initializing upload...'); + setUploadProgress(0); + setSelectedProjectId(projectId); + setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null); + + let createdVideoId: string | null = null; + let uploadToken: string | null = null; + + try { + const title = getDefaultTitleFromFile(file); + + const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + + const initPayload = (await initResponse.json().catch(() => null)) as { + data?: { + videoId: string; + libraryId: string; + signature: string; + expirationTime: number; + uploadToken: string; + }; + error?: string; + } | null; + + if (!initResponse.ok || !initPayload?.data) { + throw new Error(initPayload?.error || 'Failed to initialize upload'); + } + + createdVideoId = initPayload.data.videoId; + uploadToken = initPayload.data.uploadToken; + pendingUploadRef.current = { + projectId, + videoId: createdVideoId, + uploadToken, + }; + + const { Upload } = await import('tus-js-client'); + await new Promise((resolve, reject) => { + const upload = new Upload(file, { + endpoint: 'https://video.bunnycdn.com/tusupload', + retryDelays: [0, 3000, 5000, 10000, 20000], + headers: { + AuthorizationSignature: initPayload.data!.signature, + AuthorizationExpire: initPayload.data!.expirationTime.toString(), + VideoId: initPayload.data!.videoId, + LibraryId: initPayload.data!.libraryId, + }, + metadata: { + filetype: file.type, + title, + }, + onError: (error) => { + activeTusUploadRef.current = null; + reject(new Error(error.message)); + }, + onProgress: (bytesUploaded, bytesTotal) => { + const percentage = Number(((bytesUploaded / bytesTotal) * 100).toFixed(1)); + setUploadProgress(percentage); + setUploadStatus(`Uploading... ${percentage}%`); + }, + onSuccess: () => { + activeTusUploadRef.current = null; + resolve(); + }, + }); + + activeTusUploadRef.current = upload; + upload.start(); + }); + + setUploadStatus('Saving video...'); + + const createResponse = await fetch(`/api/projects/${projectId}/videos`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + description: null, + videoUrl: `https://iframe.mediadelivery.net/embed/${initPayload.data.libraryId}/${initPayload.data.videoId}`, + providerId: 'bunny', + videoId: initPayload.data.videoId, + thumbnailUrl: `https://vz-thumbnail.b-cdn.net/${initPayload.data.videoId}/thumbnail.jpg`, + duration: null, + uploadToken, + }), + }); + + const createPayload = (await createResponse.json().catch(() => null)) as { error?: string } | null; + + if (!createResponse.ok) { + throw new Error(createPayload?.error || 'Failed to create video'); + } + + toast.success(`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`); + setDialogOpen(false); + setDroppedFile(null); + cleanupUploadState(); + router.push(`/projects/${projectId}`); + router.refresh(); + } catch (error) { + console.error('Drag-drop upload failed:', error); + + if (cancelRequestedRef.current) { + setUploadStatus(''); + setUploadProgress(0); + setIsUploading(false); + return; + } + + if (createdVideoId && uploadToken) { + try { + await fetch(`/api/projects/${projectId}/videos/bunny-init`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ videoId: createdVideoId, uploadToken }), + }); + } catch (cleanupError) { + console.error('Failed to cleanup pending upload:', cleanupError); + } + } + + setUploadStatus(''); + setUploadProgress(0); + setIsUploading(false); + toast.error(error instanceof Error ? error.message : 'Failed to upload video'); + } + }, [cleanupUploadState, projectsById, router]); + + const handleDropFile = useCallback((file: File) => { + if (!canUpload) { + toast.error('You do not have permission to upload videos here'); + return; + } + + setDroppedFile(file); + + if (fixedProjectId) { + void uploadFileToProject(file, fixedProjectId, fixedProjectName); + return; + } + + setDialogOpen(true); + void ensureProjectsLoaded(); + }, [canUpload, ensureProjectsLoaded, fixedProjectId, fixedProjectName, uploadFileToProject]); + + 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 videoFile = extractVideoFile(event.dataTransfer); + if (!videoFile) { + toast.error('Please drop a valid video file'); + return; + } + + handleDropFile(videoFile); + }; + + 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); + }; + }, [handleDropFile]); + + return ( + <> + {isDragActive && ( +
+
+
+ +

Drop video to upload

+

+ {fixedProjectId + ? `Upload to ${fixedProjectName ?? 'current project'}` + : 'Drop now, then choose a project card.'} +

+
+
+
+ )} + + { + if (!open) { + if (isUploading) { + setShowCancelUploadDialog(true); + return; + } + setDroppedFile(null); + setUploadStatus(''); + setUploadProgress(0); + setSelectedProjectId(fixedProjectId ?? null); + setSelectedProjectName(fixedProjectName ?? null); + } + setDialogOpen(open); + }} + > + + + Choose a project + + {droppedFile + ? 'Upload 1 video to:' + : 'Drop a video file anywhere on this page to start.'} + + + +
+ {!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 && ( +
+
+
+ )} +
+ )} + + {!fixedProjectId && !isUploading && droppedFile && ( +

+ Click a project card to start uploading. +

+ )} +
+ +
+ + + + + Cancel upload? + + A video upload is in progress. If you cancel now, the current upload will be discarded. + + + + Keep uploading + { + void cancelPendingUpload(); + }} + > + Cancel upload + + + + + + ); +}