Files
vfxreview/app/client/[token]/review/[versionId]/page.tsx
T
twotalesanimation 842a8fe036
Deploy / deploy (push) Successful in 3m34s
feat(board): paste client feedback from WhatsApp
"Paste feedback" on each board card takes a client's message as it arrived
and splits it into rounds ("FEEDBACK 113 DUE Oct 7 12:00 PM") and per-shot
notes, tolerating WhatsApp copy prefixes, bold markers, wrapped lines,
several codes per note and codes without the show prefix.

A review step shows each note's shot, the version the client was sent,
the player frame and a "move task to Changes" tick (on by default).
Saving creates a FeedbackRound, a player comment per note tagged
source=WHATSAPP (shown as "Client feedback") and a board line under the
round's due time; ticking the line off resolves the comment.

Quoted frames are read as comp frames: shot frameStart or the house 1001,
plus the slate (recorded, else 1 for pipeline previews). Version.duration
is never stored on live, so the length-based slate inference can't be used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:31:34 +02:00

880 lines
34 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import Image from "next/image";
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/components/ui/use-toast";
import { cn, frameToTimecode, getInitials } from "@/lib/utils";
import {
Film,
ArrowLeft,
CheckCircle2,
XCircle,
AlertCircle,
MessageSquare,
Send,
Clock,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
Copy,
Check,
Download,
Pencil,
Trash2,
} from "lucide-react";
import { useReviewStore } from "@/hooks/use-review-player";
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
import { HandleBadge } from "@/components/player/HandleBadge";
interface Comment {
id: string;
frameNumber: number;
timestamp: number;
text: string;
isResolved: boolean;
/** "WHATSAPP" = client feedback sent by message and logged by the studio */
source?: string | null;
createdAt: string;
/** This reviewer wrote it, so they can change or remove it. Set by the API. */
canEdit?: boolean;
author: { id: string; name: string | null; image: string | null; email: string };
replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[];
}
interface Version {
id: string;
versionNumber: number;
fileUrl: string;
fps: number;
duration: number | null;
frameCount?: number | null;
/** High quality review MP4 — the player's quality switch. */
hqFileUrl?: string | null;
/** Where the High Res download comes from: this version's ProRes, or the shot's legacy file. */
highResSource?: "version" | "shot" | null;
handleHead?: number | null;
handleTail?: number | null;
pictureStartFrame?: number | null;
approvalStatus: string;
notes: string | null;
// Top-level high-res flags (normalised from either shot or task.shot)
hasHighRes?: boolean;
highResShotId?: string | null;
highResFilename?: string | null;
shot?: {
id: string;
shotCode: string;
hasHighRes?: boolean;
highResFilename?: string | null;
project: { id: string; name: string; code: string };
} | null;
task?: {
id: string;
title: string;
type: string;
project: { id: string; name: string; code: string };
shot?: { id: string; shotCode: string } | null;
asset?: { assetCode: string; name: string } | null;
} | null;
}
const APPROVAL_STATUS_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
const QUEUE_KEY = 'client-portal-queue';
export default function ClientReviewPage({
params,
}: {
params: Promise<{ token: string; versionId: string }>;
}) {
const [token, setToken] = useState("");
const [version, setVersion] = useState<Version | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [requiresPassword, setRequiresPassword] = useState(false);
const [versionId, setVersionId] = useState("");
const currentFrame = useReviewStore((s) => s.currentFrame);
// Comment form state
const [commentText, setCommentText] = useState("");
const [commentFrame, setCommentFrame] = useState<number | null>(null);
const [submittingComment, setSubmittingComment] = useState(false);
// Editing / deleting one's own notes
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [deletingComment, setDeletingComment] = useState<Comment | null>(null);
const [deleting, setDeleting] = useState(false);
// Approval dialog
const [approvalDialog, setApprovalDialog] = useState<{
open: boolean;
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
}>({ open: false, status: null });
const [approvalNotes, setApprovalNotes] = useState("");
const [submittingApproval, setSubmittingApproval] = useState(false);
const [currentApprovalStatus, setCurrentApprovalStatus] = useState("PENDING_REVIEW");
const [nextReview, setNextReview] = useState<{ versionId: string; label: string } | null>(null);
const [prevReview, setPrevReview] = useState<{ versionId: string; label: string } | null>(null);
const [copiedTaskName, setCopiedTaskName] = useState(false);
const [downloadingHighRes, setDownloadingHighRes] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(true);
const playerRef = useRef<ReviewPlayerRef>(null);
const { toast } = useToast();
useEffect(() => {
params.then(({ token: t, versionId: vId }) => {
setToken(t);
setVersionId(vId);
loadVersion(t, vId);
});
}, [params]);
useEffect(() => {
if (!versionId) return;
try {
const queueJson = sessionStorage.getItem(QUEUE_KEY);
if (!queueJson) { setNextReview(null); setPrevReview(null); return; }
const queue: Array<{ versionId: string; label: string }> = JSON.parse(queueJson);
const idx = queue.findIndex((item) => item.versionId === versionId);
setNextReview(idx >= 0 && idx < queue.length - 1 ? queue[idx + 1] : null);
setPrevReview(idx > 0 ? queue[idx - 1] : null);
} catch {
setNextReview(null);
setPrevReview(null);
}
}, [versionId]);
const loadVersion = (t: string, vId: string) => {
setLoading(true);
setError(null);
fetch(`/api/client/${t}/versions/${vId}`)
.then(async (r) => {
if (r.status === 401) {
const data = await r.json().catch(() => ({}));
if (data.requiresPassword) {
setRequiresPassword(true);
return null;
}
}
if (!r.ok) throw new Error("Invalid or expired review link");
return r.json();
})
.then((data) => {
if (!data) return;
setVersion(data.version);
setComments(data.comments);
setCurrentApprovalStatus(data.version.approvalStatus);
setRequiresPassword(false);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
};
const refreshComments = useCallback(async (t: string, vId: string) => {
const res = await fetch(`/api/client/${t}/versions/${vId}`);
if (res.ok) {
const data = await res.json();
setComments(data.comments);
}
}, []);
const handlePlayerAddComment = useCallback((frameNumber: number, _timestamp: number) => {
playerRef.current?.pause();
setCommentFrame(frameNumber);
}, []);
const handleSubmitComment = async () => {
if (!commentText.trim() || commentFrame === null || !version) return;
setSubmittingComment(true);
try {
const res = await fetch(`/api/client/${token}/comment`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionId: version.id,
frameNumber: commentFrame,
timestamp: commentFrame / version.fps,
text: commentText.trim(),
}),
});
if (!res.ok) throw new Error("Failed to post comment");
setCommentText("");
setCommentFrame(null);
await refreshComments(token, version.id);
toast({ title: "Comment added" });
} catch {
toast({ title: "Failed to add comment", variant: "destructive" });
} finally {
setSubmittingComment(false);
}
};
const beginEditComment = (comment: Comment) => {
setEditingCommentId(comment.id);
setEditingText(comment.text);
};
const cancelEditComment = () => {
setEditingCommentId(null);
setEditingText("");
};
const handleSaveEditedComment = async () => {
const trimmed = editingText.trim();
if (!editingCommentId || !trimmed || !version) return;
setSavingEdit(true);
try {
const res = await fetch(`/api/client/${token}/comment/${editingCommentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: trimmed }),
});
if (!res.ok) throw new Error("Failed to save note");
cancelEditComment();
await refreshComments(token, version.id);
toast({ title: "Note updated" });
} catch {
toast({ title: "Failed to update note", variant: "destructive" });
} finally {
setSavingEdit(false);
}
};
const handleDeleteComment = async () => {
if (!deletingComment || !version) return;
setDeleting(true);
try {
const res = await fetch(`/api/client/${token}/comment/${deletingComment.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete note");
setDeletingComment(null);
// Deleting a drawing note also removes its strokes server-side, and those
// live in the canvas's own state — a full reload remounts the player so
// the frame does not keep showing marks that no longer exist.
loadVersion(token, version.id);
toast({ title: "Note deleted" });
} catch {
toast({ title: "Failed to delete note", variant: "destructive" });
} finally {
setDeleting(false);
}
};
const handleDownloadHighRes = async () => {
const shotId = version?.highResShotId ?? version?.shot?.id;
if (!version || !token) return;
const endpoint =
version.highResSource === "version"
? `/api/client/${token}/versions/${version.id}/highres/download`
: shotId
? `/api/client/${token}/shots/${shotId}/highres/download`
: null;
if (!endpoint) return;
setDownloadingHighRes(true);
try {
const res = await fetch(endpoint);
if (!res.ok) throw new Error("Could not get download link");
const { url } = await res.json();
// Open the presigned URL — the Content-Disposition header forces a download
window.open(url, "_blank", "noopener,noreferrer");
} catch {
toast({ title: "Download failed", variant: "destructive" });
} finally {
setDownloadingHighRes(false);
}
};
const handleSubmitApproval = async () => {
if (!approvalDialog.status || !version) return;
setSubmittingApproval(true);
try {
const res = await fetch(`/api/client/${token}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionId: version.id,
status: approvalDialog.status,
notes: approvalNotes,
}),
});
if (!res.ok) throw new Error("Failed to submit decision");
setCurrentApprovalStatus(approvalDialog.status);
setApprovalDialog({ open: false, status: null });
setApprovalNotes("");
toast({
title:
approvalDialog.status === "APPROVED"
? "Shot approved!"
: approvalDialog.status === "REJECTED"
? "Version rejected"
: "Changes requested",
description:
approvalDialog.status === "APPROVED"
? "The shot has been marked as client approved."
: undefined,
});
} catch {
toast({ title: "Failed to submit decision", variant: "destructive" });
} finally {
setSubmittingApproval(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (requiresPassword) {
return (
<ReviewPasswordGate
token={token}
onUnlocked={() => loadVersion(token, versionId)}
/>
);
}
if (error || !version) {
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
<Film className="h-10 w-10 text-zinc-600" />
<h1 className="text-xl font-semibold text-white">Version unavailable</h1>
<p className="text-zinc-400 text-sm">{error}</p>
</div>
);
}
const approvalStyle = APPROVAL_STATUS_STYLES[currentApprovalStatus] ?? "";
return (
<div className="flex flex-col h-screen bg-zinc-950 text-white overflow-hidden">
{/* Header */}
{/* Phones: back + shot + prev/next on the first row, status and decision
buttons on a second full-width row. From md: the three-column grid. */}
<header className="flex flex-wrap items-center gap-2 md:grid md:grid-cols-[1fr_auto_1fr] px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
{/* Left: back + shot path */}
<div className="flex items-center gap-3 min-w-0 flex-1 md:flex-none">
<Link
href={`/client/${token}`}
className="text-zinc-400 hover:text-white transition-colors flex items-center gap-1.5 text-sm shrink-0"
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">All Shots</span>
</Link>
<div className="h-4 w-px bg-zinc-700 shrink-0" />
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded bg-black flex items-center justify-center shrink-0">
<Image src="/logo.svg" alt="Logo" width={14} height={14} />
</div>
{(() => {
const project = version.shot?.project ?? version.task?.project;
const contextCode =
version.shot?.shotCode ??
version.task?.shot?.shotCode ??
version.task?.asset?.assetCode ??
null;
const taskName = version.task?.title ?? null;
return (
<>
<span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span>
<span className="text-zinc-600">/</span>
{contextCode && (
<span className="font-mono font-semibold truncate">{contextCode}</span>
)}
{taskName && (
<>
<span className="text-zinc-600">/</span>
<span className="font-mono text-sm text-zinc-300 truncate hidden sm:block">{taskName}</span>
<button
onClick={() => {
navigator.clipboard.writeText(taskName).then(() => {
setCopiedTaskName(true);
setTimeout(() => setCopiedTaskName(false), 1500);
});
}}
className="shrink-0 p-0.5 rounded text-zinc-500 hover:text-zinc-300 transition-colors"
title="Copy task name"
>
{copiedTaskName ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</>
)}
</>
);
})()}
</div>
</div>
{/* Center: status badge + decision buttons (their own row on phones) */}
<div className="order-last md:order-none flex w-full md:w-auto items-center gap-2 shrink-0">
<span
className={cn(
"text-xs px-2.5 py-1 rounded-full border inline-flex items-center gap-1 shrink-0 mr-auto md:mr-0",
approvalStyle
)}
>
{currentApprovalStatus === "PENDING_REVIEW" ? (
<Clock className="h-3 w-3" />
) : currentApprovalStatus === "APPROVED" ? (
<CheckCircle2 className="h-3 w-3" />
) : currentApprovalStatus === "NEEDS_CHANGES" ? (
<AlertCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{currentApprovalStatus.replace(/_/g, " ")}
</span>
<Button
size="sm"
variant="outline"
className="h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
onClick={() => setApprovalDialog({ open: true, status: "NEEDS_CHANGES" })}
>
<AlertCircle className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Needs Changes</span>
</Button>
<Button
size="sm"
className="h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
onClick={() => setApprovalDialog({ open: true, status: "APPROVED" })}
>
<CheckCircle2 className="h-3.5 w-3.5" />
Approve
</Button>
{(version.hasHighRes || version.shot?.hasHighRes) && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs gap-1 text-sky-400 border-sky-500/30 hover:bg-sky-500/10"
disabled={downloadingHighRes}
onClick={handleDownloadHighRes}
title={version.highResFilename ?? version.shot?.highResFilename ?? "Download high-res file"}
>
<Download className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{downloadingHighRes ? "Getting link…" : "High Res"}</span>
</Button>
)}
</div>
{/* Right: Prev / Next navigation */}
<div className="flex items-center gap-2 justify-end shrink-0">
{prevReview ? (
<Link
href={`/client/${token}/review/${prevReview.versionId}`}
className="inline-flex items-center gap-1 text-sm font-medium text-zinc-300 hover:text-white transition-colors shrink-0"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">Prev</span>
<span className="font-mono text-xs text-zinc-500 hidden md:inline">{prevReview.label}</span>
</Link>
) : (
<span className="w-14" />
)}
{(prevReview || nextReview) && <div className="h-6 w-px bg-zinc-700" />}
{nextReview ? (
<Link
href={`/client/${token}/review/${nextReview.versionId}`}
className="inline-flex items-center gap-1 text-sm font-medium text-zinc-300 hover:text-white transition-colors shrink-0"
>
<span className="font-mono text-xs text-zinc-500 hidden md:inline">{nextReview.label}</span>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="h-4 w-4" />
</Link>
) : (
<span className="w-14" />
)}
</div>
</header>
{/* Main: player + comments — stacked on mobile, side-by-side on md+ */}
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
{/* Player */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
<ReviewPlayer
ref={playerRef}
videoUrl={version.fileUrl}
fps={version.fps}
comments={comments as any}
versionId={version.id}
onAddComment={handlePlayerAddComment}
handles={version}
hqVideoUrl={version.hqFileUrl}
// The director watches the cut as cut; handles are still one
// toggle away, and flagged whenever the playhead is in one.
defaultEditRangeOnly
saveAnnotationFn={async (data) => {
const res = await fetch(`/api/client/${token}/annotation`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to save annotation");
}}
addCommentFn={async (data) => {
const res = await fetch(`/api/client/${token}/comment`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to post comment");
await refreshComments(token, version.id);
}}
/>
</div>
{/* Comment panel — collapsible */}
<div
className={cn(
"shrink-0 flex flex-col border-t md:border-t-0 md:border-l border-zinc-800 bg-zinc-900 transition-all duration-200",
commentsOpen ? "h-64 md:h-auto md:w-72 xl:w-80" : "h-10 md:h-auto md:w-10"
)}
>
{/* Collapse toggle */}
<button
onClick={() => setCommentsOpen((o) => !o)}
className="flex items-center justify-center h-10 w-full border-b border-zinc-800 hover:bg-zinc-800/60 transition-colors shrink-0"
title={commentsOpen ? "Collapse notes" : "Expand notes"}
>
{/* Below the video on phones, so down/up there and right/left from md. */}
{commentsOpen ? (
<>
<ChevronDown className="h-4 w-4 text-zinc-400 md:hidden" />
<ChevronRight className="h-4 w-4 text-zinc-400 hidden md:block" />
</>
) : (
<>
<ChevronUp className="h-4 w-4 text-zinc-400 md:hidden" />
<ChevronLeft className="h-4 w-4 text-zinc-400 hidden md:block" />
</>
)}
{!commentsOpen && (
<MessageSquare className="h-4 w-4 text-zinc-400 mt-1" />
)}
</button>
{commentsOpen && (
<div className="px-4 py-3 border-b border-zinc-800 flex items-center gap-2 shrink-0">
<MessageSquare className="h-4 w-4 text-zinc-400" />
<h3 className="text-sm font-semibold text-white">Notes</h3>
{comments.length > 0 && (
<span className="text-xs text-zinc-500">({comments.length})</span>
)}
</div>
)}
{commentsOpen && <ScrollArea className="flex-1 px-4 py-3">
{comments.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p>No notes yet.</p>
<p className="text-xs mt-1">Pause the video and add a note at any frame.</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment.id} className="space-y-2">
{editingCommentId === comment.id ? (
// Editing replaces the note in place, so it stays in the
// list at its own frame rather than jumping to a dialog.
<div className="rounded-lg bg-zinc-800/70 p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-400">Editing note</span>
<span className="ml-auto font-mono text-xs text-amber-400/80">
{frameToTimecode(comment.frameNumber, version.fps)}
</span>
</div>
<Textarea
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
rows={3}
maxLength={5000}
autoFocus
className="bg-zinc-900 border-zinc-700 text-sm resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSaveEditedComment();
if (e.key === "Escape") cancelEditComment();
}}
/>
<div className="flex gap-2">
<Button
size="sm"
className="h-7 text-xs"
disabled={!editingText.trim() || savingEdit}
onClick={handleSaveEditedComment}
>
{savingEdit ? "Saving…" : "Save"}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={cancelEditComment}
disabled={savingEdit}
>
Cancel
</Button>
</div>
</div>
) : (
<div
className="group rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
onClick={() => playerRef.current?.seekToFrame(comment.frameNumber)}
title={`Jump to frame ${comment.frameNumber}`}
>
<div className="flex items-center gap-2">
<Avatar className="h-5 w-5">
<AvatarFallback className="text-[9px] bg-zinc-700">
{getInitials(comment.author.name ?? comment.author.email)}
</AvatarFallback>
</Avatar>
<span className="text-xs text-zinc-300 font-medium">
{comment.source === "WHATSAPP"
? "Client feedback"
: comment.author.name ?? comment.author.email.split("@")[0]}
</span>
<span className="ml-auto font-mono text-xs text-amber-400/80">
{frameToTimecode(comment.frameNumber, version.fps)}
</span>
<HandleBadge frame={comment.frameNumber} />
</div>
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
{comment.canEdit && (
// Own notes only. The buttons stop propagation so they
// do not also seek the player to the note's frame.
<div className="flex items-center gap-1 pt-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-zinc-400 hover:text-white hover:bg-zinc-700"
onClick={(e) => {
e.stopPropagation();
beginEditComment(comment);
}}
>
<Pencil className="h-3 w-3" />
Edit
</button>
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-zinc-400 hover:text-red-400 hover:bg-zinc-700"
onClick={(e) => {
e.stopPropagation();
setDeletingComment(comment);
}}
>
<Trash2 className="h-3 w-3" />
Delete
</button>
</div>
)}
</div>
)}
{comment.replies.map((reply) => (
<div key={reply.id} className="ml-4 rounded-lg bg-zinc-800/40 border border-zinc-700/50 p-2.5">
<span className="text-xs text-zinc-400 font-medium">
{reply.author.name ?? "Reply"}
</span>
<p className="text-xs text-zinc-300 mt-1">{reply.text}</p>
</div>
))}
</div>
))}
</div>
)}
</ScrollArea>}
{commentsOpen && commentFrame !== null ? (
<div className="border-t border-zinc-800 p-3 space-y-2">
<p className="flex items-center gap-2 text-xs text-zinc-500 font-mono">
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
<HandleBadge frame={commentFrame} />
</p>
<Textarea
placeholder="Add your note..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="min-h-[80px] text-sm resize-none"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmitComment();
if (e.key === "Escape") setCommentFrame(null);
}}
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => { setCommentFrame(null); setCommentText(""); }}
>
Cancel
</Button>
<Button
size="sm"
className="flex-1 h-8 text-xs gap-1"
onClick={handleSubmitComment}
disabled={!commentText.trim() || submittingComment}
>
<Send className="h-3 w-3" />
Send
</Button>
</div>
</div>
) : commentsOpen ? (
<div className="border-t border-zinc-800 p-3">
<Button
variant="outline"
className="w-full text-sm gap-2 h-9"
onClick={() => {
playerRef.current?.pause();
setCommentFrame(currentFrame);
}}
>
<MessageSquare className="h-4 w-4" />
Add Note at Current Frame
</Button>
</div>
) : null}
</div>
</div>
{/* Approval dialog */}
<Dialog
open={approvalDialog.open}
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{approvalDialog.status === "APPROVED"
? "✅ Approve this version"
: approvalDialog.status === "NEEDS_CHANGES"
? "✏️ Request changes"
: "❌ Reject this version"}
</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
{approvalDialog.status === "APPROVED"
? "Confirm you're happy with this version. You can add an optional note below."
: "Describe what needs to change so the team can action it quickly."}
</p>
<Textarea
value={approvalNotes}
onChange={(e) => setApprovalNotes(e.target.value)}
placeholder={
approvalDialog.status === "APPROVED"
? "Looks great! (optional)"
: "Please describe the changes needed..."
}
className="min-h-[100px]"
autoFocus
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setApprovalDialog({ open: false, status: null })}
>
Cancel
</Button>
<Button
onClick={handleSubmitApproval}
disabled={submittingApproval}
className={cn(
approvalDialog.status === "APPROVED"
? "bg-emerald-600 hover:bg-emerald-500 text-white"
: approvalDialog.status === "NEEDS_CHANGES"
? "bg-orange-600 hover:bg-orange-500 text-white"
: "bg-red-700 hover:bg-red-600 text-white"
)}
>
{submittingApproval
? "Submitting..."
: approvalDialog.status === "APPROVED"
? "Approve"
: approvalDialog.status === "NEEDS_CHANGES"
? "Request Changes"
: "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation — a note may already have been actioned by the
studio, so removing it is worth one deliberate step. */}
<Dialog
open={!!deletingComment}
onOpenChange={(open) => { if (!open) setDeletingComment(null); }}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete this note?</DialogTitle>
</DialogHeader>
{deletingComment && (
<div className="space-y-2">
<p className="text-xs text-zinc-500 font-mono">
Frame {deletingComment.frameNumber} ·{" "}
{version ? frameToTimecode(deletingComment.frameNumber, version.fps) : ""}
</p>
<p className="rounded bg-zinc-800/70 p-3 text-sm text-zinc-200">
{deletingComment.text}
</p>
<p className="text-xs text-zinc-500">
This removes it for everyone, including the studio. It cannot be undone.
</p>
</div>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeletingComment(null)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteComment}
disabled={deleting}
>
{deleting ? "Deleting…" : "Delete note"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}