feat: link takes to pipeline shots via dropdown selector
Deploy / deploy (push) Successful in 2m41s

This commit is contained in:
twotalesanimation
2026-07-11 21:26:52 +02:00
parent b9953ff543
commit 42e614c592
3 changed files with 135 additions and 1 deletions
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
function requireRole(role: string) {
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
}
// GET /api/shoot-log/shots?projectId=xxx
// Returns a lightweight shot list for the pipeline-link selector
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!requireRole(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const projectId = req.nextUrl.searchParams.get("projectId");
if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 });
const shots = await db.shot.findMany({
where: { projectId },
select: {
id: true,
shotCode: true,
scene: true,
episode: true,
description: true,
status: true,
},
orderBy: [{ episode: "asc" }, { shotCode: "asc" }],
});
return NextResponse.json({ shots });
}
@@ -21,6 +21,7 @@ function sanitize(data: Record<string, unknown>) {
"hasSurvey","hasLidar","hasWitnessCamera","hasLensGrid","hasTexturePhotos","hasPhotogrammetry",
"weather","sunDirection","artificialLights",
"supervisorNotes","continuityNotes","vfxRequirements","quality",
"pipelineShotId",
]);
return Object.fromEntries(
Object.entries(data)
+100 -1
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
Trash2, AlertCircle, ChevronDown, ChevronUp,
Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -29,6 +30,7 @@ export interface FullTake {
id: string;
setupId: string;
takeNumber: number;
pipelineShotId: string | null;
scene: string | null;
shotLabel: string | null;
unitLabel: string | null;
@@ -199,6 +201,96 @@ function SectionHeader({ title }: { title: string }) {
);
}
// ─── Shot selector ────────────────────────────────────────────────────────────
interface ShotOption {
id: string;
shotCode: string;
scene: string | null;
episode: string | null;
}
function ShotSelector({
projectId,
value,
onChange,
}: {
projectId: string;
value: string | null;
onChange: (id: string | null) => void;
}) {
const [search, setSearch] = useState("");
const { data, isLoading } = useQuery<{ shots: ShotOption[] }>({
queryKey: ["project-shots", projectId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/shots?projectId=${projectId}`);
if (!res.ok) throw new Error("Failed");
return res.json();
},
staleTime: 60_000,
enabled: !!projectId,
});
const shots = data?.shots ?? [];
const filtered = search
? shots.filter(
(s) =>
s.shotCode.toLowerCase().includes(search.toLowerCase()) ||
(s.scene ?? "").toLowerCase().includes(search.toLowerCase()) ||
(s.episode ?? "").toLowerCase().includes(search.toLowerCase())
)
: shots;
const selected = shots.find((s) => s.id === value);
return (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Filter shots…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-xs bg-zinc-900 border-zinc-700"
/>
<div className="relative flex items-center gap-2">
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
disabled={isLoading}
className="flex-1 h-9 rounded-lg border border-zinc-700 bg-zinc-900 text-sm text-white px-3 pr-8 focus:outline-none focus:border-amber-600 disabled:opacity-50"
>
<option value=""> Not linked </option>
{filtered.map((s) => (
<option key={s.id} value={s.id}>
{s.shotCode}
{s.episode ? ` · Ep ${s.episode}` : ""}
{s.scene ? ` (sc. ${s.scene})` : ""}
</option>
))}
</select>
{value && (
<button
onClick={() => onChange(null)}
className="shrink-0 p-1.5 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors"
title="Unlink shot"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{selected && (
<div className="flex items-center gap-1.5 text-[11px] text-amber-400">
<Link2 className="h-3 w-3 shrink-0" />
<span>
Linked to <strong>{selected.shotCode}</strong>
{selected.episode ? ` · Ep ${selected.episode}` : ""}
</span>
</div>
)}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
@@ -342,6 +434,13 @@ export function TakeEditorPanel({
<TF value={String(fields.takeNumber)} onChange={() => {}} placeholder="—" className="opacity-60 cursor-default" />
</Field>
</div>
<Field label="Pipeline Shot" className="max-w-sm">
<ShotSelector
projectId={take.setup.shootDay.projectId}
value={fields.pipelineShotId}
onChange={(id) => set("pipelineShotId", id)}
/>
</Field>
{/* Camera */}
<SectionHeader title="Camera" />