2e9fa45580
Deploy / deploy (push) Successful in 3m31s
Renders run 8 frames past the cut at each end. Directors were flagging glitches in unfixed handles as shot problems, so the player now marks them — without trimming or burning anything into the EXRs/MOV/MP4. - Version.handleHead/handleTail/pictureStartFrame, Export.handleHead/ handleTail; migration backfills pipeline exports and their preview versions from the manifest's handleFrames (else 8) - AE panel: "Includes handles [8] frames each end" -> manifest.review; older manifests fall back to source.handleFrames - finalize copies handles onto the Version; worker 1.6.4 reports the head slate length (pictureStartSeconds), else the player infers it as MP4 frames - EXR frameCount - Player: hatched HANDLE / SLATE regions and cut marks on the timeline, "HEAD/TAIL HANDLE · NOT IN EDIT" overlay on handle frames, HANDLE badge on notes, "Edit range only" toggle (default on in the client portal) - Staff Handles editor on the review page for manual uploads/corrections Also fixes two player bugs found while testing: timeToFrame floored float error (every seek landed a frame early), and J/reverse never ran (the keyboard effect's cleanup stopped it on the next render). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
864 lines
34 KiB
TypeScript
864 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;
|
|
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;
|
|
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 (!shotId || !token) return;
|
|
setDownloadingHighRes(true);
|
|
try {
|
|
const res = await fetch(`/api/client/${token}/shots/${shotId}/highres/download`);
|
|
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}
|
|
// 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.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>
|
|
);
|
|
}
|