From 0be1818ff561001fd528652245b6072072667e0a Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:33:59 +0200 Subject: [PATCH] new playlist page --- app/(dashboard)/playlist/PlaylistClient.tsx | 332 ++++++++++++++++++++ app/(dashboard)/playlist/page.tsx | 19 ++ app/api/playlist/route.ts | 65 ++++ components/layout/Sidebar.tsx | 2 + 4 files changed, 418 insertions(+) create mode 100644 app/(dashboard)/playlist/PlaylistClient.tsx create mode 100644 app/(dashboard)/playlist/page.tsx create mode 100644 app/api/playlist/route.ts diff --git a/app/(dashboard)/playlist/PlaylistClient.tsx b/app/(dashboard)/playlist/PlaylistClient.tsx new file mode 100644 index 0000000..d2b2d13 --- /dev/null +++ b/app/(dashboard)/playlist/PlaylistClient.tsx @@ -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 = { + 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 = { + PENDING_REVIEW: Clock, + APPROVED: CheckCircle2, + REJECTED: AlertCircle, + NEEDS_CHANGES: AlertCircle, +}; + +export function PlaylistClient({ projects }: PlaylistClientProps) { + const playerRef = useRef(null); + const reset = useReviewStore((s) => s.reset); + + const [projectId, setProjectId] = useState(null); + const [shots, setShots] = useState([]); + const [activeShot, setActiveShot] = useState(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(); + 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 ( +
+ {/* Toolbar */} +
+ Playlist +
+ + +
+ + {activeShot && ( +
+ {activeShot.shotCode} + + {(() => { + const Icon = APPROVAL_ICONS[activeShot.latestVersion.approvalStatus] ?? Clock; + return ; + })()} + {activeShot.latestVersion.approvalStatus.replace(/_/g, " ")} + + + + +
+ )} +
+ + {/* Body: player + shot panel */} +
+ {/* Player */} +
+ {activeShot ? ( + + ) : ( +
+ +

+ {!projectId + ? "Select a project to start" + : isLoading + ? "" + : "No shots with video versions found"} +

+ {isLoading && } +
+ )} +
+ + {/* Shot panel */} +
+
+ Shots + {shots.length > 0 && ( + {shots.length} + )} +
+ +
+ {isLoading ? ( +
+ +
+ ) : !projectId ? ( +
+ +

Select a project

+
+ ) : shots.length === 0 ? ( +
+ +

No video versions found

+
+ ) : isEpisodic ? ( +
+ {episodeGroups.map(([episode, episodeShots]) => ( +
+
+ + Ep {episode} + +
+ {episodeShots.map((shot) => ( + handleSelectShot(shot)} + /> + ))} +
+ ))} +
+ ) : ( +
+ {shots.map((shot) => ( + handleSelectShot(shot)} + /> + ))} +
+ )} +
+
+
+
+ ); +} + +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 ( + + ); +} diff --git a/app/(dashboard)/playlist/page.tsx b/app/(dashboard)/playlist/page.tsx new file mode 100644 index 0000000..7c60479 --- /dev/null +++ b/app/(dashboard)/playlist/page.tsx @@ -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 ; +} diff --git a/app/api/playlist/route.ts b/app/api/playlist/route.ts new file mode 100644 index 0000000..f87fb89 --- /dev/null +++ b/app/api/playlist/route.ts @@ -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 }); +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 85ce8f9..80fd386 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -17,6 +17,7 @@ import { ListTodo, CalendarRange, BarChart2, + ListVideo, } from 'lucide-react'; import { useState } from 'react'; import { useSession } from 'next-auth/react'; @@ -26,6 +27,7 @@ const navItems = [ { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { href: '/projects', label: 'Projects', icon: FolderOpen }, { 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: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true }, { href: '/clients', label: 'Clients', icon: Users, adminOnly: true },