Client View updates
Deploy / deploy (push) Successful in 2m47s

This commit is contained in:
twotalesanimation
2026-07-09 14:52:50 +02:00
parent 4624a4f67d
commit 6fc818a3db
+177 -14
View File
@@ -16,6 +16,8 @@ import {
ArrowUpDown, ArrowUpDown,
Copy, Copy,
Check, Check,
Search,
X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Montserrat } from 'next/font/google'; import { Montserrat } from 'next/font/google';
@@ -65,6 +67,7 @@ interface FlatItem {
label: string; label: string;
description: string | null; description: string | null;
thumbnailUrl: string | null; thumbnailUrl: string | null;
episodeKey: string;
} }
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@@ -128,6 +131,7 @@ const TASK_TYPE_LABELS: Record<string, string> = {
const SCROLL_KEY = 'client-portal-scroll'; const SCROLL_KEY = 'client-portal-scroll';
const QUEUE_KEY = 'client-portal-queue'; const QUEUE_KEY = 'client-portal-queue';
const STATE_KEY = 'client-portal-state'; const STATE_KEY = 'client-portal-state';
const PREFS_KEY_PREFIX = 'client-portal-prefs-';
function getLatestVersion(task: ClientTask): ClientVersion | undefined { function getLatestVersion(task: ClientTask): ClientVersion | undefined {
return task.versions[0]; return task.versions[0];
@@ -149,6 +153,8 @@ export default function ClientPortalPage({
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set()); const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState<string | null>(null); const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode'); const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode');
const [searchQuery, setSearchQuery] = useState<string>('');
const [episodeFilter, setEpisodeFilter] = useState<string | null>(null);
const toggleEpisode = (ep: string) => { const toggleEpisode = (ep: string) => {
setCollapsedEpisodes((prev) => { setCollapsedEpisodes((prev) => {
@@ -179,14 +185,29 @@ export default function ClientPortalPage({
}, [params]); }, [params]);
useEffect(() => { useEffect(() => {
if (!loading) { if (!loading && token) {
const savedState = sessionStorage.getItem(STATE_KEY); const savedState = sessionStorage.getItem(STATE_KEY);
if (savedState) { if (savedState) {
sessionStorage.removeItem(STATE_KEY); sessionStorage.removeItem(STATE_KEY);
try { 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 (sf !== undefined) setStatusFilter(sf);
if (sm !== undefined) setSortMode(sm); 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 */ } } catch { /* ignore */ }
} }
const saved = sessionStorage.getItem(SCROLL_KEY); 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) => { const loadProject = (t: string) => {
setLoading(true); setLoading(true);
@@ -296,7 +333,33 @@ export default function ClientPortalPage({
return status === statusFilter; 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[] = [ const allFlatItems: FlatItem[] = [
...shots.flatMap((shot) => ...shots.flatMap((shot) =>
@@ -305,6 +368,7 @@ export default function ClientPortalPage({
label: shot.shotCode, label: shot.shotCode,
description: shot.description, description: shot.description,
thumbnailUrl: shot.thumbnailUrl, thumbnailUrl: shot.thumbnailUrl,
episodeKey: shot.episode ?? 'Shots',
})), })),
), ),
...assetTasks.map((task) => ({ ...assetTasks.map((task) => ({
@@ -312,11 +376,22 @@ export default function ClientPortalPage({
label: task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type] ?? task.type, label: task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type] ?? task.type,
description: null, description: null,
thumbnailUrl: null, thumbnailUrl: null,
episodeKey: '__assets__',
})), })),
]; ];
const filteredFlatItems = allFlatItems const filteredFlatItems = allFlatItems
.filter((item) => taskMatchesFilter(item.task)) .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) => { .sort((a, b) => {
const aDate = getLatestVersion(a.task)?.createdAt ?? ''; const aDate = getLatestVersion(a.task)?.createdAt ?? '';
const bDate = getLatestVersion(b.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); .filter((x): x is { versionId: string; label: string } => x !== null);
} }
const queue: Array<{ versionId: string; label: string }> = []; const queue: Array<{ versionId: string; label: string }> = [];
Object.entries(shotsByEpisode).forEach(([, epShots]) => { Object.entries(shotsByEpisode)
.filter(([episode]) => episodeFilter === null || episodeFilter === episode)
.forEach(([, epShots]) => {
epShots epShots
.map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) })) .map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) }))
.filter((shot) => shot.tasks.length > 0) .filter((shot) => shot.tasks.length > 0)
.filter(shotMatchesSearch)
.forEach((shot) => { .forEach((shot) => {
shot.tasks.forEach((task) => { shot.tasks.forEach((task) => {
const ver = getLatestVersion(task); const ver = getLatestVersion(task);
@@ -354,7 +432,16 @@ export default function ClientPortalPage({
const handleReviewClick = () => { const handleReviewClick = () => {
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); sessionStorage.setItem(SCROLL_KEY, String(window.scrollY));
sessionStorage.setItem(QUEUE_KEY, JSON.stringify(getReviewQueue())); 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 ( return (
@@ -451,21 +538,88 @@ export default function ClientPortalPage({
<main className="max-w-5xl mx-auto px-6 py-8 space-y-4"> <main className="max-w-5xl mx-auto px-6 py-8 space-y-4">
{/* Controls bar */} {/* Controls bar */}
<div className="space-y-3">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 pointer-events-none" />
<input
type="text"
placeholder="Search shots, tasks..."
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Filter / sort row */}
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-center justify-between gap-3 flex-wrap">
<div> {/* Episode filter buttons */}
<div className="flex items-center gap-2 flex-wrap">
{showEpisodeFilters && (
<>
<button
onClick={() => setEpisodeFilter(null)}
className={cn(
'inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
episodeFilter === null
? 'bg-zinc-700 border-zinc-500 text-white'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
All
</button>
{episodeKeys.map((key) => (
<button
key={key}
onClick={() => setEpisodeFilter(episodeFilter === key ? null : key)}
className={cn(
'inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
episodeFilter === key
? 'bg-zinc-700 border-zinc-500 text-white'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
{isEpisodic ? `EP ${key}` : key}
</button>
))}
{assetTasks.length > 0 && (
<button
onClick={() =>
setEpisodeFilter(episodeFilter === '__assets__' ? null : '__assets__')
}
className={cn(
'inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
episodeFilter === '__assets__'
? 'bg-zinc-700 border-zinc-500 text-white'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
Assets
</button>
)}
</>
)}
{statusFilter && ( {statusFilter && (
<span className="text-sm text-zinc-400"> <span className="text-xs text-zinc-500">
Showing:{' '} {showEpisodeFilters && '·'}{' '}
<span className="text-white font-medium">
{statusFilter === 'APPROVED' {statusFilter === 'APPROVED'
? 'Approved' ? 'Approved'
: statusFilter === 'PENDING_REVIEW' : statusFilter === 'PENDING_REVIEW'
? 'Awaiting Review' ? 'Awaiting Review'
: 'Needs Changes'} : 'Needs Changes'}
</span> </span>
</span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => onClick={() =>
@@ -481,11 +635,16 @@ export default function ClientPortalPage({
<ArrowUpDown className="h-3 w-3" /> <ArrowUpDown className="h-3 w-3" />
{sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'} {sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'}
</button> </button>
{(statusFilter !== null || sortMode !== 'episode') && ( {(statusFilter !== null ||
sortMode !== 'episode' ||
searchQuery !== '' ||
episodeFilter !== null) && (
<button <button
onClick={() => { onClick={() => {
setStatusFilter(null); setStatusFilter(null);
setSortMode('episode'); setSortMode('episode');
setSearchQuery('');
setEpisodeFilter(null);
}} }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-zinc-700 bg-zinc-800 text-zinc-400 hover:text-zinc-300 transition-all" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-zinc-700 bg-zinc-800 text-zinc-400 hover:text-zinc-300 transition-all"
> >
@@ -495,6 +654,7 @@ export default function ClientPortalPage({
)} )}
</div> </div>
</div> </div>
</div>
{sortMode === 'recent' ? ( {sortMode === 'recent' ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -614,10 +774,13 @@ export default function ClientPortalPage({
</div> </div>
) : ( ) : (
<> <>
{Object.entries(shotsByEpisode).map(([episode, epShots]) => { {Object.entries(shotsByEpisode)
.filter(([episode]) => episodeFilter === null || episodeFilter === episode)
.map(([episode, epShots]) => {
const filteredEpShots = epShots const filteredEpShots = epShots
.map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) })) .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; if (filteredEpShots.length === 0) return null;