0c4f94d56a
Deploy / deploy (push) Successful in 3m14s
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>
1067 lines
44 KiB
TypeScript
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>
|
|
);
|
|
}
|