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.'}
+
+
+
+
+ )}
+
+
+
+
+
+
+ Cancel upload?
+
+ A video upload is in progress. If you cancel now, the current upload will be discarded.
+
+
+
+ Keep uploading
+ {
+ void cancelPendingUpload();
+ }}
+ >
+ Cancel upload
+
+
+
+
+ >
+ );
+}