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
+203 -40
View File
@@ -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<string, string> = {
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<Set<string>>(new Set());
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode');
const [searchQuery, setSearchQuery] = useState<string>('');
const [episodeFilter, setEpisodeFilter] = useState<string | null>(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({
<main className="max-w-5xl mx-auto px-6 py-8 space-y-4">
{/* Controls bar */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
{statusFilter && (
<span className="text-sm text-zinc-400">
Showing:{' '}
<span className="text-white font-medium">
<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">
{/* 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 && (
<span className="text-xs text-zinc-500">
{showEpisodeFilters && '·'}{' '}
{statusFilter === 'APPROVED'
? 'Approved'
: statusFilter === 'PENDING_REVIEW'
? 'Awaiting Review'
: 'Needs Changes'}
</span>
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() =>
setSortMode((prev) => (prev === 'episode' ? 'recent' : 'episode'))
}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
sortMode === 'recent'
? 'bg-amber-500/10 border-amber-500/30 text-amber-400'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
<ArrowUpDown className="h-3 w-3" />
{sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'}
</button>
{(statusFilter !== null || sortMode !== 'episode') && (
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setStatusFilter(null);
setSortMode('episode');
}}
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"
onClick={() =>
setSortMode((prev) => (prev === 'episode' ? 'recent' : 'episode'))
}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
sortMode === 'recent'
? 'bg-amber-500/10 border-amber-500/30 text-amber-400'
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
)}
>
<RotateCcw className="h-3 w-3" />
Reset
<ArrowUpDown className="h-3 w-3" />
{sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'}
</button>
)}
{(statusFilter !== null ||
sortMode !== 'episode' ||
searchQuery !== '' ||
episodeFilter !== null) && (
<button
onClick={() => {
setStatusFilter(null);
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"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
)}
</div>
</div>
</div>
@@ -614,10 +774,13 @@ export default function ClientPortalPage({
</div>
) : (
<>
{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;