feat(video-upload): add global drag-and-drop uploader with project selection and permission gating

This commit is contained in:
Yusuf İpek
2026-02-26 10:31:51 +03:00
parent 15fa9452a4
commit 6dd44953a3
6 changed files with 606 additions and 10 deletions
+10 -1
View File
@@ -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 (
<div className="px-6 lg:px-8 py-8 w-full">
<VideoDragDropUploader canUpload={canUploadVideos} />
<ProjectFilter
projects={serializedProjects}
workspaces={workspaces}
+29 -8
View File
@@ -49,15 +49,35 @@ export default async function DashboardPage({
distinct: ['workspaceId']
});
const creatableWorkspaces = await db.workspace.count({
where: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
],
},
});
const [creatableWorkspaces, editableProject] = await Promise.all([
db.workspace.count({
where: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
],
},
}),
db.project.findFirst({
where: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
{
workspace: {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id, role: 'ADMIN' } } },
],
},
},
],
},
select: { id: true },
}),
]);
const canCreateProjects = creatableWorkspaces > 0;
const canUploadVideos = Boolean(editableProject);
const workspaceMap = new Map<string, string>();
for (const project of accessibleProjects) {
@@ -116,6 +136,7 @@ export default async function DashboardPage({
workspaces={workspaces}
totalPages={totalPages}
canCreateProjects={canCreateProjects}
canUploadVideos={canUploadVideos}
/>
);
}
@@ -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 (
<>
<VideoDragDropUploader
fixedProjectId={projectId}
fixedProjectName={project.name}
canUpload={canEdit}
/>
{/* Project Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
@@ -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 (
<div className="px-6 lg:px-8 py-8 w-full">
<VideoDragDropUploader
workspaceId={workspaceId}
canUpload={isAdmin && workspace._count.projects > 0}
/>
{/* Back & Header */}
<div className="mb-6">
<Link
+1 -1
View File
@@ -218,7 +218,6 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
<Play className="h-12 w-12 text-white" fill="white" />
</div>
)}
<Badge className="absolute bottom-2 right-2 bg-black/70">{video.duration}</Badge>
</div>
</Link>
@@ -231,6 +230,7 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
<Badge variant="secondary" className="text-xs">
v{video.currentVersion}
</Badge>
<span className="text-xs">{video.duration}</span>
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
+554
View File
@@ -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<unknown> | 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<ProjectOption[]>(projectOptions ?? []);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [isDragActive, setIsDragActive] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [droppedFile, setDroppedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [showCancelUploadDialog, setShowCancelUploadDialog] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(fixedProjectId ?? null);
const [selectedProjectName, setSelectedProjectName] = useState<string | null>(fixedProjectName ?? null);
const activeTusUploadRef = useRef<ActiveTusUpload | null>(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<string, ProjectOption>();
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<void>((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 && (
<div className="pointer-events-none fixed inset-0 z-[100] bg-background/80 backdrop-blur-sm">
<div className="flex h-full items-center justify-center px-4">
<div className="w-full max-w-2xl rounded-2xl border-2 border-dashed border-primary bg-background p-10 text-center shadow-2xl">
<UploadCloud className="mx-auto mb-4 h-10 w-10 text-primary" />
<p className="text-lg font-semibold">Drop video to upload</p>
<p className="mt-2 text-sm text-muted-foreground">
{fixedProjectId
? `Upload to ${fixedProjectName ?? 'current project'}`
: 'Drop now, then choose a project card.'}
</p>
</div>
</div>
</div>
)}
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
if (!open) {
if (isUploading) {
setShowCancelUploadDialog(true);
return;
}
setDroppedFile(null);
setUploadStatus('');
setUploadProgress(0);
setSelectedProjectId(fixedProjectId ?? null);
setSelectedProjectName(fixedProjectName ?? null);
}
setDialogOpen(open);
}}
>
<DialogContent className="border-2 border-border bg-background text-foreground sm:max-w-xl">
<DialogHeader className="space-y-1">
<DialogTitle className="text-2xl font-bold">Choose a project</DialogTitle>
<DialogDescription>
{droppedFile
? 'Upload 1 video to:'
: 'Drop a video file anywhere on this page to start.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{!fixedProjectId && (
<div className="space-y-2">
{isLoadingProjects ? (
<p className="text-sm text-muted-foreground">Loading projects...</p>
) : projects.length > 0 ? (
<div className="max-h-80 overflow-y-auto border border-border">
{projects.map((project) => (
<button
key={project.id}
tabIndex={droppedFile && !isUploading ? 0 : -1}
disabled={!droppedFile || isUploading}
aria-disabled={!droppedFile || isUploading}
className={`block w-full border-b border-border p-4 text-left transition-colors last:border-b-0 ${selectedProjectId === project.id ? 'bg-accent' : 'bg-background'} ${!droppedFile || isUploading ? 'opacity-60' : 'hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'}`}
onClick={() => {
if (!droppedFile || isUploading) return;
setSelectedProjectId(project.id);
setSelectedProjectName(project.name);
void uploadFileToProject(droppedFile, project.id, project.name);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
if (!droppedFile || isUploading) return;
event.preventDefault();
setSelectedProjectId(project.id);
setSelectedProjectName(project.name);
void uploadFileToProject(droppedFile, project.id, project.name);
}}
>
<p className="text-2xl font-semibold leading-tight text-foreground">{project.name}</p>
<p className="mt-1 text-sm text-muted-foreground">{project.description || 'Project'}</p>
</button>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No projects available</p>
)}
</div>
)}
{(selectedProjectId || fixedProjectId) && (
<p className="text-sm text-muted-foreground">
Target: <span className="font-medium text-foreground">{selectedProjectName ?? fixedProjectName ?? projectsById.get(selectedProjectId ?? '')}</span>
</p>
)}
{isUploading && (
<div className="space-y-2">
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{uploadStatus || 'Uploading...'}
</p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="h-2 w-full rounded-full bg-secondary">
<div className="h-2 rounded-full bg-primary transition-all" style={{ width: `${uploadProgress}%` }} />
</div>
)}
</div>
)}
{!fixedProjectId && !isUploading && droppedFile && (
<p className="text-xs text-muted-foreground">
Click a project card to start uploading.
</p>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showCancelUploadDialog} onOpenChange={setShowCancelUploadDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel upload?</AlertDialogTitle>
<AlertDialogDescription>
A video upload is in progress. If you cancel now, the current upload will be discarded.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!isUploading}>Keep uploading</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!isUploading}
onClick={() => {
void cancelPendingUpload();
}}
>
Cancel upload
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}