This commit is contained in:
@@ -34,10 +34,11 @@ export async function GET(
|
||||
projectId: session.projectId,
|
||||
sharedWithClient: true,
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
|
||||
orderBy: [{ episode: "asc" }, { sequence: "asc" }, { shotCode: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
episode: true,
|
||||
sequence: true,
|
||||
description: true,
|
||||
status: true,
|
||||
|
||||
+241
-154
@@ -10,6 +10,7 @@ import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -42,6 +43,7 @@ interface ClientTask {
|
||||
interface ClientShot {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
episode: string | null;
|
||||
sequence: string | null;
|
||||
description: string | null;
|
||||
status: string;
|
||||
@@ -164,6 +166,15 @@ export default function ClientPortalPage({
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
const allTasks = [...shots.flatMap((s) => s.tasks), ...assetTasks];
|
||||
const totalTasks = allTasks.length;
|
||||
const approved = allTasks.filter(
|
||||
@@ -176,11 +187,13 @@ export default function ClientPortalPage({
|
||||
).length;
|
||||
const pending = totalTasks - approved - needsChanges;
|
||||
|
||||
const shotsBySequence = shots.reduce<Record<string, ClientShot[]>>(
|
||||
// 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 seq = shot.sequence ?? 'Shots';
|
||||
if (!acc[seq]) acc[seq] = [];
|
||||
acc[seq].push(shot);
|
||||
const key = shot.episode ?? 'Shots';
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(shot);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
@@ -248,165 +261,239 @@ export default function ClientPortalPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-10">
|
||||
{Object.entries(shotsBySequence).map(([sequence, seqShots]) => (
|
||||
<div key={sequence}>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
|
||||
{sequence}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{seqShots.map((shot) => (
|
||||
<div key={shot.id} className="space-y-1">
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
{shot.thumbnailUrl && (
|
||||
<div className="relative flex-shrink-0 w-40 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>
|
||||
)}
|
||||
<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>
|
||||
{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
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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="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>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{assetTasks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
|
||||
Assets
|
||||
</h2>
|
||||
<div className="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 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',
|
||||
)}
|
||||
</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>
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
|
||||
|
||||
// ── Touch scrubbing ──────────────────────────────────────────────────────
|
||||
const getFrameFromTouch = useCallback(
|
||||
(touch: Touch): number => {
|
||||
(touch: { clientX: number }): number => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || totalFrames === 0) return 0;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
Reference in New Issue
Block a user