+203
-40
@@ -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,48 +538,121 @@ 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="flex items-center justify-between gap-3 flex-wrap">
|
<div className="space-y-3">
|
||||||
<div>
|
{/* Search input */}
|
||||||
{statusFilter && (
|
<div className="relative">
|
||||||
<span className="text-sm text-zinc-400">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 pointer-events-none" />
|
||||||
Showing:{' '}
|
<input
|
||||||
<span className="text-white font-medium">
|
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'
|
{statusFilter === 'APPROVED'
|
||||||
? 'Approved'
|
? 'Approved'
|
||||||
: statusFilter === 'PENDING_REVIEW'
|
: statusFilter === 'PENDING_REVIEW'
|
||||||
? 'Awaiting Review'
|
? 'Awaiting Review'
|
||||||
: 'Needs Changes'}
|
: 'Needs Changes'}
|
||||||
</span>
|
</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',
|
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
<ArrowUpDown className="h-3 w-3" />
|
|
||||||
{sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'}
|
<div className="flex items-center gap-2">
|
||||||
</button>
|
|
||||||
{(statusFilter !== null || sortMode !== 'episode') && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
setStatusFilter(null);
|
setSortMode((prev) => (prev === 'episode' ? 'recent' : 'episode'))
|
||||||
setSortMode('episode');
|
}
|
||||||
}}
|
className={cn(
|
||||||
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"
|
'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" />
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
Reset
|
{sortMode === 'recent' ? 'Sorted by Recent' : 'Sort by Recent'}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user