Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+389
View File
@@ -0,0 +1,389 @@
"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>
);
}