Files
vfxreview/app/client/[token]/page.tsx
T
twotalesanimation 0c4f94d56a
Deploy / deploy (push) Successful in 3m14s
feat(client-portal): readable shot list on phones
On a phone the approval badge was drawn over the version name on every row,
the TWO TALES logo broke over two lines, and in "recent" sort the date
wrapped onto three. Below sm the version name now takes its own line with
the badge under it, the logo stays on one line and a long link label is
truncated, and the date no longer wraps. Hover-only copy buttons are hidden
on phones, where they were invisible but still took space.

Desktop is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:01:05 +02:00

1067 lines
44 KiB
TypeScript

'use client';
import { useState, useRef, useEffect } from 'react';
import { useScrollMemory } from '@/hooks/use-scroll-memory';
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;
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<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',
};
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<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);
const [requiresPassword, setRequiresPassword] = useState(false);
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode');
const [searchQuery, setSearchQuery] = useState<string>('');
const [episodeFilter, setEpisodeFilter] = useState<string | null>(null);
// Filters and collapsed episodes set the page height, so the scroll
// position is restored only once they are back.
const [viewRestored, setViewRestored] = useState(false);
const pageRef = useRef<HTMLDivElement>(null);
useScrollMemory(token ? `clientPortal:${token}` : null, pageRef, viewRestored);
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<string | null>(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 */ }
}
setViewRestored(true);
}
}, [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 (
<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 (requiresPassword) {
return (
<ReviewPasswordGate
token={token}
onUnlocked={() => loadProject(token)}
/>
);
}
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;
// Group shots by episode; null episode → "Shots"
const isEpisodic = shots.some((s) => s.episode != null);
const shotsByEpisode = shots.reduce<Record<string, ClientShot[]>>(
(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(QUEUE_KEY, JSON.stringify(getReviewQueue()));
sessionStorage.setItem(
STATE_KEY,
JSON.stringify({
statusFilter,
sortMode,
searchQuery,
episodeFilter,
collapsedEpisodes: Array.from(collapsedEpisodes),
}),
);
};
return (
<div ref={pageRef} 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 whitespace-nowrap">
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="ml-4 min-w-0 truncate text-right text-sm text-zinc-400" title={sessionLabel}>{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">
<button
onClick={() => setStatusFilter(null)}
className={cn(
'rounded-lg px-4 py-3 text-center min-w-[80px] transition-all',
!statusFilter
? 'bg-zinc-700 ring-1 ring-zinc-500'
: 'bg-zinc-800 hover:bg-zinc-700/80',
)}
>
<p className="text-2xl font-bold text-white">{totalTasks}</p>
<p className="text-xs text-zinc-400 mt-0.5">Items</p>
</button>
<button
onClick={() => toggleStatusFilter('APPROVED')}
className={cn(
'rounded-lg px-4 py-3 text-center min-w-[80px] transition-all border',
statusFilter === 'APPROVED'
? 'bg-emerald-900/50 border-emerald-700/60 ring-1 ring-emerald-600/50'
: 'bg-emerald-900/30 border-emerald-800/30 hover:bg-emerald-900/50',
)}
>
<p className="text-2xl font-bold text-emerald-400">{approved}</p>
<p className="text-xs text-zinc-400 mt-0.5">Approved</p>
</button>
<button
onClick={() => toggleStatusFilter('PENDING_REVIEW')}
className={cn(
'rounded-lg px-4 py-3 text-center min-w-[80px] transition-all border',
statusFilter === 'PENDING_REVIEW'
? 'bg-amber-900/40 border-amber-700/50 ring-1 ring-amber-600/50'
: 'bg-amber-900/20 border-amber-800/20 hover:bg-amber-900/40',
)}
>
<p className="text-2xl font-bold text-amber-400">{pending}</p>
<p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p>
</button>
{needsChanges > 0 && (
<button
onClick={() => toggleStatusFilter('NEEDS_CHANGES')}
className={cn(
'rounded-lg px-4 py-3 text-center min-w-[80px] transition-all border',
statusFilter === 'NEEDS_CHANGES'
? 'bg-red-900/40 border-red-700/50 ring-1 ring-red-600/50'
: 'bg-red-900/20 border-red-800/20 hover:bg-red-900/40',
)}
>
<p className="text-2xl font-bold text-red-400">{needsChanges}</p>
<p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p>
</button>
)}
</div>
</div>
</div>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-4">
{/* Controls bar */}
<div className="space-y-3">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 pointer-events-none" />
<input
type="text"
placeholder="Search shots, tasks..."
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Filter / sort row */}
<div className="flex items-center justify-between gap-3 flex-wrap">
{/* Episode filter buttons */}
<div className="flex items-center gap-2 flex-wrap">
{showEpisodeFilters && (
<>
<button
onClick={() => setEpisodeFilter(null)}
className={cn(
'inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
episodeFilter === null
? 'bg-zinc-700 border-zinc-500 text-white'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
All
</button>
{episodeKeys.map((key) => (
<button
key={key}
onClick={() => setEpisodeFilter(episodeFilter === key ? null : key)}
className={cn(
'inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
episodeFilter === key
? 'bg-zinc-700 border-zinc-500 text-white'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
{isEpisodic ? `EP ${key}` : key}
</button>
))}
{assetTasks.length > 0 && (
<button
onClick={() =>
setEpisodeFilter(episodeFilter === '__assets__' ? null : '__assets__')
}
className={cn(
'inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
episodeFilter === '__assets__'
? 'bg-zinc-700 border-zinc-500 text-white'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
Assets
</button>
)}
</>
)}
{statusFilter && (
<span className="text-xs text-zinc-500">
{showEpisodeFilters && '·'}{' '}
{statusFilter === 'APPROVED'
? 'Approved'
: statusFilter === 'PENDING_REVIEW'
? 'Awaiting Review'
: 'Needs Changes'}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() =>
setSortMode((prev) => (prev === 'episode' ? 'recent' : 'episode'))
}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
sortMode === 'recent'
? 'bg-amber-500/10 border-amber-500/30 text-amber-400'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
<ArrowUpDown className="h-3 w-3" />
{sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'}
</button>
{(statusFilter !== null ||
sortMode !== 'episode' ||
searchQuery !== '' ||
episodeFilter !== null) && (
<button
onClick={() => {
setStatusFilter(null);
setSortMode('episode');
setSearchQuery('');
setEpisodeFilter(null);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-zinc-700 bg-zinc-800 text-zinc-400 hover:text-zinc-300 transition-all"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
)}
</div>
</div>
</div>
{sortMode === 'recent' ? (
<div className="space-y-3">
{filteredFlatItems.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>
{statusFilter
? 'No items match the current filter.'
: 'No items have been shared for review yet.'}
</p>
</div>
) : (
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 (
<div key={task.id} className="space-y-1">
<div className="flex items-center gap-2 px-1">
{item.thumbnailUrl && (
<div className="relative w-14 aspect-[2.39] rounded overflow-hidden border border-zinc-800 shrink-0">
<Image
src={item.thumbnailUrl}
alt={item.label}
fill
className="object-cover"
/>
</div>
)}
<div className="flex min-w-0 items-center gap-1.5 group/itemlabel">
<p className="font-mono text-xs text-zinc-500">
{item.label}
{item.description && (
<span className="font-sans text-zinc-600 ml-2">
{item.description}
</span>
)}
</p>
<button
onClick={() => copyToClipboard(item.label, `label-${task.id}`)}
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover/itemlabel:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy name"
>
{copiedId === `label-${task.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
{ver?.createdAt && (
<p className="ml-auto shrink-0 whitespace-nowrap text-xs text-zinc-600">
{formatDate(ver.createdAt)}
</p>
)}
</div>
<Link
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
onClick={handleReviewClick}
className={cn(
'flex flex-wrap sm:flex-nowrap items-center gap-x-4 gap-y-2 p-3 sm: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="min-w-0 w-full sm:w-auto sm:flex-1">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-white min-w-0 break-words">
{task.title}
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy task name"
>
{copiedId === `task-${task.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
</div>
{ver ? (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium shrink-0',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
) : (
<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 ml-auto sm:ml-0" />
</Link>
</div>
);
})
)}
</div>
) : (
<>
{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 (
<div key={episode} className="rounded-xl border border-zinc-800 overflow-hidden">
{/* Episode header — clickable */}
<button
className="w-full flex items-center gap-3 px-5 py-4 bg-zinc-900 hover:bg-zinc-800/80 transition-colors text-left"
onClick={() => toggleEpisode(episode)}
>
<ChevronDown
className={cn(
'h-4 w-4 text-zinc-400 shrink-0 transition-transform duration-200',
isCollapsed && '-rotate-90',
)}
/>
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold uppercase tracking-widest text-zinc-400">
{isEpisodic ? `Episode ${episode}` : episode}
</span>
<span className="ml-3 text-xs text-zinc-600">
{filteredEpShots.length}{' '}
{filteredEpShots.length === 1 ? 'shot' : 'shots'}
</span>
</div>
{/* Per-episode progress pills */}
<div className="flex items-center gap-2 shrink-0">
{epApproved > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-900/40 border border-emerald-800/40 text-emerald-400 text-xs">
<CheckCircle2 className="h-3 w-3" />
{epApproved}
</span>
)}
{epChanges > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-900/30 border border-red-800/30 text-red-400 text-xs">
<AlertCircle className="h-3 w-3" />
{epChanges}
</span>
)}
{epPending > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-900/20 border border-amber-800/20 text-amber-400 text-xs">
<Clock className="h-3 w-3" />
{epPending}
</span>
)}
</div>
</button>
{/* Collapsible shot list */}
{!isCollapsed && (
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40">
{filteredEpShots.map((shot) => (
<div key={shot.id} className="px-5 py-4 space-y-2">
<div className="flex items-center gap-3">
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-32 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<div className="flex items-center gap-1.5 group/shotcode">
<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>
<button
onClick={() => copyToClipboard(shot.shotCode, `shot-${shot.id}`)}
className="shrink-0 p-0.5 rounded opacity-0 group-hover/shotcode:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy shot code"
>
{copiedId === `shot-${shot.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</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}` : '#'}
onClick={handleReviewClick}
className={cn(
'flex flex-wrap sm:flex-nowrap items-center gap-x-4 gap-y-2 p-3 sm: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="min-w-0 w-full sm:w-auto sm:flex-1">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-white min-w-0 break-words">
{task.title}
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy task name"
>
{copiedId === `task-${task.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
{ver.createdAt && (
<span className="text-xs text-zinc-600 hidden sm:block">
{formatDate(ver.createdAt)}
</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 ml-auto sm:ml-0" />
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
})}
{filteredAssetTasks.length > 0 && (
<div className="rounded-xl border border-zinc-800 overflow-hidden">
{/* Assets header */}
<button
className="w-full flex items-center gap-3 px-5 py-4 bg-zinc-900 hover:bg-zinc-800/80 transition-colors text-left"
onClick={() => toggleEpisode('__assets__')}
>
<ChevronDown
className={cn(
'h-4 w-4 text-zinc-400 shrink-0 transition-transform duration-200',
collapsedEpisodes.has('__assets__') && '-rotate-90',
)}
/>
<span className="text-xs font-semibold uppercase tracking-widest text-zinc-400 flex-1">
Assets
</span>
<span className="text-xs text-zinc-600">
{filteredAssetTasks.length}{' '}
{filteredAssetTasks.length === 1 ? 'item' : 'items'}
</span>
</button>
{!collapsedEpisodes.has('__assets__') && (
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 p-4 space-y-2">
{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 (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
onClick={handleReviewClick}
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">
<div className="flex items-center gap-1.5">
<p className="text-sm text-zinc-300 truncate">
{task.title}
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
title="Copy task name"
>
{copiedId === `task-${task.id}` ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
<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>
)}
{totalTasks > 0 && filteredFlatItems.length === 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 match the current filter.</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>
);
}