Files
OpenFrame/app/(dashboard)/projects/[projectId]/project-content-client.tsx
T
yusufipk 95dcf92d8b fix: clamp page after bulk video delete
When every video on the current page was bulk-deleted, router.refresh()
re-queried the same out-of-range page and rendered "No videos yet" even
though earlier pages still had videos. Clamp to the last valid page based
on the remaining video count, falling back to refresh in place.
2026-06-27 13:35:08 +02:00

517 lines
17 KiB
TypeScript

'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,
} 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 {
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 type { DirectUploadProvider } from '@/components/video-page/types';
import {
runProjectDownloadManifest,
type ProjectDownloadManifest,
} from '@/lib/client/project-download';
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<SerializedVideo[]>(videos);
const [selectedVideoIds, setSelectedVideoIds] = useState<string[]>([]);
const [selectionMode, setSelectionMode] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = useState(false);
const canSelectVideos = canDownloadProject || canEdit;
useEffect(() => {
setLocalVideos(videos);
}, [videos]);
const selectedCount = selectedVideoIds.length;
const allSelected = useMemo(
() => allVideoIds.length > 0 && selectedCount === allVideoIds.length,
[allVideoIds.length, selectedCount]
);
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 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(() => {
setSelectedVideoIds(allVideoIds);
}, [allVideoIds]);
const handleDeselectAll = useCallback(() => {
setSelectedVideoIds([]);
}, []);
const handleClearSelection = useCallback(() => {
setSelectedVideoIds([]);
setSelectionMode(false);
}, []);
const handleEnterSelectionMode = useCallback(() => {
setSelectionMode(true);
}, []);
const startProjectDownload = useCallback(
async (videoIds?: string[]) => {
if (!canDownloadProject || isDownloading) return;
const query =
videoIds && videoIds.length > 0
? `?videoIds=${encodeURIComponent(videoIds.join(','))}`
: '';
setIsDownloading(true);
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;
}
toast.info(`Starting download of ${manifest.totalFiles} files…`);
await runProjectDownloadManifest(manifest);
toast.success(`Started ${manifest.totalFiles} file downloads`);
} catch {
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 (
<>
<VideoDragDropUploader
fixedProjectId={projectId}
fixedProjectName={project.name}
canUpload={canEdit && directUploadsEnabled}
directUploadProvider={directUploadProvider}
/>
{/* Project Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
<Badge variant="outline" className="flex items-center gap-1">
{project.visibility === 'PUBLIC' && <Globe className="h-3 w-3" />}
{project.visibility === 'INVITE' && <UserPlus className="h-3 w-3" />}
{project.visibility === 'PRIVATE' && <Lock className="h-3 w-3" />}
{project.visibility.toLowerCase()}
</Badge>
</div>
<div className="flex items-center gap-2">
{project.workspace && (
<Link href={`/workspaces/${project.workspace.id}`}>
<Badge
variant="secondary"
className="flex items-center gap-1 hover:bg-accent transition-colors"
>
<Building2 className="h-3 w-3" />
{project.workspace.name}
</Badge>
</Link>
)}
{project.description && (
<span className="text-muted-foreground">{project.description}</span>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mt-4 sm:mt-0">
<Button
variant="outline"
size="sm"
onClick={() => {
const newOrder = sortOrder === 'desc' ? 'asc' : 'desc';
router.push(`?${createQueryString('sort', newOrder)}`);
}}
className="flex items-center gap-2"
>
{sortOrder === 'desc' ? (
<>
<ArrowDown className="h-4 w-4" />
Newest first
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
Oldest first
</>
)}
</Button>
{canDownloadProject && localVideos.length > 0 && !selectionMode && (
<Button
variant="outline"
size="sm"
onClick={() => startProjectDownload()}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download project
</Button>
)}
{canEdit && (
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share
</Link>
</Button>
)}
{(isOwner || project.members[0]?.role === 'ADMIN') && (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/members`}>
<Users className="h-4 w-4 mr-2" />
Members
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/settings`}>
<Settings className="h-4 w-4 mr-2" />
Settings
</Link>
</Button>
</>
)}
{canEdit && (
<Button size="sm" asChild>
<Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" />
Add Video
</Link>
</Button>
)}
</div>
</div>
{selectionMode && (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
<span className="text-sm font-medium">Selection mode</span>
<span className="text-sm text-muted-foreground">
{selectedCount > 0 ? `${selectedCount} selected` : 'None selected'}
</span>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={allSelected ? handleDeselectAll : handleSelectAll}
>
{allSelected ? 'Deselect all' : 'Select all'}
</Button>
<Button variant="ghost" size="sm" onClick={handleClearSelection}>
Cancel
</Button>
{canDownloadProject && (
<Button
variant="outline"
size="sm"
onClick={() => startProjectDownload(selectedVideoIds)}
disabled={isDownloading || selectedCount === 0}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download selected
</Button>
)}
{canEdit && (
<Button
variant="destructive"
size="sm"
onClick={() => setShowDeleteSelectedDialog(true)}
disabled={selectedCount === 0 || isDeletingSelected}
>
{isDeletingSelected ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Delete selected
</Button>
)}
</div>
</div>
)}
{/* Videos Grid */}
{localVideos.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{localVideos.map((video) => (
<VideoCard
key={video.id}
video={video}
projectId={projectId}
canManage={canEdit}
canSelect={canSelectVideos}
selectionMode={selectionMode}
selected={selectedVideoIds.includes(video.id)}
onEnterSelectionMode={handleEnterSelectionMode}
onSelectedChange={(selected) => toggleVideoSelection(video.id, selected)}
onDeleted={handleVideoDeleted}
/>
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<Play className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No videos yet</h3>
<p className="text-muted-foreground text-center mb-4">
Add your first video to start collecting feedback
</p>
{canEdit && (
<Button asChild>
<Link href={`/projects/${projectId}/videos/new`}>
<Plus className="h-4 w-4 mr-2" />
Add Video
</Link>
</Button>
)}
</CardContent>
</Card>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-end space-x-2">
<Button variant="outline" size="sm" disabled={currentPage <= 1} asChild={currentPage > 1}>
{currentPage > 1 ? (
<Link href={`?${createQueryString('page', (currentPage - 1).toString())}`}>
Previous
</Link>
) : (
'Previous'
)}
</Button>
<span className="text-sm font-medium">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
asChild={currentPage < totalPages}
>
{currentPage < totalPages ? (
<Link href={`?${createQueryString('page', (currentPage + 1).toString())}`}>Next</Link>
) : (
'Next'
)}
</Button>
</div>
)}
<AlertDialog open={showDeleteSelectedDialog} onOpenChange={setShowDeleteSelectedDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {selectedCount} video{selectedCount === 1 ? '' : 's'}?
</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingSelected}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(event) => {
event.preventDefault();
void handleDeleteSelected();
}}
disabled={isDeletingSelected || selectedCount === 0}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeletingSelected && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Delete selected
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}