@@ -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.
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 + '/');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user