Files
vfxreview/app/(dashboard)/playlist/PlaylistClient.tsx
T
twotalesanimation f16d0b0466
Deploy / deploy (push) Failing after 2m49s
new playlist page 2
2026-06-07 20:27:10 +02:00

335 lines
11 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
import Link from "next/link";
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
import { useReviewStore } from "@/hooks/use-review-player";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
Film,
Loader2,
CheckCircle2,
AlertCircle,
Clock,
ExternalLink,
} from "lucide-react";
interface Project {
id: string;
name: string;
code: string;
projectType: string;
}
interface PlaylistVersion {
id: string;
versionNumber: number;
fileUrl: string;
thumbnailUrl: string | null;
posterUrl: string | null;
fps: number;
approvalStatus: string;
taskTitle: string;
}
interface PlaylistShot {
id: string;
shotCode: string;
episode: string | null;
status: string;
thumbnailUrl: string | null;
latestVersion: PlaylistVersion;
}
interface PlaylistClientProps {
projects: Project[];
}
const APPROVAL_COLORS: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
const APPROVAL_ICONS: Record<string, React.ElementType> = {
PENDING_REVIEW: Clock,
APPROVED: CheckCircle2,
REJECTED: AlertCircle,
NEEDS_CHANGES: AlertCircle,
};
export function PlaylistClient({ projects }: PlaylistClientProps) {
const playerRef = useRef<ReviewPlayerRef>(null);
const reset = useReviewStore((s) => s.reset);
const [projectId, setProjectId] = useState<string | null>(null);
const [shots, setShots] = useState<PlaylistShot[]>([]);
const [activeShot, setActiveShot] = useState<PlaylistShot | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Restore last project from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem("playlist:lastProjectId");
if (saved && projects.some((p) => p.id === saved)) {
setProjectId(saved);
}
}, [projects]);
// Fetch shots whenever project changes
useEffect(() => {
if (!projectId) {
setShots([]);
setActiveShot(null);
return;
}
let cancelled = false;
setIsLoading(true);
fetch(`/api/playlist?projectId=${projectId}`)
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const list: PlaylistShot[] = data.shots ?? [];
setShots(list);
// Auto-select the first shot, or keep the current one if it still exists
setActiveShot((prev) => {
const stillExists = prev && list.some((s) => s.id === prev.id);
return stillExists ? prev : (list[0] ?? null);
});
})
.catch(() => {
if (!cancelled) setShots([]);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => { cancelled = true; };
}, [projectId]);
const handleProjectChange = (id: string) => {
localStorage.setItem("playlist:lastProjectId", id);
setProjectId(id);
};
const handleSelectShot = useCallback((shot: PlaylistShot) => {
reset();
setActiveShot(shot);
}, [reset]);
// Group shots by episode for episodic projects
const selectedProject = projects.find((p) => p.id === projectId);
const isEpisodic = selectedProject?.projectType === "EPISODIC";
const episodeGroups: [string, PlaylistShot[]][] = isEpisodic
? (() => {
const map = new Map<string, PlaylistShot[]>();
for (const shot of shots) {
const key = shot.episode ?? "(No Episode)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
}
return Array.from(map.entries());
})()
: [];
return (
<div className="flex h-full overflow-hidden flex-col">
{/* Toolbar */}
<div className="flex items-center gap-4 px-4 py-2.5 border-b border-border bg-card shrink-0">
<span className="text-sm font-semibold text-white">Playlist</span>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-400 shrink-0">Project:</label>
<Select value={projectId ?? ""} onValueChange={handleProjectChange}>
<SelectTrigger className="w-64 h-8 text-sm">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
<span className="font-mono text-xs text-zinc-400 mr-2">{p.code}</span>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{activeShot && (
<div className="flex items-center gap-2 ml-auto">
<span className="font-mono text-sm text-white">{activeShot.shotCode}</span>
<Badge className={cn("text-xs gap-1", APPROVAL_COLORS[activeShot.latestVersion.approvalStatus])}>
{(() => {
const Icon = APPROVAL_ICONS[activeShot.latestVersion.approvalStatus] ?? Clock;
return <Icon className="h-3 w-3" />;
})()}
{activeShot.latestVersion.approvalStatus.replace(/_/g, " ")}
</Badge>
<Link
href={`/review/${activeShot.latestVersion.id}`}
className="text-zinc-500 hover:text-zinc-200 transition-colors"
title="Open full review page"
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</div>
)}
</div>
{/* Body: player + shot panel */}
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Player */}
<div className="flex-1 min-w-0 bg-black flex flex-col">
{activeShot ? (
<ReviewPlayer
key={activeShot.latestVersion.id}
ref={playerRef}
videoUrl={activeShot.latestVersion.fileUrl}
versionId={activeShot.latestVersion.id}
fps={activeShot.latestVersion.fps}
comments={[]}
annotations={[]}
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-zinc-600">
<Film className="h-12 w-12 opacity-30" />
<p className="text-sm">
{!projectId
? "Select a project to start"
: isLoading
? ""
: "No shots with video versions found"}
</p>
{isLoading && <Loader2 className="h-5 w-5 animate-spin opacity-50" />}
</div>
)}
</div>
{/* Shot panel */}
<div className="w-64 xl:w-72 shrink-0 flex flex-col border-l border-border bg-zinc-950 overflow-hidden">
<div className="px-3 py-2.5 border-b border-border shrink-0 flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Shots</span>
{shots.length > 0 && (
<span className="text-xs text-zinc-600">{shots.length}</span>
)}
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-16 text-zinc-600">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : !projectId ? (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-zinc-600">
<Film className="h-7 w-7 opacity-30" />
<p className="text-xs text-center">Select a project</p>
</div>
) : shots.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-zinc-600">
<Film className="h-7 w-7 opacity-30" />
<p className="text-xs text-center">No video versions found</p>
</div>
) : isEpisodic ? (
<div>
{episodeGroups.map(([episode, episodeShots]) => (
<div key={episode}>
<div className="px-3 py-1.5 bg-zinc-900/80 sticky top-0 z-10 border-b border-zinc-800">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
Ep {episode}
</span>
</div>
{episodeShots.map((shot) => (
<ShotThumbnail
key={shot.id}
shot={shot}
isActive={activeShot?.id === shot.id}
onClick={() => handleSelectShot(shot)}
/>
))}
</div>
))}
</div>
) : (
<div>
{shots.map((shot) => (
<ShotThumbnail
key={shot.id}
shot={shot}
isActive={activeShot?.id === shot.id}
onClick={() => handleSelectShot(shot)}
/>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
function ShotThumbnail({
shot,
isActive,
onClick,
}: {
shot: PlaylistShot;
isActive: boolean;
onClick: () => void;
}) {
const thumb = shot.latestVersion.thumbnailUrl ?? shot.latestVersion.posterUrl ?? shot.thumbnailUrl;
const ApprovalIcon = APPROVAL_ICONS[shot.latestVersion.approvalStatus] ?? Clock;
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-start gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-zinc-800/60 group",
isActive
? "bg-blue-500/10 border-l-2 border-l-blue-500"
: "hover:bg-zinc-800/40 border-l-2 border-l-transparent"
)}
>
{/* Thumbnail */}
<div className="w-20 aspect-[2.39/1] rounded overflow-hidden bg-zinc-800 shrink-0 flex items-center justify-center">
{thumb ? (
<Image
src={thumb}
alt={shot.shotCode}
width={80}
height={34}
className="object-cover w-full h-full"
/>
) : (
<Film className="h-4 w-4 text-zinc-600" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0 pt-0.5">
<p className="font-mono text-xs font-semibold text-white truncate">{shot.shotCode}</p>
<p className="text-[10px] text-zinc-500 truncate">{shot.latestVersion.taskTitle}</p>
<div className={cn(
"flex items-center gap-1 mt-0.5 text-[10px]",
APPROVAL_COLORS[shot.latestVersion.approvalStatus]?.split(" ").find(c => c.startsWith("text-")) ?? "text-zinc-400"
)}>
<ApprovalIcon className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">
v{String(shot.latestVersion.versionNumber).padStart(3, "0")}
{" · "}
{shot.latestVersion.approvalStatus.replace(/_/g, " ")}
</span>
</div>
</div>
</button>
);
}