mobile support for client review player2
Deploy / deploy (push) Successful in 2m31s

This commit is contained in:
twotalesanimation
2026-06-12 09:33:54 +02:00
parent 289e0c367a
commit 72ce7af1ec
3 changed files with 244 additions and 156 deletions
+2 -1
View File
@@ -34,10 +34,11 @@ export async function GET(
projectId: session.projectId, projectId: session.projectId,
sharedWithClient: true, sharedWithClient: true,
}, },
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }], orderBy: [{ episode: "asc" }, { sequence: "asc" }, { shotCode: "asc" }],
select: { select: {
id: true, id: true,
shotCode: true, shotCode: true,
episode: true,
sequence: true, sequence: true,
description: true, description: true,
status: true, status: true,
+241 -154
View File
@@ -10,6 +10,7 @@ import {
AlertCircle, AlertCircle,
Clock, Clock,
ChevronRight, ChevronRight,
ChevronDown,
Package, Package,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -42,6 +43,7 @@ interface ClientTask {
interface ClientShot { interface ClientShot {
id: string; id: string;
shotCode: string; shotCode: string;
episode: string | null;
sequence: string | null; sequence: string | null;
description: string | null; description: string | null;
status: string; 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 allTasks = [...shots.flatMap((s) => s.tasks), ...assetTasks];
const totalTasks = allTasks.length; const totalTasks = allTasks.length;
const approved = allTasks.filter( const approved = allTasks.filter(
@@ -176,11 +187,13 @@ export default function ClientPortalPage({
).length; ).length;
const pending = totalTasks - approved - needsChanges; 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) => { (acc, shot) => {
const seq = shot.sequence ?? 'Shots'; const key = shot.episode ?? 'Shots';
if (!acc[seq]) acc[seq] = []; if (!acc[key]) acc[key] = [];
acc[seq].push(shot); acc[key].push(shot);
return acc; return acc;
}, },
{}, {},
@@ -248,165 +261,239 @@ export default function ClientPortalPage({
</div> </div>
</div> </div>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-10"> <main className="max-w-5xl mx-auto px-6 py-8 space-y-4">
{Object.entries(shotsBySequence).map(([sequence, seqShots]) => ( {Object.entries(shotsByEpisode).map(([episode, epShots]) => {
<div key={sequence}> const isCollapsed = collapsedEpisodes.has(episode);
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3"> const epTasks = epShots.flatMap((s) => s.tasks);
{sequence} const epApproved = epTasks.filter(
</h2> (t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
<div className="space-y-4"> ).length;
{seqShots.map((shot) => ( const epChanges = epTasks.filter((t) =>
<div key={shot.id} className="space-y-1"> ['REJECTED', 'NEEDS_CHANGES'].includes(
<div className="flex items-center gap-3 px-1"> getLatestVersion(t)?.approvalStatus ?? '',
{shot.thumbnailUrl && ( ),
<div className="relative flex-shrink-0 w-40 aspect-[2.39] rounded overflow-hidden border border-zinc-800"> ).length;
<Image const epPending = epTasks.length - epApproved - epChanges;
src={shot.thumbnailUrl}
alt={shot.shotCode} return (
fill <div key={episode} className="rounded-xl border border-zinc-800 overflow-hidden">
className="object-cover" {/* Episode header — clickable */}
/> <button
</div> 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)}
<p className="font-mono text-sm font-semibold text-zinc-300"> >
{shot.shotCode} <ChevronDown
{shot.description && ( className={cn(
<span className="font-sans font-normal text-zinc-500 ml-2"> 'h-4 w-4 text-zinc-400 shrink-0 transition-transform duration-200',
{shot.description} isCollapsed && '-rotate-90',
</span> )}
)} />
</p> <div className="flex-1 min-w-0">
</div> <span className="text-xs font-semibold uppercase tracking-widest text-zinc-400">
<div className="space-y-1.5 pl-3 border-l border-zinc-800"> {isEpisodic ? `Episode ${episode}` : episode}
{shot.tasks.map((task) => { </span>
const ver = getLatestVersion(task); <span className="ml-3 text-xs text-zinc-600">
const approvalKey = {epShots.length} {epShots.length === 1 ? 'shot' : 'shots'}
ver?.approvalStatus ?? 'PENDING_REVIEW'; </span>
const approval = </div>
APPROVAL_STYLES[approvalKey] ?? {/* Per-episode progress pills */}
APPROVAL_STYLES.PENDING_REVIEW; <div className="flex items-center gap-2 shrink-0">
const ApprovalIcon = approval.Icon; {epApproved > 0 && (
return ( <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">
<Link <CheckCircle2 className="h-3 w-3" />
key={task.id} {epApproved}
href={ver ? `/client/${token}/review/${ver.id}` : '#'} </span>
className={cn( )}
'flex items-center gap-4 p-4 rounded-xl border transition-all group', {epChanges > 0 && (
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70', <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">
!ver && 'pointer-events-none opacity-40', <AlertCircle className="h-3 w-3" />
)} {epChanges}
> </span>
<div className="flex-1 min-w-0"> )}
<p className="text-sm font-medium text-white"> {epPending > 0 && (
{task.title} <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">
</p> <Clock className="h-3 w-3" />
<p className="text-xs text-zinc-500"> {epPending}
{TASK_TYPE_LABELS[task.type] ?? task.type} </span>
</p> )}
{ver?.notes && ( </div>
<p className="text-xs text-zinc-500 truncate italic mt-0.5"> </button>
&ldquo;{ver.notes}&rdquo;
</p> {/* 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> </div>
{ver ? ( )}
<div className="flex items-center gap-3 shrink-0"> <p className="font-mono text-sm font-semibold text-zinc-300">
<span className="text-xs font-mono text-zinc-500"> {shot.shotCode}
v{String(ver.versionNumber).padStart(3, '0')} {shot.description && (
</span> <span className="font-sans font-normal text-zinc-500 ml-2">
<span {shot.description}
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> </span>
)} )}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" /> </p>
</Link> </div>
); <div className="space-y-1.5 pl-3 border-l border-zinc-800">
})} {shot.tasks.map((task) => {
</div> 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">
&ldquo;{ver.notes}&rdquo;
</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> </div>
</div> );
))} })}
{assetTasks.length > 0 && ( {assetTasks.length > 0 && (
<div> <div className="rounded-xl border border-zinc-800 overflow-hidden">
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3"> {/* Assets header */}
Assets <button
</h2> className="w-full flex items-center gap-3 px-5 py-4 bg-zinc-900 hover:bg-zinc-800/80 transition-colors text-left"
<div className="space-y-2"> onClick={() => toggleEpisode('__assets__')}
{assetTasks.map((task) => { >
const ver = getLatestVersion(task); <ChevronDown
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW'; className={cn(
const approval = 'h-4 w-4 text-zinc-400 shrink-0 transition-transform duration-200',
APPROVAL_STYLES[approvalKey] ?? collapsedEpisodes.has('__assets__') && '-rotate-90',
APPROVAL_STYLES.PENDING_REVIEW; )}
const ApprovalIcon = approval.Icon; />
return ( <span className="text-xs font-semibold uppercase tracking-widest text-zinc-400 flex-1">
<Link Assets
key={task.id} </span>
href={ver ? `/client/${token}/review/${ver.id}` : '#'} <span className="text-xs text-zinc-600">
className={cn( {assetTasks.length} {assetTasks.length === 1 ? 'item' : 'items'}
'flex items-center gap-4 p-4 rounded-xl border transition-all group', </span>
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70', </button>
!ver && 'pointer-events-none opacity-40',
)} {!collapsedEpisodes.has('__assets__') && (
> <div className="divide-y divide-zinc-800/60 bg-zinc-950/40 p-4 space-y-2">
<Package className="h-4 w-4 text-zinc-500 shrink-0" /> {assetTasks.map((task) => {
<div className="min-w-[90px]"> const ver = getLatestVersion(task);
<p className="font-mono font-semibold text-white text-sm"> const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]} const approval =
</p> APPROVAL_STYLES[approvalKey] ??
<p className="text-xs text-zinc-500"> APPROVAL_STYLES.PENDING_REVIEW;
{TASK_TYPE_LABELS[task.type] ?? task.type} const ApprovalIcon = approval.Icon;
</p> return (
</div> <Link
<div className="flex-1 min-w-0"> key={task.id}
<p className="text-sm text-zinc-300 truncate"> href={ver ? `/client/${token}/review/${ver.id}` : '#'}
{task.title} className={cn(
</p> 'flex items-center gap-4 p-4 rounded-xl border transition-all group',
{ver?.notes && ( 'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
<p className="text-xs text-zinc-500 truncate italic mt-0.5"> !ver && 'pointer-events-none opacity-40',
&ldquo;{ver.notes}&rdquo;
</p>
)} )}
</div> >
{ver ? ( <Package className="h-4 w-4 text-zinc-500 shrink-0" />
<div className="flex items-center gap-3 shrink-0"> <div className="min-w-[90px]">
<span className="text-xs font-mono text-zinc-500"> <p className="font-mono font-semibold text-white text-sm">
v{String(ver.versionNumber).padStart(3, '0')} {task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}
</span> </p>
<span <p className="text-xs text-zinc-500">
className={cn( {TASK_TYPE_LABELS[task.type] ?? task.type}
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium', </p>
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div> </div>
) : ( <div className="flex-1 min-w-0">
<span className="text-xs text-zinc-600 shrink-0"> <p className="text-sm text-zinc-300 truncate">
No versions yet {task.title}
</span> </p>
)} {ver?.notes && (
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" /> <p className="text-xs text-zinc-500 truncate italic mt-0.5">
</Link> &ldquo;{ver.notes}&rdquo;
); </p>
})} )}
</div> </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>
)} )}
+1 -1
View File
@@ -172,7 +172,7 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
// ── Touch scrubbing ────────────────────────────────────────────────────── // ── Touch scrubbing ──────────────────────────────────────────────────────
const getFrameFromTouch = useCallback( const getFrameFromTouch = useCallback(
(touch: Touch): number => { (touch: { clientX: number }): number => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas || totalFrames === 0) return 0; if (!canvas || totalFrames === 0) return 0;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();