Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 633301c491 | |||
| 83aad38053 | |||
| a4d5777e1d | |||
| 3a7ba689ed | |||
| 41cf79abbe | |||
| 7a7088f2ad | |||
| 0aedc4b1fa | |||
| d7e37de18f |
+1
-1
@@ -23,7 +23,7 @@ Dockerfile*
|
||||
.github
|
||||
migrations
|
||||
README.md
|
||||
uploads
|
||||
uploads*
|
||||
.git
|
||||
*.tar
|
||||
*.sql
|
||||
@@ -542,5 +542,4 @@ For reference, the endpoints called by `VFXReviewConnector.jsx`:
|
||||
| Retry Export | `POST /api/ext/exports/{id}/retry` (E14) |
|
||||
| Regenerate Previews | `POST /api/ext/exports/{id}/regenerate-preview` |
|
||||
| Load QC Queue | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5) |
|
||||
| Build QC Review Session | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5): uses `scene`, `episode`, `seqTimecodeStart`, `handleHead`, `handleTail` |
|
||||
| QC Pass / QC Fail | `POST /api/ext/exports/{id}/qc` (E4) |
|
||||
|
||||
+15
-1201
File diff suppressed because it is too large
Load Diff
@@ -245,7 +245,6 @@ shot record, and submits it to the farm.
|
||||
|---|---|
|
||||
| **Urgent** | Priority 20 — claimable outside the render window, ahead of normal jobs |
|
||||
| **No preview** | Renders the EXRs but builds no preview, inheriting the previous export's MOV, review MP4 and thumbnail |
|
||||
| **Keep AEP** | Saves a reduced copy of the project in the export's version folder (see below) |
|
||||
|
||||
**No preview** exists for deliverables framed differently from the review —
|
||||
unscaled or un-punched-in EXRs. A preview rebuilt from those frames would be
|
||||
@@ -256,37 +255,6 @@ package still gets a MOV.
|
||||
Leave it unticked for an ordinary submission: the preview is what QC and the
|
||||
client review see.
|
||||
|
||||
#### Export AEP (Keep AEP)
|
||||
|
||||
With **Keep AEP** ticked (the default, remembered per machine), every export
|
||||
keeps a copy of the project it was queued from, so the exact comp behind a
|
||||
render can be reopened later:
|
||||
|
||||
```
|
||||
{version folder}/{shotCode}_cmp_TT_{version}.aep e.g. .../UNG_102_019_010/v005/UNG_102_019_010_cmp_TT_v005.aep
|
||||
```
|
||||
|
||||
- The copy is **reduced** to the exported comp and everything it uses (nested
|
||||
comps, footage, solids). Everything else in the project is dropped.
|
||||
- Nothing is collected. Plates and EXRs stay where they are and the copy links
|
||||
to them by path, so it stays a few MB.
|
||||
- It is saved after the export is submitted, into the same version folder the
|
||||
farm renders into. The worker only reads and clears `*.exr` files there, so
|
||||
the `.aep` is never touched. Re-queuing the same version overwrites it, just
|
||||
as the render overwrites the EXRs.
|
||||
- AE can only reduce the open project, so the panel does Save As, Reduce Project,
|
||||
Save, and then **reopens your project**. Your project was saved first and is
|
||||
never changed, but the reopen clears undo. Untick **Keep AEP** to skip it.
|
||||
- The copy is the project as it was when you queued. The farm still renders
|
||||
your working AEP, so a save made before the job starts goes into the render
|
||||
but not into the copy.
|
||||
- Reduce Project keeps only comps that are used as layers. An expression that
|
||||
points at a comp with `comp("Name")`, where that comp isn't nested anywhere,
|
||||
will break in the copy.
|
||||
|
||||
If the copy can't be saved, the export is still queued and the status line says
|
||||
`AEP not saved: …` with the reason.
|
||||
|
||||
---
|
||||
|
||||
## QC
|
||||
@@ -299,63 +267,10 @@ QC is per-export: a new export always needs new QC.
|
||||
|---|---|
|
||||
| **Load QC Queue** | Lists every export in `READY_FOR_QC` for the selected project, oldest first. Entries flagged with a warning count had a non-blocking validation warning worth a closer look. |
|
||||
| **Open Render** (or double-click) | Imports that export's actual EXR sequence into a `QC_{shot}_{version}` comp at full res and opens it. Re-selecting an already-imported shot just reopens the comp. |
|
||||
| **Compare Versions** | Opens the QC comp and stacks earlier versions of the shot under the export, one layer each (`v003`, `v002`…), lined up by EXR timecode (or frame number). Solo a layer to see that version, or set the top layer to **Difference** to see exactly what changed. With more than one earlier version, a dialog lets you untick the ones you don't need. Versions already in the comp are skipped. |
|
||||
| **Build QC Review Session** | Lays out the whole project's QC pass in one go. Every shot in the queue gets its `QC_{shot}_{version}` comp, and each scene gets an edit and a contact sheet. See *QC review session* below. |
|
||||
| **Notes** | Sent with the decision. Optional on pass; a fail with no notes asks for confirmation, because the artist only sees what you write here. |
|
||||
| **QC Pass** | Marks the version **ready for delivery** and nothing else — nothing is shared to the client, no approval state changes, no task moves. |
|
||||
| **QC Fail** | Sets the export to `QC_FAILED` and the comp task to **CHANGES**. The artist fixes and queues a new version; the old export is superseded. |
|
||||
|
||||
**Where Compare Versions looks:** next to the export's own render folder (resolved
|
||||
as for Open Render), in both on-disk layouts. That means farm version folders
|
||||
(`{shot}/v002/`, `{shot}/v003/`…) and the older flat folder with
|
||||
`{shot}_cmp_TT_{ver}.#####.exr` side by side. Only versions numbered below the
|
||||
export's are used. If earlier exports were written under a different root (see
|
||||
*More than one on-disk convention*), they won't be found.
|
||||
|
||||
### QC review session
|
||||
|
||||
**Build QC Review Session** loads the queue for the selected project. A dialog
|
||||
lists its scenes. Ctrl-click a scene to leave it out, or untick *Also import
|
||||
earlier versions*. Everything is built in one undo step, under
|
||||
`QC_SESSION_{project}` in the Project panel:
|
||||
|
||||
| Item | What it is |
|
||||
|---|---|
|
||||
| `{scene}/shots/QC_{shot}_{version}` | One comp per shot, reusing the comp from Open Render if there is one. The export sits on top, earlier versions under it (`v003`, `v002`…), and the shot's plates at the bottom, found as Build Shot finds them. **Only the export and the main plate (the longest) are switched on.** |
|
||||
| `{scene}/QC_EDIT_{scene}` | The scene's shot comps as one continuous edit. Each shot is cut to the edit, with the handles the render carries trimmed off, and has a layer marker with its name. |
|
||||
| `{scene}/QC_CONTACT_{scene}` | Every shot of the scene in a grid, each starting at its cut in, with its name under it. |
|
||||
| `_footage` | The EXR sequences it imported. Plates already in the project are reused, not imported again. |
|
||||
|
||||
A scene is the shot record's scene, which is not always the scene in the shot
|
||||
code. Episodic shows group by episode too (`UNG_109_SC013`), and standard ones
|
||||
by show (`DH2_VFX_SC033`). Shots run in sequence-timecode order when every shot
|
||||
in the scene has one, and in shot-code order otherwise. Only the newest queued
|
||||
version of a shot gets a comp; older queued versions appear as earlier versions
|
||||
in it.
|
||||
|
||||
Plates and earlier versions are lined up with the export by EXR timecode. A
|
||||
timecode more than a shot's length away is ignored, because a shot's elements
|
||||
can carry unrelated camera timecodes. The file frame number is used next, then
|
||||
the export's first frame. Exports from before handles were recorded have no
|
||||
handle count, so they play in full in the edit, and the status line counts them.
|
||||
|
||||
Running the button again adds anything new to the shot comps and rebuilds the
|
||||
scene edits and contact sheets from scratch. Don't do your own work in those
|
||||
two comps.
|
||||
|
||||
**Approving from the session.** QC Pass and QC Fail act on the shot in front of
|
||||
you, without finding it in the list:
|
||||
|
||||
- the `QC_{shot}_{version}` comp in the viewer, or
|
||||
- the shot layer (or its label) selected in a scene edit or contact sheet, or
|
||||
- with nothing selected in a scene edit, the shot under the playhead.
|
||||
|
||||
The confirmation names the shot, and a fail with notes is confirmed too when it
|
||||
came from the viewer. If a scene comp is active and no shot is picked, nothing
|
||||
is sent. Pick a shot, or switch to another comp to use the list selection. After
|
||||
a decision, that shot's comp and its layers in the scene comps are labelled
|
||||
**green** (pass) or **red** (fail).
|
||||
|
||||
Passing an export is what makes it eligible for a delivery package on the web
|
||||
app's **Pipeline → Deliveries** page.
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getUnreadCommentsForShot,
|
||||
getUnreadCommentSummaries,
|
||||
markShotCommentsRead as markRead,
|
||||
} from "@/lib/comment-reads";
|
||||
|
||||
export async function listUnreadShotComments(shotId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
return getUnreadCommentsForShot(session.user.id, z.string().min(1).parse(shotId));
|
||||
}
|
||||
|
||||
const markSchema = z.object({
|
||||
shotId: z.string().min(1),
|
||||
upTo: z.string().datetime(),
|
||||
});
|
||||
|
||||
export async function markShotCommentsRead(shotId: string, upTo: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
const parsed = markSchema.parse({ shotId, upTo });
|
||||
await markRead(session.user.id, parsed.shotId, new Date(parsed.upTo));
|
||||
revalidatePath("/shot-status");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Mark every flagged shot in a project read, up to what each one had when checked. */
|
||||
export async function markProjectCommentsRead(projectId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
const summaries = await getUnreadCommentSummaries(session.user.id, z.string().min(1).parse(projectId));
|
||||
for (const [shotId, s] of Object.entries(summaries)) {
|
||||
await markRead(session.user.id, shotId, new Date(s.latestAt));
|
||||
}
|
||||
revalidatePath("/shot-status");
|
||||
return { success: true, count: Object.keys(summaries).length };
|
||||
}
|
||||
+13
-11
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
|
||||
import { notifyCommentsAdded, notifyCommentReply } from "@/lib/notifications";
|
||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||
import { createDisplayEvent } from "@/lib/display-events";
|
||||
|
||||
@@ -36,7 +36,6 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the shot artist if commenter is not the artist
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: parsed.versionId },
|
||||
include: {
|
||||
@@ -56,16 +55,19 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
|
||||
version?.task?.title ??
|
||||
"Task";
|
||||
|
||||
if (version?.shot?.artistId && version.shot.artistId !== session.user.id) {
|
||||
const user = await db.user.findUnique({ where: { id: session.user.id } });
|
||||
await notifyFeedbackAdded({
|
||||
artistId: version.shot.artistId,
|
||||
shotCode: version.shot.shotCode,
|
||||
frameNumber: parsed.frameNumber,
|
||||
// Staff and the shot's artist — never the commenter. A failed notification
|
||||
// must not fail a comment that is already saved.
|
||||
const user = await db.user.findUnique({ where: { id: session.user.id }, select: { name: true } });
|
||||
await notifyCommentsAdded({
|
||||
notes: [{
|
||||
versionId: parsed.versionId,
|
||||
authorName: user?.name ?? session.user.email ?? "Someone",
|
||||
});
|
||||
}
|
||||
shotCode,
|
||||
frameNumber: parsed.frameNumber,
|
||||
artistIds: [version?.shot?.artistId, version?.task?.assignedArtistId],
|
||||
}],
|
||||
authorId: session.user.id,
|
||||
authorName: user?.name ?? session.user.email ?? "Someone",
|
||||
}).catch((err) => console.error("[comments] notify failed", err));
|
||||
|
||||
// Slack notification
|
||||
if (slackWebhook) {
|
||||
|
||||
+26
-2
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse";
|
||||
import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse";
|
||||
import { updateTaskStatus } from "@/actions/tasks";
|
||||
import { notifyCommentsAdded, type NewCommentNote } from "@/lib/notifications";
|
||||
|
||||
/** Comment.source for notes pasted from a client message */
|
||||
const SOURCE = "WHATSAPP";
|
||||
@@ -176,7 +177,13 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
|
||||
const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])];
|
||||
const versions = await db.version.findMany({
|
||||
where: { id: { in: versionIds } },
|
||||
select: { id: true, fps: true, shotId: true, task: { select: { id: true, shotId: true, status: true, projectId: true } }, shot: { select: { projectId: true } } },
|
||||
select: {
|
||||
id: true,
|
||||
fps: true,
|
||||
shotId: true,
|
||||
task: { select: { id: true, shotId: true, status: true, projectId: true, assignedArtistId: true, shot: { select: { shotCode: true } } } },
|
||||
shot: { select: { projectId: true, shotCode: true, artistId: true } },
|
||||
},
|
||||
});
|
||||
const vById = new Map(versions.map((v) => [v.id, v]));
|
||||
for (const n of notes) {
|
||||
@@ -195,6 +202,7 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
|
||||
let sort = (last?.sortOrder ?? -1) + 1;
|
||||
|
||||
let comments = 0;
|
||||
const saved: NewCommentNote[] = [];
|
||||
await db.$transaction(async (tx) => {
|
||||
for (const r of parsed.rounds) {
|
||||
const round = await tx.feedbackRound.create({
|
||||
@@ -221,7 +229,15 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (comment) comments++;
|
||||
if (comment && v) {
|
||||
comments++;
|
||||
saved.push({
|
||||
versionId: v.id,
|
||||
shotCode: v.shot?.shotCode ?? v.task?.shot?.shotCode ?? n.code.toUpperCase(),
|
||||
frameNumber: frame,
|
||||
artistIds: [v.shot?.artistId, v.task?.assignedArtistId],
|
||||
});
|
||||
}
|
||||
await tx.boardItem.create({
|
||||
data: {
|
||||
projectId: parsed.projectId,
|
||||
@@ -247,6 +263,14 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
|
||||
}
|
||||
for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES");
|
||||
|
||||
// Only after the transaction commits — a rolled-back paste notifies no one.
|
||||
await notifyCommentsAdded({
|
||||
notes: saved,
|
||||
authorId: user.id,
|
||||
authorName: user.name ?? user.email ?? "Someone",
|
||||
origin: "whatsapp",
|
||||
}).catch((err) => console.error("[feedback] notify failed", err));
|
||||
|
||||
for (const id of versionIds) revalidatePath(`/review/${id}`);
|
||||
revalidatePath("/board");
|
||||
return { success: true, notes: notes.length, comments, tasksChanged: tasks.size };
|
||||
|
||||
@@ -83,6 +83,9 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
const [projectId, setProjectId] = useState<string>("");
|
||||
const [episode, setEpisode] = useState<string>("");
|
||||
const [building, setBuilding] = useState(false);
|
||||
// Tracked as exclusions so every ready shot — including one that passes QC
|
||||
// while the page is open — starts selected.
|
||||
const [deselected, setDeselected] = useState<Set<string>>(() => new Set());
|
||||
const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null);
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
@@ -116,14 +119,36 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
async function build(ep: string) {
|
||||
if (!projectId) return;
|
||||
function toggleExport(exportId: string, selected: boolean) {
|
||||
setDeselected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selected) next.delete(exportId);
|
||||
else next.add(exportId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setEpisodeSelected(exportIds: string[], selected: boolean) {
|
||||
setDeselected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of exportIds) {
|
||||
if (selected) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function build(ep: string, exportIds: string[]) {
|
||||
if (!projectId || exportIds.length === 0) return;
|
||||
setBuilding(true);
|
||||
try {
|
||||
// Always send the explicit list: the package holds exactly the shots
|
||||
// ticked on screen, never one that turned ready since the page loaded.
|
||||
const res = await fetch("/api/deliveries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId, episode: ep || null }),
|
||||
body: JSON.stringify({ projectId, episode: ep || null, exportIds }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
@@ -179,6 +204,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
onChange={(e) => {
|
||||
setProjectId(e.target.value);
|
||||
setEpisode("");
|
||||
setDeselected(new Set());
|
||||
}}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
|
||||
>
|
||||
@@ -219,30 +245,69 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
Nothing has passed QC yet. Exports appear here once QC passes them.
|
||||
</div>
|
||||
)}
|
||||
{shown.map((ep) => (
|
||||
{shown.map((ep) => {
|
||||
const allIds = ep.exports.map((x) => x.exportId);
|
||||
const selectedIds = allIds.filter((id) => !deselected.has(id));
|
||||
const allSelected = selectedIds.length === allIds.length;
|
||||
const selectedExports = ep.exports.filter((x) => !deselected.has(x.exportId));
|
||||
const missingChecksum = selectedExports.filter((x) => !x.hasChecksum).length;
|
||||
return (
|
||||
<div key={ep.episode} className="border-b border-zinc-800/60 last:border-0">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 flex-wrap">
|
||||
<div>
|
||||
<span className="text-white text-sm font-medium">
|
||||
{ep.episode ? `Episode ${ep.episode}` : "No episode"}
|
||||
</span>
|
||||
<span className="ml-3 text-sm text-zinc-400">Ready: {ep.count} shots</span>
|
||||
{ep.exports.some((x) => !x.hasChecksum) && (
|
||||
<span className="ml-3 text-sm text-zinc-400">
|
||||
{canBuild ? `Selected: ${selectedIds.length} of ${ep.count} shots` : `Ready: ${ep.count} shots`}
|
||||
</span>
|
||||
{canBuild && selectedIds.length > 0 && (
|
||||
<span className="ml-3 text-xs text-zinc-500">
|
||||
{formatBytes(selectedExports.reduce((sum, x) => sum + (x.totalBytes ?? 0), 0))}
|
||||
</span>
|
||||
)}
|
||||
{missingChecksum > 0 && (
|
||||
<span className="ml-3 text-xs text-amber-400">
|
||||
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
|
||||
{missingChecksum} without a validation checksum
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canBuild && (
|
||||
<Button size="sm" onClick={() => build(ep.episode)} disabled={building}>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Build delivery
|
||||
</Button>
|
||||
<span className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEpisodeSelected(allIds, !allSelected)}
|
||||
>
|
||||
{allSelected ? "Deselect all" : "Select all"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => build(ep.episode, selectedIds)}
|
||||
disabled={building || selectedIds.length === 0}
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Build delivery ({selectedIds.length})
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 pb-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{ep.exports.map((x) => (
|
||||
<div key={x.exportId} className="flex items-center gap-2 text-xs">
|
||||
{ep.exports.map((x) => {
|
||||
const selected = !deselected.has(x.exportId);
|
||||
return (
|
||||
<div
|
||||
key={x.exportId}
|
||||
className={cn("flex items-center gap-2 text-xs", canBuild && !selected && "opacity-50")}
|
||||
>
|
||||
{canBuild && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => toggleExport(x.exportId, e.target.checked)}
|
||||
aria-label={`Include ${x.shotCode} in the package`}
|
||||
/>
|
||||
)}
|
||||
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
|
||||
{x.shotCode}
|
||||
</Link>
|
||||
@@ -251,10 +316,12 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
|
||||
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TaskCard } from "@/components/tasks/TaskCard";
|
||||
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
|
||||
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sortShotsForList } from "@/lib/shot-order";
|
||||
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight, Calendar, Pencil, Check, X } from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
|
||||
@@ -147,13 +148,7 @@ export function ProjectTabsClient({
|
||||
// For episodic projects: group and sort shots by episode → scene → shotNumber
|
||||
const episodeGroups: [string, ShotWithDetails[]][] = projectType === "EPISODIC"
|
||||
? (() => {
|
||||
const sorted = [...shots].sort((a, b) => {
|
||||
const ea = a.episode ?? "";
|
||||
const eb = b.episode ?? "";
|
||||
if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true });
|
||||
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
|
||||
return a.shotNumber - b.shotNumber;
|
||||
});
|
||||
const sorted = sortShotsForList(shots, projectType);
|
||||
const map = new Map<string, ShotWithDetails[]>();
|
||||
for (const shot of sorted) {
|
||||
const key = shot.episode ?? "(No Episode)";
|
||||
@@ -167,13 +162,7 @@ export function ProjectTabsClient({
|
||||
// For standard projects: group by custom shot group when any exist
|
||||
const standardGroups: [string, ShotWithDetails[]][] = projectType === "STANDARD" && shots.some((s) => s.shotGroup)
|
||||
? (() => {
|
||||
const sorted = [...shots].sort((a, b) => {
|
||||
const ga = a.shotGroup?.name ?? "\uFFFF";
|
||||
const gb = b.shotGroup?.name ?? "\uFFFF";
|
||||
if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true });
|
||||
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
|
||||
return a.shotNumber - b.shotNumber;
|
||||
});
|
||||
const sorted = sortShotsForList(shots, projectType);
|
||||
const map = new Map<string, ShotWithDetails[]>();
|
||||
for (const shot of sorted) {
|
||||
const key = shot.shotGroup?.name ?? "(Ungrouped)";
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
Pencil,
|
||||
Check,
|
||||
LayoutList,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
@@ -78,12 +80,47 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
|
||||
type ShotNavTarget = { id: string; shotCode: string } | null;
|
||||
type ShotNav = { prev: ShotNavTarget; next: ShotNavTarget; position: number; total: number };
|
||||
|
||||
function ShotNavLink({
|
||||
projectId,
|
||||
target,
|
||||
direction,
|
||||
}: {
|
||||
projectId: string;
|
||||
target: ShotNavTarget;
|
||||
direction: "prev" | "next";
|
||||
}) {
|
||||
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
|
||||
const label = direction === "prev" ? "Previous shot" : "Next shot";
|
||||
if (!target) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" disabled className="h-7 gap-1 px-2 font-mono text-xs">
|
||||
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
|
||||
<span className="hidden sm:inline">{direction === "prev" ? "Prev" : "Next"}</span>
|
||||
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild className="h-7 gap-1 px-2 font-mono text-xs">
|
||||
<Link href={`/projects/${projectId}/shots/${target.id}`} title={`${label}: ${target.shotCode}`}>
|
||||
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
|
||||
<span className="hidden sm:inline max-w-[10rem] truncate">{target.shotCode}</span>
|
||||
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShotDetailPage() {
|
||||
const params = useParams<{ id: string; shotId: string }>();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [shot, setShot] = useState<ShotWithDetails | null>(null);
|
||||
const [projectName, setProjectName] = useState<string>("");
|
||||
const [shotNav, setShotNav] = useState<ShotNav | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [canApprove, setCanApprove] = useState(false);
|
||||
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
|
||||
@@ -108,6 +145,7 @@ export default function ShotDetailPage() {
|
||||
const data = await res.json();
|
||||
setShot(data.shot);
|
||||
setProjectName(data.projectName ?? "");
|
||||
setShotNav(data.shotNav ?? null);
|
||||
setCanApprove(data.canApprove ?? false);
|
||||
setCanInternallyApprove(data.canInternallyApprove ?? false);
|
||||
setTasks(data.tasks ?? []);
|
||||
@@ -242,6 +280,17 @@ export default function ShotDetailPage() {
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-mono">{shot.shotCode}</span>
|
||||
|
||||
{/* Prev / next shot, in the project page's list order */}
|
||||
{shotNav && shotNav.total > 1 && (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ShotNavLink projectId={params.id} target={shotNav.prev} direction="prev" />
|
||||
<span className="px-1 text-xs tabular-nums">
|
||||
{shotNav.position} / {shotNav.total}
|
||||
</span>
|
||||
<ShotNavLink projectId={params.id} target={shotNav.next} direction="next" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { AlertCircle, Check, Loader2, MessageSquareReply } from "lucide-react";
|
||||
import { listUnreadShotComments, markShotCommentsRead } from "@/actions/comment-reads";
|
||||
import type { UnreadCommentItem } from "@/lib/comment-reads";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
/**
|
||||
* The "!" beside a shot code while it has comments this user has not marked
|
||||
* read. Opening it lists them; they stay flagged until "Mark read" is clicked —
|
||||
* opening the list or the shot is not enough.
|
||||
*/
|
||||
export function NewCommentsMarker({
|
||||
shotId,
|
||||
shotCode,
|
||||
count,
|
||||
latestAt,
|
||||
onRead,
|
||||
}: {
|
||||
shotId: string;
|
||||
shotCode: string;
|
||||
count: number;
|
||||
latestAt: string;
|
||||
onRead: (shotId: string) => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<UnreadCommentItem[] | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [marking, startMarking] = useTransition();
|
||||
|
||||
const load = async () => {
|
||||
setLoadError(false);
|
||||
try {
|
||||
setItems(await listUnreadShotComments(shotId));
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const markRead = () =>
|
||||
startMarking(async () => {
|
||||
// Read up to what was listed; with the list not loaded, up to what the
|
||||
// board showed. Anything newer stays flagged.
|
||||
const upTo = items?.[0]?.createdAt ?? latestAt;
|
||||
try {
|
||||
await markShotCommentsRead(shotId, upTo);
|
||||
setOpen(false);
|
||||
onRead(shotId);
|
||||
} catch {
|
||||
toast({ title: "Could not mark comments read", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) load();
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
|
||||
aria-label={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
|
||||
className="flex items-center justify-center h-4 min-w-4 rounded-full bg-orange-500 px-1 text-[10px] font-bold leading-none text-white hover:bg-orange-400 shrink-0"
|
||||
>
|
||||
!{count > 1 && <span className="ml-0.5 font-medium">{count}</span>}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="z-50 w-96 rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||
<span className="text-xs font-medium text-zinc-300">
|
||||
New on <span className="font-mono text-white">{shotCode}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={markRead}
|
||||
disabled={marking}
|
||||
className="flex items-center gap-1 rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{marking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
Mark read
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{loadError ? (
|
||||
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-red-400">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Could not load the comments.
|
||||
</p>
|
||||
) : items === null ? (
|
||||
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-zinc-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-3 py-4 text-xs text-zinc-500">
|
||||
Nothing new — the comments may have been deleted.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
<Link
|
||||
href={`/review/${item.versionId}`}
|
||||
className="block px-3 py-2 hover:bg-zinc-800/60"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500">
|
||||
{item.kind === "reply" && <MessageSquareReply className="h-3 w-3" />}
|
||||
<span className="text-zinc-300">{item.authorName}</span>
|
||||
{item.source === "WHATSAPP" && (
|
||||
<span className="rounded bg-green-500/15 px-1 text-green-400">client</span>
|
||||
)}
|
||||
<span className="font-mono">
|
||||
v{String(item.versionNumber).padStart(3, "0")} · f{item.frameNumber}
|
||||
</span>
|
||||
<span className="ml-auto">
|
||||
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-3 whitespace-pre-line text-xs text-zinc-200">
|
||||
{item.text}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots,
|
||||
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||
import { markProjectCommentsRead } from "@/actions/comment-reads";
|
||||
import { NewCommentsMarker } from "./NewCommentsMarker";
|
||||
import {
|
||||
Film,
|
||||
Clock,
|
||||
@@ -81,6 +83,8 @@ type ShotRow = {
|
||||
manualDeliveryNote: string | null;
|
||||
shotVersion: string | null;
|
||||
delivery: ShotDeliverySummary;
|
||||
/** Comments from others this user has not marked read; null = none. */
|
||||
unreadComments: { count: number; latestAt: string } | null;
|
||||
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||
shotGroup: { id: string; name: string } | null;
|
||||
};
|
||||
@@ -905,6 +909,23 @@ function DeliveryCell({ delivery, shotVersion }: { delivery: ShotDeliverySummary
|
||||
);
|
||||
}
|
||||
|
||||
/** On a section header: how many of its shots have new comments, so a
|
||||
* collapsed episode, scene or group still shows there is something to read. */
|
||||
function SectionUnreadBadge({ shots }: { shots: ShotRow[] }) {
|
||||
const flagged = shots.filter((s) => s.unreadComments).length;
|
||||
if (!flagged) return null;
|
||||
const label = `${flagged} shot${flagged !== 1 ? "s" : ""} with new comments`;
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="flex items-center gap-0.5 rounded-full bg-orange-500 px-1.5 h-4 text-[10px] font-bold leading-none text-white shrink-0"
|
||||
>
|
||||
!<span className="font-medium">{flagged}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ShotTable({
|
||||
shots,
|
||||
canManage,
|
||||
@@ -912,6 +933,7 @@ function ShotTable({
|
||||
selectedIds,
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
onCommentsRead,
|
||||
}: {
|
||||
shots: ShotRow[];
|
||||
canManage: boolean;
|
||||
@@ -919,6 +941,7 @@ function ShotTable({
|
||||
selectedIds: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onToggleAll: (ids: string[], checked: boolean) => void;
|
||||
onCommentsRead: (shotId: string) => void;
|
||||
}) {
|
||||
const allChecked = shots.length > 0 && shots.every((s) => selectedIds.has(s.id));
|
||||
const someChecked = shots.some((s) => selectedIds.has(s.id));
|
||||
@@ -1014,6 +1037,15 @@ function ShotTable({
|
||||
>
|
||||
{shot.shotCode}
|
||||
</Link>
|
||||
{shot.unreadComments && (
|
||||
<NewCommentsMarker
|
||||
shotId={shot.id}
|
||||
shotCode={shot.shotCode}
|
||||
count={shot.unreadComments.count}
|
||||
latestAt={shot.unreadComments.latestAt}
|
||||
onRead={onCommentsRead}
|
||||
/>
|
||||
)}
|
||||
{shot.shotVersion && (
|
||||
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
|
||||
)}
|
||||
@@ -1106,7 +1138,7 @@ export function ShotStatusClient({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
selectedProject,
|
||||
shots,
|
||||
shots: initialShots,
|
||||
episodeDueDates,
|
||||
artists,
|
||||
canManage,
|
||||
@@ -1114,6 +1146,18 @@ export function ShotStatusClient({
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Shots marked read here drop their "!" at once, ahead of the refresh; the
|
||||
// refreshed props are authoritative, so the local set is cleared when they land.
|
||||
const [readShotIds, setReadShotIds] = useState<Set<string>>(new Set());
|
||||
useEffect(() => setReadShotIds(new Set()), [initialShots]);
|
||||
const shots = readShotIds.size
|
||||
? initialShots.map((s) => (readShotIds.has(s.id) ? { ...s, unreadComments: null } : s))
|
||||
: initialShots;
|
||||
const markShotReadLocally = (shotId: string) =>
|
||||
setReadShotIds((prev) => new Set(prev).add(shotId));
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const [markingAllRead, startMarkingAllRead] = useTransition();
|
||||
|
||||
// Restore last selected project if no project is currently selected
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) {
|
||||
@@ -1144,6 +1188,7 @@ export function ShotStatusClient({
|
||||
if (!selectedProjectId) return;
|
||||
setExpandRestored(false);
|
||||
setStatusFilter(new Set());
|
||||
setUnreadOnly(false);
|
||||
const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group";
|
||||
setGroupMode(mode);
|
||||
setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? []));
|
||||
@@ -1246,13 +1291,43 @@ export function ShotStatusClient({
|
||||
if (next.size > 0) {
|
||||
setSelectedIds((prev) => {
|
||||
const kept = new Set<string>();
|
||||
for (const s of shots) if (prev.has(s.id) && next.has(s.status)) kept.add(s.id);
|
||||
for (const s of shots)
|
||||
if (prev.has(s.id) && next.has(s.status) && (!unreadOnly || s.unreadComments)) kept.add(s.id);
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const visibleShots = statusFilter.size > 0 ? shots.filter((s) => statusFilter.has(s.status)) : shots;
|
||||
const visibleShots = shots.filter(
|
||||
(s) => (statusFilter.size === 0 || statusFilter.has(s.status)) && (!unreadOnly || s.unreadComments)
|
||||
);
|
||||
const unreadShotCount = shots.filter((s) => s.unreadComments).length;
|
||||
const isFiltered = statusFilter.size > 0 || unreadOnly;
|
||||
|
||||
const toggleUnreadOnly = () => {
|
||||
const next = !unreadOnly;
|
||||
setUnreadOnly(next);
|
||||
// Bulk actions must never reach shots the filter has hidden.
|
||||
if (next) {
|
||||
setSelectedIds((prev) => {
|
||||
const kept = new Set<string>();
|
||||
for (const s of shots) if (prev.has(s.id) && s.unreadComments) kept.add(s.id);
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () =>
|
||||
startMarkingAllRead(async () => {
|
||||
if (!selectedProjectId) return;
|
||||
try {
|
||||
await markProjectCommentsRead(selectedProjectId);
|
||||
setReadShotIds(new Set(shots.filter((s) => s.unreadComments).map((s) => s.id)));
|
||||
setUnreadOnly(false);
|
||||
} catch {
|
||||
toast({ title: "Could not mark comments read", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const handleBulkDueDate = async (date: string, includeTasks: boolean) => {
|
||||
try {
|
||||
@@ -1457,7 +1532,7 @@ export function ShotStatusClient({
|
||||
{selectionCount === 0 ? (
|
||||
<>
|
||||
<span className="text-xs text-zinc-500 self-center">
|
||||
{statusFilter.size > 0 ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
|
||||
{isFiltered ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
|
||||
</span>
|
||||
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
|
||||
const count = statusCounts[key] ?? 0;
|
||||
@@ -1485,10 +1560,43 @@ export function ShotStatusClient({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{statusFilter.size > 0 && (
|
||||
{unreadShotCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleUnreadOnly}
|
||||
aria-pressed={unreadOnly}
|
||||
title={unreadOnly ? "Show all shots" : "Show only shots with new comments"}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Badge
|
||||
className={cn(
|
||||
"gap-1 text-xs cursor-pointer bg-orange-500/15 text-orange-300 border-orange-500/30",
|
||||
unreadOnly && "ring-1 ring-current"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">!</span>
|
||||
{unreadShotCount} with new comments
|
||||
</Badge>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markingAllRead}
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{markingAllRead ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||
Mark all read
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{isFiltered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(new Set())}
|
||||
onClick={() => {
|
||||
setStatusFilter(new Set());
|
||||
setUnreadOnly(false);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
@@ -1589,6 +1697,7 @@ export function ShotStatusClient({
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<SectionUnreadBadge shots={episodeShots} />
|
||||
{epDueDate && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
@@ -1608,6 +1717,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1647,6 +1757,7 @@ export function ShotStatusClient({
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<SectionUnreadBadge shots={sceneShots} />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
@@ -1657,6 +1768,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1696,6 +1808,7 @@ export function ShotStatusClient({
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<SectionUnreadBadge shots={groupShots} />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
@@ -1706,6 +1819,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1722,6 +1836,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ShotStatusClient } from "./ShotStatusClient";
|
||||
import { summariseShotDelivery } from "@/lib/render-pipeline/shot-delivery-summary";
|
||||
import { getUnreadCommentSummaries, type UnreadCommentSummary } from "@/lib/comment-reads";
|
||||
|
||||
async function getProjects() {
|
||||
return db.project.findMany({
|
||||
@@ -29,14 +30,11 @@ async function getAssignableArtists() {
|
||||
});
|
||||
}
|
||||
|
||||
const shotCodeCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
async function getShotsForProject(projectId: string) {
|
||||
const shots = await db.shot.findMany({
|
||||
where: { projectId },
|
||||
orderBy: [
|
||||
{ episode: { sort: "asc", nulls: "last" } },
|
||||
{ scene: "asc" },
|
||||
{ shotNumber: "asc" },
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
@@ -89,6 +87,16 @@ async function getShotsForProject(projectId: string) {
|
||||
},
|
||||
});
|
||||
|
||||
// Sorted by shot code rather than scene + shotNumber: imports and the
|
||||
// external API assign shotNumber as "max + 10" (or 0), which needn't match
|
||||
// the number in the code, so new shots could sink to the bottom.
|
||||
shots.sort(
|
||||
(a, b) =>
|
||||
(a.episode === null ? 1 : 0) - (b.episode === null ? 1 : 0) ||
|
||||
shotCodeCollator.compare(a.episode ?? "", b.episode ?? "") ||
|
||||
shotCodeCollator.compare(a.shotCode, b.shotCode)
|
||||
);
|
||||
|
||||
return shots.map(({ exports, ...shot }) => ({
|
||||
...shot,
|
||||
delivery: summariseShotDelivery({
|
||||
@@ -116,12 +124,14 @@ export default async function ShotStatusPage({
|
||||
const { projectId } = await searchParams;
|
||||
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||
|
||||
const [projects, shots, episodeDueDates, artists] = await Promise.all([
|
||||
const [projects, shotRows, episodeDueDates, artists, unread] = await Promise.all([
|
||||
getProjects(),
|
||||
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
|
||||
projectId ? getEpisodeDueDatesForProject(projectId) : Promise.resolve([]),
|
||||
canManage ? getAssignableArtists() : Promise.resolve([]),
|
||||
projectId ? getUnreadCommentSummaries(session.user.id, projectId) : Promise.resolve({} as Record<string, UnreadCommentSummary>),
|
||||
]);
|
||||
const shots = shotRows.map((shot) => ({ ...shot, unreadComments: unread[shot.id] ?? null }));
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||
import { notifyCommentsAdded } from "@/lib/notifications";
|
||||
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
|
||||
|
||||
export async function POST(
|
||||
@@ -28,8 +29,8 @@ export async function POST(
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
|
||||
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
|
||||
shot: { select: { projectId: true, shotCode: true, artistId: true, project: { select: { slackWebhook: true } } } },
|
||||
task: { select: { projectId: true, title: true, assignedArtistId: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
|
||||
},
|
||||
});
|
||||
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||
@@ -70,6 +71,18 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
await notifyCommentsAdded({
|
||||
notes: [{
|
||||
versionId,
|
||||
shotCode,
|
||||
frameNumber,
|
||||
artistIds: [version.shot?.artistId, version.task?.assignedArtistId],
|
||||
}],
|
||||
authorId: user.id,
|
||||
authorName: user.name ?? user.email,
|
||||
origin: "client",
|
||||
}).catch((err) => console.error("[client comment] notify failed", err));
|
||||
|
||||
// canEdit mirrors the GET shape — a note you just wrote is always your own.
|
||||
return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { sortShotsForList } from "@/lib/shot-order";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
@@ -38,9 +39,35 @@ export async function GET(
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: shot.projectId },
|
||||
select: { name: true },
|
||||
select: {
|
||||
name: true,
|
||||
projectType: true,
|
||||
shots: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
episode: true,
|
||||
scene: true,
|
||||
shotNumber: true,
|
||||
shotGroup: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Neighbours in the project page's shot list order, for prev/next navigation
|
||||
const orderedShots = project ? sortShotsForList(project.shots, project.projectType) : [];
|
||||
const shotIndex = orderedShots.findIndex((s) => s.id === shotId);
|
||||
const neighbour = (i: number) =>
|
||||
shotIndex >= 0 && orderedShots[i] ? { id: orderedShots[i].id, shotCode: orderedShots[i].shotCode } : null;
|
||||
const shotNav = {
|
||||
prev: neighbour(shotIndex - 1),
|
||||
next: neighbour(shotIndex + 1),
|
||||
position: shotIndex + 1,
|
||||
total: orderedShots.length,
|
||||
};
|
||||
|
||||
const [tasks, artists] = await Promise.all([
|
||||
db.task.findMany({
|
||||
where: { shotId },
|
||||
@@ -99,6 +126,7 @@ export async function GET(
|
||||
return NextResponse.json({
|
||||
shot: shotSerialized,
|
||||
projectName: project?.name ?? "",
|
||||
shotNav,
|
||||
canApprove,
|
||||
canInternallyApprove: ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string),
|
||||
tasks,
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ services:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
# Uploads live on the Hetzner volume, not the root disk (uid 1001 must own it)
|
||||
- /mnt/HC_Volume_105945736/vfxreview-uploads:/app/uploads
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* New-comment tracking for the shot-status board. A comment or reply is new to
|
||||
* a user when someone else wrote it on one of the shot's versions (directly or
|
||||
* through a task on the shot) after the user's ShotCommentRead marker. With no
|
||||
* marker the baseline is when the account was created, so a new user does not
|
||||
* inherit the whole back catalogue.
|
||||
*
|
||||
* Comments with no author (the author's account was deleted) count as someone
|
||||
* else's. Pasted WhatsApp notes are authored by whoever pasted them, so they
|
||||
* are new to everyone but that person.
|
||||
*/
|
||||
|
||||
export type UnreadCommentSummary = {
|
||||
count: number;
|
||||
/** createdAt of the newest unread item — what "mark read" reads up to. */
|
||||
latestAt: string;
|
||||
};
|
||||
|
||||
export async function getUnreadCommentSummaries(
|
||||
userId: string,
|
||||
projectId: string
|
||||
): Promise<Record<string, UnreadCommentSummary>> {
|
||||
const rows = await db.$queryRaw<{ shotId: string; count: number; latestAt: Date }[]>(Prisma.sql`
|
||||
WITH me AS (
|
||||
SELECT "createdAt" FROM "users" WHERE "id" = ${userId}
|
||||
),
|
||||
items AS (
|
||||
SELECT COALESCE(v."shotId", t."shotId") AS "shotId", c."createdAt", c."authorId"
|
||||
FROM "comments" c
|
||||
JOIN "versions" v ON v."id" = c."versionId"
|
||||
LEFT JOIN "tasks" t ON t."id" = v."taskId"
|
||||
UNION ALL
|
||||
SELECT COALESCE(v."shotId", t."shotId") AS "shotId", r."createdAt", r."authorId"
|
||||
FROM "comment_replies" r
|
||||
JOIN "comments" c ON c."id" = r."commentId"
|
||||
JOIN "versions" v ON v."id" = c."versionId"
|
||||
LEFT JOIN "tasks" t ON t."id" = v."taskId"
|
||||
)
|
||||
SELECT i."shotId", COUNT(*)::int AS "count", MAX(i."createdAt") AS "latestAt"
|
||||
FROM items i
|
||||
JOIN "shots" s ON s."id" = i."shotId"
|
||||
LEFT JOIN "shot_comment_reads" scr ON scr."shotId" = s."id" AND scr."userId" = ${userId}
|
||||
WHERE s."projectId" = ${projectId}
|
||||
AND i."authorId" IS DISTINCT FROM ${userId}
|
||||
AND i."createdAt" > COALESCE(scr."readAt", (SELECT "createdAt" FROM me))
|
||||
GROUP BY i."shotId"
|
||||
`);
|
||||
|
||||
return Object.fromEntries(
|
||||
rows.map((r) => [r.shotId, { count: r.count, latestAt: r.latestAt.toISOString() }])
|
||||
);
|
||||
}
|
||||
|
||||
export type UnreadCommentItem = {
|
||||
id: string;
|
||||
kind: "comment" | "reply";
|
||||
text: string;
|
||||
authorName: string;
|
||||
source: string | null;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
frameNumber: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** The unread comments and replies on one shot, newest first. */
|
||||
export async function getUnreadCommentsForShot(
|
||||
userId: string,
|
||||
shotId: string
|
||||
): Promise<UnreadCommentItem[]> {
|
||||
const [user, marker] = await Promise.all([
|
||||
db.user.findUnique({ where: { id: userId }, select: { createdAt: true } }),
|
||||
db.shotCommentRead.findUnique({ where: { userId_shotId: { userId, shotId } } }),
|
||||
]);
|
||||
const since = marker?.readAt ?? user?.createdAt ?? new Date(0);
|
||||
|
||||
const onShot: Prisma.VersionWhereInput = { OR: [{ shotId }, { task: { shotId } }] };
|
||||
// `not: userId` alone would also drop authorless rows (SQL NULL), which count.
|
||||
const byOthers = { OR: [{ authorId: null }, { authorId: { not: userId } }] };
|
||||
const author = { select: { name: true, email: true } };
|
||||
const version = { select: { id: true, versionNumber: true } };
|
||||
|
||||
const [comments, replies] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: { version: onShot, createdAt: { gt: since }, ...byOthers },
|
||||
select: { id: true, text: true, source: true, frameNumber: true, createdAt: true, author, version },
|
||||
}),
|
||||
db.commentReply.findMany({
|
||||
where: { comment: { version: onShot }, createdAt: { gt: since }, ...byOthers },
|
||||
select: {
|
||||
id: true,
|
||||
text: true,
|
||||
createdAt: true,
|
||||
author,
|
||||
comment: { select: { frameNumber: true, version } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const items: UnreadCommentItem[] = [
|
||||
...comments.map((c) => ({
|
||||
id: c.id,
|
||||
kind: "comment" as const,
|
||||
text: c.text,
|
||||
authorName: c.author?.name ?? c.author?.email ?? "Unknown",
|
||||
source: c.source,
|
||||
versionId: c.version.id,
|
||||
versionNumber: c.version.versionNumber,
|
||||
frameNumber: c.frameNumber,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
})),
|
||||
...replies.map((r) => ({
|
||||
id: r.id,
|
||||
kind: "reply" as const,
|
||||
text: r.text,
|
||||
authorName: r.author?.name ?? r.author?.email ?? "Unknown",
|
||||
source: null,
|
||||
versionId: r.comment.version.id,
|
||||
versionNumber: r.comment.version.versionNumber,
|
||||
frameNumber: r.comment.frameNumber,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
];
|
||||
return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a user's marker forward to `upTo` — the newest item they were shown,
|
||||
* not "now", so a comment that lands while the list is open stays unread.
|
||||
* Never moves a marker backwards.
|
||||
*/
|
||||
export async function markShotCommentsRead(userId: string, shotId: string, upTo: Date) {
|
||||
const readAt = upTo > new Date() ? new Date() : upTo;
|
||||
const existing = await db.shotCommentRead.findUnique({
|
||||
where: { userId_shotId: { userId, shotId } },
|
||||
});
|
||||
if (existing && existing.readAt >= readAt) return;
|
||||
await db.shotCommentRead.upsert({
|
||||
where: { userId_shotId: { userId, shotId } },
|
||||
create: { userId, shotId, readAt },
|
||||
update: { readAt },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ db: {} }));
|
||||
|
||||
import { buildCommentNotifications } from "./notifications";
|
||||
|
||||
const note = (versionId: string, shotCode: string, frameNumber: number, artistIds: (string | null)[] = []) => ({
|
||||
versionId,
|
||||
shotCode,
|
||||
frameNumber,
|
||||
artistIds,
|
||||
});
|
||||
|
||||
describe("buildCommentNotifications", () => {
|
||||
it("tells every staff member and the artist, never the author", () => {
|
||||
const out = buildCommentNotifications({
|
||||
notes: [note("v1", "DH2_010", 1012, ["artist"])],
|
||||
authorId: "sup",
|
||||
authorName: "Sam",
|
||||
staffIds: ["sup", "prod", "admin"],
|
||||
});
|
||||
expect(out.map((p) => p.userId).sort()).toEqual(["admin", "artist", "prod"]);
|
||||
expect(out[0]).toMatchObject({
|
||||
type: "FEEDBACK_ADDED",
|
||||
title: "New feedback",
|
||||
message: "Sam commented on DH2_010 at frame 1012",
|
||||
data: { versionId: "v1", shotCode: "DH2_010", frameNumber: 1012 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not notify an artist twice when they are also staff or on both shot and task", () => {
|
||||
const out = buildCommentNotifications({
|
||||
notes: [note("v1", "A", 1, ["prod", "prod", null])],
|
||||
authorId: "x",
|
||||
authorName: "X",
|
||||
staffIds: ["prod"],
|
||||
});
|
||||
expect(out.map((p) => p.userId)).toEqual(["prod"]);
|
||||
});
|
||||
|
||||
it("labels client comments", () => {
|
||||
const [p] = buildCommentNotifications({
|
||||
notes: [note("v1", "A", 5)],
|
||||
authorId: "client",
|
||||
authorName: "Netflix",
|
||||
origin: "client",
|
||||
staffIds: ["prod"],
|
||||
});
|
||||
expect(p.title).toBe("New client feedback");
|
||||
expect(p.message).toBe("Client Netflix commented on A at frame 5");
|
||||
});
|
||||
|
||||
it("groups a pasted round into one notification per version", () => {
|
||||
const out = buildCommentNotifications({
|
||||
notes: [note("v1", "A", 1, ["art1"]), note("v1", "A", 9), note("v2", "B", 3, ["art2"])],
|
||||
authorId: "prod",
|
||||
authorName: "Pat",
|
||||
origin: "whatsapp",
|
||||
staffIds: ["prod", "sup"],
|
||||
});
|
||||
const msgs = out.map((p) => `${p.userId}: ${p.message}`).sort();
|
||||
expect(msgs).toEqual([
|
||||
"art1: Client feedback (logged by Pat) left 2 notes on A",
|
||||
"art2: Client feedback (logged by Pat) commented on B at frame 3",
|
||||
"sup: Client feedback (logged by Pat) commented on B at frame 3",
|
||||
"sup: Client feedback (logged by Pat) left 2 notes on A",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends nothing for no notes", () => {
|
||||
expect(
|
||||
buildCommentNotifications({ notes: [], authorId: "a", authorName: "A", staffIds: ["b"] })
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
+94
-23
@@ -78,29 +78,6 @@ export async function notifyNewVersionUploaded(params: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the shot's artist about new feedback.
|
||||
*/
|
||||
export async function notifyFeedbackAdded(params: {
|
||||
artistId: string;
|
||||
shotCode: string;
|
||||
frameNumber: number;
|
||||
versionId: string;
|
||||
authorName: string;
|
||||
}) {
|
||||
await createNotification({
|
||||
userId: params.artistId,
|
||||
type: NotificationType.FEEDBACK_ADDED,
|
||||
title: "New feedback on your shot",
|
||||
message: `${params.authorName} commented on ${params.shotCode} at frame ${params.frameNumber}`,
|
||||
data: {
|
||||
versionId: params.versionId,
|
||||
shotCode: params.shotCode,
|
||||
frameNumber: params.frameNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify relevant users of an approval status change.
|
||||
*/
|
||||
@@ -250,3 +227,97 @@ export async function notifyCommentReply(params: {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** One new comment, as the recipients of its notification need it. */
|
||||
export interface NewCommentNote {
|
||||
versionId: string;
|
||||
shotCode: string;
|
||||
frameNumber: number;
|
||||
/** The shot's artist and, for a task version, the task's artist. */
|
||||
artistIds: (string | null | undefined)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who hears about new comments, and what they read. Pure so it can be tested:
|
||||
* every active staff member plus each note's artist, never the author.
|
||||
* Notes are grouped per version so a pasted round of client feedback is one
|
||||
* notification per shot rather than one per line.
|
||||
*/
|
||||
export function buildCommentNotifications(params: {
|
||||
notes: NewCommentNote[];
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
/** "client" = typed on a client review link; "whatsapp" = pasted by staff. */
|
||||
origin?: "client" | "whatsapp";
|
||||
staffIds: string[];
|
||||
}): CreateNotificationPayload[] {
|
||||
const byVersion = new Map<string, NewCommentNote[]>();
|
||||
for (const n of params.notes) {
|
||||
byVersion.set(n.versionId, [...(byVersion.get(n.versionId) ?? []), n]);
|
||||
}
|
||||
|
||||
const who =
|
||||
params.origin === "client"
|
||||
? `Client ${params.authorName}`
|
||||
: params.origin === "whatsapp"
|
||||
? `Client feedback (logged by ${params.authorName})`
|
||||
: params.authorName;
|
||||
const title = params.origin ? "New client feedback" : "New feedback";
|
||||
|
||||
const payloads: CreateNotificationPayload[] = [];
|
||||
for (const [versionId, notes] of byVersion) {
|
||||
const { shotCode, frameNumber } = notes[0];
|
||||
const message =
|
||||
notes.length === 1
|
||||
? `${who} commented on ${shotCode} at frame ${frameNumber}`
|
||||
: `${who} left ${notes.length} notes on ${shotCode}`;
|
||||
const recipients = new Set([
|
||||
...params.staffIds,
|
||||
...notes.flatMap((n) => n.artistIds).filter((id): id is string => !!id),
|
||||
]);
|
||||
recipients.delete(params.authorId);
|
||||
for (const userId of recipients) {
|
||||
payloads.push({
|
||||
userId,
|
||||
type: NotificationType.FEEDBACK_ADDED,
|
||||
title,
|
||||
message,
|
||||
data: { versionId, shotCode, frameNumber },
|
||||
});
|
||||
}
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify staff and the shot's artist about new comments. Artists who are not
|
||||
* staff are only told when they are still active.
|
||||
*/
|
||||
export async function notifyCommentsAdded(params: {
|
||||
notes: NewCommentNote[];
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
origin?: "client" | "whatsapp";
|
||||
}) {
|
||||
if (params.notes.length === 0) return;
|
||||
const artistIds = [
|
||||
...new Set(params.notes.flatMap((n) => n.artistIds).filter((id): id is string => !!id)),
|
||||
];
|
||||
const [staff, activeArtists] = await Promise.all([
|
||||
db.user.findMany({
|
||||
where: { role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] }, isActive: true },
|
||||
select: { id: true },
|
||||
}),
|
||||
db.user.findMany({
|
||||
where: { id: { in: artistIds }, isActive: true, role: { not: "CLIENT" } },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
const active = new Set(activeArtists.map((u) => u.id));
|
||||
const payloads = buildCommentNotifications({
|
||||
...params,
|
||||
notes: params.notes.map((n) => ({ ...n, artistIds: n.artistIds.filter((id) => id && active.has(id)) })),
|
||||
staffIds: staff.map((u) => u.id),
|
||||
});
|
||||
if (payloads.length) await createNotifications(payloads);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export async function getQCQueue(filter: QCQueueFilter) {
|
||||
orderBy: { statusChangedAt: "asc" }, // oldest first — nothing should sit in QC
|
||||
take: limit,
|
||||
include: {
|
||||
shot: { select: { id: true, shotCode: true, episode: true, scene: true, seqTimecodeStart: true, project: { select: { code: true, name: true } } } },
|
||||
shot: { select: { id: true, shotCode: true, episode: true, scene: true, project: { select: { code: true, name: true } } } },
|
||||
validations: { where: { status: { in: ["FAIL", "WARN"] } }, select: { checkName: true, status: true, message: true } },
|
||||
},
|
||||
});
|
||||
@@ -167,11 +167,6 @@ export async function getQCQueue(filter: QCQueueFilter) {
|
||||
frameStart: e.frameStart,
|
||||
frameEnd: e.frameEnd,
|
||||
fps: e.fps,
|
||||
// The panel's QC session cuts each shot into a scene edit: in edit order,
|
||||
// with the handles the render carries trimmed off. Null on old exports.
|
||||
seqTimecodeStart: e.shot.seqTimecodeStart,
|
||||
handleHead: e.handleHead,
|
||||
handleTail: e.handleTail,
|
||||
deliveryMovPath: e.deliveryMovPath,
|
||||
thumbnailUrl: e.thumbnailKey ? `/api/files/${e.thumbnailKey}` : null,
|
||||
previewUrl: e.previewMovKey ? `/api/files/${e.previewMovKey}` : null,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// The order shots appear in on the project page's Shots tab. Shared with the
|
||||
// shot detail page so its previous/next navigation walks the same list.
|
||||
|
||||
type OrderableShot = {
|
||||
episode?: string | null;
|
||||
scene: string;
|
||||
shotNumber: number;
|
||||
shotGroup?: { name: string } | null;
|
||||
};
|
||||
|
||||
export function sortShotsForList<T extends OrderableShot>(shots: T[], projectType: string): T[] {
|
||||
// Episodic: episode → scene → shotNumber
|
||||
if (projectType === "EPISODIC") {
|
||||
return [...shots].sort((a, b) => {
|
||||
const ea = a.episode ?? "";
|
||||
const eb = b.episode ?? "";
|
||||
if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true });
|
||||
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
|
||||
return a.shotNumber - b.shotNumber;
|
||||
});
|
||||
}
|
||||
|
||||
// Standard with custom groups: group → scene → shotNumber, ungrouped last
|
||||
if (projectType === "STANDARD" && shots.some((s) => s.shotGroup)) {
|
||||
return [...shots].sort((a, b) => {
|
||||
const ga = a.shotGroup?.name ?? "";
|
||||
const gb = b.shotGroup?.name ?? "";
|
||||
if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true });
|
||||
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
|
||||
return a.shotNumber - b.shotNumber;
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise the list keeps the query's createdAt order
|
||||
return shots;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Per-user read marker for a shot's comments: the shot-status board flags a
|
||||
-- shot with "!" while it has comments from others newer than the marker.
|
||||
CREATE TABLE "shot_comment_reads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"shotId" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "shot_comment_reads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "shot_comment_reads_userId_shotId_key" ON "shot_comment_reads"("userId", "shotId");
|
||||
CREATE INDEX "shot_comment_reads_shotId_idx" ON "shot_comment_reads"("shotId");
|
||||
|
||||
ALTER TABLE "shot_comment_reads" ADD CONSTRAINT "shot_comment_reads_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "shot_comment_reads" ADD CONSTRAINT "shot_comment_reads_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Everything already commented counts as read on launch, so the board starts
|
||||
-- clean instead of flagging every shot that has ever had a note.
|
||||
INSERT INTO "shot_comment_reads" ("id", "userId", "shotId", "readAt")
|
||||
SELECT md5(u."id" || ':' || s."id"), u."id", s."id", CURRENT_TIMESTAMP
|
||||
FROM "users" u
|
||||
CROSS JOIN "shots" s
|
||||
WHERE u."role" <> 'CLIENT'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "comments" c
|
||||
JOIN "versions" v ON v."id" = c."versionId"
|
||||
LEFT JOIN "tasks" t ON t."id" = v."taskId"
|
||||
WHERE COALESCE(v."shotId", t."shotId") = s."id"
|
||||
);
|
||||
@@ -263,6 +263,7 @@ model User {
|
||||
createdTakes Take[] @relation("TakeCreator")
|
||||
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
|
||||
assignedBoardItems BoardItem[] @relation("BoardItemAssignee")
|
||||
shotCommentReads ShotCommentRead[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -498,6 +499,7 @@ model Shot {
|
||||
exports Export[]
|
||||
deliveryRequestLines DeliveryRequestLine[]
|
||||
boardItems BoardItem[]
|
||||
commentReads ShotCommentRead[]
|
||||
|
||||
@@unique([projectId, shotCode])
|
||||
@@map("shots")
|
||||
@@ -675,6 +677,24 @@ model Approval {
|
||||
@@map("approvals")
|
||||
}
|
||||
|
||||
/// How far each user has read a shot's comments. A comment or reply on any of
|
||||
/// the shot's versions, written by someone else after `readAt`, is new to that
|
||||
/// user and flags the shot on the shot-status board until they mark it read.
|
||||
/// No row = nothing read since the account was created.
|
||||
model ShotCommentRead {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
shotId String
|
||||
readAt DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, shotId])
|
||||
@@index([shotId])
|
||||
@@map("shot_comment_reads")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
Reference in New Issue
Block a user