feat(shots): previous/next shot navigation on the shot page
Deploy / deploy (push) Successful in 3m26s
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:
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user