From 27d9618995fccb68d1ea519564cdcbced195dfee Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:41:19 +0200 Subject: [PATCH] feat(client-portal): group shots by scene 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 --- app/api/client/[token]/project/route.ts | 7 +- app/client/[token]/page.tsx | 363 +++++++++++++++--------- 2 files changed, 231 insertions(+), 139 deletions(-) diff --git a/app/api/client/[token]/project/route.ts b/app/api/client/[token]/project/route.ts index cfbd0a9..ccc2100 100644 --- a/app/api/client/[token]/project/route.ts +++ b/app/api/client/[token]/project/route.ts @@ -48,6 +48,7 @@ export async function GET( clientCode: true, episode: true, sequence: true, + scene: true, description: true, status: true, shotApprovalStatus: true, @@ -124,9 +125,13 @@ export async function GET( // code by convention ("UNG_106_010_020_cmp_TT_v004"). The internal code is // not sent at all — there is nothing here for it to be useful for, and // leaking it invites the client to quote it back at us. - shots: shots.map(({ generatedThumbnailUrl, clientCode, ...shot }) => ({ + // The scene is our numbering too, and an alias does not have to share it + // (SNV's "SNV_101_0463_CP_VFX" is our scene 001), so an aliased shot goes + // out without one rather than with a number the client never used. + shots: shots.map(({ generatedThumbnailUrl, clientCode, scene, ...shot }) => ({ ...shot, shotCode: clientShotCode({ ...shot, clientCode }), + scene: clientShotCode({ ...shot, clientCode }) === shot.shotCode ? scene || null : null, thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }), tasks: shot.tasks.map((task) => ({ ...task, diff --git a/app/client/[token]/page.tsx b/app/client/[token]/page.tsx index cab4968..9f3a9af 100644 --- a/app/client/[token]/page.tsx +++ b/app/client/[token]/page.tsx @@ -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(shots: T[]): [string, T[]][] { + const map = new Map(); + 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>(new Set()); const [statusFilter, setStatusFilter] = useState(null); const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode'); + const [groupByScene, setGroupByScene] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [episodeFilter, setEpisodeFilter] = useState(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>( (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 ( +
+ {nApproved > 0 && ( + + + {nApproved} + + )} + {nChanges > 0 && ( + + + {nChanges} + + )} + {nPending > 0 && ( + + + {nPending} + + )} +
+ ); + }; + + const renderShot = (shot: ClientShot) => ( +
+
+ {shot.thumbnailUrl && ( +
+ {shot.shotCode} +
+ )} +
+

+ {shot.shotCode} + {shot.description && ( + + {shot.description} + + )} +

+ +
+
+
+ {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 ( + +
+
+

+ {task.title} +

+ +
+

+ {TASK_TYPE_LABELS[task.type] ?? task.type} +

+
+ {ver ? ( +
+ {ver.createdAt && ( + + {formatDate(ver.createdAt)} + + )} + + + {approval.label} + +
+ ) : ( + + No versions yet + + )} + + + ); + })} +
+
+ ); + return (
@@ -618,6 +789,20 @@ export default function ClientPortalPage({
+ {hasScenes && sortMode === 'episode' && ( + + )} {/* Collapsible shot list */} {!isCollapsed && (
- {filteredEpShots.map((shot) => ( -
-
- {shot.thumbnailUrl && ( -
- {shot.shotCode} -
- )} -
-

- {shot.shotCode} - {shot.description && ( - - {shot.description} - - )} -

- -
-
-
- {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 ( - { + const key = sceneKey(episode, scene); + const sceneCollapsed = collapsedEpisodes.has(key); + return ( +
+ -
-

- {TASK_TYPE_LABELS[task.type] ?? task.type} -

-
- {ver ? ( -
- {ver.createdAt && ( - - {formatDate(ver.createdAt)} - - )} - - - {approval.label} - -
- ) : ( - - No versions yet + +
+ + {scene === NO_SCENE ? 'No scene' : `Scene ${scene}`} - )} - - - ); - })} -
-
- ))} + + {sceneShots.length}{' '} + {sceneShots.length === 1 ? 'shot' : 'shots'} + +
+ {statusPills(sceneShots.flatMap((s) => s.tasks))} + + {!sceneCollapsed && ( +
+ {sceneShots.map(renderShot)} +
+ )} +
+ ); + }) + : filteredEpShots.map(renderShot)} )}