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
+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" />