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",
|
"hasSurvey","hasLidar","hasWitnessCamera","hasLensGrid","hasTexturePhotos","hasPhotogrammetry",
|
||||||
"weather","sunDirection","artificialLights",
|
"weather","sunDirection","artificialLights",
|
||||||
"supervisorNotes","continuityNotes","vfxRequirements","quality",
|
"supervisorNotes","continuityNotes","vfxRequirements","quality",
|
||||||
|
"pipelineShotId",
|
||||||
]);
|
]);
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(data)
|
Object.entries(data)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
|
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
|
||||||
Trash2, AlertCircle, ChevronDown, ChevronUp,
|
Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -29,6 +30,7 @@ export interface FullTake {
|
|||||||
id: string;
|
id: string;
|
||||||
setupId: string;
|
setupId: string;
|
||||||
takeNumber: number;
|
takeNumber: number;
|
||||||
|
pipelineShotId: string | null;
|
||||||
scene: string | null;
|
scene: string | null;
|
||||||
shotLabel: string | null;
|
shotLabel: string | null;
|
||||||
unitLabel: 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 ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -342,6 +434,13 @@ export function TakeEditorPanel({
|
|||||||
<TF value={String(fields.takeNumber)} onChange={() => {}} placeholder="—" className="opacity-60 cursor-default" />
|
<TF value={String(fields.takeNumber)} onChange={() => {}} placeholder="—" className="opacity-60 cursor-default" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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 */}
|
{/* Camera */}
|
||||||
<SectionHeader title="Camera" />
|
<SectionHeader title="Camera" />
|
||||||
|
|||||||
Reference in New Issue
Block a user