'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { Plus, Settings, Share2, Play, Users, Building2, ArrowUp, ArrowDown, Globe, UserPlus, Lock, Download, Loader2, Trash2, ChevronDown, FolderInput, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { VideoCard } from '@/components/video-card'; import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader'; import { MoveVideosDialog } from '@/components/move-videos-dialog'; import type { DirectUploadProvider } from '@/components/video-page/types'; import { runProjectDownloadManifest, type ProjectDownloadManifest, } from '@/lib/client/project-download'; import { downloadProgressPercent } from '@/lib/client/download-file'; import { createDownloadProgressToast, type DownloadProgressToastHandle, } from '@/components/download-progress-toast'; interface SerializedVideo { id: string; title: string; thumbnailUrl: string; currentVersion: number; commentCount: number; duration: string; lastUpdated: string; updatedAt: string; } interface ProjectContentClientProps { project: { name: string; description: string | null; visibility: string; allowDownloads: boolean; workspace: { id: string; name: string } | null; members: { role: string }[]; }; projectId: string; videos: SerializedVideo[]; allVideoIds: string[]; canEdit: boolean; canDownloadProject: boolean; isOwner: boolean; workspaceRole: string | null; totalPages: number; currentPage: number; pageSize: number; directUploadsEnabled: boolean; directUploadProvider: DirectUploadProvider; } export function ProjectContentClient({ project, projectId, videos, allVideoIds, canEdit, canDownloadProject, isOwner, totalPages, currentPage, pageSize, directUploadsEnabled, directUploadProvider, }: ProjectContentClientProps) { const router = useRouter(); const searchParams = useSearchParams(); const sortOrder = searchParams.get('sort') || 'desc'; const [localVideos, setLocalVideos] = useState(videos); const [selectedVideoIds, setSelectedVideoIds] = useState([]); const [selectionMode, setSelectionMode] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [includeAssetsInDownload, setIncludeAssetsInDownload] = useState(false); const [isDeletingSelected, setIsDeletingSelected] = useState(false); const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = useState(false); const [showMoveSelectedDialog, setShowMoveSelectedDialog] = useState(false); const canSelectVideos = canDownloadProject || canEdit; useEffect(() => { setLocalVideos(videos); }, [videos]); const selectedCount = selectedVideoIds.length; const pageVideoIds = useMemo(() => localVideos.map((video) => video.id), [localVideos]); const allSelected = useMemo( () => pageVideoIds.length > 0 && pageVideoIds.every((id) => selectedVideoIds.includes(id)), [pageVideoIds, selectedVideoIds] ); const createQueryString = useCallback( (name: string, value: string) => { const params = new URLSearchParams(searchParams.toString()); params.set(name, value); if (name !== 'page') { params.set('page', '1'); } return params.toString(); }, [searchParams] ); const handleVideoDeleted = useCallback((videoId: string) => { setLocalVideos((prev) => prev.filter((video) => video.id !== videoId)); setSelectedVideoIds((prev) => prev.filter((id) => id !== videoId)); }, []); const handleVideosMoved = useCallback((movedIds: string[]) => { const moved = new Set(movedIds); setLocalVideos((prev) => prev.filter((video) => !moved.has(video.id))); setSelectedVideoIds([]); setSelectionMode(false); }, []); const toggleVideoSelection = useCallback((videoId: string, selected: boolean) => { setSelectedVideoIds((prev) => { if (selected) { if (prev.includes(videoId)) return prev; return [...prev, videoId]; } return prev.filter((id) => id !== videoId); }); }, []); const handleSelectAll = useCallback(() => { // Scope selection to the current page only. Selecting every video across // every page from a single button is too easy to trigger by accident when // the user only meant the videos they can see. setSelectedVideoIds((prev) => { const next = new Set(prev); pageVideoIds.forEach((id) => next.add(id)); return Array.from(next); }); }, [pageVideoIds]); const handleDeselectAll = useCallback(() => { const pageIds = new Set(pageVideoIds); setSelectedVideoIds((prev) => prev.filter((id) => !pageIds.has(id))); }, [pageVideoIds]); const handleClearSelection = useCallback(() => { setSelectedVideoIds([]); setSelectionMode(false); }, []); const handleEnterSelectionMode = useCallback(() => { setSelectionMode(true); }, []); const startProjectDownload = useCallback( async (videoIds?: string[], options?: { allVersions?: boolean; includeAssets?: boolean }) => { if (!canDownloadProject || isDownloading) return; const searchParams = new URLSearchParams(); if (videoIds && videoIds.length > 0) { searchParams.set('videoIds', videoIds.join(',')); } if (options?.allVersions) { searchParams.set('versions', 'all'); } if (options?.includeAssets) { searchParams.set('assets', '1'); } const query = searchParams.toString() ? `?${searchParams.toString()}` : ''; setIsDownloading(true); let progressToast: DownloadProgressToastHandle | null = null; try { const response = await fetch(`/api/projects/${projectId}/download${query}`, { cache: 'no-store', }); const body = await response.json().catch(() => null); if (!response.ok) { const message = typeof body?.error === 'string' ? body.error : 'Failed to prepare project download'; toast.error(message); return; } const manifest = body?.data as ProjectDownloadManifest | undefined; if (!manifest?.files?.length) { toast.error('No downloadable files found'); return; } progressToast = createDownloadProgressToast(`project-download-${projectId}`, { title: `Downloading ${manifest.totalFiles} files`, description: 'Starting…', }); await runProjectDownloadManifest(manifest, (p) => { const percent = downloadProgressPercent({ receivedBytes: p.receivedBytes, totalBytes: p.totalBytes, }); progressToast?.update({ title: `Downloading file ${p.index}/${p.total}`, description: `${p.fileName}${percent !== null ? ` · ${percent}%` : ''}`, percent, }); }); progressToast.success(`Downloaded ${manifest.totalFiles} files`); } catch { // The progress panel never expires on its own, so clear it before the // error toast replaces it. progressToast?.dismiss(); toast.error('Failed to start project download'); } finally { setIsDownloading(false); } }, [canDownloadProject, isDownloading, projectId] ); const handleDeleteSelected = useCallback(async () => { if (!canEdit || selectedCount === 0 || isDeletingSelected) return; setIsDeletingSelected(true); try { const response = await fetch(`/api/projects/${projectId}/videos/bulk-delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoIds: selectedVideoIds }), }); const body = await response.json().catch(() => null); if (!response.ok) { const message = typeof body?.error === 'string' ? body.error : 'Failed to delete selected videos'; toast.error(message); return; } const deletedIds = new Set(selectedVideoIds); setLocalVideos((prev) => prev.filter((video) => !deletedIds.has(video.id))); setSelectedVideoIds([]); setSelectionMode(false); setShowDeleteSelectedDialog(false); toast.success( typeof body?.data?.message === 'string' ? body.data.message : 'Selected videos deleted' ); // The current page may now be out of range (e.g. we deleted every video // on it). Clamp to the last valid page so the refresh lands on a page // that still has videos instead of showing "No videos yet". const remainingTotal = allVideoIds.filter((id) => !deletedIds.has(id)).length; const newTotalPages = Math.max(1, Math.ceil(remainingTotal / pageSize)); if (currentPage > newTotalPages) { router.push(`?${createQueryString('page', newTotalPages.toString())}`); } else { router.refresh(); } } catch { toast.error('Failed to delete selected videos'); } finally { setIsDeletingSelected(false); } }, [ allVideoIds, canEdit, createQueryString, currentPage, isDeletingSelected, pageSize, projectId, router, selectedCount, selectedVideoIds, ]); return ( <> {/* Project Header */}

{project.name}

{project.visibility === 'PUBLIC' && } {project.visibility === 'INVITE' && } {project.visibility === 'PRIVATE' && } {project.visibility.toLowerCase()}
{project.workspace && ( {project.workspace.name} )} {project.description && ( {project.description} )}
{canDownloadProject && localVideos.length > 0 && !selectionMode && ( setIncludeAssetsInDownload(checked === true)} onSelect={(event) => event.preventDefault()} > Include assets startProjectDownload(undefined, { includeAssets: includeAssetsInDownload }) } > Latest version only startProjectDownload(undefined, { allVersions: true, includeAssets: includeAssetsInDownload, }) } > All versions )} {canEdit && ( )} {(isOwner || project.members[0]?.role === 'ADMIN') && ( <> )} {canEdit && ( )}
{selectionMode && (
Selection mode {selectedCount > 0 ? `${selectedCount} selected` : 'None selected'}
{canDownloadProject && ( setIncludeAssetsInDownload(checked === true)} onSelect={(event) => event.preventDefault()} > Include assets startProjectDownload(selectedVideoIds, { includeAssets: includeAssetsInDownload, }) } > Latest version only startProjectDownload(selectedVideoIds, { allVersions: true, includeAssets: includeAssetsInDownload, }) } > All versions )} {canEdit && ( )} {canEdit && ( )}
)} {/* Videos Grid */} {localVideos.length > 0 ? (
{localVideos.map((video) => ( toggleVideoSelection(video.id, selected)} onDeleted={handleVideoDeleted} /> ))}
) : (

No videos yet

Add your first video to start collecting feedback

{canEdit && ( )}
)} {/* Pagination */} {totalPages > 1 && (
Page {currentPage} of {totalPages}
)} Delete {selectedCount} video{selectedCount === 1 ? '' : 's'}? This will permanently delete the selected videos, all of their versions, comments, and stored media from Bunny and Cloudflare R2. This action cannot be undone. Cancel { event.preventDefault(); void handleDeleteSelected(); }} disabled={isDeletingSelected || selectedCount === 0} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {isDeletingSelected && } Delete selected ); }