514 lines
20 KiB
TypeScript
514 lines
20 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,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Montserrat } from 'next/font/google';
|
|
|
|
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 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',
|
|
};
|
|
|
|
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 [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
|
|
|
|
const toggleEpisode = (ep: string) => {
|
|
setCollapsedEpisodes((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(ep)) next.delete(ep); else next.add(ep);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
params.then(({ token: t }) => {
|
|
setToken(t);
|
|
fetch(`/api/client/${t}/project`)
|
|
.then((r) => {
|
|
if (!r.ok) throw new Error('Invalid or expired review link');
|
|
return r.json();
|
|
})
|
|
.then((data) => {
|
|
setProject(data.project);
|
|
setShots(data.shots ?? []);
|
|
setAssetTasks(data.assetTasks ?? []);
|
|
setSessionLabel(data.sessionLabel ?? '');
|
|
})
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false));
|
|
});
|
|
}, [params]);
|
|
|
|
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 (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;
|
|
},
|
|
{},
|
|
);
|
|
|
|
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">
|
|
<div className="bg-zinc-800 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
|
<p className="text-2xl font-bold text-white">{totalTasks}</p>
|
|
<p className="text-xs text-zinc-400 mt-0.5">Items</p>
|
|
</div>
|
|
<div className="bg-emerald-900/30 border border-emerald-800/30 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
|
<p className="text-2xl font-bold text-emerald-400">{approved}</p>
|
|
<p className="text-xs text-zinc-400 mt-0.5">Approved</p>
|
|
</div>
|
|
<div className="bg-amber-900/20 border border-amber-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
|
<p className="text-2xl font-bold text-amber-400">{pending}</p>
|
|
<p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p>
|
|
</div>
|
|
{needsChanges > 0 && (
|
|
<div className="bg-red-900/20 border border-red-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
|
<p className="text-2xl font-bold text-red-400">
|
|
{needsChanges}
|
|
</p>
|
|
<p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<main className="max-w-5xl mx-auto px-6 py-8 space-y-4">
|
|
{Object.entries(shotsByEpisode).map(([episode, epShots]) => {
|
|
const isCollapsed = collapsedEpisodes.has(episode);
|
|
const epTasks = epShots.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">
|
|
{epShots.length} {epShots.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">
|
|
{epShots.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>
|
|
)}
|
|
<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>
|
|
</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}` : '#'}
|
|
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">
|
|
<p className="text-sm font-medium text-white">
|
|
{task.title}
|
|
</p>
|
|
<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">
|
|
“{ver.notes}”
|
|
</p>
|
|
)}
|
|
</div>
|
|
{ver ? (
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<span className="hidden sm:inline text-xs font-mono text-zinc-500">
|
|
v{String(ver.versionNumber).padStart(3, '0')}
|
|
</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" />
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{assetTasks.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">
|
|
{assetTasks.length} {assetTasks.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">
|
|
{assetTasks.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}` : '#'}
|
|
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">
|
|
<p className="text-sm text-zinc-300 truncate">
|
|
{task.title}
|
|
</p>
|
|
{ver?.notes && (
|
|
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
|
“{ver.notes}”
|
|
</p>
|
|
)}
|
|
</div>
|
|
{ver ? (
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<span className="text-xs font-mono text-zinc-500">
|
|
v{String(ver.versionNumber).padStart(3, '0')}
|
|
</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" />
|
|
</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>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|