@@ -0,0 +1,332 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { PlaylistClient } from "./PlaylistClient";
|
||||||
|
|
||||||
|
export const metadata = { title: "Playlist" };
|
||||||
|
|
||||||
|
export default async function PlaylistPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const projects = await db.project.findMany({
|
||||||
|
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||||
|
select: { id: true, name: true, code: true, projectType: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return <PlaylistClient projects={projects} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = req.nextUrl.searchParams.get("projectId");
|
||||||
|
if (!projectId) {
|
||||||
|
return NextResponse.json({ error: "projectId required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shots = await db.shot.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
versions: {
|
||||||
|
some: {
|
||||||
|
shotId: { not: null },
|
||||||
|
mimeType: { startsWith: "video/" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ episode: "asc" }, { scene: "asc" }, { shotNumber: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shotCode: true,
|
||||||
|
episode: true,
|
||||||
|
status: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
versions: {
|
||||||
|
where: {
|
||||||
|
isLatest: true,
|
||||||
|
mimeType: { startsWith: "video/" },
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
fileUrl: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
posterUrl: true,
|
||||||
|
fps: true,
|
||||||
|
approvalStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return shots that actually have a latest video version
|
||||||
|
const playlist = shots
|
||||||
|
.map((shot) => ({
|
||||||
|
id: shot.id,
|
||||||
|
shotCode: shot.shotCode,
|
||||||
|
episode: shot.episode,
|
||||||
|
status: shot.status,
|
||||||
|
thumbnailUrl: shot.thumbnailUrl,
|
||||||
|
latestVersion: shot.versions[0] ?? null,
|
||||||
|
}))
|
||||||
|
.filter((shot) => shot.latestVersion !== null);
|
||||||
|
|
||||||
|
return NextResponse.json({ shots: playlist });
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ListTodo,
|
ListTodo,
|
||||||
CalendarRange,
|
CalendarRange,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
|
ListVideo,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
@@ -26,6 +27,7 @@ const navItems = [
|
|||||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: '/projects', label: 'Projects', icon: FolderOpen },
|
{ href: '/projects', label: 'Projects', icon: FolderOpen },
|
||||||
{ href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true },
|
{ href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true },
|
||||||
|
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
|
||||||
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
|
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
|
||||||
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
|
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
|
||||||
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
|
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user