feat(shot-status): flag shots with new comments until marked read
A per-user ShotCommentRead marker records how far each person has read a shot's comments. Comments and replies from others after the marker put an orange "!" beside the shot code; its popover lists them and only "Mark read" clears it. A chip filters the board to flagged shots and marks all read. The migration marks existing comments as read so the board starts clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 { previewMovReview, buildMovReview } from "@/actions/deliveries";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||||
|
import { markProjectCommentsRead } from "@/actions/comment-reads";
|
||||||
|
import { NewCommentsMarker } from "./NewCommentsMarker";
|
||||||
import {
|
import {
|
||||||
Film,
|
Film,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -81,6 +83,8 @@ type ShotRow = {
|
|||||||
manualDeliveryNote: string | null;
|
manualDeliveryNote: string | null;
|
||||||
shotVersion: string | null;
|
shotVersion: string | null;
|
||||||
delivery: ShotDeliverySummary;
|
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;
|
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||||
shotGroup: { id: string; name: string } | null;
|
shotGroup: { id: string; name: string } | null;
|
||||||
};
|
};
|
||||||
@@ -912,6 +916,7 @@ function ShotTable({
|
|||||||
selectedIds,
|
selectedIds,
|
||||||
onToggle,
|
onToggle,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
|
onCommentsRead,
|
||||||
}: {
|
}: {
|
||||||
shots: ShotRow[];
|
shots: ShotRow[];
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
@@ -919,6 +924,7 @@ function ShotTable({
|
|||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
onToggleAll: (ids: string[], checked: boolean) => void;
|
onToggleAll: (ids: string[], checked: boolean) => void;
|
||||||
|
onCommentsRead: (shotId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const allChecked = shots.length > 0 && shots.every((s) => selectedIds.has(s.id));
|
const allChecked = shots.length > 0 && shots.every((s) => selectedIds.has(s.id));
|
||||||
const someChecked = shots.some((s) => selectedIds.has(s.id));
|
const someChecked = shots.some((s) => selectedIds.has(s.id));
|
||||||
@@ -1014,6 +1020,15 @@ function ShotTable({
|
|||||||
>
|
>
|
||||||
{shot.shotCode}
|
{shot.shotCode}
|
||||||
</Link>
|
</Link>
|
||||||
|
{shot.unreadComments && (
|
||||||
|
<NewCommentsMarker
|
||||||
|
shotId={shot.id}
|
||||||
|
shotCode={shot.shotCode}
|
||||||
|
count={shot.unreadComments.count}
|
||||||
|
latestAt={shot.unreadComments.latestAt}
|
||||||
|
onRead={onCommentsRead}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{shot.shotVersion && (
|
{shot.shotVersion && (
|
||||||
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
|
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
|
||||||
)}
|
)}
|
||||||
@@ -1106,7 +1121,7 @@ export function ShotStatusClient({
|
|||||||
projects,
|
projects,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
shots,
|
shots: initialShots,
|
||||||
episodeDueDates,
|
episodeDueDates,
|
||||||
artists,
|
artists,
|
||||||
canManage,
|
canManage,
|
||||||
@@ -1114,6 +1129,18 @@ export function ShotStatusClient({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
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
|
// Restore last selected project if no project is currently selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
@@ -1144,6 +1171,7 @@ export function ShotStatusClient({
|
|||||||
if (!selectedProjectId) return;
|
if (!selectedProjectId) return;
|
||||||
setExpandRestored(false);
|
setExpandRestored(false);
|
||||||
setStatusFilter(new Set());
|
setStatusFilter(new Set());
|
||||||
|
setUnreadOnly(false);
|
||||||
const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group";
|
const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group";
|
||||||
setGroupMode(mode);
|
setGroupMode(mode);
|
||||||
setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? []));
|
setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? []));
|
||||||
@@ -1246,13 +1274,43 @@ export function ShotStatusClient({
|
|||||||
if (next.size > 0) {
|
if (next.size > 0) {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const kept = new Set<string>();
|
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;
|
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) => {
|
const handleBulkDueDate = async (date: string, includeTasks: boolean) => {
|
||||||
try {
|
try {
|
||||||
@@ -1457,7 +1515,7 @@ export function ShotStatusClient({
|
|||||||
{selectionCount === 0 ? (
|
{selectionCount === 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs text-zinc-500 self-center">
|
<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>
|
</span>
|
||||||
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
|
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
|
||||||
const count = statusCounts[key] ?? 0;
|
const count = statusCounts[key] ?? 0;
|
||||||
@@ -1485,10 +1543,43 @@ export function ShotStatusClient({
|
|||||||
</button>
|
</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
|
<button
|
||||||
type="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"
|
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
@@ -1608,6 +1699,7 @@ export function ShotStatusClient({
|
|||||||
projectId={selectedProjectId!}
|
projectId={selectedProjectId!}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onToggle={toggleShot}
|
onToggle={toggleShot}
|
||||||
|
onCommentsRead={markShotReadLocally}
|
||||||
onToggleAll={toggleMany}
|
onToggleAll={toggleMany}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1657,6 +1749,7 @@ export function ShotStatusClient({
|
|||||||
projectId={selectedProjectId!}
|
projectId={selectedProjectId!}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onToggle={toggleShot}
|
onToggle={toggleShot}
|
||||||
|
onCommentsRead={markShotReadLocally}
|
||||||
onToggleAll={toggleMany}
|
onToggleAll={toggleMany}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1706,6 +1799,7 @@ export function ShotStatusClient({
|
|||||||
projectId={selectedProjectId!}
|
projectId={selectedProjectId!}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onToggle={toggleShot}
|
onToggle={toggleShot}
|
||||||
|
onCommentsRead={markShotReadLocally}
|
||||||
onToggleAll={toggleMany}
|
onToggleAll={toggleMany}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1722,6 +1816,7 @@ export function ShotStatusClient({
|
|||||||
projectId={selectedProjectId!}
|
projectId={selectedProjectId!}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onToggle={toggleShot}
|
onToggle={toggleShot}
|
||||||
|
onCommentsRead={markShotReadLocally}
|
||||||
onToggleAll={toggleMany}
|
onToggleAll={toggleMany}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { auth } from "@/auth";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ShotStatusClient } from "./ShotStatusClient";
|
import { ShotStatusClient } from "./ShotStatusClient";
|
||||||
import { summariseShotDelivery } from "@/lib/render-pipeline/shot-delivery-summary";
|
import { summariseShotDelivery } from "@/lib/render-pipeline/shot-delivery-summary";
|
||||||
|
import { getUnreadCommentSummaries, type UnreadCommentSummary } from "@/lib/comment-reads";
|
||||||
|
|
||||||
async function getProjects() {
|
async function getProjects() {
|
||||||
return db.project.findMany({
|
return db.project.findMany({
|
||||||
@@ -116,12 +117,14 @@ export default async function ShotStatusPage({
|
|||||||
const { projectId } = await searchParams;
|
const { projectId } = await searchParams;
|
||||||
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
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(),
|
getProjects(),
|
||||||
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
|
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
|
||||||
projectId ? getEpisodeDueDatesForProject(projectId) : Promise.resolve([]),
|
projectId ? getEpisodeDueDatesForProject(projectId) : Promise.resolve([]),
|
||||||
canManage ? getAssignableArtists() : 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;
|
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -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,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")
|
createdTakes Take[] @relation("TakeCreator")
|
||||||
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
|
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
|
||||||
assignedBoardItems BoardItem[] @relation("BoardItemAssignee")
|
assignedBoardItems BoardItem[] @relation("BoardItemAssignee")
|
||||||
|
shotCommentReads ShotCommentRead[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -498,6 +499,7 @@ model Shot {
|
|||||||
exports Export[]
|
exports Export[]
|
||||||
deliveryRequestLines DeliveryRequestLine[]
|
deliveryRequestLines DeliveryRequestLine[]
|
||||||
boardItems BoardItem[]
|
boardItems BoardItem[]
|
||||||
|
commentReads ShotCommentRead[]
|
||||||
|
|
||||||
@@unique([projectId, shotCode])
|
@@unique([projectId, shotCode])
|
||||||
@@map("shots")
|
@@map("shots")
|
||||||
@@ -675,6 +677,24 @@ model Approval {
|
|||||||
@@map("approvals")
|
@@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 {
|
model Notification {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
Reference in New Issue
Block a user