"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; 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, } from "lucide-react"; import { useReviewStore } from "@/hooks/use-review-player"; interface Comment { id: string; frameNumber: number; timestamp: number; text: string; isResolved: boolean; createdAt: string; 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; approvalStatus: string; notes: string | null; shot?: { id: string; shotCode: string; project: { id: string; name: string; code: string }; } | null; task?: { id: string; title: string; type: string; project: { id: string; name: string; code: string }; shot?: { shotCode: string } | null; asset?: { assetCode: string; name: string } | null; } | null; } const APPROVAL_STATUS_STYLES: Record = { 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", }; export default function ClientReviewPage({ params, }: { params: Promise<{ token: string; versionId: string }>; }) { const [token, setToken] = useState(""); const [version, setVersion] = useState(null); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const currentFrame = useReviewStore((s) => s.currentFrame); // Comment form state const [commentText, setCommentText] = useState(""); const [commentFrame, setCommentFrame] = useState(null); const [submittingComment, setSubmittingComment] = 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 playerRef = useRef(null); const { toast } = useToast(); useEffect(() => { params.then(({ token: t, versionId }) => { setToken(t); fetch(`/api/client/${t}/versions/${versionId}`) .then((r) => { if (!r.ok) throw new Error("Invalid or expired review link"); return r.json(); }) .then((data) => { setVersion(data.version); setComments(data.comments); setCurrentApprovalStatus(data.version.approvalStatus); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }); }, [params]); 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 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" ? "Version approved!" : approvalDialog.status === "REJECTED" ? "Version rejected" : "Changes requested", }); } catch { toast({ title: "Failed to submit decision", variant: "destructive" }); } finally { setSubmittingApproval(false); } }; if (loading) { return (
); } if (error || !version) { return (

Version unavailable

{error}

); } const approvalStyle = APPROVAL_STATUS_STYLES[currentApprovalStatus] ?? ""; return (
{/* Header */}
All Shots
{(() => { const project = version.shot?.project ?? version.task?.project; const contextCode = version.shot?.shotCode ?? version.task?.shot?.shotCode ?? version.task?.asset?.assetCode ?? null; return ( <> {project?.code} / {contextCode && ( <> {contextCode} / )} v{String(version.versionNumber).padStart(3, "0")} ); })()}
{/* Decision buttons */}
{/* Main: player + comments */}
{/* Player */}
{/* Comment panel */}

Notes {comments.length > 0 && ( ({comments.length}) )}

{comments.length === 0 ? (

No notes yet.

Pause the video and add a note at any frame.

) : (
{comments.map((comment) => (
playerRef.current?.seekToFrame(comment.frameNumber)} title={`Jump to frame ${comment.frameNumber}`} >
{getInitials(comment.author.name ?? comment.author.email)} {comment.author.name ?? comment.author.email.split("@")[0]} {frameToTimecode(comment.frameNumber, version.fps)}

{comment.text}

{comment.replies.map((reply) => (
{reply.author.name ?? "Reply"}

{reply.text}

))}
))}
)}
{/* Add note */} {commentFrame !== null ? (

Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}