Files
vfxreview/app/client/[token]/page.tsx
T
twotalesanimation 0f403f13aa
Deploy / deploy (push) Failing after 45s
futher client page updates
2026-06-19 12:19:58 +02:00

906 lines
36 KiB
TypeScript

'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<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 SCROLL_KEY = 'client-portal-scroll';
const QUEUE_KEY = 'client-portal-queue';
const STATE_KEY = 'client-portal-state';
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 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) {
const savedState = sessionStorage.getItem(STATE_KEY);
if (savedState) {
sessionStorage.removeItem(STATE_KEY);
try {
const { statusFilter: sf, sortMode: sm } = JSON.parse(savedState);
if (sf !== undefined) setStatusFilter(sf);
if (sm !== undefined) setSortMode(sm);
} catch { /* ignore */ }
}
const saved = sessionStorage.getItem(SCROLL_KEY);
if (saved) {
sessionStorage.removeItem(SCROLL_KEY);
requestAnimationFrame(() => {
window.scrollTo(0, parseInt(saved, 10));
});
}
}
}, [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 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);
});
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).forEach(([, epShots]) => {
epShots
.map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) }))
.filter((shot) => shot.tasks.length > 0)
.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 }));
};
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">
<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="flex items-center justify-between gap-3 flex-wrap">
<div>
{statusFilter && (
<span className="text-sm text-zinc-400">
Showing:{' '}
<span className="text-white font-medium">
{statusFilter === 'APPROVED'
? 'Approved'
: statusFilter === 'PENDING_REVIEW'
? 'Awaiting Review'
: 'Needs Changes'}
</span>
</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') && (
<button
onClick={() => {
setStatusFilter(null);
setSortMode('episode');
}}
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>
{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 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="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 text-xs text-zinc-600">
{formatDate(ver.createdAt)}
</p>
)}
</div>
<Link
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',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-white">
{task.title}
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="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>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</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" />
</Link>
</div>
);
})
)}
</div>
) : (
<>
{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 (
<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(
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-white">
{task.title}
</p>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
className="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>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</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>
))}
</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( 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="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>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</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>
);
}