reference support
Deploy / deploy (push) Successful in 3m38s

This commit is contained in:
twotalesanimation
2026-07-12 10:27:46 +02:00
parent 42e614c592
commit c09d06b6c7
6 changed files with 413 additions and 3 deletions
+45
View File
@@ -0,0 +1,45 @@
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/[shotId]
// Returns a shot's thumbnail + references for display in the shoot-log
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ shotId: string }> }
) {
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 { shotId } = await params;
const shot = await db.shot.findUnique({
where: { id: shotId },
select: {
id: true,
shotCode: true,
scene: true,
episode: true,
thumbnailUrl: true,
references: {
orderBy: { sortOrder: "asc" },
select: {
id: true,
fileUrl: true,
fileName: true,
label: true,
sortOrder: true,
},
},
},
});
if (!shot) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ shot });
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadFile } from "@/lib/storage";
function requireAuth(role: string) {
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
}
// GET /api/shots/[shotId]/references
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ shotId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { shotId } = await params;
const references = await db.shotReference.findMany({
where: { shotId },
orderBy: { sortOrder: "asc" },
});
return NextResponse.json({
references: references.map((r) => ({
...r,
fileSize: r.fileSize != null ? Number(r.fileSize) : null,
})),
});
}
// POST /api/shots/[shotId]/references (multipart upload)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ shotId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!requireAuth(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { shotId } = await params;
const shot = await db.shot.findUnique({ where: { id: shotId }, select: { id: true } });
if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 });
const formData = await req.formData();
const file = formData.get("file") as File | null;
const label = (formData.get("label") as string) || null;
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
if (!file.type.startsWith("image/"))
return NextResponse.json({ error: "Only image files accepted" }, { status: 400 });
const maxSize = 50 * 1024 * 1024; // 50 MB
if (file.size > maxSize)
return NextResponse.json({ error: "File too large (max 50 MB)" }, { status: 413 });
const buffer = Buffer.from(await file.arrayBuffer());
const uploaded = await uploadFile(buffer, file.name, file.type, "image");
const maxOrder = await db.shotReference.aggregate({
where: { shotId },
_max: { sortOrder: true },
});
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
const reference = await db.shotReference.create({
data: {
shotId,
label,
fileUrl: uploaded.url,
fileKey: uploaded.key,
fileName: file.name,
fileSize: BigInt(file.size),
sortOrder,
},
});
return NextResponse.json({
reference: { ...reference, fileSize: Number(reference.fileSize) },
});
}
// DELETE /api/shots/[shotId]/references/[refId] handled via query param
// DELETE /api/shots/[shotId]/references?refId=xxx
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ shotId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!requireAuth(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { shotId } = await params;
const { searchParams } = new URL(req.url);
const refId = searchParams.get("refId");
if (!refId) return NextResponse.json({ error: "refId required" }, { status: 400 });
const ref = await db.shotReference.findFirst({ where: { id: refId, shotId } });
if (!ref) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.shotReference.delete({ where: { id: refId } });
return NextResponse.json({ ok: true });
}
+85 -1
View File
@@ -4,8 +4,9 @@ import { useEffect, useRef, useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2, ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X, Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X, BookImage,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -201,6 +202,86 @@ function SectionHeader({ title }: { title: string }) {
); );
} }
// ─── Linked shot panel ────────────────────────────────────────────────────────
interface LinkedShot {
id: string;
shotCode: string;
scene: string | null;
episode: string | null;
thumbnailUrl: string | null;
references: { id: string; fileUrl: string; fileName: string; label: string | null }[];
}
function LinkedShotPanel({ shotId }: { shotId: string }) {
const { data, isLoading } = useQuery<{ shot: LinkedShot }>({
queryKey: ["linked-shot", shotId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/shots/${shotId}`);
if (!res.ok) throw new Error("Failed");
return res.json();
},
staleTime: 60_000,
enabled: !!shotId,
});
if (isLoading) {
return (
<div className="flex items-center gap-2 text-xs text-zinc-500 mt-2">
<Loader2 className="h-3 w-3 animate-spin" /> Loading shot data
</div>
);
}
const shot = data?.shot;
if (!shot) return null;
const hasContent = shot.thumbnailUrl || shot.references.length > 0;
if (!hasContent) return null;
return (
<div className="mt-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 space-y-3">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
<BookImage className="h-3 w-3" />
Shot Reference {shot.shotCode}
</div>
{shot.thumbnailUrl && (
<div className="relative w-full max-w-xs aspect-[2.39] rounded-md overflow-hidden border border-zinc-700">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
sizes="320px"
/>
</div>
)}
{shot.references.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{shot.references.map((ref) => (
<div key={ref.id} className="relative aspect-square rounded-md overflow-hidden border border-zinc-700 bg-zinc-950 group">
<Image
src={ref.fileUrl}
alt={ref.label ?? ref.fileName}
fill
className="object-cover"
sizes="100px"
/>
{ref.label && (
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[9px] text-zinc-300 truncate px-1 py-0.5">
{ref.label}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
// ─── Shot selector ──────────────────────────────────────────────────────────── // ─── Shot selector ────────────────────────────────────────────────────────────
interface ShotOption { interface ShotOption {
@@ -440,6 +521,9 @@ export function TakeEditorPanel({
value={fields.pipelineShotId} value={fields.pipelineShotId}
onChange={(id) => set("pipelineShotId", id)} onChange={(id) => set("pipelineShotId", id)}
/> />
{fields.pipelineShotId && (
<LinkedShotPanel shotId={fields.pipelineShotId} />
)}
</Field> </Field>
{/* Camera */} {/* Camera */}
+141 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -20,7 +20,143 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { updateShot, deleteShot } from "@/actions/shots"; import { updateShot, deleteShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react"; import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2 } from "lucide-react";
// ─── Shot References ──────────────────────────────────────────────────────────
interface ShotRef {
id: string;
fileUrl: string;
fileName: string;
label: string | null;
sortOrder: number;
}
function ShotReferencesSection({ shotId }: { shotId: string }) {
const { toast } = useToast();
const [refs, setRefs] = useState<ShotRef[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchRefs = useCallback(async () => {
try {
const res = await fetch(`/api/shots/${shotId}/references`);
if (res.ok) {
const data = await res.json();
setRefs(data.references);
}
} finally {
setLoading(false);
}
}, [shotId]);
useEffect(() => { fetchRefs(); }, [fetchRefs]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
try {
for (const file of files) {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/shots/${shotId}/references`, { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text());
const { reference } = await res.json();
setRefs((prev) => [...prev, reference]);
}
} catch (err) {
toast({ title: "Upload failed", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleDelete = async (refId: string) => {
try {
const res = await fetch(`/api/shots/${shotId}/references?refId=${refId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Delete failed");
setRefs((prev) => prev.filter((r) => r.id !== refId));
} catch {
toast({ title: "Failed to delete reference", variant: "destructive" });
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<BookImage className="h-4 w-4 text-amber-500" />
References
</div>
<Separator />
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading
</div>
) : (
<>
{refs.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{refs.map((ref) => (
<div key={ref.id} className="relative group aspect-square rounded-lg overflow-hidden border border-border bg-zinc-900">
<Image
src={ref.fileUrl}
alt={ref.label ?? ref.fileName}
fill
className="object-cover"
sizes="160px"
/>
{ref.label && (
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[10px] text-zinc-300 truncate px-1.5 py-0.5">
{ref.label}
</div>
)}
<button
type="button"
onClick={() => handleDelete(ref.id)}
className="absolute top-1 right-1 bg-black/70 hover:bg-red-900 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove reference"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 text-sm text-muted-foreground cursor-pointer transition-colors disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{uploading ? "Uploading…" : "Add reference images"}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleUpload}
/>
</div>
</>
)}
</div>
);
}
// ─── Settings form ────────────────────────────────────────────────────────────
const settingsSchema = z.object({ const settingsSchema = z.object({
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"), shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
@@ -318,6 +454,9 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
/> />
</div> </div>
{/* References */}
<ShotReferencesSection shotId={shot.id} />
<Button type="submit" disabled={isSaving}> <Button type="submit" disabled={isSaving}>
{isSaving ? "Saving\u2026" : "Save Changes"} {isSaving ? "Saving\u2026" : "Save Changes"}
</Button> </Button>
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "shot_references" (
"id" TEXT NOT NULL,
"shotId" TEXT NOT NULL,
"label" TEXT,
"fileUrl" TEXT NOT NULL,
"fileKey" TEXT NOT NULL DEFAULT '',
"fileName" TEXT NOT NULL DEFAULT '',
"fileSize" BIGINT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "shot_references_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "shot_references" ADD CONSTRAINT "shot_references_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+17
View File
@@ -357,6 +357,7 @@ model Shot {
versions Version[] versions Version[]
tasks Task[] tasks Task[]
footagePlates FootagePlate[] footagePlates FootagePlate[]
references ShotReference[]
loggedTakes Take[] @relation("TakeToShot") loggedTakes Take[] @relation("TakeToShot")
@@unique([projectId, shotCode]) @@unique([projectId, shotCode])
@@ -379,6 +380,22 @@ model FootagePlate {
@@map("footage_plates") @@map("footage_plates")
} }
model ShotReference {
id String @id @default(cuid())
shotId String
label String?
fileUrl String
fileKey String @default("")
fileName String @default("")
fileSize BigInt?
sortOrder Int @default(0)
createdAt DateTime @default(now())
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
@@map("shot_references")
}
model ShotGroup { model ShotGroup {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String