7a7088f2ad
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>
147 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|