Supervisor feature
Deploy / deploy (push) Successful in 3m5s

This commit is contained in:
twotalesanimation
2026-07-11 15:06:12 +02:00
parent 6fc818a3db
commit e24fd8eda0
19 changed files with 3175 additions and 2 deletions
+467
View File
@@ -0,0 +1,467 @@
# Feature: On-Set VFX Supervisor Shot Log
## Overview
Add a new module to the existing VFX Review project called **Shot Logging** (working title: **VFX Supervisor Reports**).
This is **not** simply a spreadsheet of takes. It is a production tool designed for VFX Supervisors to log every setup and take while on set using an iPad or phone.
The design must fit naturally into the existing VFX Review architecture, coding standards, authentication system, permissions and database structure.
This feature will later integrate with Shot Reports, Reviews, Assets and the VFX pipeline, so everything must be designed around reusable relationships instead of duplicated data.
---
# Primary Goals
The application must allow a supervisor to:
* create a shoot day
* create setups
* create unlimited takes within a setup
* quickly duplicate previous take information
* review previous takes without leaving the current page
* attach unlimited reference images
* capture complete VFX metadata
* work efficiently while standing on set with an iPad
The workflow should require as few taps as possible.
---
# Future Integration
Design the database so this module can later connect to:
* Shot Reports
* Review Sessions
* Final Shots
* Assets
* Camera Reports
* Production Notes
* Editorial
* Deliverables
For example:
A Shot Report should later be able to display
* every logged take
* supervisor notes
* all reference images
* HDRI status
* tracking marker notes
* camera information
without duplicating data.
Avoid storing information twice.
---
# UI Requirements
This page is intended primarily for iPad landscape orientation.
Requirements:
* touch friendly controls
* large tap targets
* Apple Pencil friendly
* responsive
* works well on desktop
* works on phones for quick edits
The page should never feel like filling in a spreadsheet.
Think of it as a digital clipboard.
---
# Layout
Use a split layout.
LEFT SIDE
A scrollable list of all takes.
Example
Setup A
Take 1
Take 2
Take 3
Take 4
Each take should display:
* take number
* clip name
* quality colour
* notes indicator
* image count
* warning indicators
Selecting a take loads it instantly.
No page reload.
---
RIGHT SIDE
Displays the editable take.
Changes should autosave.
---
# Fast Navigation
Allow:
Previous Take
Next Take
Duplicate Previous Take
New Take
New Setup
Keyboard shortcuts on desktop.
Large touch buttons on iPad.
---
# Smart Autofill
The application should remember previous values during the current shoot.
Examples
Lens
Cooke S4 50mm
Suggest again
Camera
Alexa Mini LF
Suggest again
FPS
24
ISO
800
White Balance
5600
Filters
reuse previous
Clip names should intelligently suggest the next sequential clip name where possible.
Changing only one or two fields between takes should be the normal workflow.
---
# Required Fields
General
* Production
* Shoot Day
* Date
* Unit
* Scene
* Shot
* Setup
* Take
Camera
* Camera Letter
* Clip Name
* Roll
* Camera Model
* Resolution
* Codec
* FPS
* Shutter
* ISO
* White Balance
* Colour Space
Lens
* Lens Set
* Lens
* T Stop
* Filters
* Anamorphic
Tracking
Checkboxes for
* HDRI
* Chrome Ball
* Grey Ball
* Macbeth Chart
* Clean Plate
* Survey
* Lidar
* Witness Camera
* Lens Grid
* Texture Photos
* Photogrammetry
Lighting
* Weather
* Sun Direction
* Artificial Lights
Supervisor
* Notes
* Continuity
* VFX Requirements
* Quality Rating
---
# Unlimited Images
Each take can contain unlimited images.
Support:
* drag and drop
* upload
* direct camera capture
* iPad camera
* phone camera
Images should upload in the background.
Do not block editing while uploads complete.
Each image should store:
* filename
* timestamp
* uploaded by
* optional caption
* optional category
Suggested categories
* Slate
* Camera Position
* Wide Reference
* Lens
* Lighting
* HDRI
* Tracking
* Texture
* Witness
* Miscellaneous
Images should appear as thumbnails.
Selecting a thumbnail opens a gallery viewer.
Allow deleting, renaming and reordering.
---
# Previous Takes Panel
One of the most important features.
The supervisor should always be able to see previous takes while entering the current one.
Provide:
* previous take summary
* compare current vs previous
* highlight changed fields
For example
Lens
50mm
85mm
highlighted
ISO
800
800
unchanged
This makes continuity checking very fast.
---
# Autosave
Every field should autosave.
No Save button.
Unsaved changes should be visually indicated.
---
# Offline Ready
The architecture should support offline editing later.
Design APIs and local state with synchronisation in mind.
---
# Attachments
Each take should later support attaching:
* videos
* HDRIs
* LiDAR files
* lens grids
* PDFs
* documents
Design the database with generic attachment support rather than image-only support.
---
# Database Design
Normalise data appropriately.
Suggested entities:
ShootDay
Setup
Take
TakeImage
TakeAttachment
Lens
Camera
Location
Weather
User
Avoid storing repeated strings where practical.
---
# API Design
Create REST endpoints consistent with the existing VFX Review API.
Support:
Create
Read
Update
Delete
Autosave
Bulk image upload
Image deletion
Pagination where appropriate.
---
# UX Goals
The application should feel fast enough that a supervisor can log an entire take in under 20 seconds.
The interface should minimise typing.
Prioritise:
* autocomplete
* remembered values
* one-tap selections
* large touch controls
* quick navigation
* minimal modal dialogs
The supervisor should never lose context while moving between takes.
---
# Future Enhancements
Keep the architecture open for:
* voice notes with speech-to-text
* Apple Pencil annotations
* drawing on reference images
* GPS logging
* barcode/QR scanning
* camera metadata import
* live camera feed integration
* Shot Report generation
* continuity checking
* production analytics
* completeness validation (HDRI missing, no clean plate, etc.)
Build this as a first-class production module that integrates seamlessly with the existing VFX Review platform rather than as a standalone form.
+347
View File
@@ -0,0 +1,347 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { TakeQuality, AttachmentFileType, AttachmentCategory } from "@prisma/client";
// ── Permission guard ─────────────────────────────────────────────────────────
async function requireShootAccess() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
return session.user;
}
// ── Shoot Day ────────────────────────────────────────────────────────────────
const createShootDaySchema = z.object({
projectId: z.string().cuid(),
date: z.string().min(1),
unit: z.string().max(20).default("A"),
label: z.string().max(120).optional(),
notes: z.string().optional(),
});
export async function createShootDay(data: z.infer<typeof createShootDaySchema>) {
const user = await requireShootAccess();
const parsed = createShootDaySchema.parse(data);
const shootDay = await db.shootDay.create({
data: {
projectId: parsed.projectId,
date: new Date(parsed.date),
unit: parsed.unit,
label: parsed.label,
notes: parsed.notes,
createdById: user.id,
},
include: { setups: { include: { takes: { include: { attachments: true } } } } },
});
revalidatePath("/shoot-log");
return shootDay;
}
export async function updateShootDay(
id: string,
data: Partial<{ label: string; notes: string; unit: string }>
) {
await requireShootAccess();
const day = await db.shootDay.update({
where: { id },
data,
});
revalidatePath("/shoot-log");
return day;
}
// ── Setup ────────────────────────────────────────────────────────────────────
const createSetupSchema = z.object({
shootDayId: z.string().cuid(),
name: z.string().min(1).max(100),
description: z.string().optional(),
});
export async function createSetup(data: z.infer<typeof createSetupSchema>) {
await requireShootAccess();
const parsed = createSetupSchema.parse(data);
const lastSetup = await db.setup.findFirst({
where: { shootDayId: parsed.shootDayId },
orderBy: { sortOrder: "desc" },
});
const setup = await db.setup.create({
data: {
shootDayId: parsed.shootDayId,
name: parsed.name,
description: parsed.description,
sortOrder: (lastSetup?.sortOrder ?? -1) + 1,
},
include: { takes: { include: { attachments: true } } },
});
revalidatePath("/shoot-log");
return setup;
}
export async function updateSetup(id: string, data: Partial<{ name: string; description: string }>) {
await requireShootAccess();
const setup = await db.setup.update({ where: { id }, data });
revalidatePath("/shoot-log");
return setup;
}
export async function deleteSetup(id: string) {
await requireShootAccess();
await db.setup.delete({ where: { id } });
revalidatePath("/shoot-log");
}
// ── Take ─────────────────────────────────────────────────────────────────────
const takeFieldsSchema = z.object({
scene: z.string().max(100).optional(),
shotLabel: z.string().max(100).optional(),
unitLabel: z.string().max(50).optional(),
cameraLetter: z.string().max(10).optional(),
clipName: z.string().max(120).optional(),
roll: z.string().max(50).optional(),
cameraModel: z.string().max(100).optional(),
resolution: z.string().max(50).optional(),
codec: z.string().max(50).optional(),
fps: z.number().positive().optional(),
shutter: z.string().max(30).optional(),
iso: z.number().int().positive().optional(),
whiteBalance: z.number().int().positive().optional(),
colourSpace: z.string().max(80).optional(),
lensSet: z.string().max(100).optional(),
lens: z.string().max(100).optional(),
tStop: z.string().max(20).optional(),
filters: z.string().max(200).optional(),
isAnamorphic: z.boolean().optional(),
hasHdri: z.boolean().optional(),
hasChromeBall: z.boolean().optional(),
hasGreyBall: z.boolean().optional(),
hasMacbeth: z.boolean().optional(),
hasCleanPlate: z.boolean().optional(),
hasSurvey: z.boolean().optional(),
hasLidar: z.boolean().optional(),
hasWitnessCamera: z.boolean().optional(),
hasLensGrid: z.boolean().optional(),
hasTexturePhotos: z.boolean().optional(),
hasPhotogrammetry: z.boolean().optional(),
weather: z.string().max(100).optional(),
sunDirection: z.string().max(100).optional(),
artificialLights: z.string().optional(),
supervisorNotes: z.string().optional(),
continuityNotes: z.string().optional(),
vfxRequirements: z.string().optional(),
quality: z.nativeEnum(TakeQuality).optional(),
pipelineShotId: z.string().cuid().optional().or(z.literal("")),
});
export async function createTake(
setupId: string,
initialData?: Partial<z.infer<typeof takeFieldsSchema>>
) {
const user = await requireShootAccess();
const lastTake = await db.take.findFirst({
where: { setupId },
orderBy: { takeNumber: "desc" },
});
const takeNumber = (lastTake?.takeNumber ?? 0) + 1;
const take = await db.take.create({
data: {
setupId,
takeNumber,
createdById: user.id,
...sanitizeTakeData(initialData ?? {}),
},
include: { attachments: true },
});
revalidatePath("/shoot-log");
return take;
}
export async function updateTake(
id: string,
data: Partial<z.infer<typeof takeFieldsSchema>>
) {
await requireShootAccess();
const parsed = takeFieldsSchema.partial().parse(data);
const take = await db.take.update({
where: { id },
data: sanitizeTakeData(parsed),
include: { attachments: true },
});
revalidatePath("/shoot-log");
return take;
}
export async function duplicateTake(sourceId: string) {
const user = await requireShootAccess();
const source = await db.take.findUniqueOrThrow({
where: { id: sourceId },
include: { setup: true },
});
const lastTake = await db.take.findFirst({
where: { setupId: source.setupId },
orderBy: { takeNumber: "desc" },
});
const takeNumber = (lastTake?.takeNumber ?? 0) + 1;
// Increment clip name suffix if pattern matches e.g. A001_C002 -> A001_C003
const nextClipName = incrementClipName(source.clipName);
const newTake = await db.take.create({
data: {
setupId: source.setupId,
takeNumber,
createdById: user.id,
scene: source.scene,
shotLabel: source.shotLabel,
unitLabel: source.unitLabel,
cameraLetter: source.cameraLetter,
clipName: nextClipName,
roll: source.roll,
cameraModel: source.cameraModel,
resolution: source.resolution,
codec: source.codec,
fps: source.fps,
shutter: source.shutter,
iso: source.iso,
whiteBalance: source.whiteBalance,
colourSpace: source.colourSpace,
lensSet: source.lensSet,
lens: source.lens,
tStop: source.tStop,
filters: source.filters,
isAnamorphic: source.isAnamorphic,
hasHdri: source.hasHdri,
hasChromeBall: source.hasChromeBall,
hasGreyBall: source.hasGreyBall,
hasMacbeth: source.hasMacbeth,
hasCleanPlate: source.hasCleanPlate,
hasSurvey: source.hasSurvey,
hasLidar: source.hasLidar,
hasWitnessCamera: source.hasWitnessCamera,
hasLensGrid: source.hasLensGrid,
hasTexturePhotos: source.hasTexturePhotos,
hasPhotogrammetry: source.hasPhotogrammetry,
weather: source.weather,
sunDirection: source.sunDirection,
artificialLights: source.artificialLights,
quality: source.quality,
// Notes reset for new take
supervisorNotes: null,
continuityNotes: null,
vfxRequirements: null,
},
include: { attachments: true },
});
revalidatePath("/shoot-log");
return newTake;
}
export async function deleteTake(id: string) {
await requireShootAccess();
await db.take.delete({ where: { id } });
revalidatePath("/shoot-log");
}
// ── Attachments ──────────────────────────────────────────────────────────────
const addAttachmentSchema = z.object({
takeId: z.string().cuid(),
fileUrl: z.string().min(1),
fileKey: z.string().default(""),
fileName: z.string().min(1),
fileSize: z.number().optional(),
fileType: z.nativeEnum(AttachmentFileType).default("IMAGE"),
category: z.nativeEnum(AttachmentCategory).default("MISCELLANEOUS"),
caption: z.string().max(200).optional(),
});
export async function addTakeAttachment(data: z.infer<typeof addAttachmentSchema>) {
const user = await requireShootAccess();
const parsed = addAttachmentSchema.parse(data);
const lastAttachment = await db.takeAttachment.findFirst({
where: { takeId: parsed.takeId },
orderBy: { sortOrder: "desc" },
});
const attachment = await db.takeAttachment.create({
data: {
takeId: parsed.takeId,
fileUrl: parsed.fileUrl,
fileKey: parsed.fileKey,
fileName: parsed.fileName,
fileSize: parsed.fileSize ? BigInt(parsed.fileSize) : undefined,
fileType: parsed.fileType,
category: parsed.category,
caption: parsed.caption,
sortOrder: (lastAttachment?.sortOrder ?? -1) + 1,
uploadedById: user.id,
},
});
revalidatePath("/shoot-log");
return attachment;
}
export async function updateAttachmentCaption(id: string, caption: string) {
await requireShootAccess();
return db.takeAttachment.update({ where: { id }, data: { caption } });
}
export async function updateAttachmentCategory(id: string, category: AttachmentCategory) {
await requireShootAccess();
return db.takeAttachment.update({ where: { id }, data: { category } });
}
export async function deleteTakeAttachment(id: string) {
await requireShootAccess();
await db.takeAttachment.delete({ where: { id } });
revalidatePath("/shoot-log");
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function sanitizeTakeData(data: Record<string, unknown>) {
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) {
if (v === undefined) continue;
if (v === "") {
clean[k] = null;
} else {
clean[k] = v;
}
}
return clean;
}
/** Increment trailing clip number: A001_C002 → A001_C003, ROLL_007 → ROLL_008 */
function incrementClipName(clipName: string | null | undefined): string | null {
if (!clipName) return null;
const match = clipName.match(/^(.*?)(\d+)$/);
if (!match) return clipName;
const [, prefix, numStr] = match;
const next = String(Number(numStr) + 1).padStart(numStr.length, "0");
return `${prefix}${next}`;
}
+22
View File
@@ -0,0 +1,22 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { ShootLogClient } from "@/components/shoot-log/ShootLogClient";
export const metadata = { title: "Shot Log" };
export default async function ShootLogPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
redirect("/dashboard");
}
const projects = await db.project.findMany({
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
select: { id: true, name: true, code: true },
orderBy: { name: "asc" },
});
return <ShootLogClient projects={projects} />;
}
@@ -0,0 +1,66 @@
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/days/[dayId]/setups
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ dayId: 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 { dayId } = await params;
const setups = await db.setup.findMany({
where: { shootDayId: dayId },
orderBy: { sortOrder: "asc" },
include: {
takes: {
orderBy: { takeNumber: "asc" },
include: { _count: { select: { attachments: true } } },
},
},
});
return NextResponse.json({ setups });
}
// POST /api/shoot-log/days/[dayId]/setups
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ dayId: 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 { dayId } = await params;
const body = await req.json();
const { name, description } = body;
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
const last = await db.setup.findFirst({
where: { shootDayId: dayId },
orderBy: { sortOrder: "desc" },
});
const setup = await db.setup.create({
data: {
shootDayId: dayId,
name,
description: description ?? null,
sortOrder: (last?.sortOrder ?? -1) + 1,
},
include: { takes: { orderBy: { takeNumber: "asc" }, include: { _count: { select: { attachments: true } } } } },
});
return NextResponse.json({ setup }, { status: 201 });
}
+72
View File
@@ -0,0 +1,72 @@
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/days?projectId=xxx
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 days = await db.shootDay.findMany({
where: { projectId },
orderBy: { date: "desc" },
include: {
setups: {
orderBy: { sortOrder: "asc" },
include: {
takes: {
orderBy: { takeNumber: "asc" },
include: {
_count: { select: { attachments: true } },
},
},
},
},
createdBy: { select: { id: true, name: true } },
},
});
return NextResponse.json({ days });
}
// POST /api/shoot-log/days
export async function POST(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 body = await req.json();
const { projectId, date, unit, label, notes } = body;
if (!projectId || !date) {
return NextResponse.json({ error: "projectId and date required" }, { status: 400 });
}
const day = await db.shootDay.create({
data: {
projectId,
date: new Date(date),
unit: unit ?? "A",
label: label ?? null,
notes: notes ?? null,
createdById: session.user.id,
},
include: {
setups: {
orderBy: { sortOrder: "asc" },
include: { takes: { orderBy: { takeNumber: "asc" }, include: { _count: { select: { attachments: true } } } } },
},
},
});
return NextResponse.json({ day }, { status: 201 });
}
+72
View File
@@ -0,0 +1,72 @@
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/recent-values?projectId=xxx
// Returns recently used values for smart autofill on new takes
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 });
// Fetch the 5 most recent takes for this project to extract autofill candidates
const recentTakes = await db.take.findMany({
where: {
setup: { shootDay: { projectId } },
},
orderBy: { createdAt: "desc" },
take: 5,
select: {
cameraModel: true,
cameraLetter: true,
resolution: true,
codec: true,
fps: true,
shutter: true,
iso: true,
whiteBalance: true,
colourSpace: true,
lensSet: true,
lens: true,
filters: true,
isAnamorphic: true,
weather: true,
clipName: true,
},
});
// Return the most recent non-null value for each field
function latest<T>(field: keyof typeof recentTakes[0]): T | null {
for (const take of recentTakes) {
const v = take[field];
if (v !== null && v !== undefined) return v as T;
}
return null;
}
return NextResponse.json({
cameraModel: latest("cameraModel"),
cameraLetter: latest("cameraLetter"),
resolution: latest("resolution"),
codec: latest("codec"),
fps: latest("fps"),
shutter: latest("shutter"),
iso: latest("iso"),
whiteBalance: latest("whiteBalance"),
colourSpace: latest("colourSpace"),
lensSet: latest("lensSet"),
lens: latest("lens"),
filters: latest("filters"),
isAnamorphic: latest("isAnamorphic"),
weather: latest("weather"),
lastClipName: latest("clipName"),
});
}
@@ -0,0 +1,77 @@
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/setups/[setupId]/takes
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ setupId: 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 { setupId } = await params;
const takes = await db.take.findMany({
where: { setupId },
orderBy: { takeNumber: "asc" },
include: { attachments: { orderBy: { sortOrder: "asc" } } },
});
return NextResponse.json({ takes });
}
// POST /api/shoot-log/setups/[setupId]/takes
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ setupId: 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 { setupId } = await params;
const body = await req.json();
const last = await db.take.findFirst({
where: { setupId },
orderBy: { takeNumber: "desc" },
});
const takeNumber = (last?.takeNumber ?? 0) + 1;
const take = await db.take.create({
data: {
setupId,
takeNumber,
createdById: session.user.id,
...sanitize(body),
},
include: { attachments: { orderBy: { sortOrder: "asc" } } },
});
return NextResponse.json({ take }, { status: 201 });
}
function sanitize(data: Record<string, unknown>) {
const allowed = new Set([
"scene","shotLabel","unitLabel","cameraLetter","clipName","roll","cameraModel",
"resolution","codec","fps","shutter","iso","whiteBalance","colourSpace",
"lensSet","lens","tStop","filters","isAnamorphic",
"hasHdri","hasChromeBall","hasGreyBall","hasMacbeth","hasCleanPlate",
"hasSurvey","hasLidar","hasWitnessCamera","hasLensGrid","hasTexturePhotos","hasPhotogrammetry",
"weather","sunDirection","artificialLights",
"supervisorNotes","continuityNotes","vfxRequirements","quality",
]);
return Object.fromEntries(
Object.entries(data)
.filter(([k]) => allowed.has(k))
.map(([k, v]) => [k, v === "" ? null : v])
);
}
@@ -0,0 +1,46 @@
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);
}
// PATCH /api/shoot-log/takes/[takeId]/attachments/[attachmentId]
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ takeId: string; attachmentId: 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 { attachmentId } = await params;
const { caption, category } = await req.json();
const attachment = await db.takeAttachment.update({
where: { id: attachmentId },
data: {
...(caption !== undefined ? { caption } : {}),
...(category !== undefined ? { category } : {}),
},
});
return NextResponse.json({ attachment });
}
// DELETE /api/shoot-log/takes/[takeId]/attachments/[attachmentId]
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ takeId: string; attachmentId: 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 { attachmentId } = await params;
await db.takeAttachment.delete({ where: { id: attachmentId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { AttachmentFileType, AttachmentCategory } from "@prisma/client";
function requireRole(role: string) {
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
}
// GET /api/shoot-log/takes/[takeId]/attachments
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ takeId: 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 { takeId } = await params;
const attachments = await db.takeAttachment.findMany({
where: { takeId },
orderBy: { sortOrder: "asc" },
include: { uploadedBy: { select: { id: true, name: true } } },
});
return NextResponse.json({ attachments });
}
// POST /api/shoot-log/takes/[takeId]/attachments
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ takeId: 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 { takeId } = await params;
const body = await req.json();
const { fileUrl, fileKey, fileName, fileSize, fileType, category, caption } = body;
if (!fileUrl || !fileName) {
return NextResponse.json({ error: "fileUrl and fileName required" }, { status: 400 });
}
const last = await db.takeAttachment.findFirst({
where: { takeId },
orderBy: { sortOrder: "desc" },
});
const attachment = await db.takeAttachment.create({
data: {
takeId,
fileUrl,
fileKey: fileKey ?? "",
fileName,
fileSize: fileSize ? BigInt(fileSize) : undefined,
fileType: (fileType as AttachmentFileType) ?? "IMAGE",
category: (category as AttachmentCategory) ?? "MISCELLANEOUS",
caption: caption ?? null,
sortOrder: (last?.sortOrder ?? -1) + 1,
uploadedById: session.user.id,
},
});
return NextResponse.json({ attachment }, { status: 201 });
}
+90
View File
@@ -0,0 +1,90 @@
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);
}
function sanitize(data: Record<string, unknown>) {
const allowed = new Set([
"scene","shotLabel","unitLabel","cameraLetter","clipName","roll","cameraModel",
"resolution","codec","fps","shutter","iso","whiteBalance","colourSpace",
"lensSet","lens","tStop","filters","isAnamorphic",
"hasHdri","hasChromeBall","hasGreyBall","hasMacbeth","hasCleanPlate",
"hasSurvey","hasLidar","hasWitnessCamera","hasLensGrid","hasTexturePhotos","hasPhotogrammetry",
"weather","sunDirection","artificialLights",
"supervisorNotes","continuityNotes","vfxRequirements","quality",
]);
return Object.fromEntries(
Object.entries(data)
.filter(([k]) => allowed.has(k))
.map(([k, v]) => [k, v === "" ? null : v])
);
}
// GET /api/shoot-log/takes/[takeId]
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ takeId: 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 { takeId } = await params;
const take = await db.take.findUnique({
where: { id: takeId },
include: {
attachments: { orderBy: { sortOrder: "asc" } },
setup: {
include: {
shootDay: { select: { id: true, date: true, unit: true, label: true, projectId: true } },
takes: { orderBy: { takeNumber: "asc" }, select: { id: true, takeNumber: true } },
},
},
},
});
if (!take) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ take });
}
// PATCH /api/shoot-log/takes/[takeId]
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ takeId: 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 { takeId } = await params;
const body = await req.json();
const take = await db.take.update({
where: { id: takeId },
data: sanitize(body),
include: { attachments: { orderBy: { sortOrder: "asc" } } },
});
return NextResponse.json({ take });
}
// DELETE /api/shoot-log/takes/[takeId]
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ takeId: 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 { takeId } = await params;
await db.take.delete({ where: { id: takeId } });
return NextResponse.json({ ok: true });
}
+4 -2
View File
@@ -19,6 +19,7 @@ import {
BarChart2, BarChart2,
ListVideo, ListVideo,
CloudUpload, CloudUpload,
Clapperboard,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
@@ -30,6 +31,7 @@ const navItems = [
{ href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true }, { href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true },
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true }, { href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true }, { href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
{ href: '/shoot-log', label: 'Shot Log', icon: Clapperboard, supervisorOnly: true },
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true }, { href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
{ href: '/batch-upload', label: 'Batch Upload', icon: CloudUpload, adminOnly: true }, { href: '/batch-upload', label: 'Batch Upload', icon: CloudUpload, adminOnly: true },
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true }, { href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
@@ -88,8 +90,8 @@ export function Sidebar() {
{navItems.map((item) => { {navItems.map((item) => {
if (item.adminOnly && !isAdmin) return null; if (item.adminOnly && !isAdmin) return null;
if ((item as any).adminStrictOnly && session?.user?.role !== 'ADMIN') return null; if ((item as any).adminStrictOnly && session?.user?.role !== 'ADMIN') return null;
if ((item as any).hideForClient && session?.user?.role === 'CLIENT') if ((item as any).hideForClient && session?.user?.role === 'CLIENT') return null;
return null; if ((item as any).supervisorOnly && !['ADMIN', 'PRODUCER', 'SUPERVISOR'].includes(session?.user?.role ?? '')) return null;
const Icon = item.icon; const Icon = item.icon;
const isActive = const isActive =
pathname === item.href || pathname.startsWith(item.href + '/'); pathname === item.href || pathname.startsWith(item.href + '/');
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
const schema = z.object({
name: z.string().min(1, "Name required").max(100),
description: z.string().max(300).optional(),
});
type FormData = z.infer<typeof schema>;
interface Props {
shootDayId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (setup: { id: string; name: string }) => void;
}
export function NewSetupDialog({ shootDayId, open, onOpenChange, onCreated }: Props) {
const [loading, setLoading] = useState(false);
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: "" },
});
async function onSubmit(data: FormData) {
setLoading(true);
try {
const res = await fetch(`/api/shoot-log/days/${shootDayId}/setups`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to create setup");
const { setup } = await res.json();
onCreated(setup);
reset();
onOpenChange(false);
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm bg-zinc-900 border-zinc-800">
<DialogHeader>
<DialogTitle className="text-white">New Setup</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Setup Name *</Label>
<Input
{...register("name")}
placeholder="A, B, or descriptive name"
autoFocus
className="bg-zinc-800 border-zinc-700 text-white"
/>
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Description (optional)</Label>
<Input
{...register("description")}
placeholder="Camera position, location..."
className="bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create Setup"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { format } from "date-fns";
import { CalendarIcon, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
const schema = z.object({
date: z.string().min(1, "Date is required"),
unit: z.string().max(20).default("A"),
label: z.string().max(120).optional(),
notes: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface Props {
projectId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (day: { id: string; date: string; unit: string; label: string | null }) => void;
}
export function NewShootDayDialog({ projectId, open, onOpenChange, onCreate }: Props) {
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
date: format(new Date(), "yyyy-MM-dd"),
unit: "A",
},
});
async function onSubmit(data: FormData) {
setLoading(true);
try {
const res = await fetch("/api/shoot-log/days", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, ...data }),
});
if (!res.ok) throw new Error("Failed to create shoot day");
const { day } = await res.json();
onCreate(day);
reset({ date: format(new Date(), "yyyy-MM-dd"), unit: "A" });
onOpenChange(false);
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-zinc-900 border-zinc-800">
<DialogHeader>
<DialogTitle className="text-white">New Shoot Day</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Date *</Label>
<Input
type="date"
{...register("date")}
className="bg-zinc-800 border-zinc-700 text-white"
/>
{errors.date && (
<p className="text-red-400 text-xs">{errors.date.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Unit</Label>
<Input
{...register("unit")}
placeholder="A"
className="bg-zinc-800 border-zinc-700 text-white"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Label (optional)</Label>
<Input
{...register("label")}
placeholder="e.g. Ext. Warehouse — Night"
className="bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Notes (optional)</Label>
<Input
{...register("notes")}
placeholder="Any notes about this shoot day..."
className="bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create Day"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { cn } from "@/lib/utils";
import type { FullTake } from "./TakeEditorPanel";
// Fields to compare (label → key)
const COMPARE_FIELDS: { label: string; key: keyof FullTake; format?: (v: unknown) => string }[] = [
{ label: "Scene", key: "scene" },
{ label: "Shot", key: "shotLabel" },
{ label: "Camera", key: "cameraLetter" },
{ label: "Clip Name", key: "clipName" },
{ label: "Camera Model", key: "cameraModel" },
{ label: "Resolution", key: "resolution" },
{ label: "Codec", key: "codec" },
{ label: "FPS", key: "fps" },
{ label: "Shutter", key: "shutter" },
{ label: "ISO", key: "iso" },
{ label: "White Balance", key: "whiteBalance" },
{ label: "Colour Space", key: "colourSpace" },
{ label: "Lens Set", key: "lensSet" },
{ label: "Lens", key: "lens" },
{ label: "T Stop", key: "tStop" },
{ label: "Filters", key: "filters" },
{ label: "Anamorphic", key: "isAnamorphic", format: (v) => (v ? "Yes" : "No") },
{ label: "Weather", key: "weather" },
{ label: "Sun Direction", key: "sunDirection" },
];
function display(value: unknown, format?: (v: unknown) => string): string {
if (value === null || value === undefined || value === "") return "—";
if (format) return format(value);
return String(value);
}
function isChanged(a: unknown, b: unknown): boolean {
if (a === null || a === undefined) a = "";
if (b === null || b === undefined) b = "";
return String(a) !== String(b);
}
interface Props {
current: FullTake;
previous: FullTake;
}
export function PreviousTakePanel({ current, previous }: Props) {
const changedFields = COMPARE_FIELDS.filter(({ key, format }) =>
isChanged(display(current[key], format), display(previous[key], format))
);
const unchangedFields = COMPARE_FIELDS.filter(({ key, format }) =>
!isChanged(display(current[key], format), display(previous[key], format))
);
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 overflow-hidden text-xs">
<div className="grid grid-cols-[1fr_1fr_1fr] border-b border-zinc-800">
<div className="px-3 py-2 text-zinc-600 font-semibold uppercase tracking-wider text-[10px]">Field</div>
<div className="px-3 py-2 text-zinc-600 font-semibold uppercase tracking-wider text-[10px]">
T{previous.takeNumber} (prev)
</div>
<div className="px-3 py-2 text-zinc-600 font-semibold uppercase tracking-wider text-[10px]">
T{current.takeNumber} (current)
</div>
</div>
{/* Changed fields — highlighted */}
{changedFields.map(({ label, key, format }) => (
<div
key={String(key)}
className="grid grid-cols-[1fr_1fr_1fr] border-b border-zinc-800 bg-amber-500/5"
>
<div className="px-3 py-2 text-amber-400/70 font-medium">{label}</div>
<div className="px-3 py-2 text-zinc-400 line-through">
{display(previous[key], format)}
</div>
<div className="px-3 py-2 text-amber-300 font-semibold">
{display(current[key], format)}
</div>
</div>
))}
{/* Unchanged fields */}
{unchangedFields.map(({ label, key, format }) => {
const val = display(current[key], format);
if (val === "—") return null;
return (
<div
key={String(key)}
className="grid grid-cols-[1fr_1fr_1fr] border-b border-zinc-800/50 opacity-50"
>
<div className="px-3 py-1.5 text-zinc-500">{label}</div>
<div className="px-3 py-1.5 text-zinc-400">{val}</div>
<div className="px-3 py-1.5 text-zinc-400">{val}</div>
</div>
);
})}
{changedFields.length === 0 && (
<div className="px-3 py-3 text-zinc-600 italic">No changes from previous take.</div>
)}
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Clapperboard, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { TakeListPanel } from "./TakeListPanel";
import { TakeEditorPanel, type FullTake } from "./TakeEditorPanel";
import { NewShootDayDialog } from "./NewShootDayDialog";
import { NewSetupDialog } from "./NewSetupDialog";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Project {
id: string;
name: string;
code: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
interface Props {
projects: Project[];
}
export function ShootLogClient({ projects }: Props) {
const [projectId, setProjectId] = useState<string>(projects[0]?.id ?? "");
const [selectedTakeId, setSelectedTakeId] = useState<string | null>(null);
const [selectedSetupId, setSelectedSetupId] = useState<string | null>(null);
// Dialog state
const [newDayOpen, setNewDayOpen] = useState(false);
const [newSetupDayId, setNewSetupDayId] = useState<string | null>(null);
// Refresh token to force TakeListPanel to refetch
const [refreshToken, setRefreshToken] = useState(0);
const refresh = useCallback(() => setRefreshToken((t) => t + 1), []);
// ── Fetch selected take ────────────────────────────────────────────────────
const { data: takeData, isLoading: takeLoading } = useQuery<{ take: FullTake }>({
queryKey: ["take", selectedTakeId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/takes/${selectedTakeId}`);
if (!res.ok) throw new Error("Failed to load take");
return res.json();
},
enabled: !!selectedTakeId,
staleTime: 0,
});
const take = takeData?.take ?? null;
// ── Fetch previous take (same setup, takeNumber - 1) ──────────────────────
const prevTakeId = take?.setup.takes.find(
(t) => t.takeNumber === (take?.takeNumber ?? 0) - 1
)?.id;
const { data: prevTakeData } = useQuery<{ take: FullTake }>({
queryKey: ["take", prevTakeId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/takes/${prevTakeId}`);
if (!res.ok) throw new Error("Failed to load previous take");
return res.json();
},
enabled: !!prevTakeId,
staleTime: 60_000,
});
const previousTake = prevTakeData?.take ?? null;
// ── Navigation ─────────────────────────────────────────────────────────────
const allTakesInSetup = take?.setup.takes ?? [];
const currentIdx = allTakesInSetup.findIndex((t) => t.id === selectedTakeId);
const prevTakeNav = currentIdx > 0 ? allTakesInSetup[currentIdx - 1] : null;
const nextTakeNav = currentIdx < allTakesInSetup.length - 1 ? allTakesInSetup[currentIdx + 1] : null;
// ── Actions ────────────────────────────────────────────────────────────────
const createTake = useCallback(
async (setupId: string, initialData?: object) => {
const res = await fetch(`/api/shoot-log/setups/${setupId}/takes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(initialData ?? {}),
});
if (!res.ok) throw new Error("Failed to create take");
const { take } = await res.json();
refresh();
setSelectedTakeId(take.id);
setSelectedSetupId(setupId);
},
[refresh]
);
const handleDuplicate = useCallback(async () => {
if (!take) return;
// Build initial data from current take (omit id, notes, attachments)
const { id, takeNumber, attachments, setup, createdById, createdAt, updatedAt, ...rest } = take as FullTake & Record<string, unknown>;
void id; void takeNumber; void attachments; void setup; void createdById; void createdAt; void updatedAt;
// Increment clip name
const clipName = incrementClipName(take.clipName);
const initial = { ...rest, clipName, supervisorNotes: null, continuityNotes: null, vfxRequirements: null };
await createTake(take.setupId, initial);
}, [take, createTake]);
const handleDelete = useCallback(async () => {
if (!selectedTakeId) return;
if (!confirm("Delete this take? This cannot be undone.")) return;
await fetch(`/api/shoot-log/takes/${selectedTakeId}`, { method: "DELETE" });
setSelectedTakeId(null);
refresh();
}, [selectedTakeId, refresh]);
// ── Render ─────────────────────────────────────────────────────────────────
if (!projectId) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-zinc-500">
<Clapperboard className="h-12 w-12 text-zinc-700" />
<p>No active projects found.</p>
</div>
);
}
return (
<>
{/* Project selector (top bar) */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800 bg-zinc-900/60 shrink-0">
<Clapperboard className="h-4 w-4 text-amber-400 shrink-0" />
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Shot Log</span>
<div className="ml-2">
<select
value={projectId}
onChange={(e) => {
setProjectId(e.target.value);
setSelectedTakeId(null);
setSelectedSetupId(null);
setRefreshToken((t) => t + 1);
}}
className="bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white px-3 py-1.5 focus:outline-none focus:border-amber-600"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.code} {p.name}
</option>
))}
</select>
</div>
{/* Keyboard hint */}
<div className="ml-auto hidden lg:flex items-center gap-3 text-[10px] text-zinc-600">
<span> navigate</span>
<span>D duplicate</span>
<span>N new take</span>
</div>
</div>
{/* Split layout */}
<div className="flex flex-1 overflow-hidden min-h-0">
{/* Left panel */}
<div className="w-72 xl:w-80 shrink-0 border-r border-zinc-800 overflow-hidden flex flex-col">
<TakeListPanel
projectId={projectId}
selectedTakeId={selectedTakeId}
refreshToken={refreshToken}
onSelectTake={(takeId, setupId) => {
setSelectedTakeId(takeId);
setSelectedSetupId(setupId);
}}
onNewDay={() => setNewDayOpen(true)}
onNewSetup={(dayId) => setNewSetupDayId(dayId)}
onNewTake={(setupId) => createTake(setupId)}
/>
</div>
{/* Right panel */}
<div className="flex-1 overflow-hidden flex flex-col">
{takeLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-zinc-500" />
</div>
) : take ? (
<TakeEditorPanel
key={take.id}
take={take}
previousTake={previousTake}
onPrev={prevTakeNav ? () => setSelectedTakeId(prevTakeNav.id) : null}
onNext={nextTakeNav ? () => setSelectedTakeId(nextTakeNav.id) : null}
onDuplicate={handleDuplicate}
onNewTake={() => createTake(take.setupId)}
onDelete={handleDelete}
onAttachmentsChange={refresh}
/>
) : (
<div className="flex flex-col items-center justify-center h-full gap-3 text-zinc-600">
<Clapperboard className="h-10 w-10 text-zinc-700" />
<p className="text-sm">Select a take or create a new shoot day</p>
</div>
)}
</div>
</div>
{/* Dialogs */}
<NewShootDayDialog
projectId={projectId}
open={newDayOpen}
onOpenChange={setNewDayOpen}
onCreate={refresh}
/>
{newSetupDayId && (
<NewSetupDialog
shootDayId={newSetupDayId}
open={!!newSetupDayId}
onOpenChange={(o) => { if (!o) setNewSetupDayId(null); }}
onCreated={() => {
setNewSetupDayId(null);
refresh();
}}
/>
)}
</>
);
}
/** Increment trailing number in clip name: A001_C002 → A001_C003 */
function incrementClipName(clipName: string | null | undefined): string | null {
if (!clipName) return null;
const match = clipName.match(/^(.*?)(\d+)$/);
if (!match) return clipName;
const [, prefix, numStr] = match;
const next = String(Number(numStr) + 1).padStart(numStr.length, "0");
return `${prefix}${next}`;
}
+532
View File
@@ -0,0 +1,532 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import {
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
Trash2, AlertCircle, ChevronDown, ChevronUp,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { TakeImageGallery } from "./TakeImageGallery";
import { PreviousTakePanel } from "./PreviousTakePanel";
import type { TakeQuality } from "@prisma/client";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface TakeAttachmentData {
id: string;
fileUrl: string;
fileName: string;
fileType: string;
category: string;
caption: string | null;
sortOrder: number;
}
export interface FullTake {
id: string;
setupId: string;
takeNumber: number;
scene: string | null;
shotLabel: string | null;
unitLabel: string | null;
cameraLetter: string | null;
clipName: string | null;
roll: string | null;
cameraModel: string | null;
resolution: string | null;
codec: string | null;
fps: number | null;
shutter: string | null;
iso: number | null;
whiteBalance: number | null;
colourSpace: string | null;
lensSet: string | null;
lens: string | null;
tStop: string | null;
filters: string | null;
isAnamorphic: boolean;
hasHdri: boolean;
hasChromeBall: boolean;
hasGreyBall: boolean;
hasMacbeth: boolean;
hasCleanPlate: boolean;
hasSurvey: boolean;
hasLidar: boolean;
hasWitnessCamera: boolean;
hasLensGrid: boolean;
hasTexturePhotos: boolean;
hasPhotogrammetry: boolean;
weather: string | null;
sunDirection: string | null;
artificialLights: string | null;
supervisorNotes: string | null;
continuityNotes: string | null;
vfxRequirements: string | null;
quality: TakeQuality;
attachments: TakeAttachmentData[];
setup: {
takes: { id: string; takeNumber: number }[];
shootDay: { id: string; date: string; unit: string; label: string | null; projectId: string };
};
}
// ─── Quality config ───────────────────────────────────────────────────────────
const QUALITIES: { value: TakeQuality; label: string; color: string; active: string }[] = [
{ value: "FALSE_START", label: "False Start", color: "border-zinc-700 text-zinc-500", active: "bg-zinc-700 text-zinc-200 border-zinc-600" },
{ value: "NO_GOOD", label: "No Good", color: "border-red-900/60 text-red-500", active: "bg-red-900/60 text-red-200 border-red-700" },
{ value: "PRINT", label: "Print", color: "border-amber-800/60 text-amber-500", active: "bg-amber-800/60 text-amber-200 border-amber-600" },
{ value: "GOOD", label: "Good", color: "border-blue-800/60 text-blue-400", active: "bg-blue-900/60 text-blue-200 border-blue-600" },
{ value: "HERO", label: "Hero ★", color: "border-green-800/60 text-green-500", active: "bg-green-900/60 text-green-200 border-green-600" },
];
const TRACKING_ITEMS: { key: keyof FullTake; label: string }[] = [
{ key: "hasHdri", label: "HDRI" },
{ key: "hasChromeBall", label: "Chrome Ball" },
{ key: "hasGreyBall", label: "Grey Ball" },
{ key: "hasMacbeth", label: "Macbeth" },
{ key: "hasCleanPlate", label: "Clean Plate" },
{ key: "hasSurvey", label: "Survey" },
{ key: "hasLidar", label: "LiDAR" },
{ key: "hasWitnessCamera", label: "Witness Cam" },
{ key: "hasLensGrid", label: "Lens Grid" },
{ key: "hasTexturePhotos", label: "Texture Photos" },
{ key: "hasPhotogrammetry", label: "Photogrammetry" },
];
// ─── Autosave hook ────────────────────────────────────────────────────────────
type SaveStatus = "clean" | "dirty" | "saving" | "saved" | "error";
function useAutosave(takeId: string) {
const [status, setStatus] = useState<SaveStatus>("clean");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<Record<string, unknown>>({});
const flush = useCallback(async () => {
if (Object.keys(pendingRef.current).length === 0) return;
const patch = { ...pendingRef.current };
pendingRef.current = {};
setStatus("saving");
try {
const res = await fetch(`/api/shoot-log/takes/${takeId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error("save failed");
setStatus("saved");
setTimeout(() => setStatus("clean"), 2000);
} catch {
setStatus("error");
}
}, [takeId]);
const queue = useCallback(
(patch: Record<string, unknown>) => {
pendingRef.current = { ...pendingRef.current, ...patch };
setStatus("dirty");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(flush, 800);
},
[flush]
);
// Flush immediately when takeId changes
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [takeId]);
return { status, queue, flush };
}
// ─── Field components ─────────────────────────────────────────────────────────
function Field({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<label className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{label}
</label>
{children}
</div>
);
}
function TF({
value,
onChange,
placeholder,
type = "text",
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
className?: string;
}) {
return (
<Input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn("h-9 bg-zinc-900 border-zinc-700 text-sm", className)}
/>
);
}
function SectionHeader({ title }: { title: string }) {
return (
<div className="flex items-center gap-3 py-1">
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-500">{title}</span>
<div className="flex-1 h-px bg-zinc-800" />
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
take: FullTake;
previousTake: FullTake | null;
onPrev: (() => void) | null;
onNext: (() => void) | null;
onDuplicate: () => void;
onNewTake: () => void;
onDelete: () => void;
onAttachmentsChange: () => void;
}
export function TakeEditorPanel({
take,
previousTake,
onPrev,
onNext,
onDuplicate,
onNewTake,
onDelete,
onAttachmentsChange,
}: Props) {
const { status, queue } = useAutosave(take.id);
// Local field state — initialised from take prop, synced when take.id changes
const [fields, setFields] = useState<FullTake>(take);
const prevIdRef = useRef(take.id);
useEffect(() => {
if (take.id !== prevIdRef.current) {
setFields(take);
prevIdRef.current = take.id;
}
}, [take]);
const [showPrevPanel, setShowPrevPanel] = useState(false);
function set<K extends keyof FullTake>(key: K, value: FullTake[K]) {
setFields((f) => ({ ...f, [key]: value }));
queue({ [key]: value });
}
const str = (v: string | null) => v ?? "";
const num = (v: number | null) => (v !== null && v !== undefined ? String(v) : "");
// Keyboard shortcuts
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === "ArrowLeft" && onPrev) onPrev();
if (e.key === "ArrowRight" && onNext) onNext();
if (e.key === "d" && !e.metaKey && !e.ctrlKey) onDuplicate();
if (e.key === "n" && !e.metaKey && !e.ctrlKey) onNewTake();
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onPrev, onNext, onDuplicate, onNewTake]);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── Nav bar ── */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-zinc-800 shrink-0 flex-wrap">
<div className="flex items-center gap-1">
<Button size="icon-sm" variant="ghost" disabled={!onPrev} onClick={onPrev ?? undefined} title="Previous take (←)">
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="icon-sm" variant="ghost" disabled={!onNext} onClick={onNext ?? undefined} title="Next take (→)">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-white">
Setup {take.setup.shootDay.unit} · Take {take.takeNumber}
</h2>
<p className="text-[11px] text-zinc-500">
{new Date(take.setup.shootDay.date).toLocaleDateString("en-GB", {
weekday: "short", day: "numeric", month: "short", year: "numeric",
})}
{take.setup.shootDay.label ? ` · ${take.setup.shootDay.label}` : ""}
</p>
</div>
{/* Save status */}
<div className="shrink-0">
{status === "saving" && <Loader2 className="h-4 w-4 animate-spin text-zinc-500" />}
{status === "saved" && <Check className="h-4 w-4 text-green-500" />}
{status === "dirty" && <span className="h-2 w-2 rounded-full bg-amber-400 inline-block" />}
{status === "error" && <AlertCircle className="h-4 w-4 text-red-500" />}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button size="sm" variant="secondary" onClick={onDuplicate} title="Duplicate take (D)">
<Copy className="h-3.5 w-3.5" />
Dup
</Button>
<Button size="sm" variant="secondary" onClick={onNewTake} title="New take (N)">
<Plus className="h-3.5 w-3.5" />
Take
</Button>
<Button size="icon-sm" variant="destructive" onClick={onDelete} title="Delete take">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* ── Quality bar ── */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-zinc-800 shrink-0 flex-wrap">
{QUALITIES.map((q) => (
<button
key={q.value}
onClick={() => set("quality", q.value)}
className={cn(
"px-4 py-2 rounded-lg border text-xs font-semibold transition-all min-h-[36px]",
fields.quality === q.value ? q.active : q.color
)}
>
{q.label}
</button>
))}
</div>
{/* ── Scrollable form ── */}
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-4 space-y-5 max-w-4xl">
{/* General */}
<SectionHeader title="General" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<Field label="Scene">
<TF value={str(fields.scene)} onChange={(v) => set("scene", v || null)} placeholder="e.g. 12" />
</Field>
<Field label="Shot">
<TF value={str(fields.shotLabel)} onChange={(v) => set("shotLabel", v || null)} placeholder="e.g. 12A" />
</Field>
<Field label="Unit">
<TF value={str(fields.unitLabel)} onChange={(v) => set("unitLabel", v || null)} placeholder="e.g. A" />
</Field>
<Field label="Take #">
<TF value={String(fields.takeNumber)} onChange={() => {}} placeholder="—" className="opacity-60 cursor-default" />
</Field>
</div>
{/* Camera */}
<SectionHeader title="Camera" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<Field label="Camera Letter">
<TF value={str(fields.cameraLetter)} onChange={(v) => set("cameraLetter", v || null)} placeholder="A" />
</Field>
<Field label="Clip Name" className="sm:col-span-2">
<TF value={str(fields.clipName)} onChange={(v) => set("clipName", v || null)} placeholder="A001_C001" />
</Field>
<Field label="Roll">
<TF value={str(fields.roll)} onChange={(v) => set("roll", v || null)} placeholder="A001" />
</Field>
<Field label="Camera Model" className="sm:col-span-2">
<TF value={str(fields.cameraModel)} onChange={(v) => set("cameraModel", v || null)} placeholder="Alexa Mini LF" />
</Field>
<Field label="Resolution">
<TF value={str(fields.resolution)} onChange={(v) => set("resolution", v || null)} placeholder="4.5K" />
</Field>
<Field label="Codec">
<TF value={str(fields.codec)} onChange={(v) => set("codec", v || null)} placeholder="ARRIRAW" />
</Field>
<Field label="FPS">
<TF
type="number"
value={num(fields.fps)}
onChange={(v) => set("fps", v ? Number(v) : null)}
placeholder="24"
/>
</Field>
<Field label="Shutter">
<TF value={str(fields.shutter)} onChange={(v) => set("shutter", v || null)} placeholder="180°" />
</Field>
<Field label="ISO">
<TF
type="number"
value={num(fields.iso)}
onChange={(v) => set("iso", v ? Number(v) : null)}
placeholder="800"
/>
</Field>
<Field label="White Balance">
<TF
type="number"
value={num(fields.whiteBalance)}
onChange={(v) => set("whiteBalance", v ? Number(v) : null)}
placeholder="5600"
/>
</Field>
<Field label="Colour Space" className="sm:col-span-2">
<TF value={str(fields.colourSpace)} onChange={(v) => set("colourSpace", v || null)} placeholder="LogC3 AWG3" />
</Field>
</div>
{/* Lens */}
<SectionHeader title="Lens" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<Field label="Lens Set" className="sm:col-span-2">
<TF value={str(fields.lensSet)} onChange={(v) => set("lensSet", v || null)} placeholder="Cooke S4" />
</Field>
<Field label="Lens">
<TF value={str(fields.lens)} onChange={(v) => set("lens", v || null)} placeholder="50mm" />
</Field>
<Field label="T Stop">
<TF value={str(fields.tStop)} onChange={(v) => set("tStop", v || null)} placeholder="T2.8" />
</Field>
<Field label="Filters" className="sm:col-span-2">
<TF value={str(fields.filters)} onChange={(v) => set("filters", v || null)} placeholder="IRND 0.6" />
</Field>
<Field label="Anamorphic">
<button
onClick={() => set("isAnamorphic", !fields.isAnamorphic)}
className={cn(
"h-9 px-3 rounded-lg border text-xs font-medium transition-colors text-left",
fields.isAnamorphic
? "bg-amber-500/20 border-amber-600 text-amber-300"
: "bg-zinc-900 border-zinc-700 text-zinc-500"
)}
>
{fields.isAnamorphic ? "Yes" : "No"}
</button>
</Field>
</div>
{/* Tracking */}
<SectionHeader title="Tracking" />
<div className="flex flex-wrap gap-2">
{TRACKING_ITEMS.map(({ key, label }) => {
const checked = fields[key] as boolean;
return (
<button
key={key}
onClick={() => set(key as keyof FullTake, !checked as never)}
className={cn(
"px-3 py-2 rounded-lg border text-xs font-medium transition-all min-h-[36px]",
checked
? "bg-amber-500/15 border-amber-600/60 text-amber-300"
: "bg-zinc-900 border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-400"
)}
>
{label}
</button>
);
})}
</div>
{/* Environment */}
<SectionHeader title="Environment" />
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Field label="Weather" className="sm:col-span-1">
<TF value={str(fields.weather)} onChange={(v) => set("weather", v || null)} placeholder="Overcast" />
</Field>
<Field label="Sun Direction">
<TF value={str(fields.sunDirection)} onChange={(v) => set("sunDirection", v || null)} placeholder="NW high" />
</Field>
<Field label="Artificial Lights" className="sm:col-span-3">
<Textarea
value={str(fields.artificialLights)}
onChange={(e) => set("artificialLights", e.target.value || null)}
placeholder="2x HMI 12K softbox, practical tungsten..."
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
/>
</Field>
</div>
{/* Supervisor */}
<SectionHeader title="Supervisor" />
<div className="grid gap-3">
<Field label="Notes">
<Textarea
value={str(fields.supervisorNotes)}
onChange={(e) => set("supervisorNotes", e.target.value || null)}
placeholder="General notes on this take..."
className="bg-zinc-900 border-zinc-700 text-sm min-h-[80px] resize-none"
/>
</Field>
<Field label="Continuity">
<Textarea
value={str(fields.continuityNotes)}
onChange={(e) => set("continuityNotes", e.target.value || null)}
placeholder="Continuity concerns..."
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
/>
</Field>
<Field label="VFX Requirements">
<Textarea
value={str(fields.vfxRequirements)}
onChange={(e) => set("vfxRequirements", e.target.value || null)}
placeholder="Screen replacement, wire removal, creature interaction..."
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
/>
</Field>
</div>
{/* Images */}
<SectionHeader title="Reference Images" />
<TakeImageGallery
takeId={take.id}
attachments={fields.attachments}
onChange={(attachments) => {
setFields((f) => ({ ...f, attachments }));
onAttachmentsChange();
}}
/>
{/* Previous Take comparison */}
{previousTake && (
<div>
<button
onClick={() => setShowPrevPanel((p) => !p)}
className="flex items-center gap-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors mb-2"
>
{showPrevPanel ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
Compare with Take {previousTake.takeNumber}
</button>
{showPrevPanel && (
<PreviousTakePanel current={fields} previous={previousTake} />
)}
</div>
)}
{/* Bottom padding */}
<div className="h-8" />
</div>
</div>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { Upload, X, Loader2, ImageIcon, ZoomIn } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TakeAttachmentData } from "./TakeEditorPanel";
// ─── Lightbox ─────────────────────────────────────────────────────────────────
function Lightbox({
attachments,
index,
onClose,
}: {
attachments: TakeAttachmentData[];
index: number;
onClose: () => void;
}) {
const [current, setCurrent] = useState(index);
const att = attachments[current];
return (
<div
className="fixed inset-0 z-50 bg-black/90 flex flex-col items-center justify-center"
onClick={onClose}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white p-2"
onClick={onClose}
>
<X className="h-6 w-6" />
</button>
{/* Image */}
<div
className="max-w-[90vw] max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={att.fileUrl}
alt={att.fileName}
className="max-w-full max-h-[80vh] object-contain rounded-lg"
/>
</div>
{/* Caption */}
{att.caption && (
<p className="mt-3 text-sm text-zinc-300 max-w-lg text-center">{att.caption}</p>
)}
<p className="mt-1 text-xs text-zinc-500">{att.fileName}</p>
{/* Prev / Next */}
{attachments.length > 1 && (
<div className="flex gap-4 mt-4" onClick={(e) => e.stopPropagation()}>
<button
disabled={current === 0}
onClick={() => setCurrent((c) => c - 1)}
className="px-4 py-2 rounded bg-zinc-800 text-zinc-300 disabled:opacity-30 hover:bg-zinc-700 text-sm"
>
Prev
</button>
<span className="text-zinc-500 text-sm self-center">
{current + 1} / {attachments.length}
</span>
<button
disabled={current === attachments.length - 1}
onClick={() => setCurrent((c) => c + 1)}
className="px-4 py-2 rounded bg-zinc-800 text-zinc-300 disabled:opacity-30 hover:bg-zinc-700 text-sm"
>
Next
</button>
</div>
)}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
takeId: string;
attachments: TakeAttachmentData[];
onChange: (updated: TakeAttachmentData[]) => void;
}
export function TakeImageGallery({ takeId, attachments, onChange }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [dragOver, setDragOver] = useState(false);
const imageAttachments = attachments.filter((a) => a.fileType === "IMAGE");
const uploadFile = useCallback(
async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append("type", "image");
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (!res.ok) throw new Error("Upload failed");
const { url, key } = await res.json();
// Register attachment in DB
const attachRes = await fetch(`/api/shoot-log/takes/${takeId}/attachments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileUrl: url,
fileKey: key ?? "",
fileName: file.name,
fileSize: file.size,
fileType: "IMAGE",
category: "MISCELLANEOUS",
}),
});
if (!attachRes.ok) throw new Error("Failed to save attachment");
const { attachment } = await attachRes.json();
return attachment as TakeAttachmentData;
},
[takeId]
);
const handleFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter((f) => f.type.startsWith("image/"));
if (!imageFiles.length) return;
setUploading(true);
try {
const results = await Promise.all(imageFiles.map(uploadFile));
onChange([...attachments, ...results]);
} finally {
setUploading(false);
}
},
[attachments, onChange, uploadFile]
);
const handleDelete = useCallback(
async (id: string) => {
const res = await fetch(
`/api/shoot-log/takes/${takeId}/attachments/${id}`,
{ method: "DELETE" }
);
if (res.ok) {
onChange(attachments.filter((a) => a.id !== id));
}
},
[attachments, onChange, takeId]
);
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
handleFiles(e.dataTransfer.files);
},
[handleFiles]
);
return (
<div className="space-y-3">
{/* Upload zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
className={cn(
"border-2 border-dashed rounded-lg p-4 text-center transition-colors cursor-pointer",
dragOver
? "border-amber-500 bg-amber-500/5"
: "border-zinc-700 hover:border-zinc-600"
)}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
capture="environment"
className="hidden"
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
{uploading ? (
<div className="flex items-center justify-center gap-2 text-zinc-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Uploading...</span>
</div>
) : (
<div className="flex items-center justify-center gap-2 text-zinc-500">
<Upload className="h-4 w-4" />
<span className="text-sm">
Drop images here, tap to upload, or use camera
</span>
</div>
)}
</div>
{/* Thumbnail grid */}
{imageAttachments.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
{imageAttachments.map((att, i) => (
<div key={att.id} className="relative group aspect-square">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={att.fileUrl}
alt={att.caption ?? att.fileName}
className="w-full h-full object-cover rounded-lg bg-zinc-800 cursor-pointer"
onClick={() => setLightboxIndex(i)}
/>
{/* Overlay */}
<div className="absolute inset-0 rounded-lg bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 flex gap-1 transition-opacity">
<button
onClick={() => setLightboxIndex(i)}
className="p-1.5 rounded bg-black/60 text-white hover:bg-black/80"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(att.id)}
className="p-1.5 rounded bg-black/60 text-red-400 hover:bg-black/80"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Caption badge */}
{att.caption && (
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/60 rounded-b-lg text-[9px] text-zinc-300 truncate">
{att.caption}
</div>
)}
</div>
))}
</div>
)}
{imageAttachments.length === 0 && !uploading && (
<div className="flex items-center gap-2 text-zinc-600 text-xs py-2">
<ImageIcon className="h-4 w-4" />
No images yet
</div>
)}
{/* Lightbox */}
{lightboxIndex !== null && (
<Lightbox
attachments={imageAttachments}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
);
}
+319
View File
@@ -0,0 +1,319 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { Plus, ChevronDown, ChevronRight, Image, MessageSquare, Loader2, Clapperboard } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { TakeQuality } from "@prisma/client";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface TakeSummary {
id: string;
takeNumber: number;
clipName: string | null;
quality: TakeQuality;
supervisorNotes: string | null;
continuityNotes: string | null;
vfxRequirements: string | null;
_count: { attachments: number };
}
export interface SetupWithTakes {
id: string;
name: string;
description: string | null;
sortOrder: number;
takes: TakeSummary[];
}
export interface ShootDayWithSetups {
id: string;
date: string;
unit: string;
label: string | null;
setups: SetupWithTakes[];
}
// ─── Quality helpers ──────────────────────────────────────────────────────────
const QUALITY_DOT: Record<TakeQuality, string> = {
HERO: "bg-green-400",
GOOD: "bg-blue-400",
PRINT: "bg-amber-400",
NO_GOOD: "bg-red-500",
FALSE_START: "bg-zinc-500",
};
const QUALITY_LABEL: Record<TakeQuality, string> = {
HERO: "Hero",
GOOD: "Good",
PRINT: "Print",
NO_GOOD: "NG",
FALSE_START: "FS",
};
// ─── Component ───────────────────────────────────────────────────────────────
interface Props {
projectId: string;
selectedTakeId: string | null;
onSelectTake: (takeId: string, setupId: string) => void;
onNewDay: () => void;
onNewSetup: (dayId: string) => void;
onNewTake: (setupId: string) => void;
refreshToken?: number;
}
export function TakeListPanel({
projectId,
selectedTakeId,
onSelectTake,
onNewDay,
onNewSetup,
onNewTake,
refreshToken,
}: Props) {
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
const [expandedSetups, setExpandedSetups] = useState<Set<string>>(new Set());
const autoExpandedRef = useRef(false);
const { data, isLoading } = useQuery<{ days: ShootDayWithSetups[] }>({
queryKey: ["shoot-log-days", projectId, refreshToken],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/days?projectId=${projectId}`);
if (!res.ok) throw new Error("Failed to load days");
return res.json();
},
enabled: !!projectId,
staleTime: 30_000,
});
const days = data?.days ?? [];
const toggleDay = useCallback((id: string) => {
setExpandedDays((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSetup = useCallback((id: string) => {
setExpandedSetups((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
// Auto-expand the first day/setup on first load
useEffect(() => {
if (!autoExpandedRef.current && days.length > 0) {
autoExpandedRef.current = true;
const firstDay = days[0];
setExpandedDays(new Set([firstDay.id]));
if (firstDay.setups.length > 0) {
setExpandedSetups(new Set([firstDay.setups[0].id]));
}
}
}, [days]);
const hasNotes = (take: TakeSummary) =>
!!(take.supervisorNotes || take.continuityNotes || take.vfxRequirements);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-3 py-3 border-b border-zinc-800 flex items-center justify-between gap-2 shrink-0">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Shoot Days
</span>
<Button size="icon-sm" variant="ghost" onClick={onNewDay} title="New Shoot Day">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : days.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center gap-3">
<Clapperboard className="h-8 w-8 text-zinc-600" />
<p className="text-sm text-zinc-500">No shoot days yet.</p>
<Button size="sm" variant="outline" onClick={onNewDay}>
<Plus className="h-3 w-3 mr-1" />
New Shoot Day
</Button>
</div>
) : (
<div className="py-2">
{days.map((day) => {
const dayOpen = expandedDays.has(day.id);
const takeCount = day.setups.reduce((s, set) => s + set.takes.length, 0);
return (
<div key={day.id}>
{/* Day row */}
<button
onClick={() => toggleDay(day.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-zinc-800/60 transition-colors group"
>
{dayOpen ? (
<ChevronDown className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-zinc-200 truncate">
{format(new Date(day.date), "EEE d MMM yyyy")}
{day.unit && day.unit !== "A" && (
<span className="ml-1 text-zinc-500">· Unit {day.unit}</span>
)}
</div>
{day.label && (
<div className="text-[11px] text-zinc-500 truncate">{day.label}</div>
)}
</div>
<span className="text-[10px] text-zinc-600 shrink-0">{takeCount}t</span>
<Button
size="icon-sm"
variant="ghost"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onNewSetup(day.id);
}}
title="New Setup"
>
<Plus className="h-3 w-3" />
</Button>
</button>
{/* Setups */}
{dayOpen && (
<div>
{day.setups.length === 0 ? (
<div className="pl-8 pr-3 py-1">
<button
onClick={() => onNewSetup(day.id)}
className="text-[11px] text-zinc-600 hover:text-zinc-400 transition-colors"
>
+ Add setup
</button>
</div>
) : (
day.setups.map((setup) => {
const setupOpen = expandedSetups.has(setup.id);
return (
<div key={setup.id}>
{/* Setup row */}
<button
onClick={() => toggleSetup(setup.id)}
className="w-full flex items-center gap-2 pl-6 pr-3 py-1.5 text-left hover:bg-zinc-800/40 transition-colors group"
>
{setupOpen ? (
<ChevronDown className="h-3 w-3 text-zinc-600 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-zinc-600 shrink-0" />
)}
<span className="flex-1 text-xs font-medium text-zinc-300 truncate">
Setup {setup.name}
</span>
<span className="text-[10px] text-zinc-600 shrink-0">
{setup.takes.length}t
</span>
<Button
size="icon-sm"
variant="ghost"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onNewTake(setup.id);
}}
title="New Take"
>
<Plus className="h-3 w-3" />
</Button>
</button>
{/* Takes */}
{setupOpen && (
<div>
{setup.takes.map((take) => (
<button
key={take.id}
onClick={() => onSelectTake(take.id, setup.id)}
className={cn(
"w-full flex items-center gap-2 pl-10 pr-3 py-2 text-left transition-colors",
selectedTakeId === take.id
? "bg-amber-500/10 text-amber-300"
: "hover:bg-zinc-800/40 text-zinc-300"
)}
>
{/* Quality dot */}
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
QUALITY_DOT[take.quality]
)}
/>
{/* Take number */}
<span className="text-[11px] font-mono text-zinc-500 w-5 shrink-0">
T{take.takeNumber}
</span>
{/* Clip name */}
<span
className={cn(
"flex-1 text-xs truncate",
selectedTakeId === take.id
? "text-amber-200"
: "text-zinc-300"
)}
>
{take.clipName ?? (
<span className="text-zinc-600 italic">No clip name</span>
)}
</span>
{/* Indicators */}
<div className="flex items-center gap-1 shrink-0">
{hasNotes(take) && (
<MessageSquare className="h-3 w-3 text-zinc-500" />
)}
{take._count.attachments > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-zinc-500">
<Image className="h-3 w-3" />
{take._count.attachments}
</span>
)}
</div>
</button>
))}
{/* Add take button */}
<button
onClick={() => onNewTake(setup.id)}
className="w-full pl-10 pr-3 py-1.5 text-left text-[11px] text-zinc-600 hover:text-zinc-400 transition-colors"
>
+ New take
</button>
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+163
View File
@@ -113,6 +113,37 @@ enum ProjectType {
EPISODIC EPISODIC
} }
enum TakeQuality {
HERO
GOOD
PRINT
NO_GOOD
FALSE_START
}
enum AttachmentFileType {
IMAGE
VIDEO
HDRI
LIDAR
LENS_GRID
PDF
OTHER
}
enum AttachmentCategory {
SLATE
CAMERA_POSITION
WIDE_REFERENCE
LENS
LIGHTING
HDRI
TRACKING
TEXTURE
WITNESS
MISCELLANEOUS
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// AUTH MODELS (NextAuth v5 compatible) // AUTH MODELS (NextAuth v5 compatible)
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
@@ -149,6 +180,9 @@ model User {
createdTasks Task[] @relation("TaskCreator") createdTasks Task[] @relation("TaskCreator")
sharedVersions Version[] @relation("VersionSharedBy") sharedVersions Version[] @relation("VersionSharedBy")
ledAssets Asset[] @relation("AssetLead") ledAssets Asset[] @relation("AssetLead")
createdShootDays ShootDay[] @relation("ShootDayCreator")
createdTakes Take[] @relation("TakeCreator")
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
@@map("users") @@map("users")
} }
@@ -256,6 +290,7 @@ model Project {
shotGroups ShotGroup[] shotGroups ShotGroup[]
reviewSessions ReviewSession[] reviewSessions ReviewSession[]
episodeDueDates EpisodeDueDate[] episodeDueDates EpisodeDueDate[]
shootDays ShootDay[]
@@map("projects") @@map("projects")
} }
@@ -322,6 +357,7 @@ model Shot {
versions Version[] versions Version[]
tasks Task[] tasks Task[]
footagePlates FootagePlate[] footagePlates FootagePlate[]
loggedTakes Take[] @relation("TakeToShot")
@@unique([projectId, shotCode]) @@unique([projectId, shotCode])
@@map("shots") @@map("shots")
@@ -571,3 +607,130 @@ model DisplayEvent {
@@index([createdAt]) @@index([createdAt])
@@map("display_events") @@map("display_events")
} }
// ─────────────────────────────────────────────
// SHOOT LOG MODELS
// ─────────────────────────────────────────────
model ShootDay {
id String @id @default(cuid())
projectId String
date DateTime
unit String @default("A")
label String?
notes String? @db.Text
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull)
setups Setup[]
@@map("shoot_days")
}
model Setup {
id String @id @default(cuid())
shootDayId String
name String
description String? @db.Text
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shootDay ShootDay @relation(fields: [shootDayId], references: [id], onDelete: Cascade)
takes Take[]
@@map("setups")
}
model Take {
id String @id @default(cuid())
setupId String
takeNumber Int
// Optional link back to a pipeline shot
pipelineShotId String?
// General
scene String?
shotLabel String?
unitLabel String?
// Camera
cameraLetter String?
clipName String?
roll String?
cameraModel String?
resolution String?
codec String?
fps Float?
shutter String?
iso Int?
whiteBalance Int?
colourSpace String?
// Lens
lensSet String?
lens String?
tStop String?
filters String?
isAnamorphic Boolean @default(false)
// Tracking
hasHdri Boolean @default(false)
hasChromeBall Boolean @default(false)
hasGreyBall Boolean @default(false)
hasMacbeth Boolean @default(false)
hasCleanPlate Boolean @default(false)
hasSurvey Boolean @default(false)
hasLidar Boolean @default(false)
hasWitnessCamera Boolean @default(false)
hasLensGrid Boolean @default(false)
hasTexturePhotos Boolean @default(false)
hasPhotogrammetry Boolean @default(false)
// Environment
weather String?
sunDirection String?
artificialLights String? @db.Text
// Supervisor
supervisorNotes String? @db.Text
continuityNotes String? @db.Text
vfxRequirements String? @db.Text
quality TakeQuality @default(PRINT)
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
setup Setup @relation(fields: [setupId], references: [id], onDelete: Cascade)
pipelineShot Shot? @relation("TakeToShot", fields: [pipelineShotId], references: [id], onDelete: SetNull)
createdBy User? @relation("TakeCreator", fields: [createdById], references: [id], onDelete: SetNull)
attachments TakeAttachment[]
@@unique([setupId, takeNumber])
@@map("takes")
}
model TakeAttachment {
id String @id @default(cuid())
takeId String
fileUrl String
fileKey String @default("")
fileName String
fileSize BigInt?
fileType AttachmentFileType @default(IMAGE)
category AttachmentCategory @default(MISCELLANEOUS)
caption String?
sortOrder Int @default(0)
uploadedById String?
createdAt DateTime @default(now())
take Take @relation(fields: [takeId], references: [id], onDelete: Cascade)
uploadedBy User? @relation("TakeAttachmentUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
@@map("take_attachments")
}