Files
OpenFrame/app/(dashboard)/projects/[projectId]/page.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

238 lines
7.4 KiB
TypeScript

import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { GuestGate } from '@/components/guest-gate';
import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { ProjectContentClient } from './project-content-client';
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import { canDownloadProjectMedia } from '@/lib/project-download';
function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00';
const totalSeconds = Math.floor(seconds);
const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
interface ProjectPageProps {
params: Promise<{ projectId: string }>;
searchParams: Promise<{ page?: string; sort?: string }>;
}
export default async function ProjectPage({ params, searchParams }: ProjectPageProps) {
const session = await auth();
const { projectId } = await params;
const resolvedSearchParams = await searchParams;
const page = Number(resolvedSearchParams?.page) || 1;
const sortOrder = resolvedSearchParams?.sort === 'asc' ? 'asc' : 'desc';
const pageSize = 21;
const skip = (page - 1) * pageSize;
// Fetch project with videos
const project = await db.project.findUnique({
where: { id: projectId },
include: {
workspace: { select: { id: true, name: true } },
owner: { select: { id: true, name: true } },
members: {
where: { userId: session?.user?.id || '' },
select: { role: true },
},
},
});
if (!project) {
notFound();
}
const access = await checkProjectAccess(project, session?.user?.id);
// Check access
const isOwner = session?.user?.id === project.ownerId;
const isMember = project.members.length > 0;
const isPublic = project.visibility === 'PUBLIC';
// Check workspace membership
let isWorkspaceMember = false;
let workspaceRole: string | null = null;
if (session?.user?.id) {
const wsMember = await db.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId: project.workspaceId,
userId: session.user.id,
},
},
});
const ws = await db.workspace.findUnique({
where: { id: project.workspaceId },
select: { ownerId: true },
});
if (ws?.ownerId === session.user.id || wsMember) {
isWorkspaceMember = true;
workspaceRole = ws?.ownerId === session.user.id ? 'OWNER' : wsMember?.role || null;
}
}
if (!access.hasAccess || (!isOwner && !isMember && !isPublic && !isWorkspaceMember)) {
if (!session?.user?.id) {
redirect('/login');
}
redirect('/dashboard');
}
// Fetch videos separately utilizing bounds
const [paginatedVideos, totalVideos, allVideoIds] = await Promise.all([
db.video.findMany({
where: { projectId: project.id },
skip,
take: pageSize,
orderBy: [{ updatedAt: sortOrder }, { id: sortOrder }],
include: {
versions: {
where: { isActive: true },
take: 1,
include: {
_count: { select: { comments: true } },
},
},
_count: { select: { versions: true } },
},
}),
db.video.count({
where: { projectId: project.id },
}),
db.video.findMany({
where: { projectId: project.id },
select: { id: true },
orderBy: [{ position: 'asc' }, { id: 'asc' }],
}),
]);
const totalPages = Math.ceil(totalVideos / pageSize);
// Transform videos for VideoCard component
const videos = paginatedVideos.map((video) => {
const activeVersion = video.versions[0];
return {
id: video.id,
title: video.title,
thumbnailUrl:
activeVersion?.thumbnailUrl || 'https://via.placeholder.com/320x180?text=No+Thumbnail',
currentVersion: video._count.versions,
commentCount: activeVersion?._count.comments || 0,
duration: formatDuration(activeVersion?.duration),
lastUpdated: formatRelativeTime(video.updatedAt),
updatedAt: video.updatedAt.toISOString(),
};
});
const directUploadsEnabled = isDirectFileUploadEnabled();
const directUploadProvider = isS3VideoUploadsEnabled() ? 'r2' : 'bunny';
const canEdit =
access.canEdit &&
(isOwner ||
project.members[0]?.role === 'ADMIN' ||
workspaceRole === 'OWNER' ||
workspaceRole === 'ADMIN');
const isAuthenticated = !!session?.user?.id;
const canDownloadProject = canDownloadProjectMedia(project, access);
const projectData = {
name: project.name,
description: project.description,
visibility: project.visibility,
allowDownloads: project.allowDownloads,
workspace: project.workspace,
members: project.members,
};
// Guest name gate for unauthenticated users on public projects
if (!isAuthenticated && isPublic) {
return (
<GuestGate>
<div className="px-6 lg:px-8 py-8 w-full">
{/* Back link */}
<div className="mb-6">
<Link
href="/dashboard"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Projects
</Link>
</div>
<ProjectContentClient
project={projectData}
projectId={projectId}
videos={videos}
allVideoIds={allVideoIds.map((video) => video.id)}
canEdit={false}
canDownloadProject={canDownloadProject}
isOwner={false}
workspaceRole={null}
totalPages={totalPages}
currentPage={page}
pageSize={pageSize}
directUploadsEnabled={directUploadsEnabled}
directUploadProvider={directUploadProvider}
/>
</div>
</GuestGate>
);
}
return (
<div className="px-6 lg:px-8 py-8 w-full">
{/* Back link */}
<div className="mb-6">
<Link
href="/dashboard"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Projects
</Link>
</div>
<ProjectContentClient
project={projectData}
projectId={projectId}
videos={videos}
allVideoIds={allVideoIds.map((video) => video.id)}
canEdit={canEdit}
canDownloadProject={canDownloadProject}
isOwner={isOwner}
workspaceRole={workspaceRole}
totalPages={totalPages}
currentPage={page}
pageSize={pageSize}
directUploadsEnabled={directUploadsEnabled}
directUploadProvider={directUploadProvider}
/>
</div>
);
}