'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, Search, X, } 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; episodeKey: string; } 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', }; const SCROLL_KEY = 'client-portal-scroll'; const QUEUE_KEY = 'client-portal-queue'; const STATE_KEY = 'client-portal-state'; const PREFS_KEY_PREFIX = 'client-portal-prefs-'; 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 [searchQuery, setSearchQuery] = useState(''); const [episodeFilter, setEpisodeFilter] = useState(null); 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]); useEffect(() => { if (!loading && token) { const savedState = sessionStorage.getItem(STATE_KEY); if (savedState) { sessionStorage.removeItem(STATE_KEY); try { const { statusFilter: sf, sortMode: sm, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(savedState); if (sf !== undefined) setStatusFilter(sf); if (sm !== undefined) setSortMode(sm); if (sq !== undefined) setSearchQuery(sq ?? ''); if (ef !== undefined) setEpisodeFilter(ef); if (ce !== undefined) setCollapsedEpisodes(new Set(ce)); } catch { /* ignore */ } } else { try { const prefs = localStorage.getItem(PREFS_KEY_PREFIX + token); if (prefs) { const { statusFilter: sf, sortMode: sm, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(prefs); if (sf !== undefined) setStatusFilter(sf); if (sm !== undefined) setSortMode(sm); if (sq !== undefined) setSearchQuery(sq ?? ''); if (ef !== undefined) setEpisodeFilter(ef); if (ce !== undefined) setCollapsedEpisodes(new Set(ce)); } } catch { /* ignore */ } } const saved = sessionStorage.getItem(SCROLL_KEY); if (saved) { sessionStorage.removeItem(SCROLL_KEY); requestAnimationFrame(() => { window.scrollTo(0, parseInt(saved, 10)); }); } } }, [loading, token]); useEffect(() => { if (!token || loading) return; try { localStorage.setItem( PREFS_KEY_PREFIX + token, JSON.stringify({ statusFilter, sortMode, searchQuery, episodeFilter, collapsedEpisodes: Array.from(collapsedEpisodes), }), ); } catch { /* ignore */ } }, [token, statusFilter, sortMode, searchQuery, episodeFilter, collapsedEpisodes, loading]); 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 shotMatchesSearch = (shot: ClientShot): boolean => { if (!searchQuery.trim()) return true; const q = searchQuery.toLowerCase().trim(); return ( shot.shotCode.toLowerCase().includes(q) || (shot.description?.toLowerCase().includes(q) ?? false) || shot.tasks.some((t) => t.title.toLowerCase().includes(q)) ); }; const assetTaskMatchesSearch = (task: AssetTask): boolean => { if (!searchQuery.trim()) return true; const q = searchQuery.toLowerCase().trim(); return ( (task.asset?.assetCode?.toLowerCase().includes(q) ?? false) || task.title.toLowerCase().includes(q) ); }; const episodeKeys = Object.keys(shotsByEpisode); const showEpisodeFilters = episodeKeys.length > 1 || (episodeKeys.length >= 1 && assetTasks.length > 0); const filteredAssetTasks = assetTasks .filter(taskMatchesFilter) .filter(assetTaskMatchesSearch) .filter(() => episodeFilter === null || episodeFilter === '__assets__'); const allFlatItems: FlatItem[] = [ ...shots.flatMap((shot) => shot.tasks.map((task) => ({ task, label: shot.shotCode, description: shot.description, thumbnailUrl: shot.thumbnailUrl, episodeKey: shot.episode ?? 'Shots', })), ), ...assetTasks.map((task) => ({ task, label: task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type] ?? task.type, description: null, thumbnailUrl: null, episodeKey: '__assets__', })), ]; const filteredFlatItems = allFlatItems .filter((item) => taskMatchesFilter(item.task)) .filter((item) => episodeFilter === null || item.episodeKey === episodeFilter) .filter((item) => { if (!searchQuery.trim()) return true; const q = searchQuery.toLowerCase().trim(); return ( item.label.toLowerCase().includes(q) || (item.description?.toLowerCase().includes(q) ?? false) || item.task.title.toLowerCase().includes(q) ); }) .sort((a, b) => { const aDate = getLatestVersion(a.task)?.createdAt ?? ''; const bDate = getLatestVersion(b.task)?.createdAt ?? ''; return bDate.localeCompare(aDate); }); const getReviewQueue = (): Array<{ versionId: string; label: string }> => { if (sortMode === 'recent') { return filteredFlatItems .map((item) => { const ver = getLatestVersion(item.task); return ver ? { versionId: ver.id, label: item.label } : null; }) .filter((x): x is { versionId: string; label: string } => x !== null); } const queue: Array<{ versionId: string; label: string }> = []; Object.entries(shotsByEpisode) .filter(([episode]) => episodeFilter === null || episodeFilter === episode) .forEach(([, epShots]) => { epShots .map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) })) .filter((shot) => shot.tasks.length > 0) .filter(shotMatchesSearch) .forEach((shot) => { shot.tasks.forEach((task) => { const ver = getLatestVersion(task); if (ver) queue.push({ versionId: ver.id, label: shot.shotCode }); }); }); }); filteredAssetTasks.forEach((task) => { const ver = getLatestVersion(task); if (ver) queue.push({ versionId: ver.id, label: task.asset?.assetCode ?? task.title }); }); return queue; }; const handleReviewClick = () => { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); sessionStorage.setItem(QUEUE_KEY, JSON.stringify(getReviewQueue())); sessionStorage.setItem( STATE_KEY, JSON.stringify({ statusFilter, sortMode, searchQuery, episodeFilter, collapsedEpisodes: Array.from(collapsedEpisodes), }), ); }; return (
{/* Logo */}
Logo
TWO TALES vfx review
{sessionLabel && ( {sessionLabel} )}

{project.code}

{project.name}

{project.description && (

{project.description}

)}
{needsChanges > 0 && ( )}
{/* Controls bar */}
{/* Search input */}
setSearchQuery(e.target.value)} className="w-full bg-zinc-900 border border-zinc-800 rounded-lg pl-9 pr-9 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-600 transition-colors" /> {searchQuery && ( )}
{/* Filter / sort row */}
{/* Episode filter buttons */}
{showEpisodeFilters && ( <> {episodeKeys.map((key) => ( ))} {assetTasks.length > 0 && ( )} )} {statusFilter && ( {showEpisodeFilters && '·'}{' '} {statusFilter === 'APPROVED' ? 'Approved' : statusFilter === 'PENDING_REVIEW' ? 'Awaiting Review' : 'Needs Changes'} )}
{(statusFilter !== null || sortMode !== 'episode' || searchQuery !== '' || episodeFilter !== null) && ( )}
{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) .filter(([episode]) => episodeFilter === null || episodeFilter === episode) .map(([episode, epShots]) => { const filteredEpShots = epShots .map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) })) .filter((shot) => shot.tasks.length > 0) .filter(shotMatchesSearch); 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 ? (
{ver.createdAt && ( {formatDate(ver.createdAt)} )} {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.

)} )}
); }