Collapsible scene sub-headers inside each episode, in scene order with per-scene status counts; the review queue follows the same order. Aliased shots go out without a scene so our numbering never reaches the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+225
-138
@@ -19,6 +19,7 @@ import {
|
||||
Check,
|
||||
Search,
|
||||
X,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Montserrat } from 'next/font/google';
|
||||
@@ -52,6 +53,7 @@ interface ClientShot {
|
||||
shotCode: string;
|
||||
episode: string | null;
|
||||
sequence: string | null;
|
||||
scene: string | null;
|
||||
description: string | null;
|
||||
status: string;
|
||||
thumbnailUrl: string | null;
|
||||
@@ -136,6 +138,26 @@ function getLatestVersion(task: ClientTask): ClientVersion | undefined {
|
||||
return task.versions[0];
|
||||
}
|
||||
|
||||
const NO_SCENE = '__none__';
|
||||
|
||||
/** Shots split into scenes in scene order, with unscened shots last. */
|
||||
function groupShotsByScene<T extends ClientShot>(shots: T[]): [string, T[]][] {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const shot of shots) {
|
||||
const key = shot.scene || NO_SCENE;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(shot);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => {
|
||||
if (a === NO_SCENE) return 1;
|
||||
if (b === NO_SCENE) return -1;
|
||||
return a.localeCompare(b, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Collapse key for a scene, kept in the same set as the episodes. */
|
||||
const sceneKey = (episode: string, scene: string) => `${episode}::${scene}`;
|
||||
|
||||
export default function ClientPortalPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -152,6 +174,7 @@ export default function ClientPortalPage({
|
||||
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode');
|
||||
const [groupByScene, setGroupByScene] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [episodeFilter, setEpisodeFilter] = useState<string | null>(null);
|
||||
// Filters and collapsed episodes set the page height, so the scroll
|
||||
@@ -194,9 +217,10 @@ export default function ClientPortalPage({
|
||||
if (savedState) {
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
try {
|
||||
const { statusFilter: sf, sortMode: sm, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(savedState);
|
||||
const { statusFilter: sf, sortMode: sm, groupByScene: gs, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(savedState);
|
||||
if (sf !== undefined) setStatusFilter(sf);
|
||||
if (sm !== undefined) setSortMode(sm);
|
||||
if (gs !== undefined) setGroupByScene(gs);
|
||||
if (sq !== undefined) setSearchQuery(sq ?? '');
|
||||
if (ef !== undefined) setEpisodeFilter(ef);
|
||||
if (ce !== undefined) setCollapsedEpisodes(new Set(ce));
|
||||
@@ -205,9 +229,10 @@ export default function ClientPortalPage({
|
||||
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);
|
||||
const { statusFilter: sf, sortMode: sm, groupByScene: gs, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(prefs);
|
||||
if (sf !== undefined) setStatusFilter(sf);
|
||||
if (sm !== undefined) setSortMode(sm);
|
||||
if (gs !== undefined) setGroupByScene(gs);
|
||||
if (sq !== undefined) setSearchQuery(sq ?? '');
|
||||
if (ef !== undefined) setEpisodeFilter(ef);
|
||||
if (ce !== undefined) setCollapsedEpisodes(new Set(ce));
|
||||
@@ -226,13 +251,14 @@ export default function ClientPortalPage({
|
||||
JSON.stringify({
|
||||
statusFilter,
|
||||
sortMode,
|
||||
groupByScene,
|
||||
searchQuery,
|
||||
episodeFilter,
|
||||
collapsedEpisodes: Array.from(collapsedEpisodes),
|
||||
}),
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}, [token, statusFilter, sortMode, searchQuery, episodeFilter, collapsedEpisodes, loading]);
|
||||
}, [token, statusFilter, sortMode, groupByScene, searchQuery, episodeFilter, collapsedEpisodes, loading]);
|
||||
|
||||
const loadProject = (t: string) => {
|
||||
setLoading(true);
|
||||
@@ -309,6 +335,8 @@ export default function ClientPortalPage({
|
||||
|
||||
// Group shots by episode; null episode → "Shots"
|
||||
const isEpisodic = shots.some((s) => s.episode != null);
|
||||
const hasScenes = shots.some((s) => s.scene);
|
||||
const showScenes = groupByScene && hasScenes;
|
||||
const shotsByEpisode = shots.reduce<Record<string, ClientShot[]>>(
|
||||
(acc, shot) => {
|
||||
const key = shot.episode ?? 'Shots';
|
||||
@@ -409,10 +437,11 @@ export default function ClientPortalPage({
|
||||
Object.entries(shotsByEpisode)
|
||||
.filter(([episode]) => episodeFilter === null || episodeFilter === episode)
|
||||
.forEach(([, epShots]) => {
|
||||
epShots
|
||||
const visible = epShots
|
||||
.map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) }))
|
||||
.filter((shot) => shot.tasks.length > 0)
|
||||
.filter(shotMatchesSearch)
|
||||
.filter(shotMatchesSearch);
|
||||
(showScenes ? groupShotsByScene(visible).flatMap(([, sc]) => sc) : visible)
|
||||
.forEach((shot) => {
|
||||
shot.tasks.forEach((task) => {
|
||||
const ver = getLatestVersion(task);
|
||||
@@ -434,6 +463,7 @@ export default function ClientPortalPage({
|
||||
JSON.stringify({
|
||||
statusFilter,
|
||||
sortMode,
|
||||
groupByScene,
|
||||
searchQuery,
|
||||
episodeFilter,
|
||||
collapsedEpisodes: Array.from(collapsedEpisodes),
|
||||
@@ -441,6 +471,147 @@ export default function ClientPortalPage({
|
||||
);
|
||||
};
|
||||
|
||||
// Approved / needs changes / awaiting counts for an episode or scene header.
|
||||
const statusPills = (tasks: ClientTask[]) => {
|
||||
const nApproved = tasks.filter(
|
||||
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
|
||||
).length;
|
||||
const nChanges = tasks.filter((t) =>
|
||||
['REJECTED', 'NEEDS_CHANGES'].includes(
|
||||
getLatestVersion(t)?.approvalStatus ?? '',
|
||||
),
|
||||
).length;
|
||||
const nPending = tasks.length - nApproved - nChanges;
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{nApproved > 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" />
|
||||
{nApproved}
|
||||
</span>
|
||||
)}
|
||||
{nChanges > 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" />
|
||||
{nChanges}
|
||||
</span>
|
||||
)}
|
||||
{nPending > 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" />
|
||||
{nPending}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderShot = (shot: ClientShot) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={pageRef} className="min-h-screen bg-zinc-950 text-white">
|
||||
<header className="border-b border-zinc-800 bg-zinc-900">
|
||||
@@ -618,6 +789,20 @@ export default function ClientPortalPage({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasScenes && sortMode === 'episode' && (
|
||||
<button
|
||||
onClick={() => setGroupByScene((prev) => !prev)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
|
||||
groupByScene
|
||||
? 'bg-amber-500/10 border-amber-500/30 text-amber-400'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
|
||||
)}
|
||||
>
|
||||
<Layers className="h-3 w-3" />
|
||||
{groupByScene ? 'Grouped by Scene' : 'Group by Scene'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSortMode((prev) => (prev === 'episode' ? 'recent' : 'episode'))
|
||||
@@ -778,15 +963,6 @@ export default function ClientPortalPage({
|
||||
|
||||
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">
|
||||
@@ -810,137 +986,48 @@ export default function ClientPortalPage({
|
||||
{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>
|
||||
{statusPills(epTasks)}
|
||||
</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',
|
||||
)}
|
||||
{showScenes
|
||||
? groupShotsByScene(filteredEpShots).map(([scene, sceneShots]) => {
|
||||
const key = sceneKey(episode, scene);
|
||||
const sceneCollapsed = collapsedEpisodes.has(key);
|
||||
return (
|
||||
<div key={key}>
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-5 py-2.5 bg-zinc-900/60 hover:bg-zinc-800/60 transition-colors text-left"
|
||||
onClick={() => toggleEpisode(key)}
|
||||
>
|
||||
<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
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-zinc-500 shrink-0 transition-transform duration-200',
|
||||
sceneCollapsed && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-zinc-400">
|
||||
{scene === NO_SCENE ? 'No scene' : `Scene ${scene}`}
|
||||
</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>
|
||||
))}
|
||||
<span className="ml-3 text-xs text-zinc-600">
|
||||
{sceneShots.length}{' '}
|
||||
{sceneShots.length === 1 ? 'shot' : 'shots'}
|
||||
</span>
|
||||
</div>
|
||||
{statusPills(sceneShots.flatMap((s) => s.tasks))}
|
||||
</button>
|
||||
{!sceneCollapsed && (
|
||||
<div className="divide-y divide-zinc-800/60 border-t border-zinc-800/60">
|
||||
{sceneShots.map(renderShot)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: filteredEpShots.map(renderShot)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user