+203
-40
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user