From 6fc818a3db49fc515a106cceb54922b10166ae20 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:52:50 +0200 Subject: [PATCH] Client View updates --- app/client/[token]/page.tsx | 243 ++++++++++++++++++++++++++++++------ 1 file changed, 203 insertions(+), 40 deletions(-) diff --git a/app/client/[token]/page.tsx b/app/client/[token]/page.tsx index e0b2eda..1f3cef4 100644 --- a/app/client/[token]/page.tsx +++ b/app/client/[token]/page.tsx @@ -16,6 +16,8 @@ import { ArrowUpDown, Copy, Check, + Search, + X, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Montserrat } from 'next/font/google'; @@ -65,6 +67,7 @@ interface FlatItem { label: string; description: string | null; thumbnailUrl: string | null; + episodeKey: string; } function formatDate(dateStr: string): string { @@ -128,6 +131,7 @@ const TASK_TYPE_LABELS: Record = { const SCROLL_KEY = 'client-portal-scroll'; const QUEUE_KEY = 'client-portal-queue'; const STATE_KEY = 'client-portal-state'; +const PREFS_KEY_PREFIX = 'client-portal-prefs-'; function getLatestVersion(task: ClientTask): ClientVersion | undefined { return task.versions[0]; @@ -149,6 +153,8 @@ export default function ClientPortalPage({ const [collapsedEpisodes, setCollapsedEpisodes] = useState>(new Set()); const [statusFilter, setStatusFilter] = useState(null); const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode'); + const [searchQuery, setSearchQuery] = useState(''); + const [episodeFilter, setEpisodeFilter] = useState(null); const toggleEpisode = (ep: string) => { setCollapsedEpisodes((prev) => { @@ -179,14 +185,29 @@ export default function ClientPortalPage({ }, [params]); useEffect(() => { - if (!loading) { + if (!loading && token) { const savedState = sessionStorage.getItem(STATE_KEY); if (savedState) { sessionStorage.removeItem(STATE_KEY); try { - const { statusFilter: sf, sortMode: sm } = JSON.parse(savedState); + const { statusFilter: sf, sortMode: sm, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(savedState); if (sf !== undefined) setStatusFilter(sf); if (sm !== undefined) setSortMode(sm); + if (sq !== undefined) setSearchQuery(sq ?? ''); + if (ef !== undefined) setEpisodeFilter(ef); + if (ce !== undefined) setCollapsedEpisodes(new Set(ce)); + } catch { /* ignore */ } + } else { + 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); + if (sf !== undefined) setStatusFilter(sf); + if (sm !== undefined) setSortMode(sm); + if (sq !== undefined) setSearchQuery(sq ?? ''); + if (ef !== undefined) setEpisodeFilter(ef); + if (ce !== undefined) setCollapsedEpisodes(new Set(ce)); + } } catch { /* ignore */ } } const saved = sessionStorage.getItem(SCROLL_KEY); @@ -197,7 +218,23 @@ export default function ClientPortalPage({ }); } } - }, [loading]); + }, [loading, token]); + + useEffect(() => { + if (!token || loading) return; + try { + localStorage.setItem( + PREFS_KEY_PREFIX + token, + JSON.stringify({ + statusFilter, + sortMode, + searchQuery, + episodeFilter, + collapsedEpisodes: Array.from(collapsedEpisodes), + }), + ); + } catch { /* ignore */ } + }, [token, statusFilter, sortMode, searchQuery, episodeFilter, collapsedEpisodes, loading]); const loadProject = (t: string) => { setLoading(true); @@ -296,7 +333,33 @@ export default function ClientPortalPage({ return status === statusFilter; }; - const filteredAssetTasks = assetTasks.filter(taskMatchesFilter); + const shotMatchesSearch = (shot: ClientShot): boolean => { + if (!searchQuery.trim()) return true; + const q = searchQuery.toLowerCase().trim(); + return ( + shot.shotCode.toLowerCase().includes(q) || + (shot.description?.toLowerCase().includes(q) ?? false) || + shot.tasks.some((t) => t.title.toLowerCase().includes(q)) + ); + }; + + const assetTaskMatchesSearch = (task: AssetTask): boolean => { + if (!searchQuery.trim()) return true; + const q = searchQuery.toLowerCase().trim(); + return ( + (task.asset?.assetCode?.toLowerCase().includes(q) ?? false) || + task.title.toLowerCase().includes(q) + ); + }; + + const episodeKeys = Object.keys(shotsByEpisode); + const showEpisodeFilters = + episodeKeys.length > 1 || (episodeKeys.length >= 1 && assetTasks.length > 0); + + const filteredAssetTasks = assetTasks + .filter(taskMatchesFilter) + .filter(assetTaskMatchesSearch) + .filter(() => episodeFilter === null || episodeFilter === '__assets__'); const allFlatItems: FlatItem[] = [ ...shots.flatMap((shot) => @@ -305,6 +368,7 @@ export default function ClientPortalPage({ label: shot.shotCode, description: shot.description, thumbnailUrl: shot.thumbnailUrl, + episodeKey: shot.episode ?? 'Shots', })), ), ...assetTasks.map((task) => ({ @@ -312,11 +376,22 @@ export default function ClientPortalPage({ label: task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type] ?? task.type, description: null, thumbnailUrl: null, + episodeKey: '__assets__', })), ]; const filteredFlatItems = allFlatItems .filter((item) => taskMatchesFilter(item.task)) + .filter((item) => episodeFilter === null || item.episodeKey === episodeFilter) + .filter((item) => { + if (!searchQuery.trim()) return true; + const q = searchQuery.toLowerCase().trim(); + return ( + item.label.toLowerCase().includes(q) || + (item.description?.toLowerCase().includes(q) ?? false) || + item.task.title.toLowerCase().includes(q) + ); + }) .sort((a, b) => { const aDate = getLatestVersion(a.task)?.createdAt ?? ''; const bDate = getLatestVersion(b.task)?.createdAt ?? ''; @@ -333,10 +408,13 @@ export default function ClientPortalPage({ .filter((x): x is { versionId: string; label: string } => x !== null); } const queue: Array<{ versionId: string; label: string }> = []; - Object.entries(shotsByEpisode).forEach(([, epShots]) => { + Object.entries(shotsByEpisode) + .filter(([episode]) => episodeFilter === null || episodeFilter === episode) + .forEach(([, epShots]) => { epShots .map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) })) .filter((shot) => shot.tasks.length > 0) + .filter(shotMatchesSearch) .forEach((shot) => { shot.tasks.forEach((task) => { const ver = getLatestVersion(task); @@ -354,7 +432,16 @@ export default function ClientPortalPage({ const handleReviewClick = () => { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); sessionStorage.setItem(QUEUE_KEY, JSON.stringify(getReviewQueue())); - sessionStorage.setItem(STATE_KEY, JSON.stringify({ statusFilter, sortMode })); + sessionStorage.setItem( + STATE_KEY, + JSON.stringify({ + statusFilter, + sortMode, + searchQuery, + episodeFilter, + collapsedEpisodes: Array.from(collapsedEpisodes), + }), + ); }; return ( @@ -451,48 +538,121 @@ export default function ClientPortalPage({
{/* Controls bar */} -
-
- {statusFilter && ( - - Showing:{' '} - +
+ {/* Search input */} +
+ + setSearchQuery(e.target.value)} + className="w-full bg-zinc-900 border border-zinc-800 rounded-lg pl-9 pr-9 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-600 transition-colors" + /> + {searchQuery && ( + + )} +
+ + {/* Filter / sort row */} +
+ {/* Episode filter buttons */} +
+ {showEpisodeFilters && ( + <> + + {episodeKeys.map((key) => ( + + ))} + {assetTasks.length > 0 && ( + + )} + + )} + {statusFilter && ( + + {showEpisodeFilters && 'ยท'}{' '} {statusFilter === 'APPROVED' ? 'Approved' : statusFilter === 'PENDING_REVIEW' ? 'Awaiting Review' : 'Needs Changes'} - - )} -
-
- - {(statusFilter !== null || sortMode !== 'episode') && ( +
+ +
- )} + {(statusFilter !== null || + sortMode !== 'episode' || + searchQuery !== '' || + episodeFilter !== null) && ( + + )} +
@@ -614,10 +774,13 @@ export default function ClientPortalPage({
) : ( <> - {Object.entries(shotsByEpisode).map(([episode, epShots]) => { + {Object.entries(shotsByEpisode) + .filter(([episode]) => episodeFilter === null || episodeFilter === episode) + .map(([episode, epShots]) => { const filteredEpShots = epShots .map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) })) - .filter((shot) => shot.tasks.length > 0); + .filter((shot) => shot.tasks.length > 0) + .filter(shotMatchesSearch); if (filteredEpShots.length === 0) return null;