This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user