diff --git a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx index 89ec6c7..76d2324 100644 --- a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx +++ b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx @@ -13,6 +13,7 @@ import { TaskCard } from "@/components/tasks/TaskCard"; import { NewTaskDialog } from "@/components/tasks/NewTaskDialog"; import { KanbanBoard } from "@/components/tasks/KanbanBoard"; import { cn } from "@/lib/utils"; +import { sortShotsForList } from "@/lib/shot-order"; import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight, Calendar, Pencil, Check, X } from "lucide-react"; import type { ShotWithDetails } from "@/types"; import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab"; @@ -147,13 +148,7 @@ export function ProjectTabsClient({ // For episodic projects: group and sort shots by episode → scene → shotNumber const episodeGroups: [string, ShotWithDetails[]][] = projectType === "EPISODIC" ? (() => { - const sorted = [...shots].sort((a, b) => { - const ea = a.episode ?? ""; - const eb = b.episode ?? ""; - if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true }); - if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true }); - return a.shotNumber - b.shotNumber; - }); + const sorted = sortShotsForList(shots, projectType); const map = new Map(); for (const shot of sorted) { const key = shot.episode ?? "(No Episode)"; @@ -167,13 +162,7 @@ export function ProjectTabsClient({ // For standard projects: group by custom shot group when any exist const standardGroups: [string, ShotWithDetails[]][] = projectType === "STANDARD" && shots.some((s) => s.shotGroup) ? (() => { - const sorted = [...shots].sort((a, b) => { - const ga = a.shotGroup?.name ?? "\uFFFF"; - const gb = b.shotGroup?.name ?? "\uFFFF"; - if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true }); - if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true }); - return a.shotNumber - b.shotNumber; - }); + const sorted = sortShotsForList(shots, projectType); const map = new Map(); for (const shot of sorted) { const key = shot.shotGroup?.name ?? "(Ungrouped)"; diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 0e049c2..71d671d 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -31,6 +31,8 @@ import { Pencil, Check, LayoutList, + ChevronLeft, + ChevronRight, } from "lucide-react"; import { Input } from "@/components/ui/input"; import type { ShotWithDetails } from "@/types"; @@ -78,12 +80,47 @@ const PRIORITY_CONFIG: Record = { import { useToast } from "@/components/ui/use-toast"; import { shotThumbnail } from "@/lib/shot-thumbnail"; +type ShotNavTarget = { id: string; shotCode: string } | null; +type ShotNav = { prev: ShotNavTarget; next: ShotNavTarget; position: number; total: number }; + +function ShotNavLink({ + projectId, + target, + direction, +}: { + projectId: string; + target: ShotNavTarget; + direction: "prev" | "next"; +}) { + const Icon = direction === "prev" ? ChevronLeft : ChevronRight; + const label = direction === "prev" ? "Previous shot" : "Next shot"; + if (!target) { + return ( + + ); + } + return ( + + ); +} + export default function ShotDetailPage() { const params = useParams<{ id: string; shotId: string }>(); const router = useRouter(); const { toast } = useToast(); const [shot, setShot] = useState(null); const [projectName, setProjectName] = useState(""); + const [shotNav, setShotNav] = useState(null); const [loading, setLoading] = useState(true); const [canApprove, setCanApprove] = useState(false); const [canInternallyApprove, setCanInternallyApprove] = useState(false); @@ -108,6 +145,7 @@ export default function ShotDetailPage() { const data = await res.json(); setShot(data.shot); setProjectName(data.projectName ?? ""); + setShotNav(data.shotNav ?? null); setCanApprove(data.canApprove ?? false); setCanInternallyApprove(data.canInternallyApprove ?? false); setTasks(data.tasks ?? []); @@ -242,6 +280,17 @@ export default function ShotDetailPage() { / {shot.shotCode} + + {/* Prev / next shot, in the project page's list order */} + {shotNav && shotNav.total > 1 && ( +
+ + + {shotNav.position} / {shotNav.total} + + +
+ )} {/* Header */} diff --git a/app/api/shots/[shotId]/route.ts b/app/api/shots/[shotId]/route.ts index b69a076..65b55ba 100644 --- a/app/api/shots/[shotId]/route.ts +++ b/app/api/shots/[shotId]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; +import { sortShotsForList } from "@/lib/shot-order"; export async function GET( req: NextRequest, @@ -38,9 +39,35 @@ export async function GET( const project = await db.project.findUnique({ where: { id: shot.projectId }, - select: { name: true }, + select: { + name: true, + projectType: true, + shots: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + shotCode: true, + episode: true, + scene: true, + shotNumber: true, + shotGroup: { select: { name: true } }, + }, + }, + }, }); + // Neighbours in the project page's shot list order, for prev/next navigation + const orderedShots = project ? sortShotsForList(project.shots, project.projectType) : []; + const shotIndex = orderedShots.findIndex((s) => s.id === shotId); + const neighbour = (i: number) => + shotIndex >= 0 && orderedShots[i] ? { id: orderedShots[i].id, shotCode: orderedShots[i].shotCode } : null; + const shotNav = { + prev: neighbour(shotIndex - 1), + next: neighbour(shotIndex + 1), + position: shotIndex + 1, + total: orderedShots.length, + }; + const [tasks, artists] = await Promise.all([ db.task.findMany({ where: { shotId }, @@ -99,6 +126,7 @@ export async function GET( return NextResponse.json({ shot: shotSerialized, projectName: project?.name ?? "", + shotNav, canApprove, canInternallyApprove: ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string), tasks, diff --git a/lib/shot-order.ts b/lib/shot-order.ts new file mode 100644 index 0000000..a092cbb --- /dev/null +++ b/lib/shot-order.ts @@ -0,0 +1,36 @@ +// The order shots appear in on the project page's Shots tab. Shared with the +// shot detail page so its previous/next navigation walks the same list. + +type OrderableShot = { + episode?: string | null; + scene: string; + shotNumber: number; + shotGroup?: { name: string } | null; +}; + +export function sortShotsForList(shots: T[], projectType: string): T[] { + // Episodic: episode → scene → shotNumber + if (projectType === "EPISODIC") { + return [...shots].sort((a, b) => { + const ea = a.episode ?? ""; + const eb = b.episode ?? ""; + if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true }); + if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true }); + return a.shotNumber - b.shotNumber; + }); + } + + // Standard with custom groups: group → scene → shotNumber, ungrouped last + if (projectType === "STANDARD" && shots.some((s) => s.shotGroup)) { + return [...shots].sort((a, b) => { + const ga = a.shotGroup?.name ?? "￿"; + const gb = b.shotGroup?.name ?? "￿"; + if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true }); + if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true }); + return a.shotNumber - b.shotNumber; + }); + } + + // Otherwise the list keeps the query's createdAt order + return shots; +}