390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
|
|
import { CommentPanel } from "@/components/comments/CommentPanel";
|
|
import { VersionList } from "@/components/versions/VersionList";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { cn } from "@/lib/utils";
|
|
import { submitApproval } from "@/actions/approvals";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
|
|
import {
|
|
CheckCircle2,
|
|
XCircle,
|
|
AlertCircle,
|
|
ChevronDown,
|
|
ArrowLeft,
|
|
Film,
|
|
} from "lucide-react";
|
|
|
|
interface ReviewPageClientProps {
|
|
version: {
|
|
id: string;
|
|
versionNumber: number;
|
|
fileUrl: string;
|
|
fps: number;
|
|
duration: number | null;
|
|
frameCount: number | null;
|
|
approvalStatus: string;
|
|
notes: string | null;
|
|
isClientVisible: boolean;
|
|
shot?: {
|
|
id: string;
|
|
shotCode: string;
|
|
project: { id: string; name: string; code: string };
|
|
versions: {
|
|
id: string;
|
|
versionNumber: number;
|
|
approvalStatus: string;
|
|
isLatest: boolean;
|
|
fps: number;
|
|
duration: number | null;
|
|
notes: string | null;
|
|
fileSize: number | null;
|
|
createdAt: Date;
|
|
}[];
|
|
} | null;
|
|
task?: {
|
|
id: string;
|
|
title: string;
|
|
project: { id: string; name: string; code: string };
|
|
shot?: { id: string; shotCode: string } | null;
|
|
asset?: { id: string; assetCode: string; name: string } | null;
|
|
versions: {
|
|
id: string;
|
|
versionNumber: number;
|
|
approvalStatus: string;
|
|
isLatest: boolean;
|
|
fps: number;
|
|
duration: number | null;
|
|
notes: string | null;
|
|
fileSize: number | null;
|
|
createdAt: Date;
|
|
}[];
|
|
} | null;
|
|
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
|
};
|
|
comments: any[];
|
|
annotations: any[];
|
|
canApprove: boolean;
|
|
canShare: boolean;
|
|
currentUserId: string;
|
|
}
|
|
|
|
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",
|
|
};
|
|
|
|
export function ReviewPageClient({
|
|
version,
|
|
comments: initialComments,
|
|
annotations: initialAnnotations,
|
|
canApprove,
|
|
canShare,
|
|
currentUserId,
|
|
}: ReviewPageClientProps) {
|
|
const [comments, setComments] = useState(initialComments);
|
|
const [annotations, setAnnotations] = useState(initialAnnotations);
|
|
const [pendingFrame, setPendingFrame] = useState<number | null>(null);
|
|
const [approvalDialog, setApprovalDialog] = useState<{
|
|
open: boolean;
|
|
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
|
|
}>({ open: false, status: null });
|
|
const [approvalNotes, setApprovalNotes] = useState("");
|
|
const [isSubmittingApproval, setIsSubmittingApproval] = useState(false);
|
|
const [showVersions, setShowVersions] = useState(false);
|
|
|
|
const playerRef = useRef<ReviewPlayerRef>(null);
|
|
const { toast } = useToast();
|
|
const router = useRouter();
|
|
|
|
const handleAddComment = useCallback(() => {
|
|
// Pause player first
|
|
playerRef.current?.pause();
|
|
const frame = playerRef.current ? undefined : undefined;
|
|
// Frame is tracked in ReviewPlayer store — CommentPanel reads from there
|
|
setPendingFrame(Date.now()); // trigger effect with timestamp trick
|
|
}, []);
|
|
|
|
const handleCommentsChange = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`/api/versions/${version.id}/comments`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setComments(data.comments);
|
|
}
|
|
} catch {
|
|
// silently ignore
|
|
}
|
|
}, [version.id]);
|
|
|
|
const handleAnnotationSaved = useCallback(async () => {
|
|
// Refresh both annotations (for timeline markers) and comments (for the auto-comment)
|
|
try {
|
|
const [annRes, cmtRes] = await Promise.all([
|
|
fetch(`/api/versions/${version.id}/annotations`),
|
|
fetch(`/api/versions/${version.id}/comments`),
|
|
]);
|
|
if (annRes.ok) setAnnotations((await annRes.json()).annotations);
|
|
if (cmtRes.ok) setComments((await cmtRes.json()).comments);
|
|
} catch {
|
|
// silently ignore
|
|
}
|
|
}, [version.id]);
|
|
|
|
const handleApprovalSubmit = async () => {
|
|
if (!approvalDialog.status) return;
|
|
setIsSubmittingApproval(true);
|
|
try {
|
|
await submitApproval({
|
|
versionId: version.id,
|
|
status: approvalDialog.status,
|
|
notes: approvalNotes,
|
|
});
|
|
toast({
|
|
title:
|
|
approvalDialog.status === "APPROVED"
|
|
? "Version approved!"
|
|
: approvalDialog.status === "REJECTED"
|
|
? "Version rejected"
|
|
: "Changes requested",
|
|
});
|
|
setApprovalDialog({ open: false, status: null });
|
|
setApprovalNotes("");
|
|
router.refresh();
|
|
} catch (err) {
|
|
toast({
|
|
title: "Failed to submit approval",
|
|
description: (err as Error).message,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsSubmittingApproval(false);
|
|
}
|
|
};
|
|
|
|
const openApproval = (status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES") => {
|
|
setApprovalDialog({ open: true, status });
|
|
};
|
|
|
|
// Resolve context: shot or task
|
|
const project = version.shot?.project ?? version.task?.project;
|
|
const contextCode =
|
|
version.shot?.shotCode ??
|
|
version.task?.shot?.shotCode ??
|
|
version.task?.asset?.assetCode ??
|
|
null;
|
|
const backHref = version.shot
|
|
? `/projects/${project?.id}/shots/${version.shot.id}`
|
|
: version.task
|
|
? `/tasks/${version.task.id}`
|
|
: `/dashboard`;
|
|
const versionList = version.shot?.versions ?? version.task?.versions ?? [];
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen bg-background overflow-hidden">
|
|
{/* Top bar */}
|
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card shrink-0">
|
|
<Link
|
|
href={backHref}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Link>
|
|
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
<span className="text-xs text-muted-foreground hidden sm:block">
|
|
{project?.code}
|
|
</span>
|
|
<span className="text-muted-foreground">/</span>
|
|
{contextCode && (
|
|
<>
|
|
<span className="font-mono text-sm font-semibold">{contextCode}</span>
|
|
<span className="text-muted-foreground">/</span>
|
|
</>
|
|
)}
|
|
|
|
{/* Version selector */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-7 gap-1 font-mono text-sm">
|
|
<Film className="h-3.5 w-3.5" />
|
|
v{String(version.versionNumber).padStart(3, "0")}
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
{versionList.map((v) => (
|
|
<DropdownMenuItem key={v.id} asChild>
|
|
<Link href={`/review/${v.id}`}>
|
|
v{String(v.versionNumber).padStart(3, "0")}
|
|
{v.isLatest && " (latest)"}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{/* Approval status */}
|
|
<span
|
|
className={cn(
|
|
"text-xs px-2 py-0.5 rounded-full border hidden sm:inline-flex items-center gap-1",
|
|
APPROVAL_STATUS_STYLES[version.approvalStatus] ?? ""
|
|
)}
|
|
>
|
|
{version.approvalStatus.replace("_", " ")}
|
|
</span>
|
|
|
|
{/* Share with Client */}
|
|
{canShare && (
|
|
<ShareWithClientButton
|
|
versionId={version.id}
|
|
isAlreadyShared={version.isClientVisible}
|
|
onShared={() => {/* router.refresh() handled inside component */}}
|
|
/>
|
|
)}
|
|
|
|
{/* Approval actions */}
|
|
{canApprove && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
|
onClick={() => openApproval("NEEDS_CHANGES")}
|
|
>
|
|
<AlertCircle className="h-3 w-3" />
|
|
<span className="hidden sm:inline">Needs Changes</span>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-xs gap-1 text-red-400 border-red-500/30 hover:bg-red-500/10"
|
|
onClick={() => openApproval("REJECTED")}
|
|
>
|
|
<XCircle className="h-3 w-3" />
|
|
<span className="hidden sm:inline">Reject</span>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="h-7 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
|
|
onClick={() => openApproval("APPROVED")}
|
|
>
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
<span className="hidden sm:inline">Approve</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main content — player + comments */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Player — 70% */}
|
|
<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}
|
|
annotations={annotations}
|
|
versionId={version.id}
|
|
onAddComment={handleAddComment}
|
|
onAnnotationSaved={handleAnnotationSaved}
|
|
/>
|
|
</div>
|
|
|
|
{/* Comment panel — 30%, min 280px */}
|
|
<div className="w-80 xl:w-96 shrink-0 flex flex-col border-l border-border">
|
|
<CommentPanel
|
|
versionId={version.id}
|
|
fps={version.fps}
|
|
comments={comments}
|
|
onCommentsChange={handleCommentsChange}
|
|
pendingFrame={pendingFrame}
|
|
onPendingFrameCleared={() => setPendingFrame(null)}
|
|
onSeekToFrame={(frame) => playerRef.current?.seekToFrame(frame)}
|
|
/>
|
|
</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 Version"
|
|
: approvalDialog.status === "REJECTED"
|
|
? "Reject Version"
|
|
: "Request Changes"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<Label>Notes (optional)</Label>
|
|
<Textarea
|
|
value={approvalNotes}
|
|
onChange={(e) => setApprovalNotes(e.target.value)}
|
|
placeholder={
|
|
approvalDialog.status === "APPROVED"
|
|
? "Any final comments..."
|
|
: "Describe the required changes..."
|
|
}
|
|
className="min-h-[100px]"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setApprovalDialog({ open: false, status: null })}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleApprovalSubmit}
|
|
disabled={isSubmittingApproval}
|
|
className={
|
|
approvalDialog.status === "APPROVED"
|
|
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
|
: approvalDialog.status === "REJECTED"
|
|
? "bg-red-600 hover:bg-red-500 text-white"
|
|
: "bg-orange-600 hover:bg-orange-500 text-white"
|
|
}
|
|
>
|
|
{isSubmittingApproval ? "Submitting..." : "Confirm"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|