Files
vfxreview/app/(dashboard)/shot-status/NewCommentsMarker.tsx
T
twotalesanimation 7a7088f2ad 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>
2026-10-17 09:06:25 +02:00

147 lines
5.5 KiB
TypeScript

"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>
);
}