Initial commit
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
Film,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Montserrat } from 'next/font/google';
|
||||
|
||||
const montserrat = Montserrat({
|
||||
subsets: ['latin'],
|
||||
weight: ['200', '500', '600'],
|
||||
});
|
||||
|
||||
interface ClientVersion {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
approvalStatus: string;
|
||||
fps: number;
|
||||
duration: number | null;
|
||||
thumbnailUrl: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ClientTask {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
versions: ClientVersion[];
|
||||
}
|
||||
|
||||
interface ClientShot {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
sequence: string | null;
|
||||
description: string | null;
|
||||
status: string;
|
||||
thumbnailUrl: string | null;
|
||||
tasks: ClientTask[];
|
||||
}
|
||||
|
||||
interface AssetTask extends ClientTask {
|
||||
asset?: { id: string; assetCode: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const APPROVAL_STYLES: Record<
|
||||
string,
|
||||
{ label: string; className: string; Icon: React.ElementType }
|
||||
> = {
|
||||
PENDING_REVIEW: {
|
||||
label: 'Awaiting Review',
|
||||
className: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
Icon: Clock,
|
||||
},
|
||||
APPROVED: {
|
||||
label: 'Approved',
|
||||
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
Icon: CheckCircle2,
|
||||
},
|
||||
REJECTED: {
|
||||
label: 'Rejected',
|
||||
className: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
Icon: XCircle,
|
||||
},
|
||||
NEEDS_CHANGES: {
|
||||
label: 'Needs Changes',
|
||||
className: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
||||
Icon: AlertCircle,
|
||||
},
|
||||
};
|
||||
|
||||
const TASK_TYPE_LABELS: Record<string, string> = {
|
||||
TRACK: 'Tracking',
|
||||
ROTO: 'Roto',
|
||||
KEY: 'Keying',
|
||||
COMP: 'Comp',
|
||||
FX: 'FX',
|
||||
LIGHTING: 'Lighting',
|
||||
RENDER: 'Render',
|
||||
ANIMATION: 'Animation',
|
||||
MODEL: 'Model',
|
||||
TEXTURE: 'Texture',
|
||||
RIG: 'Rig',
|
||||
LOOKDEV: 'Lookdev',
|
||||
GENERAL: 'Task',
|
||||
};
|
||||
|
||||
function getLatestVersion(task: ClientTask): ClientVersion | undefined {
|
||||
return task.versions[0];
|
||||
}
|
||||
|
||||
export default function ClientPortalPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const [token, setToken] = useState<string>('');
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [shots, setShots] = useState<ClientShot[]>([]);
|
||||
const [assetTasks, setAssetTasks] = useState<AssetTask[]>([]);
|
||||
const [sessionLabel, setSessionLabel] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
params.then(({ token: t }) => {
|
||||
setToken(t);
|
||||
fetch(`/api/client/${t}/project`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('Invalid or expired review link');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setProject(data.project);
|
||||
setShots(data.shots ?? []);
|
||||
setAssetTasks(data.assetTasks ?? []);
|
||||
setSessionLabel(data.sessionLabel ?? '');
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
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 (error || !project) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center">
|
||||
<Film className="h-6 w-6 text-black" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-white">
|
||||
Review link unavailable
|
||||
</h1>
|
||||
<p className="text-zinc-400 text-sm max-w-sm">
|
||||
{error ??
|
||||
'This review link has expired or is no longer active. Please request a new link from your studio contact.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allTasks = [...shots.flatMap((s) => s.tasks), ...assetTasks];
|
||||
const totalTasks = allTasks.length;
|
||||
const approved = allTasks.filter(
|
||||
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
|
||||
).length;
|
||||
const needsChanges = allTasks.filter((t) =>
|
||||
['REJECTED', 'NEEDS_CHANGES'].includes(
|
||||
getLatestVersion(t)?.approvalStatus ?? '',
|
||||
),
|
||||
).length;
|
||||
const pending = totalTasks - approved - needsChanges;
|
||||
|
||||
const shotsBySequence = shots.reduce<Record<string, ClientShot[]>>(
|
||||
(acc, shot) => {
|
||||
const seq = shot.sequence ?? 'Shots';
|
||||
if (!acc[seq]) acc[seq] = [];
|
||||
acc[seq].push(shot);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white">
|
||||
<header className="border-b border-zinc-800 bg-zinc-900">
|
||||
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
|
||||
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
|
||||
</div>
|
||||
|
||||
<div className={montserrat.className}>
|
||||
<span className="block text-2xl font-light text-white leading-none">
|
||||
TWO TALES
|
||||
</span>
|
||||
|
||||
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
|
||||
vfx review
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{sessionLabel && (
|
||||
<span className="text-sm text-zinc-400">{sessionLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="border-b border-zinc-800 bg-zinc-900/50">
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider font-medium mb-1">
|
||||
{project.code}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{project.name}</h1>
|
||||
{project.description && (
|
||||
<p className="text-zinc-400 text-sm max-w-xl">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mt-6">
|
||||
<div className="bg-zinc-800 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||
<p className="text-2xl font-bold text-white">{totalTasks}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">Items</p>
|
||||
</div>
|
||||
<div className="bg-emerald-900/30 border border-emerald-800/30 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||
<p className="text-2xl font-bold text-emerald-400">{approved}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">Approved</p>
|
||||
</div>
|
||||
<div className="bg-amber-900/20 border border-amber-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||
<p className="text-2xl font-bold text-amber-400">{pending}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p>
|
||||
</div>
|
||||
{needsChanges > 0 && (
|
||||
<div className="bg-red-900/20 border border-red-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{needsChanges}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-10">
|
||||
{Object.entries(shotsBySequence).map(([sequence, seqShots]) => (
|
||||
<div key={sequence}>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
|
||||
{sequence}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{seqShots.map((shot) => (
|
||||
<div key={shot.id} className="space-y-1">
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
{shot.thumbnailUrl && (
|
||||
<div className="relative flex-shrink-0 w-40 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
alt={shot.shotCode}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="font-mono text-sm font-semibold text-zinc-300">
|
||||
{shot.shotCode}
|
||||
{shot.description && (
|
||||
<span className="font-sans font-normal text-zinc-500 ml-2">
|
||||
{shot.description}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
|
||||
{shot.tasks.map((task) => {
|
||||
const ver = getLatestVersion(task);
|
||||
const approvalKey =
|
||||
ver?.approvalStatus ?? 'PENDING_REVIEW';
|
||||
const approval =
|
||||
APPROVAL_STYLES[approvalKey] ??
|
||||
APPROVAL_STYLES.PENDING_REVIEW;
|
||||
const ApprovalIcon = approval.Icon;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||
className={cn(
|
||||
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
|
||||
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||
!ver && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white">
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||
</p>
|
||||
{ver?.notes && (
|
||||
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
||||
“{ver.notes}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{ver ? (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs font-mono text-zinc-500">
|
||||
v{String(ver.versionNumber).padStart(3, '0')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
|
||||
approval.className,
|
||||
)}
|
||||
>
|
||||
<ApprovalIcon className="h-3 w-3" />
|
||||
{approval.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 shrink-0">
|
||||
No versions yet
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{assetTasks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
|
||||
Assets
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{assetTasks.map((task) => {
|
||||
const ver = getLatestVersion(task);
|
||||
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
|
||||
const approval =
|
||||
APPROVAL_STYLES[approvalKey] ??
|
||||
APPROVAL_STYLES.PENDING_REVIEW;
|
||||
const ApprovalIcon = approval.Icon;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||
className={cn(
|
||||
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
|
||||
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||
!ver && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<Package className="h-4 w-4 text-zinc-500 shrink-0" />
|
||||
<div className="min-w-[90px]">
|
||||
<p className="font-mono font-semibold text-white text-sm">
|
||||
{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-300 truncate">
|
||||
{task.title}
|
||||
</p>
|
||||
{ver?.notes && (
|
||||
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
||||
“{ver.notes}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{ver ? (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs font-mono text-zinc-500">
|
||||
v{String(ver.versionNumber).padStart(3, '0')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
|
||||
approval.className,
|
||||
)}
|
||||
>
|
||||
<ApprovalIcon className="h-3 w-3" />
|
||||
{approval.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 shrink-0">
|
||||
No versions yet
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalTasks === 0 && (
|
||||
<div className="text-center py-16 text-zinc-500">
|
||||
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No items have been shared for review yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-zinc-800 py-6 text-center text-xs text-zinc-600">
|
||||
Powered by <span className="text-zinc-400">TTDEV</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
"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<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 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 currentFrame = useReviewStore((s) => s.currentFrame);
|
||||
|
||||
// Comment form state
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [commentFrame, setCommentFrame] = useState<number | null>(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<ReviewPlayerRef>(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 (
|
||||
<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 (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 */}
|
||||
<header className="flex items-center gap-3 px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
|
||||
<Link
|
||||
href={`/client/${token}`}
|
||||
className="text-zinc-400 hover:text-white transition-colors flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">All Shots</span>
|
||||
</Link>
|
||||
|
||||
<div className="h-4 w-px bg-zinc-700" />
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-5 h-5 rounded bg-amber-500 flex items-center justify-center shrink-0">
|
||||
<Film className="h-3 w-3 text-black" />
|
||||
</div>
|
||||
{(() => {
|
||||
const project = version.shot?.project ?? version.task?.project;
|
||||
const contextCode =
|
||||
version.shot?.shotCode ??
|
||||
version.task?.shot?.shotCode ??
|
||||
version.task?.asset?.assetCode ??
|
||||
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">{contextCode}</span>
|
||||
<span className="text-zinc-600">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="font-mono text-sm text-zinc-300">
|
||||
v{String(version.versionNumber).padStart(3, "0")}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2.5 py-1 rounded-full border hidden sm:inline-flex items-center gap-1",
|
||||
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>
|
||||
|
||||
{/* Decision buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<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" />
|
||||
<span className="hidden sm:inline">Approve</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main: player + comments */}
|
||||
<div className="flex 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment panel */}
|
||||
<div className="w-72 xl:w-80 shrink-0 flex flex-col border-l border-zinc-800 bg-zinc-900">
|
||||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-zinc-400" />
|
||||
Notes
|
||||
{comments.length > 0 && (
|
||||
<span className="text-xs text-zinc-500">({comments.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div
|
||||
className="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>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
|
||||
</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>
|
||||
|
||||
{/* Add note */}
|
||||
{commentFrame !== null ? (
|
||||
<div className="border-t border-zinc-800 p-3 space-y-2">
|
||||
<p className="text-xs text-zinc-500 font-mono">
|
||||
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user