'use client'; import { useState, useEffect } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { Film, CheckCircle2, XCircle, AlertCircle, Clock, ChevronRight, ChevronDown, Package, RotateCcw, ArrowUpDown, Copy, Check, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Montserrat } from 'next/font/google'; import { ReviewPasswordGate } from '@/components/clients/ReviewPasswordGate'; 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; episode: string | null; 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 FlatItem { task: ClientTask | AssetTask; label: string; description: string | null; thumbnailUrl: string | null; } function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric', }); } 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 = { 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(''); const [project, setProject] = useState(null); const [shots, setShots] = useState([]); const [assetTasks, setAssetTasks] = useState([]); const [sessionLabel, setSessionLabel] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [requiresPassword, setRequiresPassword] = useState(false); const [collapsedEpisodes, setCollapsedEpisodes] = useState>(new Set()); const [statusFilter, setStatusFilter] = useState(null); const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode'); const toggleEpisode = (ep: string) => { setCollapsedEpisodes((prev) => { const next = new Set(prev); if (next.has(ep)) next.delete(ep); else next.add(ep); return next; }); }; const toggleStatusFilter = (filter: string) => { setStatusFilter((prev) => (prev === filter ? null : filter)); }; const [copiedId, setCopiedId] = useState(null); const copyToClipboard = (text: string, id: string) => { navigator.clipboard.writeText(text).then(() => { setCopiedId(id); setTimeout(() => setCopiedId(null), 1500); }); }; useEffect(() => { params.then(({ token: t }) => { setToken(t); loadProject(t); }); }, [params]); const loadProject = (t: string) => { setLoading(true); setError(null); fetch(`/api/client/${t}/project`) .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; setProject(data.project); setShots(data.shots ?? []); setAssetTasks(data.assetTasks ?? []); setSessionLabel(data.sessionLabel ?? ''); setRequiresPassword(false); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }; if (loading) { return (
); } if (requiresPassword) { return ( loadProject(token)} /> ); } if (error || !project) { return (

Review link unavailable

{error ?? 'This review link has expired or is no longer active. Please request a new link from your studio contact.'}

); } 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; // Group shots by episode; null episode → "Shots" const isEpisodic = shots.some((s) => s.episode != null); const shotsByEpisode = shots.reduce>( (acc, shot) => { const key = shot.episode ?? 'Shots'; if (!acc[key]) acc[key] = []; acc[key].push(shot); return acc; }, {}, ); const taskMatchesFilter = (t: ClientTask | AssetTask): boolean => { if (!statusFilter) return true; const status = getLatestVersion(t)?.approvalStatus; if (statusFilter === 'NEEDS_CHANGES') { return ['REJECTED', 'NEEDS_CHANGES'].includes(status ?? ''); } if (statusFilter === 'PENDING_REVIEW') { return !status || status === 'PENDING_REVIEW'; } return status === statusFilter; }; const filteredAssetTasks = assetTasks.filter(taskMatchesFilter); const allFlatItems: FlatItem[] = [ ...shots.flatMap((shot) => shot.tasks.map((task) => ({ task, label: shot.shotCode, description: shot.description, thumbnailUrl: shot.thumbnailUrl, })), ), ...assetTasks.map((task) => ({ task, label: task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type] ?? task.type, description: null, thumbnailUrl: null, })), ]; const filteredFlatItems = allFlatItems .filter((item) => taskMatchesFilter(item.task)) .sort((a, b) => { const aDate = getLatestVersion(a.task)?.createdAt ?? ''; const bDate = getLatestVersion(b.task)?.createdAt ?? ''; return bDate.localeCompare(aDate); }); return (
{/* Logo */}
Logo
TWO TALES vfx review
{sessionLabel && ( {sessionLabel} )}

{project.code}

{project.name}

{project.description && (

{project.description}

)}
{needsChanges > 0 && ( )}
{/* Controls bar */}
{statusFilter && ( Showing:{' '} {statusFilter === 'APPROVED' ? 'Approved' : statusFilter === 'PENDING_REVIEW' ? 'Awaiting Review' : 'Needs Changes'} )}
{(statusFilter !== null || sortMode !== 'episode') && ( )}
{sortMode === 'recent' ? (
{filteredFlatItems.length === 0 ? (

{statusFilter ? 'No items match the current filter.' : 'No items have been shared for review yet.'}

) : ( filteredFlatItems.map((item) => { const task = item.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 (
{item.thumbnailUrl && (
{item.label}
)}

{item.label} {item.description && ( {item.description} )}

{ver?.createdAt && (

{formatDate(ver.createdAt)}

)}

{task.title}

{TASK_TYPE_LABELS[task.type] ?? task.type}

{ver?.notes && (

“{ver.notes}”

)}
{ver ? ( {approval.label} ) : ( No versions yet )}
); }) )}
) : ( <> {Object.entries(shotsByEpisode).map(([episode, epShots]) => { const filteredEpShots = epShots .map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) })) .filter((shot) => shot.tasks.length > 0); if (filteredEpShots.length === 0) return null; const isCollapsed = collapsedEpisodes.has(episode); const epTasks = filteredEpShots.flatMap((s) => s.tasks); const epApproved = epTasks.filter( (t) => getLatestVersion(t)?.approvalStatus === 'APPROVED', ).length; const epChanges = epTasks.filter((t) => ['REJECTED', 'NEEDS_CHANGES'].includes( getLatestVersion(t)?.approvalStatus ?? '', ), ).length; const epPending = epTasks.length - epApproved - epChanges; return (
{/* Episode header — clickable */} {/* Collapsible shot list */} {!isCollapsed && (
{filteredEpShots.map((shot) => (
{shot.thumbnailUrl && (
{shot.shotCode}
)}

{shot.shotCode} {shot.description && ( {shot.description} )}

{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 (

{task.title}

{TASK_TYPE_LABELS[task.type] ?? task.type}

{ver?.notes && (

“{ver.notes}”

)}
{ver ? (
{approval.label}
) : ( No versions yet )} ); })}
))}
)}
); })} {filteredAssetTasks.length > 0 && (
{/* Assets header */} {!collapsedEpisodes.has('__assets__') && (
{filteredAssetTasks.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 (

{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}

{TASK_TYPE_LABELS[task.type] ?? task.type}

{task.title}

{ver?.notes && (

“{ver.notes}”

)}
{ver ? (
{approval.label}
) : ( No versions yet )} ); })}
)}
)} {totalTasks === 0 && (

No items have been shared for review yet.

)} {totalTasks > 0 && filteredFlatItems.length === 0 && (

No items match the current filter.

)} )}
); }