feat(shots): previous/next shot navigation on the shot page
Deploy / deploy (push) Successful in 3m26s

Prev/next buttons beside the breadcrumb walk the project's shot list in
the same order as the Shots tab. The ordering now lives in
lib/shot-order.ts, shared by the project page and the shot API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-13 07:45:24 +02:00
parent d7e37de18f
commit 0aedc4b1fa
4 changed files with 117 additions and 15 deletions
@@ -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<string, ShotWithDetails[]>();
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<string, ShotWithDetails[]>();
for (const shot of sorted) {
const key = shot.shotGroup?.name ?? "(Ungrouped)";
@@ -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<string, { label: string; dot: string }> = {
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 (
<Button variant="outline" size="sm" disabled className="h-7 gap-1 px-2 font-mono text-xs">
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{direction === "prev" ? "Prev" : "Next"}</span>
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
</Button>
);
}
return (
<Button variant="outline" size="sm" asChild className="h-7 gap-1 px-2 font-mono text-xs">
<Link href={`/projects/${projectId}/shots/${target.id}`} title={`${label}: ${target.shotCode}`}>
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
<span className="hidden sm:inline max-w-[10rem] truncate">{target.shotCode}</span>
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
</Link>
</Button>
);
}
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
const router = useRouter();
const { toast } = useToast();
const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [shotNav, setShotNav] = useState<ShotNav | null>(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() {
</Link>
<span>/</span>
<span className="text-foreground font-mono">{shot.shotCode}</span>
{/* Prev / next shot, in the project page's list order */}
{shotNav && shotNav.total > 1 && (
<div className="ml-auto flex items-center gap-1">
<ShotNavLink projectId={params.id} target={shotNav.prev} direction="prev" />
<span className="px-1 text-xs tabular-nums">
{shotNav.position} / {shotNav.total}
</span>
<ShotNavLink projectId={params.id} target={shotNav.next} direction="next" />
</div>
)}
</div>
{/* Header */}
+29 -1
View File
@@ -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,
+36
View File
@@ -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<T extends OrderableShot>(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;
}