Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from 'next/navigation';
import { auth } from '@/auth';
import Image from 'next/image';
import { Montserrat } from 'next/font/google';
const montserrat = Montserrat({
subsets: ['latin'],
weight: ['200', '500', '600'],
});
export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (session?.user) redirect('/dashboard');
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
<div className="w-full max-w-md px-4">
{/* Logo / Brand */}
<div className="text-center mb-8">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-md bg-black border border-zinc-800 shadow-2xl">
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
</div>
<div className={`${montserrat.className} mt-4`}>
<span className="block text-2xl font-light tracking-wide text-white leading-none">
TWO TALES
</span>
<span className="block text-[11px] tracking-[0.28em] italic uppercase text-zinc-500 leading-none mt-1">
vfx review
</span>
</div>
</div>
{children}
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Eye, EyeOff, LogIn } from "lucide-react";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(1, "Password is required"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [showPassword, setShowPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormValues) => {
setAuthError(null);
const result = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});
if (result?.error) {
setAuthError("Invalid email or password");
return;
}
router.push(callbackUrl);
router.refresh();
};
return (
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-xl align-middle text-center">Sign in</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@studio.com"
autoComplete="email"
autoFocus
{...register("email")}
/>
{errors.email && (
<p className="text-xs text-red-400">{errors.email.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
autoComplete="current-password"
className="pr-10"
{...register("password")}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{errors.password && (
<p className="text-xs text-red-400">{errors.password.message}</p>
)}
</div>
{authError && (
<p className="text-sm text-red-400 text-center">{authError}</p>
)}
<Button type="submit" className="w-full gap-2" disabled={isSubmitting}>
<LogIn className="h-4 w-4" />
{isSubmitting ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { Building2, Mail, User2, Phone, ArrowLeft, ExternalLink, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ShareReviewDialog } from "@/components/clients/ShareReviewDialog";
import { ReviewSessionList } from "@/components/clients/ReviewSessionList";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Client Detail" };
async function getClient(clientId: string) {
return db.client.findUnique({
where: { id: clientId },
include: {
projects: {
orderBy: { createdAt: "desc" },
include: {
_count: { select: { shots: true } },
},
},
},
});
}
async function getReviewSessions(projectIds: string[]) {
if (projectIds.length === 0) return [];
return db.reviewSession.findMany({
where: { projectId: { in: projectIds }, isActive: true },
orderBy: { createdAt: "desc" },
include: { project: { select: { name: true } } },
});
}
export default async function ClientDetailPage({
params,
}: {
params: Promise<{ clientId: string }>;
}) {
const { clientId } = await params;
const session = await auth();
if (!session || !["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
redirect("/dashboard");
}
const client = await getClient(clientId);
if (!client) notFound();
const projectIds = client.projects.map((p) => p.id);
const reviewSessions = await getReviewSessions(projectIds);
const PROJECT_STATUS_COLORS: Record<string, string> = {
ACTIVE: "text-emerald-400 bg-emerald-500/10",
COMPLETED: "text-zinc-400 bg-zinc-700",
ON_HOLD: "text-amber-400 bg-amber-500/10",
CANCELLED: "text-red-400 bg-red-500/10",
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Back */}
<Link
href="/clients"
className="inline-flex items-center gap-1.5 text-sm text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft className="h-4 w-4" />
All Clients
</Link>
{/* Client header */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0">
<Building2 className="h-6 w-6 text-zinc-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">{client.company}</h1>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full mt-1 inline-block",
client.isActive
? "bg-emerald-500/10 text-emerald-400"
: "bg-zinc-700 text-zinc-500"
)}
>
{client.isActive ? "Active" : "Inactive"}
</span>
</div>
</div>
<ShareReviewDialog
clientId={client.id}
clientEmail={client.email ?? ""}
projects={client.projects}
>
<Button className="gap-2 shrink-0">
<ExternalLink className="h-4 w-4" />
Share Review Link
</Button>
</ShareReviewDialog>
</div>
<div className="mt-5 grid grid-cols-1 sm:grid-cols-3 gap-4">
{client.contactPerson && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<User2 className="h-4 w-4 text-zinc-600 shrink-0" />
<span>{client.contactPerson}</span>
</div>
)}
{client.email && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<Mail className="h-4 w-4 text-zinc-600 shrink-0" />
<a href={`mailto:${client.email}`} className="hover:text-amber-400 transition-colors">
{client.email}
</a>
</div>
)}
{client.phone && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<Phone className="h-4 w-4 text-zinc-600 shrink-0" />
<span>{client.phone}</span>
</div>
)}
</div>
{client.notes && (
<p className="mt-4 text-sm text-zinc-400 border-t border-zinc-800 pt-4">
{client.notes}
</p>
)}
</div>
{/* Projects */}
<div>
<h2 className="text-base font-semibold text-white mb-3">
Projects ({client.projects.length})
</h2>
{client.projects.length === 0 ? (
<p className="text-sm text-zinc-500">No projects assigned to this client.</p>
) : (
<div className="space-y-2">
{client.projects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="flex items-center gap-4 p-4 rounded-xl border border-zinc-800 bg-zinc-900 hover:border-zinc-600 hover:bg-zinc-800/60 transition-all group"
>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white">{project.name}</p>
<p className="text-xs font-mono text-zinc-500">{project.code}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-zinc-500">
{project._count.shots} shots
</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
PROJECT_STATUS_COLORS[project.status] ?? "text-zinc-400 bg-zinc-700"
)}
>
{project.status}
</span>
</div>
</Link>
))}
</div>
)}
</div>
{/* Review sessions */}
<ReviewSessionList sessions={reviewSessions} />
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Metadata } from "next";
import Link from "next/link";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { PlusCircle, Building2, Mail, User2, FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { NewClientDialog } from "@/components/clients/NewClientDialog";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Clients" };
async function getClients() {
return db.client.findMany({
orderBy: { company: "asc" },
include: { _count: { select: { projects: true } } },
});
}
export default async function ClientsPage() {
const session = await auth();
if (!session || !["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
redirect("/dashboard");
}
const clients = await getClients();
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Clients</h1>
<p className="text-sm text-zinc-400 mt-0.5">
{clients.length} client{clients.length !== 1 ? "s" : ""} total
</p>
</div>
<NewClientDialog>
<Button className="gap-2">
<PlusCircle className="h-4 w-4" />
New Client
</Button>
</NewClientDialog>
</div>
{/* Grid */}
{clients.length === 0 ? (
<div className="rounded-xl border border-dashed border-zinc-700 bg-zinc-900/40 p-16 text-center">
<Building2 className="h-10 w-10 mx-auto mb-3 text-zinc-600" />
<p className="text-zinc-400 font-medium">No clients yet</p>
<p className="text-zinc-600 text-sm mt-1">
Add your first client to start sharing reviews.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{clients.map((client) => (
<Link
key={client.id}
href={`/clients/${client.id}`}
className={cn(
"rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4",
"hover:border-zinc-600 hover:bg-zinc-800/60 transition-all group"
)}
>
{/* Avatar + company */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0 group-hover:bg-zinc-700 transition-colors">
<Building2 className="h-5 w-5 text-zinc-400" />
</div>
<div className="min-w-0">
<p className="font-semibold text-white truncate">{client.company}</p>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
client.isActive
? "bg-emerald-500/10 text-emerald-400"
: "bg-zinc-700 text-zinc-500"
)}
>
{client.isActive ? "Active" : "Inactive"}
</span>
</div>
</div>
{/* Details */}
<div className="space-y-1.5">
{client.contactPerson && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<User2 className="h-3.5 w-3.5 text-zinc-600 shrink-0" />
<span className="truncate">{client.contactPerson}</span>
</div>
)}
{client.email && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<Mail className="h-3.5 w-3.5 text-zinc-600 shrink-0" />
<span className="truncate">{client.email}</span>
</div>
)}
</div>
{/* Project count */}
<div className="flex items-center gap-1.5 text-xs text-zinc-500 pt-2 border-t border-zinc-800">
<FolderOpen className="h-3.5 w-3.5" />
<span>{client._count.projects} project{client._count.projects !== 1 ? "s" : ""}</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { StatsCards } from "@/components/dashboard/StatsCards";
import { ShotQueue } from "@/components/dashboard/ShotQueue";
import { RecentActivity } from "@/components/dashboard/RecentActivity";
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
import type { DashboardStats } from "@/types";
export const metadata = { title: "Dashboard" };
async function getDashboardData(userId: string, role: string) {
const isArtist = role === "ARTIST";
const isClient = role === "CLIENT";
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart.getTime() + 86400000);
const [awaitingReview, needsRevisions, approved, activeProjects,
tasksDueToday, tasksInReview, tasksOverdue, myTasksCount] =
await Promise.all([
db.version.count({ where: { approvalStatus: "PENDING_REVIEW" } }),
db.version.count({ where: { approvalStatus: "NEEDS_CHANGES" } }),
db.version.count({ where: { approvalStatus: "APPROVED" } }),
db.project.count({ where: { status: "ACTIVE" } }),
db.task.count({
where: {
dueDate: { gte: todayStart, lt: todayEnd },
status: { not: "DONE" },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: {
status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: {
dueDate: { lt: todayStart },
status: { not: "DONE" },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: { assignedArtistId: userId, status: { not: "DONE" } },
}),
]);
const shotsWhere = isArtist ? { artistId: userId } : {};
const shots = await db.shot.findMany({
where: { ...shotsWhere, status: { not: "COMPLETE" } },
take: 15,
orderBy: { updatedAt: "desc" },
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
include: { comments: { select: { id: true, isResolved: true } } },
},
},
});
// My tasks (for widget)
const myTasks = await db.task.findMany({
where: {
assignedArtistId: userId,
status: { not: "DONE" },
},
take: 8,
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
},
});
// Tasks in review (for supervisors/producers)
const reviewTasks = isArtist || isClient ? [] : await db.task.findMany({
where: { status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] } },
take: 8,
orderBy: { updatedAt: "desc" },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: { select: { id: true, name: true, image: true, email: true } },
},
});
const activity = await db.notification.findMany({
where: isClient ? { userId } : {},
take: 20,
orderBy: { createdAt: "desc" },
include: { user: { select: { name: true, image: true } } },
});
// Schedule data (for producers/supervisors/admins)
const scheduledTasks =
!isArtist && !isClient
? await db.task.findMany({
where: { scheduledStartDate: { not: null }, status: { not: "DONE" } },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: { scheduledStartDate: "asc" },
})
: [];
const scheduleArtists =
!isArtist && !isClient
? await db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
orderBy: { name: "asc" },
})
: [];
const stats: DashboardStats = {
awaitingReview,
needsRevisions,
approved,
overdue: tasksOverdue,
activeProjects,
tasksDueToday,
tasksInReview,
tasksOverdue,
myTasksCount,
};
return { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists };
}
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) return null;
const { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists } =
await getDashboardData(session.user.id, session.user.role);
const isClient = session.user.role === "CLIENT";
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role
);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="mb-4">
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 mt-1">
Welcome back, {session.user.name ?? session.user.email}
</p>
</div>
<StatsCards stats={stats} />
{!isClient && (
<TaskWidgets
myTasks={myTasks as any}
reviewTasks={reviewTasks as any}
role={session.user.role}
/>
)}
{canSeeSchedule && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Schedule Overview
</h2>
<ScheduleWidgets
scheduledTasks={scheduledTasks as any}
artists={scheduleArtists}
/>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Shot Queue
</h2>
<div className="rounded-lg border border-border bg-card p-1">
<ShotQueue shots={shots as any} />
</div>
</div>
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Recent Activity
</h2>
<div className="rounded-lg border border-border bg-card h-[400px] overflow-hidden">
<RecentActivity activities={activity as any} />
</div>
</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
return (
<div className="flex h-screen bg-zinc-950 overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ShotCard } from "@/components/shots/ShotCard";
import { NewShotDialog } from "@/components/shots/NewShotDialog";
import { Film, Plus } from "lucide-react";
import type { ShotWithDetails } from "@/types";
interface ProjectShotsClientProps {
projectId: string;
shots: ShotWithDetails[];
}
export function ProjectShotsClient({ projectId, shots }: ProjectShotsClientProps) {
const [showNew, setShowNew] = useState(false);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Shots</h2>
<Button size="sm" className="gap-2" onClick={() => setShowNew(true)}>
<Plus className="h-4 w-4" />
New Shot
</Button>
</div>
{shots.length === 0 ? (
<div className="text-center py-10 text-muted-foreground border border-dashed border-border rounded-lg">
<Film className="h-8 w-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">No shots yet</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{shots.map((shot) => (
<ShotCard key={shot.id} shot={shot} projectId={projectId} />
))}
</div>
)}
<NewShotDialog
projectId={projectId}
open={showNew}
onClose={() => setShowNew(false)}
onSuccess={() => setShowNew(false)}
/>
</div>
);
}
@@ -0,0 +1,245 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ShotCard } from "@/components/shots/ShotCard";
import { NewShotDialog } from "@/components/shots/NewShotDialog";
import { ImportShotsDialog } from "@/components/shots/ImportShotsDialog";
import { AssetCard } from "@/components/assets/AssetCard";
import { NewAssetDialog } from "@/components/assets/NewAssetDialog";
import { TaskCard } from "@/components/tasks/TaskCard";
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
import { cn } from "@/lib/utils";
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp } from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
type Tab = "shots" | "assets" | "tasks" | "kanban" | "settings";
interface Artist {
id: string;
name: string | null;
email: string;
}
interface Client {
id: string;
company: string;
}
interface TeamMember {
id: string;
name: string | null;
email: string;
role: string;
}
interface ProjectTabsClientProps {
projectId: string;
projectType: "STANDARD" | "EPISODIC";
projectSettings: {
id: string;
name: string;
code: string;
showId: string;
projectType: "STANDARD" | "EPISODIC";
description: string | null;
status: string;
clientId: string | null;
producerId: string | null;
supervisorId: string | null;
dueDate: Date | null;
startDate: Date | null;
slackWebhook: string | null;
slackChannel: string | null;
};
clients: Client[];
teamMembers: TeamMember[];
shots: ShotWithDetails[];
assets: any[];
tasks: any[];
artists: Artist[];
canManage: boolean;
}
export function ProjectTabsClient({
projectId,
projectType,
projectSettings,
clients,
teamMembers,
shots,
assets,
tasks,
artists,
canManage,
}: ProjectTabsClientProps) {
const [activeTab, setActiveTab] = useState<Tab>("shots");
const [showNewShot, setShowNewShot] = useState(false);
const [showImportShots, setShowImportShots] = useState(false);
const [showNewAsset, setShowNewAsset] = useState(false);
const [showNewTask, setShowNewTask] = useState(false);
const tabs: { id: Tab; label: string; icon: React.ElementType; count: number; managerOnly?: boolean }[] = [
{ id: "shots", label: "Shots", icon: Film, count: shots.length },
{ id: "assets", label: "Assets", icon: Package, count: assets.length },
{ id: "tasks", label: "All Tasks", icon: ListTodo, count: tasks.length },
{ id: "kanban", label: "Kanban", icon: LayoutDashboard, count: 0 },
{ id: "settings", label: "Settings", icon: Settings, count: 0, managerOnly: true },
];
const visibleTabs = tabs.filter((t) => !t.managerOnly || canManage);
return (
<div className="space-y-4">
{/* Tab bar */}
<div className="flex items-center justify-between border-b border-border pb-0">
<div className="flex">
{visibleTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === tab.id
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Icon className="h-4 w-4" />
{tab.label}
{tab.count > 0 && (
<span className="text-xs text-zinc-500">{tab.count}</span>
)}
</button>
);
})}
</div>
{/* Context-sensitive add button */}
{canManage && (
<div className="pb-1">
{activeTab === "shots" && (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-2 h-8" onClick={() => setShowImportShots(true)}>
<FileUp className="h-3.5 w-3.5" /> Import CSV
</Button>
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewShot(true)}>
<Plus className="h-3.5 w-3.5" /> New Shot
</Button>
</div>
)}
{activeTab === "assets" && (
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewAsset(true)}>
<Plus className="h-3.5 w-3.5" /> New Asset
</Button>
)}
{(activeTab === "tasks" || activeTab === "kanban") && (
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewTask(true)}>
<Plus className="h-3.5 w-3.5" /> New Task
</Button>
)}
</div>
)}
</div>
{/* Tab content */}
{activeTab === "shots" && (
<div>
{shots.length === 0 ? (
<EmptyState icon={Film} label="No shots yet" />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{shots.map((shot) => (
<ShotCard key={shot.id} shot={shot} projectId={projectId} />
))}
</div>
)}
</div>
)}
{activeTab === "assets" && (
<div className="space-y-2">
{assets.length === 0 ? (
<EmptyState icon={Package} label="No assets yet" />
) : (
assets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
projectId={projectId}
artists={artists}
canManage={canManage}
/>
))
)}
</div>
)}
{activeTab === "tasks" && (
<div className="space-y-1">
{tasks.length === 0 ? (
<EmptyState icon={ListTodo} label="No tasks yet" />
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} canManage={canManage} />
))
)}
</div>
)}
{activeTab === "kanban" && (
<KanbanBoard tasks={tasks} projectId={projectId} artists={artists} />
)}
{activeTab === "settings" && canManage && (
<ProjectSettingsTab
project={projectSettings}
clients={clients}
teamMembers={teamMembers}
/>
)}
{/* Dialogs */}
<NewShotDialog
projectId={projectId}
projectType={projectType}
open={showNewShot}
onClose={() => setShowNewShot(false)}
onSuccess={() => setShowNewShot(false)}
/>
<ImportShotsDialog
projectId={projectId}
projectType={projectType}
open={showImportShots}
onClose={() => setShowImportShots(false)}
onSuccess={() => setShowImportShots(false)}
/>
<NewAssetDialog
projectId={projectId}
open={showNewAsset}
onClose={() => setShowNewAsset(false)}
onSuccess={() => setShowNewAsset(false)}
/>
<NewTaskDialog
projectId={projectId}
artists={artists}
open={showNewTask}
onClose={() => setShowNewTask(false)}
onSuccess={() => setShowNewTask(false)}
/>
</div>
);
}
function EmptyState({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
return (
<div className="text-center py-10 text-muted-foreground border border-dashed border-border rounded-lg">
<Icon className="h-8 w-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">{label}</p>
</div>
);
}
@@ -0,0 +1,106 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
import { ArrowLeft, LayoutDashboard } from "lucide-react";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = await db.project.findUnique({
where: { id },
select: { name: true },
});
return { title: project ? `${project.name} — Kanban` : "Kanban" };
}
export default async function KanbanPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect("/login");
// Clients cannot access kanban
if (session.user.role === "CLIENT") redirect(`/projects/${id}`);
const project = await db.project.findUnique({
where: { id },
select: { id: true, name: true, code: true },
});
if (!project) notFound();
const [tasks, artists] = await Promise.all([
db.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
createdAt: true,
},
},
},
}),
db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
}),
]);
return (
<div className="p-6 space-y-6 max-w-[1800px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
Projects
</Link>
<span>/</span>
<Link
href={`/projects/${id}`}
className="hover:text-foreground transition-colors"
>
{project.name}
</Link>
<span>/</span>
<span className="text-foreground">Kanban</span>
</div>
{/* Header */}
<div className="flex items-center gap-3">
<Link
href={`/projects/${id}`}
className="text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<LayoutDashboard className="h-5 w-5 text-amber-400" />
<div>
<h1 className="text-xl font-bold">{project.name}</h1>
<p className="text-sm text-muted-foreground">Kanban Board {tasks.length} tasks</p>
</div>
</div>
{/* Board */}
<KanbanBoard tasks={tasks} projectId={id} artists={artists} />
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { Badge } from "@/components/ui/badge";
import { ProjectTabsClient } from "./ProjectTabsClient";
import {
Film,
Layers,
CheckCircle2,
ListTodo,
} from "lucide-react";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const project = await db.project.findUnique({ where: { id }, select: { name: true } });
return { title: project?.name ?? "Project" };
}
async function getProject(id: string) {
return db.project.findUnique({
where: { id },
include: {
client: true,
producer: { select: { id: true, name: true, image: true, email: true } },
supervisor: { select: { id: true, name: true, image: true, email: true } },
shots: {
orderBy: { createdAt: "asc" },
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
include: {
comments: { select: { id: true, isResolved: true } },
},
},
},
},
assets: {
orderBy: { assetCode: "asc" },
include: {
lead: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { tasks: true } },
tasks: {
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
},
},
},
tasks: {
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
},
},
});
}
async function getProjectArtists() {
return db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
});
}
async function getClients() {
return db.client.findMany({
where: { isActive: true },
select: { id: true, company: true },
orderBy: { company: "asc" },
});
}
async function getTeamMembers() {
return db.user.findMany({
where: { isActive: true, role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] } },
select: { id: true, name: true, email: true, role: true },
orderBy: { name: "asc" },
});
}
export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
const [project, artists, clients, teamMembers] = await Promise.all([
getProject(id),
getProjectArtists(),
getClients(),
getTeamMembers(),
]);
if (!project) notFound();
const canManage = session?.user && ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const totalShots = project.shots.length;
const approvedShots = project.shots.filter((s) => s.status === "COMPLETE").length;
const totalTasks = project.tasks.length;
const doneTasks = project.tasks.filter((t) => t.status === "DONE").length;
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link href="/projects" className="hover:text-white transition-colors">
Projects
</Link>
<span>/</span>
<span className="text-white">{project.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="font-mono text-sm text-muted-foreground">{project.code}</span>
{project.client && (
<span className="text-sm text-muted-foreground"> {project.client.company}</span>
)}
<Badge
className={
project.status === "ACTIVE"
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-zinc-500/10 text-zinc-400"
}
>
{project.status.replace("_", " ")}
</Badge>
</div>
<h1 className="text-3xl font-bold text-white">{project.name}</h1>
{project.description && (
<p className="text-zinc-400 mt-1">{project.description}</p>
)}
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-4 gap-3 max-w-md">
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
<Layers className="h-4 w-4 text-zinc-400 mb-1" />
<span className="text-xl font-bold text-white">{totalShots}</span>
<span className="text-xs text-zinc-400">shots</span>
</div>
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
<CheckCircle2 className="h-4 w-4 text-green-400 mb-1" />
<span className="text-xl font-bold text-white">{approvedShots}</span>
<span className="text-xs text-zinc-400">approved</span>
</div>
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
<ListTodo className="h-4 w-4 text-amber-400 mb-1" />
<span className="text-xl font-bold text-white">{totalTasks}</span>
<span className="text-xs text-zinc-400">tasks</span>
</div>
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
<Film className="h-4 w-4 text-blue-400 mb-1" />
<span className="text-xl font-bold text-white">{doneTasks}</span>
<span className="text-xs text-zinc-400">done</span>
</div>
</div>
{/* Tabs */}
<ProjectTabsClient
projectId={id}
projectType={project.projectType}
projectSettings={{
id: project.id,
name: project.name,
code: project.code,
showId: project.showId,
projectType: project.projectType,
description: project.description,
status: project.status,
clientId: project.clientId,
producerId: project.producerId,
supervisorId: project.supervisorId,
dueDate: project.dueDate,
startDate: project.startDate,
slackWebhook: project.slackWebhook,
slackChannel: project.slackChannel,
}}
clients={clients}
teamMembers={teamMembers}
shots={project.shots as any}
assets={project.assets as any}
tasks={project.tasks as any}
artists={artists}
canManage={!!canManage}
/>
</div>
);
}
@@ -0,0 +1,226 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { TaskList } from "@/components/tasks/TaskList";
import { Separator } from "@/components/ui/separator";
import { getInitials } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
Film,
ArrowLeft,
Clock,
AlertCircle,
CheckCircle2,
Settings,
ListTodo,
} from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
const STATUS_CONFIG: Record<
string,
{ label: string; className: string; Icon: React.ElementType }
> = {
WAITING: { label: "Waiting", className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", Icon: Clock },
IN_PROGRESS: { label: "In Progress", className: "bg-blue-500/10 text-blue-400 border-blue-500/20", Icon: Film },
IN_REVIEW: { label: "In Review", className: "bg-amber-500/10 text-amber-400 border-amber-500/20", Icon: AlertCircle },
REVISIONS: { label: "Revisions", className: "bg-orange-500/10 text-orange-400 border-orange-500/20", Icon: AlertCircle },
COMPLETE: { label: "Complete", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", Icon: CheckCircle2 },
};
const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
LOW: { label: "Low", dot: "bg-zinc-400" },
NORMAL: { label: "Normal", dot: "bg-blue-400" },
HIGH: { label: "High", dot: "bg-amber-400" },
CRITICAL: { label: "Critical", dot: "bg-red-500" },
};
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
const router = useRouter();
const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [loading, setLoading] = useState(true);
const [canApprove, setCanApprove] = useState(false);
const [tasks, setTasks] = useState<any[]>([]);
const [artists, setArtists] = useState<any[]>([]);
const [canManage, setCanManage] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "settings">("tasks");
const fetchShot = async () => {
try {
const res = await fetch(`/api/shots/${params.shotId}?projectId=${params.id}`);
if (res.status === 404) {
router.push("/projects");
return;
}
const data = await res.json();
setShot(data.shot);
setProjectName(data.projectName ?? "");
setCanApprove(data.canApprove ?? false);
setTasks(data.tasks ?? []);
setArtists(data.artists ?? []);
setCanManage(data.canApprove ?? false);
} catch {
router.push("/projects");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchShot();
}, [params.shotId]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Film className="h-6 w-6 animate-pulse text-muted-foreground" />
</div>
);
}
if (!shot) return null;
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const priorityCfg = PRIORITY_CONFIG[shot.priority] ?? PRIORITY_CONFIG.NORMAL;
const { Icon: StatusIcon } = statusCfg;
return (
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
Projects
</Link>
<span>/</span>
<Link href={`/projects/${params.id}`} className="hover:text-foreground transition-colors">
{projectName}
</Link>
<span>/</span>
<span className="text-foreground font-mono">{shot.shotCode}</span>
</div>
{/* Header */}
<div className="flex items-start gap-6">
{/* Thumbnail cinema scope 2.39:1 */}
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
<Link href={`/projects/${params.id}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
{shot.sequence && (
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
)}
</div>
{shot.description && (
<p className="text-muted-foreground ">{shot.description}</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<Badge className={statusCfg.className} variant="outline">
<StatusIcon className="h-3 w-3 mr-1" />
{statusCfg.label}
</Badge>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span
className={`h-2 w-2 rounded-full ${priorityCfg.dot}`}
/>
{priorityCfg.label} Priority
</div>
<span className="text-sm text-muted-foreground">{shot.fps} fps</span>
{shot.artist && (
<div className="flex items-center gap-1.5">
<Avatar className="h-5 w-5">
<AvatarImage src={shot.artist.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(shot.artist.name ?? shot.artist.email)}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">
{shot.artist.name ?? shot.artist.email}
</span>
</div>
)}
</div>
</div>
</div>
<Separator />
{/* Tabs */}
<div>
<div className="flex border-b border-border mb-5">
<button
onClick={() => setActiveTab("tasks")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "tasks"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<ListTodo className="h-4 w-4" />
Tasks
</button>
{canManage && (
<button
onClick={() => setActiveTab("settings")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "settings"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Settings className="h-4 w-4" />
Settings
</button>
)}
</div>
{activeTab === "tasks" && (
<TaskList
tasks={tasks}
projectId={params.id}
shotId={shot.id}
artists={artists}
canManage={canManage}
onTaskCreated={fetchShot}
/>
)}
{activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)}
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ProjectCard } from "@/components/projects/ProjectCard";
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
import { Plus, Search, Loader2 } from "lucide-react";
export default function ProjectsPage() {
const [search, setSearch] = useState("");
const [showNew, setShowNew] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["projects", search],
queryFn: async () => {
const res = await fetch(`/api/projects?q=${encodeURIComponent(search)}`);
if (!res.ok) throw new Error("Failed to fetch projects");
return res.json() as Promise<{ projects: any[] }>;
},
staleTime: 30_000,
});
const { data: clientsData } = useQuery({
queryKey: ["clients"],
queryFn: async () => {
const res = await fetch("/api/clients");
if (!res.ok) return { clients: [] };
return res.json() as Promise<{ clients: { id: string; company: string }[] }>;
},
staleTime: 60_000,
});
const projects = data?.projects ?? [];
const clients = clientsData?.clients ?? [];
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="flex items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Projects</h1>
<p className="text-zinc-400 mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
</p>
</div>
<Button onClick={() => setShowNew(true)} className="gap-2">
<Plus className="h-4 w-4" />
New Project
</Button>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Grid */}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No projects found.</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setShowNew(true)}
>
Create your first project
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
<NewProjectDialog
open={showNew}
onClose={() => setShowNew(false)}
clients={clients}
/>
</div>
);
}
@@ -0,0 +1,592 @@
"use client";
import { useState, useRef, useCallback, useMemo } from "react";
import {
DndContext,
DragStartEvent,
DragEndEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
addDays,
startOfWeek,
startOfDay,
format,
differenceInDays,
isBefore,
parseISO,
} from "date-fns";
import { scheduleTask, unscheduleTask } from "@/actions/schedule";
import { ScheduleTimeline } from "@/components/schedule/ScheduleTimeline";
import { BacklogPanel } from "@/components/schedule/BacklogPanel";
import { ScheduleFilters } from "@/components/schedule/ScheduleFilters";
import { CalendarDays, PanelRightOpen, PanelRightClose, Columns2, Rows } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
export const DAY_WIDTH = 216;
export const ROW_HEIGHT = 56;
export const HEADER_HEIGHT = 44;
export const LABEL_WIDTH = 208;
export const NUM_DAYS = 35;
export const SLOT_HEIGHT = 48; // task height = ROW_HEIGHT - 8
export interface ScheduleTask {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: string | null;
estimatedHours: number | null;
scheduledStartDate: string | null;
scheduledEndDate: string | null;
scheduleNotes: string | null;
assignedArtistId: string | null;
assignedArtist: {
id: string;
name: string | null;
email: string;
image: string | null;
} | null;
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
asset: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
}
export interface ScheduleArtist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
export interface ActiveDragData {
type: "scheduled" | "backlog" | "resize";
taskId: string;
duration?: number;
estimatedHours?: number | null;
originalEndDate?: string;
}
interface SchedulePageClientProps {
artists: ScheduleArtist[];
tasks: ScheduleTask[];
backlog: ScheduleTask[];
projects: { id: string; name: string; code: string }[];
canEdit: boolean;
currentUserId: string;
activeProject?: string;
activeArtist?: string;
}
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function calcDuration(task: ScheduleTask): number {
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
}
export function SchedulePageClient({
artists,
tasks,
backlog,
projects,
canEdit,
currentUserId,
activeProject,
activeArtist,
}: SchedulePageClientProps) {
const { toast } = useToast();
const [localTasks, setLocalTasks] = useState<ScheduleTask[]>(tasks);
const [localBacklog, setLocalBacklog] = useState<ScheduleTask[]>(backlog);
const [viewStart, setViewStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
);
const [showBacklog, setShowBacklog] = useState(true);
const [filterProject, setFilterProject] = useState(activeProject ?? "");
const [filterArtist, setFilterArtist] = useState(activeArtist ?? "");
const [filterStatus, setFilterStatus] = useState("");
const [activeDrag, setActiveDrag] = useState<ActiveDragData | null>(null);
const [resizePreview, setResizePreview] = useState<{
taskId: string;
endDate: Date;
estimatedHours: number;
} | null>(null);
const [dayWidth, setDayWidth] = useState(DAY_WIDTH);
const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);
const timelineRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
})
);
const days = useMemo(
() => Array.from({ length: NUM_DAYS }, (_, i) => addDays(viewStart, i)),
[viewStart]
);
const filteredArtists = useMemo(() => {
if (!filterArtist) return artists;
return artists.filter((a) => a.id === filterArtist);
}, [artists, filterArtist]);
const filteredScheduledTasks = useMemo(() => {
return localTasks.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localTasks, filterProject, filterArtist, filterStatus]);
const filteredBacklog = useMemo(() => {
return localBacklog.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localBacklog, filterProject, filterArtist, filterStatus]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const data = event.active.data.current as ActiveDragData;
setActiveDrag(data);
},
[]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveDrag(null);
const { active, delta, over } = event;
if (!active.data.current) return;
const dragData = active.data.current as ActiveDragData;
// Resize handle - only deltaX matters
if (dragData.type === "resize") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task?.scheduledStartDate || !dragData.originalEndDate) return;
const originalEnd = toDate(dragData.originalEndDate)!;
const daysDelta = Math.round(delta.x / dayWidth);
let newEnd = addDays(originalEnd, daysDelta);
const startDate = toDate(task.scheduledStartDate)!;
if (isBefore(newEnd, startDate)) newEnd = startDate;
const prevEnd = task.scheduledEndDate;
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: newEnd.toISOString() }
: t
)
);
scheduleTask({
taskId: dragData.taskId,
scheduledStartDate: task.scheduledStartDate,
scheduledEndDate: newEnd.toISOString(),
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: prevEnd }
: t
)
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
return;
}
// Check if dropped over the backlog panel (unschedule)
if (over?.id === "backlog-drop-zone" && dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== dragData.taskId));
setLocalBacklog((prev) => [
{
...task,
scheduledStartDate: null,
scheduledEndDate: null,
},
...prev,
]);
unscheduleTask(dragData.taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) =>
prev.filter((t) => t.id !== dragData.taskId)
);
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
return;
}
// Drop on timeline - calculate position from translated rect
const translatedRect = active.rect.current.translated;
if (!translatedRect || !timelineRef.current) return;
const timelineBounds = timelineRef.current.getBoundingClientRect();
const scrollLeft = timelineRef.current.scrollLeft;
const centerX = translatedRect.left + translatedRect.width / 2;
const centerY = translatedRect.top + translatedRect.height / 2;
// Check if drop is within timeline bounds
const inTimeline =
centerX >= timelineBounds.left &&
centerX <= timelineBounds.right &&
centerY >= timelineBounds.top + HEADER_HEIGHT &&
centerY <= timelineBounds.bottom;
if (!inTimeline && dragData.type !== "backlog") return;
if (!inTimeline && dragData.type === "backlog") return;
const dayIndex = Math.max(
0,
Math.min(
Math.floor((centerX - timelineBounds.left + scrollLeft) / dayWidth),
NUM_DAYS - 1
)
);
const artistIndex = Math.max(
0,
Math.min(
Math.floor(
(centerY - timelineBounds.top - HEADER_HEIGHT) / rowHeight
),
filteredArtists.length - 1
)
);
const newArtist = filteredArtists[artistIndex];
if (!newArtist) return;
const taskId = dragData.taskId;
// Duration always derived from estimatedHours so width = hours
let duration = 1;
if (dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
} else if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
}
// Pack task within the dropped day using hour offsets.
// Sum hours already scheduled for this artist on that day,
// and start the new task at that hour (stored in scheduledStartDate's time component).
const existingForArtist = localTasks.filter(
(t) =>
t.assignedArtistId === newArtist.id &&
t.id !== taskId &&
t.scheduledStartDate
);
const droppedDay = startOfDay(addDays(viewStart, dayIndex));
const hoursOnDay = existingForArtist
.filter((t) => {
const tStart = toDate(t.scheduledStartDate!);
return tStart && differenceInDays(startOfDay(tStart), droppedDay) === 0;
})
.reduce((sum, t) => sum + (t.estimatedHours ?? 8), 0);
const newStartDate = new Date(droppedDay);
newStartDate.setHours(hoursOnDay);
const newEndDate = addDays(newStartDate, duration - 1);
if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (!task) return;
const scheduledTask: ScheduleTask = {
...task,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
};
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
setLocalTasks((prev) => [...prev, scheduledTask]);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [task, ...prev]);
toast({ title: "Failed to schedule task", variant: "destructive" });
});
} else if (dragData.type === "scheduled") {
const prevTask = localTasks.find((t) => t.id === taskId);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
if (prevTask) {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
}
toast({ title: "Failed to move task", variant: "destructive" });
});
}
},
[localTasks, localBacklog, viewStart, filteredArtists, toast, dayWidth, rowHeight]
);
const handleResizeMouseDown = useCallback(
(taskId: string, _currentEndDate: string) =>
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const prevTask = localTasks.find((t) => t.id === taskId);
if (!prevTask?.scheduledStartDate) return;
const startX = e.clientX;
const startDate = toDate(prevTask.scheduledStartDate)!;
const originalHours = prevTask.estimatedHours ?? 8;
const HOUR_WIDTH = dayWidth / 8; // px per 1 hour
let currentHours = originalHours;
const onMouseMove = (me: MouseEvent) => {
const deltaPx = me.clientX - startX;
const hoursDelta = Math.round(deltaPx / HOUR_WIDTH);
currentHours = Math.max(1, originalHours + hoursDelta);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const newEndDate = addDays(startDate, newDays - 1);
setResizePreview({
taskId,
endDate: newEndDate,
estimatedHours: currentHours,
});
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const finalEnd = addDays(startDate, newDays - 1);
setResizePreview(null);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: prevTask.scheduledStartDate,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
},
[localTasks, toast, dayWidth]
);
const activeDragTask = activeDrag
? localTasks.find((t) => t.id === activeDrag.taskId) ??
localBacklog.find((t) => t.id === activeDrag.taskId)
: null;
const handleUnschedule = useCallback(
(taskId: string) => {
const task = localTasks.find((t) => t.id === taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [
{ ...task, scheduledStartDate: null, scheduledEndDate: null },
...prev,
]);
unscheduleTask(taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
},
[localTasks, toast]
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-full overflow-hidden bg-zinc-950">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-zinc-800 bg-zinc-900 shrink-0">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-amber-400" />
<h1 className="text-lg font-semibold text-white">Schedule</h1>
<span className="text-sm text-zinc-500">
{format(viewStart, "MMM d")} {" "}
{format(addDays(viewStart, NUM_DAYS - 1), "MMM d, yyyy")}
</span>
</div>
<div className="flex items-center gap-2">
{/* Zoom controls */}
<div className="flex items-center gap-3 border border-zinc-700 rounded-md px-2.5 py-1">
<div className="flex items-center gap-1.5">
<Columns2 className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={60}
max={400}
step={8}
value={dayWidth}
onChange={(e) => setDayWidth(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
<div className="flex items-center gap-1.5">
<Rows className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={40}
max={120}
step={4}
value={rowHeight}
onChange={(e) => setRowHeight(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowBacklog((b) => !b)}
className="text-zinc-400 hover:text-white gap-2"
>
{showBacklog ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
{showBacklog ? "Hide" : "Backlog"}
</Button>
</div>
</div>
{/* Filters */}
<ScheduleFilters
projects={projects}
artists={artists}
filterProject={filterProject}
filterArtist={filterArtist}
filterStatus={filterStatus}
viewStart={viewStart}
onProjectChange={setFilterProject}
onArtistChange={setFilterArtist}
onStatusChange={setFilterStatus}
onViewStartChange={setViewStart}
/>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
<ScheduleTimeline
artists={filteredArtists}
tasks={filteredScheduledTasks}
days={days}
viewStart={viewStart}
canEdit={canEdit}
timelineRef={timelineRef}
resizePreview={resizePreview}
onResizeMouseDown={handleResizeMouseDown}
activeDragId={activeDrag?.taskId ?? null}
onUnschedule={handleUnschedule}
dayWidth={dayWidth}
rowHeight={rowHeight}
/>
{showBacklog && (
<BacklogPanel
tasks={filteredBacklog}
canEdit={canEdit}
/>
)}
</div>
{/* Drag overlay */}
<DragOverlay dropAnimation={null}>
{activeDragTask && (
<div className="rounded-md border border-amber-500/60 bg-amber-500/20 px-2 py-1.5 text-xs font-medium text-amber-200 shadow-xl opacity-90 max-w-[200px] truncate pointer-events-none">
{activeDragTask.shot?.shotCode ??
activeDragTask.asset?.assetCode ??
activeDragTask.title}
</div>
)}
</DragOverlay>
</div>
</DndContext>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { SchedulePageClient } from "./SchedulePageClient";
export const metadata = { title: "Schedule" };
export default async function SchedulePage({
searchParams,
}: {
searchParams: Promise<{ project?: string; artist?: string; status?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { project, artist, status } = await searchParams;
const [artists, scheduledTasks, backlogTasks, projects] = await Promise.all([
db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: [{ role: "asc" }, { name: "asc" }],
}),
db.task.findMany({
where: {
scheduledStartDate: { not: null },
status: { not: "DONE" },
...(project ? { projectId: project } : {}),
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: { scheduledStartDate: "asc" },
}),
db.task.findMany({
where: {
scheduledStartDate: null,
status: { not: "DONE" },
...(project ? { projectId: project } : {}),
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
}),
db.project.findMany({
where: { status: "ACTIVE" },
select: { id: true, name: true, code: true },
orderBy: { name: "asc" },
}),
]);
const canEdit = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role
);
const serializeTask = (t: (typeof scheduledTasks)[number]) => ({
...t,
dueDate: t.dueDate ? t.dueDate.toISOString() : null,
scheduledStartDate: t.scheduledStartDate ? t.scheduledStartDate.toISOString() : null,
scheduledEndDate: t.scheduledEndDate ? t.scheduledEndDate.toISOString() : null,
});
return (
<SchedulePageClient
artists={artists}
tasks={scheduledTasks.map(serializeTask)}
backlog={backlogTasks.map(serializeTask)}
projects={projects}
canEdit={canEdit}
currentUserId={session.user.id}
activeProject={project}
activeArtist={artist}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { auth } from "@/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/lib/utils";
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
export const metadata = { title: "Settings" };
export default async function SettingsPage() {
const session = await auth();
if (!session?.user) return null;
return (
<div className="p-6 space-y-6 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold">Account Settings</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Your Profile</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-4">
<Avatar className="h-14 w-14">
{session.user.image && <AvatarImage src={session.user.image} />}
<AvatarFallback className="text-lg">
{getInitials(session.user.name)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{session.user.name ?? "—"}</p>
<p className="text-sm text-muted-foreground">{session.user.email}</p>
<p className="text-xs text-muted-foreground mt-0.5 capitalize">
{session.user.role?.toLowerCase()}
</p>
</div>
</CardContent>
</Card>
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn, getInitials } from "@/lib/utils";
import {
CalendarDays,
ListTodo,
AlertTriangle,
Eye,
Clock,
Layers,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { TaskStatus, TaskType } from "@prisma/client";
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
interface Artist {
id: string;
name: string | null;
email: string;
}
interface Task {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
shot?: { id: string; shotCode: string } | null;
asset?: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
_count?: { versions: number };
versions?: { id: string; versionNumber: number; approvalStatus: string; createdAt: Date }[];
}
interface TasksPageClientProps {
tasks: Task[];
artists: Artist[];
currentUserId: string;
role: string;
counts: { today: number; overdue: number; inReview: number; total: number };
activeStatus?: string;
activeAssignee?: string;
}
const FILTER_TABS = [
{ label: "All", status: undefined, icon: ListTodo, color: "text-zinc-400" },
{ label: "Due Today", status: "today", icon: Clock, color: "text-amber-400" },
{ label: "Overdue", status: "overdue", icon: AlertTriangle, color: "text-red-400" },
{ label: "In Review", status: "INTERNAL_REVIEW", icon: Eye, color: "text-purple-400" },
];
export function TasksPageClient({
tasks,
artists,
currentUserId,
role,
counts,
activeStatus,
activeAssignee,
}: TasksPageClientProps) {
const router = useRouter();
const isArtist = role === "ARTIST";
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Client-side filter
const filtered = tasks.filter((task) => {
if (activeStatus === "today") {
return (
task.dueDate &&
new Date(task.dueDate) >= todayStart &&
new Date(task.dueDate) < new Date(todayStart.getTime() + 86400000) &&
task.status !== "DONE"
);
}
if (activeStatus === "overdue") {
return task.dueDate && new Date(task.dueDate) < todayStart && task.status !== "DONE";
}
if (activeStatus === "INTERNAL_REVIEW") {
return ["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(task.status);
}
return true;
});
const navigate = (params: { status?: string; assignee?: string }) => {
const sp = new URLSearchParams();
if (params.status) sp.set("status", params.status);
if (params.assignee) sp.set("assignee", params.assignee);
router.push(`/tasks?${sp.toString()}`);
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">
{isArtist ? "My Tasks" : "All Tasks"}
</h1>
<p className="text-sm text-zinc-400 mt-0.5">
{counts.total} total · {counts.overdue > 0 && (
<span className="text-red-400">{counts.overdue} overdue · </span>
)}
{counts.inReview} in review
</p>
</div>
{/* Assignee filter (non-artists) */}
{!isArtist && artists.length > 0 && (
<Select
value={activeAssignee ?? "__all__"}
onValueChange={(v) =>
navigate({ status: activeStatus, assignee: v === "__all__" ? undefined : v })
}
>
<SelectTrigger className="w-44 h-8 text-sm">
<SelectValue placeholder="All artists" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All artists</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Filter tabs */}
<div className="flex gap-1 border-b border-zinc-800 pb-0">
{FILTER_TABS.map((tab) => {
const Icon = tab.icon;
const isActive =
(!activeStatus && !tab.status) || activeStatus === tab.status;
const count =
tab.status === "today"
? counts.today
: tab.status === "overdue"
? counts.overdue
: tab.status === "INTERNAL_REVIEW"
? counts.inReview
: counts.total;
return (
<button
key={tab.label}
onClick={() =>
navigate({ status: tab.status, assignee: activeAssignee })
}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
isActive
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Icon className={cn("h-3.5 w-3.5", isActive ? "text-amber-400" : tab.color)} />
{tab.label}
{count > 0 && (
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 font-mono">
{count}
</span>
)}
</button>
);
})}
</div>
{/* Task list */}
{filtered.length === 0 ? (
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-xl">
<ListTodo className="h-8 w-8 mx-auto mb-3 text-zinc-700" />
<p className="text-zinc-500 text-sm">No tasks found</p>
</div>
) : (
<div className="space-y-1">
{filtered.map((task) => {
const cfg = TASK_STATUS_CONFIG[task.status];
const Icon = cfg.icon;
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
const isOverdue =
task.dueDate &&
new Date(task.dueDate) < now &&
task.status !== "DONE";
const latestVersion = task.versions?.[0];
return (
<Link
key={task.id}
href={`/tasks/${task.id}`}
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-transparent hover:border-zinc-800 hover:bg-zinc-900/60 transition-all group"
>
{/* Priority dot */}
<span
className={cn(
"w-1.5 h-1.5 rounded-full shrink-0",
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
)}
/>
{/* Title + context */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-200 group-hover:text-amber-400 transition-colors truncate">
{task.title}
</span>
{contextCode && (
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded shrink-0">
{contextCode}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-zinc-500">
<span>{TASK_TYPE_LABELS[task.type]}</span>
<span className="text-zinc-700">·</span>
<span>{task.project.name}</span>
</div>
</div>
{/* Meta */}
<div className="flex items-center gap-3 shrink-0">
{latestVersion && (
<span className="text-xs font-mono text-zinc-500">
v{latestVersion.versionNumber}
</span>
)}
{(task._count?.versions ?? 0) > 0 && (
<span className="flex items-center gap-1 text-[11px] text-zinc-500">
<Layers className="h-3 w-3" />
{task._count!.versions}
</span>
)}
{task.dueDate && (
<span
className={cn(
"flex items-center gap-1 text-[11px]",
isOverdue ? "text-red-400" : "text-zinc-500"
)}
>
<CalendarDays className="h-3 w-3" />
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
</span>
)}
{task.assignedArtist && (
<Avatar className="h-5 w-5">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
)}
<Badge
className={cn(
"text-[10px] border px-1.5 py-0 h-5 gap-1",
cfg.color
)}
>
<Icon className="h-2.5 w-2.5" />
{cfg.label}
</Badge>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,485 @@
"use client";
import { useState, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate, formatFileSize } from "@/lib/utils";
import { updateTask, updateTaskStatus } from "@/actions/tasks";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
import { formatDistanceToNow, format } from "date-fns";
import {
ArrowLeft,
CalendarDays,
Clock,
Film,
Layers,
Play,
Upload,
ChevronDown,
CheckCircle2,
Eye,
RefreshCw,
Loader2,
AlertCircle,
User,
ExternalLink,
} from "lucide-react";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { VersionUpload } from "@/components/versions/VersionUpload";
import type { CommentWithReplies } from "@/types";
import { CommentPanel } from "@/components/comments/CommentPanel";
const PRIORITY_COLORS: Record<string, string> = {
LOW: "text-zinc-400", NORMAL: "text-blue-400", HIGH: "text-amber-400", URGENT: "text-red-400",
};
const APPROVAL_STYLES: 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",
};
interface Artist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
interface Version {
id: string;
versionNumber: number;
fileName: string;
fileSize: bigint | null;
fps: number;
approvalStatus: string;
isLatest: boolean;
isClientVisible: boolean;
createdAt: Date;
notes: string | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
_count: { comments: number };
approvals: { id: string; user: { id: string; name: string | null; role: string } }[];
comments: CommentWithReplies[];
}
interface Task {
id: string;
title: string;
description: string | null;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
estimatedHours: number | null;
projectId: string;
assignedArtistId: string | null;
assignedArtist: Artist | null;
createdBy: { id: string; name: string | null; email: string };
project: { id: string; name: string; code: string };
shot: { id: string; shotCode: string; projectId: string } | null;
asset: { id: string; assetCode: string; name: string; projectId: string } | null;
versions: Version[];
}
interface TaskDetailClientProps {
task: Task;
artists: Artist[];
currentUserId: string;
canManage: boolean;
canUpload: boolean;
}
export function TaskDetailClient({
task,
artists,
currentUserId,
canManage,
canUpload,
}: TaskDetailClientProps) {
const router = useRouter();
const { toast } = useToast();
const [showUpload, setShowUpload] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
const statusCfg = TASK_STATUS_CONFIG[task.status];
const StatusIcon = statusCfg.icon;
const latestVersion = task.versions[0];
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "DONE";
const parentLink = task.shot
? { label: task.shot.shotCode, href: `/projects/${task.project.id}` }
: task.asset
? { label: `${task.asset.assetCode}${task.asset.name}`, href: `/projects/${task.project.id}` }
: null;
const handleStatusChange = async (newStatus: TaskStatus) => {
setUpdatingStatus(true);
try {
await updateTaskStatus(task.id, newStatus);
router.refresh();
} catch {
toast({ title: "Failed to update status", variant: "destructive" });
} finally {
setUpdatingStatus(false);
}
};
const handleAssigneeChange = async (artistId: string) => {
try {
await updateTask(task.id, { assignedArtistId: artistId === "__none__" ? null : artistId });
router.refresh();
toast({ title: "Assignment updated" });
} catch {
toast({ title: "Failed to update assignment", variant: "destructive" });
}
};
return (
<div className="min-h-screen bg-background">
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link href="/projects" className="hover:text-white transition-colors">Projects</Link>
<span>/</span>
<Link href={`/projects/${task.project.id}`} className="hover:text-white transition-colors">
{task.project.name}
</Link>
{parentLink && (
<>
<span>/</span>
<Link href={parentLink.href} className="hover:text-white transition-colors">
{parentLink.label}
</Link>
</>
)}
<span>/</span>
<span className="text-zinc-300">{task.title}</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main column */}
<div className="lg:col-span-2 space-y-5">
{/* Task header */}
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold text-white">{task.title}</h1>
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span className="font-mono">{TASK_TYPE_LABELS[task.type]}</span>
{parentLink && (
<>
<span>·</span>
<Link href={parentLink.href} className="hover:text-zinc-300">
{parentLink.label}
</Link>
</>
)}
</div>
</div>
{canUpload && (
<Button onClick={() => setShowUpload(true)} className="gap-2 shrink-0">
<Upload className="h-4 w-4" />
Upload Version
</Button>
)}
</div>
{task.description && (
<p className="text-zinc-400 text-sm leading-relaxed">{task.description}</p>
)}
</div>
{/* Version history */}
<div className="space-y-3">
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
<Layers className="h-4 w-4" />
Versions
{task.versions.length > 0 && (
<span className="text-xs text-zinc-500 font-normal">({task.versions.length})</span>
)}
</h2>
{task.versions.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-lg">
<Film className="h-8 w-8 mx-auto mb-2 text-zinc-600" />
<p className="text-sm text-muted-foreground">No versions uploaded yet</p>
{canUpload && (
<Button
variant="ghost"
size="sm"
className="mt-3 gap-2"
onClick={() => setShowUpload(true)}
>
<Upload className="h-3.5 w-3.5" />
Upload first version
</Button>
)}
</div>
) : (
<div className="space-y-2">
{task.versions.map((v) => (
<div
key={v.id}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors",
v.isLatest
? "border-amber-500/30 bg-amber-500/5"
: "border-border bg-card"
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-medium text-sm text-white">
v{String(v.versionNumber).padStart(3, "0")}
</span>
{v.isLatest && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-amber-500/15 text-amber-400 border-amber-500/30">
Latest
</Badge>
)}
<Badge className={cn("text-xs border px-1.5 py-0 h-5", APPROVAL_STYLES[v.approvalStatus])}>
{v.approvalStatus.replace(/_/g, " ")}
</Badge>
{v.isClientVisible && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-blue-500/15 text-blue-400 border-blue-500/30">
Client Visible
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-zinc-500">
<span>{v.fileName}</span>
{v.fileSize != null && (
<span>{formatFileSize(Number(v.fileSize))}</span>
)}
<span>{formatRelativeDate(new Date(v.createdAt))}</span>
{v._count.comments > 0 && (
<span className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{v._count.comments} comments
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{v.artist && (
<Avatar className="h-6 w-6">
<AvatarImage src={v.artist.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{getInitials(v.artist.name ?? v.artist.email)}
</AvatarFallback>
</Avatar>
)}
<Link href={`/review/${v.id}`}>
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs">
<Play className="h-3 w-3" />
Review
</Button>
</Link>
</div>
</div>
))}
</div>
)}
</div>
{/* Comments on latest version */}
{latestVersion && (
<div className="space-y-3">
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Comments
</h2>
<CommentPanel
versionId={latestVersion.id}
fps={latestVersion.fps}
comments={latestVersion.comments}
/>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Status */}
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
<h3 className="text-sm font-medium text-zinc-400">Status</h3>
{canManage ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={updatingStatus}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md border text-sm font-medium transition-colors",
statusCfg.color
)}
>
<span className="flex items-center gap-2">
<StatusIcon className="h-3.5 w-3.5" />
{statusCfg.label}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{(Object.keys(TASK_STATUS_CONFIG) as TaskStatus[]).map((s) => {
const cfg = TASK_STATUS_CONFIG[s];
const Icon = cfg.icon;
return (
<DropdownMenuItem
key={s}
onClick={() => handleStatusChange(s)}
className={cn(s === task.status && "font-medium")}
>
<Icon className="h-3.5 w-3.5 mr-2" />
{cfg.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className={cn("flex items-center gap-2 px-3 py-2 rounded-md border text-sm font-medium", statusCfg.color)}>
<StatusIcon className="h-3.5 w-3.5" />
{statusCfg.label}
</div>
)}
{/* Meta fields */}
<div className="space-y-3 pt-2 border-t border-border">
<div>
<p className="text-xs text-zinc-500 mb-1">Type</p>
<p className="text-sm font-medium">{TASK_TYPE_LABELS[task.type]}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Priority</p>
<p className={cn("text-sm font-medium capitalize", PRIORITY_COLORS[task.priority])}>
{task.priority}
</p>
</div>
{task.dueDate && (
<div>
<p className="text-xs text-zinc-500 mb-1">Due Date</p>
<p className={cn("text-sm flex items-center gap-1.5", isOverdue ? "text-red-400" : "text-zinc-300")}>
<CalendarDays className="h-3.5 w-3.5" />
{format(new Date(task.dueDate), "MMM d, yyyy")}
{isOverdue && <span className="text-xs">(Overdue)</span>}
</p>
</div>
)}
{task.estimatedHours && (
<div>
<p className="text-xs text-zinc-500 mb-1">Estimated</p>
<p className="text-sm flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-zinc-400" />
{task.estimatedHours}h
</p>
</div>
)}
</div>
</div>
{/* Assignee */}
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<h3 className="text-sm font-medium text-zinc-400">Assigned Artist</h3>
{canManage ? (
<Select
value={task.assignedArtistId ?? "__none__"}
onValueChange={handleAssigneeChange}
>
<SelectTrigger className="text-sm">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Unassigned</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
) : task.assignedArtist ? (
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-xs">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{task.assignedArtist.name ?? task.assignedArtist.email}</p>
<p className="text-xs text-zinc-500">{task.assignedArtist.role}</p>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">Unassigned</p>
)}
</div>
{/* Project context */}
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
<h3 className="text-sm font-medium text-zinc-400">Project</h3>
<Link
href={`/projects/${task.project.id}`}
className="flex items-center gap-1.5 text-sm text-white hover:text-amber-400 transition-colors"
>
<span className="font-mono text-zinc-500">{task.project.code}</span>
{task.project.name}
<ExternalLink className="h-3 w-3 text-zinc-500" />
</Link>
{task.shot && (
<p className="text-xs text-zinc-500">Shot: {task.shot.shotCode}</p>
)}
{task.asset && (
<p className="text-xs text-zinc-500">Asset: {task.asset.assetCode} {task.asset.name}</p>
)}
</div>
</div>
</div>
</div>
{/* Version upload dialog */}
{showUpload && (
<VersionUpload
taskId={task.id}
projectId={task.projectId}
currentVersionNumber={latestVersion?.versionNumber ?? 0}
open={showUpload}
onClose={() => setShowUpload(false)}
onSuccess={() => {
setShowUpload(false);
router.refresh();
}}
/>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { TaskDetailClient } from "./TaskDetailClient";
export async function generateMetadata({ params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const task = await db.task.findUnique({ where: { id: taskId }, select: { title: true } });
return { title: task?.title ?? "Task" };
}
async function getTask(taskId: string) {
return db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true, projectId: true } },
asset: { select: { id: true, assetCode: true, name: true, projectId: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true, role: true } },
createdBy: { select: { id: true, name: true, email: true } },
project: { select: { id: true, name: true, code: true } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
_count: { select: { comments: true } },
approvals: {
orderBy: { createdAt: "desc" },
take: 1,
include: { user: { select: { id: true, name: true, role: true } } },
},
comments: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
},
},
},
},
},
},
},
});
}
async function getProjectArtists(projectId: string) {
return db.user.findMany({
where: {
isActive: true,
},
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: { name: "asc" },
});
}
export default async function TaskPage({ params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const session = await auth();
if (!session?.user) redirect("/login");
const task = await getTask(taskId);
if (!task) notFound();
const artists = await getProjectArtists(task.projectId);
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const isAssigned = task.assignedArtistId === session.user.id;
const canUpload = canManage || isAssigned;
return (
<TaskDetailClient
task={task as any}
artists={artists}
currentUserId={session.user.id}
canManage={canManage}
canUpload={canUpload}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { TasksPageClient } from "./TasksPageClient";
export const metadata = { title: "My Tasks" };
export default async function TasksPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; assignee?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { status, assignee } = await searchParams;
const isArtist = session.user.role === "ARTIST";
// Artists only see their own tasks; others can filter by assignee
const assigneeFilter = isArtist
? session.user.id
: assignee || undefined;
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tasks = await db.task.findMany({
where: {
...(assigneeFilter ? { assignedArtistId: assigneeFilter } : {}),
},
orderBy: [{ dueDate: "asc" }, { priority: "desc" }, { createdAt: "desc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
});
const artists = isArtist
? []
: await db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
});
// Counts for filter tabs
const today = tasks.filter(
(t) => t.dueDate && new Date(t.dueDate) >= todayStart && new Date(t.dueDate) < new Date(todayStart.getTime() + 86400000) && t.status !== "DONE"
).length;
const overdue = tasks.filter(
(t) => t.dueDate && new Date(t.dueDate) < todayStart && t.status !== "DONE"
).length;
const inReview = tasks.filter((t) =>
["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(t.status)
).length;
return (
<TasksPageClient
tasks={tasks as any}
artists={artists}
currentUserId={session.user.id}
role={session.user.role}
counts={{ today, overdue, inReview, total: tasks.length }}
activeStatus={status}
activeAssignee={assignee}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Metadata } from "next";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { UsersClient } from "@/components/users/UsersClient";
import { Users } from "lucide-react";
export const metadata: Metadata = { title: "User Management" };
export default async function UsersPage() {
const session = await auth();
if (!session || session.user.role !== "ADMIN") {
redirect("/dashboard");
}
const users = await db.user.findMany({
orderBy: [{ role: "asc" }, { name: "asc" }, { email: "asc" }],
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
createdAt: true,
},
});
// Serialize dates
const serialized = users.map((u) => ({
...u,
createdAt: u.createdAt.toISOString(),
}));
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center shrink-0">
<Users className="h-5 w-5 text-amber-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-sm text-zinc-400 mt-0.5">Manage studio accounts, roles, and access</p>
</div>
</div>
<UsersClient users={serialized} currentUserId={session.user.id!} />
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { ApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
async function getOrCreateClientUser(email: string, label?: string | null) {
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const body = await req.json();
const { versionId, status, notes } = body;
const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"];
if (!versionId || !validStatuses.includes(status)) {
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
}
// Ensure the version belongs to this project via its task
const version = await db.version.findUnique({
where: { id: versionId },
include: {
task: {
include: { shot: true, project: true },
},
},
});
const projectId = version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
const user = await getOrCreateClientUser(email, session.label);
// Record approval
await db.approval.create({
data: { versionId, userId: user.id, status, notes },
});
// Update version approval status
await db.version.update({
where: { id: versionId },
data: { approvalStatus: status },
});
// Update task status based on approval decision
if (version.task) {
if (status === "APPROVED") {
await db.task.update({ where: { id: version.task.id }, data: { status: "DONE" } });
} else {
await db.task.update({ where: { id: version.task.id }, data: { status: "CHANGES" } });
}
// Recalculate derived shot status
if (version.task.shot) {
await recalcShotStatus(version.task.shot.id).catch(() => {});
}
}
// Slack notification
if (version.task?.project?.slackWebhook) {
const { slackNotifyApproval } = await import("@/lib/slack");
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
const contextCode = version.task.shot?.shotCode ?? version.task.title;
await slackNotifyApproval(version.task.project.slackWebhook, {
shotCode: contextCode,
versionLabel: `v${String(version.versionNumber).padStart(3, "0")}`,
reviewerName: user.name ?? "Client",
status,
projectName: version.task.project.name,
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
});
}
return NextResponse.json({ success: true });
}
+91
View File
@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { slackNotifyNewFeedback } from "@/lib/slack";
/** Find or create a guest user for the client reviewer based on the session email */
async function getOrCreateClientUser(email: string, label?: string | null) {
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const body = await req.json();
const { versionId, frameNumber, timestamp, text } = body;
if (!versionId || frameNumber == null || timestamp == null || !text?.trim()) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Ensure the version belongs to this project
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
},
});
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
// Resolve commenter identity
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
const user = await getOrCreateClientUser(email, session.label);
const comment = await db.comment.create({
data: {
versionId,
authorId: user.id,
frameNumber,
timestamp,
text: text.trim(),
},
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: true,
},
});
// Slack notification
const slackWebhook =
version.shot?.project?.slackWebhook ?? version.task?.project?.slackWebhook ?? null;
const shotCode =
version.shot?.shotCode ?? version.task?.shot?.shotCode ?? version.task?.title ?? "Task";
if (slackWebhook) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
await slackNotifyNewFeedback(slackWebhook, {
shotCode,
frameNumber,
authorName: user.name ?? user.email,
commentText: text.trim(),
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
});
}
return NextResponse.json({ comment }, { status: 201 });
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const project = await db.project.findUnique({
where: { id: session.projectId },
select: { id: true, name: true, code: true, description: true, status: true },
});
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
// Find shots that have at least one task with a client-visible version
const shots = await db.shot.findMany({
where: {
projectId: session.projectId,
tasks: {
some: {
versions: { some: { isClientVisible: true } },
},
},
},
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
select: {
id: true,
shotCode: true,
sequence: true,
description: true,
status: true,
thumbnailUrl: true,
tasks: {
where: {
versions: { some: { isClientVisible: true } },
},
select: {
id: true,
title: true,
type: true,
status: true,
versions: {
where: { isClientVisible: true, isLatest: true },
take: 1,
select: {
id: true,
versionNumber: true,
approvalStatus: true,
fps: true,
duration: true,
thumbnailUrl: true,
notes: true,
createdAt: true,
},
},
},
},
},
});
// Asset tasks with client-visible versions (no shotId)
const assetTasks = await db.task.findMany({
where: {
projectId: session.projectId,
shotId: null,
versions: { some: { isClientVisible: true } },
},
select: {
id: true,
title: true,
type: true,
status: true,
asset: { select: { id: true, assetCode: true, name: true } },
versions: {
where: { isClientVisible: true, isLatest: true },
take: 1,
select: {
id: true,
versionNumber: true,
approvalStatus: true,
fps: true,
duration: true,
thumbnailUrl: true,
notes: true,
createdAt: true,
},
},
},
});
// Increment access count
await db.reviewSession.update({
where: { id: session.id },
data: { accessCount: { increment: 1 } },
});
return NextResponse.json({
project,
shots,
assetTasks,
sessionLabel: session.label,
});
}
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string; versionId: string }> }
) {
const { token, versionId } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: {
include: {
project: { select: { id: true, name: true, code: true } },
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
isLatest: true,
fps: true,
duration: true,
createdAt: true,
},
},
},
},
task: {
include: {
project: { select: { id: true, name: true, code: true } },
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true, name: true } },
},
},
artist: { select: { id: true, name: true, image: true, email: true } },
approvals: {
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true } } },
},
},
});
// Resolve project: from shot or from task
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
// For task-only versions (no shot), require explicit client sharing
if (!version.shot && !version.isClientVisible) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const comments = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
},
},
},
});
const serializedVersion = {
...version,
fileSize: version.fileSize?.toString() ?? null,
};
return NextResponse.json({ version: serializedVersion, comments });
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ clientId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { clientId } = await params;
const client = await db.client.findUnique({
where: { id: clientId },
include: {
projects: {
orderBy: { createdAt: "desc" },
include: {
_count: { select: { shots: true } },
},
},
},
});
if (!client) {
return NextResponse.json({ error: "Client not found" }, { status: 404 });
}
return NextResponse.json({ client });
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ clientId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { clientId } = await params;
const body = await req.json();
const { company, contactPerson, email, phone, notes, isActive } = body;
const client = await db.client.update({
where: { id: clientId },
data: { company, contactPerson, email, phone, notes, isActive },
});
return NextResponse.json({ client });
}
+47
View File
@@ -0,0 +1,47 @@
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 clients = await db.client.findMany({
select: {
id: true,
company: true,
contactPerson: true,
email: true,
isActive: true,
_count: { select: { projects: true } },
},
orderBy: { company: "asc" },
});
return NextResponse.json({ clients });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { company, contactPerson, email, phone, notes } = body;
if (!company || !contactPerson || !email) {
return NextResponse.json({ error: "company, contactPerson and email are required" }, { status: 400 });
}
const client = await db.client.create({
data: { company, contactPerson, email, phone, notes },
});
return NextResponse.json({ client }, { status: 201 });
}
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ key: string[] }> }
) {
const { key } = await params;
// key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"]
const relativePath = key.join("/");
// Sanitize: prevent path traversal
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
const filePath = path.resolve(path.join(uploadDir, relativePath));
if (!filePath.startsWith(uploadDir)) {
return new NextResponse("Forbidden", { status: 403 });
}
if (!fs.existsSync(filePath)) {
return new NextResponse("Not found", { status: 404 });
}
const stat = fs.statSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const mimeMap: Record<string, string> = {
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mxf": "application/mxf",
".webm": "video/webm",
};
const contentType = mimeMap[ext] ?? "application/octet-stream";
// Support range requests so the HTML5 video player can seek
const rangeHeader = req.headers.get("range");
if (rangeHeader) {
const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-");
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, { start, end });
const nodeStream = stream as unknown as ReadableStream;
return new NextResponse(nodeStream, {
status: 206,
headers: {
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
"Accept-Ranges": "bytes",
"Content-Length": String(chunkSize),
"Content-Type": contentType,
},
});
}
const stream = fs.createReadStream(filePath) as unknown as ReadableStream;
return new NextResponse(stream, {
headers: {
"Content-Length": String(stat.size),
"Content-Type": contentType,
"Accept-Ranges": "bytes",
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const notifications = await db.notification.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: 50,
});
const unreadCount = await db.notification.count({
where: { userId: session.user.id, isRead: false },
});
return NextResponse.json({ notifications, unreadCount });
}
export async function PATCH() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await db.notification.updateMany({
where: { userId: session.user.id, isRead: false },
data: { isRead: true },
});
return NextResponse.json({ success: true });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const q = searchParams.get("q") ?? "";
const status = searchParams.get("status");
const projects = await db.project.findMany({
where: {
AND: [
q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ code: { contains: q, mode: "insensitive" } },
],
}
: {},
status ? { status: status as any } : {},
],
},
orderBy: { createdAt: "desc" },
include: {
client: { select: { id: true, company: true } },
producer: { select: { id: true, name: true, image: true } },
_count: { select: { shots: true } },
},
});
return NextResponse.json({ projects });
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { addDays } from "date-fns";
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");
const sessions = await db.reviewSession.findMany({
where: projectId ? { projectId } : undefined,
orderBy: { createdAt: "desc" },
include: {
project: { select: { id: true, name: true, code: true } },
},
});
return NextResponse.json({ sessions });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { projectId, label, email, expiresInDays = 30 } = body;
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
}
const project = await db.project.findUnique({ where: { id: projectId } });
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const reviewSession = await db.reviewSession.create({
data: {
projectId,
label: label || `Review — ${project.name}`,
email: email || null,
expiresAt: addDays(new Date(), expiresInDays),
},
});
const appUrl =
process.env.NEXT_PUBLIC_APP_URL ||
`${req.headers.get("x-forwarded-proto") ?? "https"}://${req.headers.get("host")}`;
const portalUrl = `${appUrl}/client/${reviewSession.token}`;
return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 });
}
export async function DELETE(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const id = req.nextUrl.searchParams.get("id");
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
await db.reviewSession.update({
where: { id },
data: { isActive: false },
});
return NextResponse.json({ success: true });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ shotId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { shotId } = await params;
const shot = await db.shot.findUnique({
where: { id: shotId },
include: {
artist: { select: { id: true, name: true, email: true, image: true } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
artist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { comments: true } },
approvals: {
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true, image: true } } },
},
},
},
},
});
if (!shot) {
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
}
const project = await db.project.findUnique({
where: { id: shot.projectId },
select: { name: true },
});
const [tasks, artists] = await Promise.all([
db.task.findMany({
where: { shotId },
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
}),
db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
}),
]);
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role as string
);
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
const shotSerialized = {
...shot,
versions: shot.versions.map((v) => ({
...v,
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
})),
};
return NextResponse.json({
shot: shotSerialized,
projectName: project?.name ?? "",
canApprove,
tasks,
artists,
});
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { taskId } = await params;
const task = await db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
createdBy: { select: { id: true, name: true, email: true } },
project: { select: { id: true, name: true, code: true } },
_count: { select: { versions: true } },
},
});
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(task);
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { taskId } = await params;
const body = await req.json();
const task = await db.task.update({
where: { id: taskId },
data: body,
});
return NextResponse.json(task);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { taskId } = await params;
await db.task.delete({ where: { id: taskId } });
return NextResponse.json({ success: true });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const projectId = searchParams.get("projectId");
const shotId = searchParams.get("shotId");
const assetId = searchParams.get("assetId");
const tasks = await db.task.findMany({
where: {
...(projectId && { projectId }),
...(shotId && { shotId }),
...(assetId && { assetId }),
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
},
});
return NextResponse.json(tasks);
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { uploadFile } from "@/lib/storage";
export const config = { api: { bodyParser: false } };
// Max 2 GB
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.type.match(/video\//)) {
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
}
if (file.size > 2 * 1024 * 1024 * 1024) {
return NextResponse.json({ error: "File too large (max 2 GB)" }, { status: 413 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const result = await uploadFile(buffer, file.name, file.type, "videos");
return NextResponse.json({ url: result.url, key: result.key });
} catch (err) {
console.error("[local-upload]", err);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { uploadFile } from "@/lib/storage";
export const config = { api: { bodyParser: false } };
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
const type = (formData.get("type") as string) || "videos";
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Validate file type based on upload type
if (type === "image" && !file.type.match(/image\//)) {
return NextResponse.json({ error: "Only image files are accepted" }, { status: 400 });
}
if (type === "video" && !file.type.match(/video\//)) {
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
}
// Size limit: 500MB for images, 2GB for videos
const maxSize = type === "image" ? 500 * 1024 * 1024 : 2 * 1024 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeStr = type === "image" ? "500 MB" : "2 GB";
return NextResponse.json(
{ error: `File too large (max ${maxSizeStr})` },
{ status: 413 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const result = await uploadFile(buffer, file.name, file.type, type);
return NextResponse.json({ url: result.url, key: result.key });
} catch (err) {
console.error("[upload]", err);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const disabled = () =>
NextResponse.json(
{
error:
"UploadThing is not configured. Add UPLOADTHING_SECRET to .env or use STORAGE_PROVIDER=local.",
},
{ status: 503 }
);
// Lazily resolve the real handlers only when the secret is present.
// This prevents the UploadThing SDK from throwing at module-load time
// when the env var is missing (e.g. STORAGE_PROVIDER=local).
async function getHandlers() {
if (!process.env.UPLOADTHING_SECRET) return null;
const { createRouteHandler } = await import("uploadthing/next");
const { uploadRouter } = await import("@/lib/uploadthing");
return createRouteHandler({ router: uploadRouter });
}
export async function GET(req: NextRequest) {
const h = await getHandlers();
return h ? h.GET(req) : disabled();
}
export async function POST(req: NextRequest) {
const h = await getHandlers();
return h ? h.POST(req) : disabled();
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const annotations = await db.annotation.findMany({
where: { versionId, isVisible: true },
include: {
author: { select: { id: true, name: true, image: true } },
},
orderBy: { frameNumber: "asc" },
});
return NextResponse.json({ annotations });
}
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const comments = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
},
},
},
});
return NextResponse.json({ comments });
}
+426
View File
@@ -0,0 +1,426 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import {
Film,
CheckCircle2,
XCircle,
AlertCircle,
Clock,
ChevronRight,
Package,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Montserrat } from 'next/font/google';
const montserrat = Montserrat({
subsets: ['latin'],
weight: ['200', '500', '600'],
});
interface ClientVersion {
id: string;
versionNumber: number;
approvalStatus: string;
fps: number;
duration: number | null;
thumbnailUrl: string | null;
notes: string | null;
createdAt: string;
}
interface ClientTask {
id: string;
title: string;
type: string;
status: string;
versions: ClientVersion[];
}
interface ClientShot {
id: string;
shotCode: string;
sequence: string | null;
description: string | null;
status: string;
thumbnailUrl: string | null;
tasks: ClientTask[];
}
interface AssetTask extends ClientTask {
asset?: { id: string; assetCode: string; name: string } | null;
}
interface Project {
id: string;
name: string;
code: string;
description: string | null;
status: string;
}
const APPROVAL_STYLES: Record<
string,
{ label: string; className: string; Icon: React.ElementType }
> = {
PENDING_REVIEW: {
label: 'Awaiting Review',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
Icon: Clock,
},
APPROVED: {
label: 'Approved',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
Icon: CheckCircle2,
},
REJECTED: {
label: 'Rejected',
className: 'bg-red-500/10 text-red-400 border-red-500/20',
Icon: XCircle,
},
NEEDS_CHANGES: {
label: 'Needs Changes',
className: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
Icon: AlertCircle,
},
};
const TASK_TYPE_LABELS: Record<string, string> = {
TRACK: 'Tracking',
ROTO: 'Roto',
KEY: 'Keying',
COMP: 'Comp',
FX: 'FX',
LIGHTING: 'Lighting',
RENDER: 'Render',
ANIMATION: 'Animation',
MODEL: 'Model',
TEXTURE: 'Texture',
RIG: 'Rig',
LOOKDEV: 'Lookdev',
GENERAL: 'Task',
};
function getLatestVersion(task: ClientTask): ClientVersion | undefined {
return task.versions[0];
}
export default function ClientPortalPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const [token, setToken] = useState<string>('');
const [project, setProject] = useState<Project | null>(null);
const [shots, setShots] = useState<ClientShot[]>([]);
const [assetTasks, setAssetTasks] = useState<AssetTask[]>([]);
const [sessionLabel, setSessionLabel] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
params.then(({ token: t }) => {
setToken(t);
fetch(`/api/client/${t}/project`)
.then((r) => {
if (!r.ok) throw new Error('Invalid or expired review link');
return r.json();
})
.then((data) => {
setProject(data.project);
setShots(data.shots ?? []);
setAssetTasks(data.assetTasks ?? []);
setSessionLabel(data.sessionLabel ?? '');
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
});
}, [params]);
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (error || !project) {
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
<div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center">
<Film className="h-6 w-6 text-black" />
</div>
<h1 className="text-xl font-semibold text-white">
Review link unavailable
</h1>
<p className="text-zinc-400 text-sm max-w-sm">
{error ??
'This review link has expired or is no longer active. Please request a new link from your studio contact.'}
</p>
</div>
);
}
const allTasks = [...shots.flatMap((s) => s.tasks), ...assetTasks];
const totalTasks = allTasks.length;
const approved = allTasks.filter(
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
).length;
const needsChanges = allTasks.filter((t) =>
['REJECTED', 'NEEDS_CHANGES'].includes(
getLatestVersion(t)?.approvalStatus ?? '',
),
).length;
const pending = totalTasks - approved - needsChanges;
const shotsBySequence = shots.reduce<Record<string, ClientShot[]>>(
(acc, shot) => {
const seq = shot.sequence ?? 'Shots';
if (!acc[seq]) acc[seq] = [];
acc[seq].push(shot);
return acc;
},
{},
);
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 bg-zinc-900">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
{/* Logo */}
<div className='flex items-center gap-3'>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
</div>
<div className={montserrat.className}>
<span className="block text-2xl font-light text-white leading-none">
TWO TALES
</span>
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
vfx review
</span>
</div>
</div>
{sessionLabel && (
<span className="text-sm text-zinc-400">{sessionLabel}</span>
)}
</div>
</header>
<div className="border-b border-zinc-800 bg-zinc-900/50">
<div className="max-w-5xl mx-auto px-6 py-8">
<p className="text-xs text-zinc-500 uppercase tracking-wider font-medium mb-1">
{project.code}
</p>
<h1 className="text-3xl font-bold text-white mb-2">{project.name}</h1>
{project.description && (
<p className="text-zinc-400 text-sm max-w-xl">
{project.description}
</p>
)}
<div className="flex flex-wrap gap-3 mt-6">
<div className="bg-zinc-800 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-white">{totalTasks}</p>
<p className="text-xs text-zinc-400 mt-0.5">Items</p>
</div>
<div className="bg-emerald-900/30 border border-emerald-800/30 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-emerald-400">{approved}</p>
<p className="text-xs text-zinc-400 mt-0.5">Approved</p>
</div>
<div className="bg-amber-900/20 border border-amber-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-amber-400">{pending}</p>
<p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p>
</div>
{needsChanges > 0 && (
<div className="bg-red-900/20 border border-red-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-red-400">
{needsChanges}
</p>
<p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p>
</div>
)}
</div>
</div>
</div>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-10">
{Object.entries(shotsBySequence).map(([sequence, seqShots]) => (
<div key={sequence}>
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
{sequence}
</h2>
<div className="space-y-4">
{seqShots.map((shot) => (
<div key={shot.id} className="space-y-1">
<div className="flex items-center gap-3 px-1">
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-40 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<p className="font-mono text-sm font-semibold text-zinc-300">
{shot.shotCode}
{shot.description && (
<span className="font-sans font-normal text-zinc-500 ml-2">
{shot.description}
</span>
)}
</p>
</div>
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
{shot.tasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey =
ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">
{task.title}
</p>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs font-mono text-zinc-500">
v{String(ver.versionNumber).padStart(3, '0')}
</span>
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link>
);
})}
</div>
</div>
))}
</div>
</div>
))}
{assetTasks.length > 0 && (
<div>
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
Assets
</h2>
<div className="space-y-2">
{assetTasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<Package className="h-4 w-4 text-zinc-500 shrink-0" />
<div className="min-w-[90px]">
<p className="font-mono font-semibold text-white text-sm">
{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}
</p>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">
{task.title}
</p>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs font-mono text-zinc-500">
v{String(ver.versionNumber).padStart(3, '0')}
</span>
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link>
);
})}
</div>
</div>
)}
{totalTasks === 0 && (
<div className="text-center py-16 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No items have been shared for review yet.</p>
</div>
)}
</main>
<footer className="border-t border-zinc-800 py-6 text-center text-xs text-zinc-600">
Powered by <span className="text-zinc-400">TTDEV</span>
</footer>
</div>
);
}
@@ -0,0 +1,482 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/components/ui/use-toast";
import { cn, frameToTimecode, getInitials } from "@/lib/utils";
import {
Film,
ArrowLeft,
CheckCircle2,
XCircle,
AlertCircle,
MessageSquare,
Send,
Clock,
} from "lucide-react";
import { useReviewStore } from "@/hooks/use-review-player";
interface Comment {
id: string;
frameNumber: number;
timestamp: number;
text: string;
isResolved: boolean;
createdAt: string;
author: { id: string; name: string | null; image: string | null; email: string };
replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[];
}
interface Version {
id: string;
versionNumber: number;
fileUrl: string;
fps: number;
duration: number | null;
approvalStatus: string;
notes: string | null;
shot?: {
id: string;
shotCode: string;
project: { id: string; name: string; code: string };
} | null;
task?: {
id: string;
title: string;
type: string;
project: { id: string; name: string; code: string };
shot?: { shotCode: string } | null;
asset?: { assetCode: string; name: string } | null;
} | null;
}
const APPROVAL_STATUS_STYLES: 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",
};
export default function ClientReviewPage({
params,
}: {
params: Promise<{ token: string; versionId: string }>;
}) {
const [token, setToken] = useState("");
const [version, setVersion] = useState<Version | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const currentFrame = useReviewStore((s) => s.currentFrame);
// Comment form state
const [commentText, setCommentText] = useState("");
const [commentFrame, setCommentFrame] = useState<number | null>(null);
const [submittingComment, setSubmittingComment] = useState(false);
// Approval dialog
const [approvalDialog, setApprovalDialog] = useState<{
open: boolean;
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
}>({ open: false, status: null });
const [approvalNotes, setApprovalNotes] = useState("");
const [submittingApproval, setSubmittingApproval] = useState(false);
const [currentApprovalStatus, setCurrentApprovalStatus] = useState("PENDING_REVIEW");
const playerRef = useRef<ReviewPlayerRef>(null);
const { toast } = useToast();
useEffect(() => {
params.then(({ token: t, versionId }) => {
setToken(t);
fetch(`/api/client/${t}/versions/${versionId}`)
.then((r) => {
if (!r.ok) throw new Error("Invalid or expired review link");
return r.json();
})
.then((data) => {
setVersion(data.version);
setComments(data.comments);
setCurrentApprovalStatus(data.version.approvalStatus);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
});
}, [params]);
const refreshComments = useCallback(async (t: string, vId: string) => {
const res = await fetch(`/api/client/${t}/versions/${vId}`);
if (res.ok) {
const data = await res.json();
setComments(data.comments);
}
}, []);
const handlePlayerAddComment = useCallback((frameNumber: number, _timestamp: number) => {
playerRef.current?.pause();
setCommentFrame(frameNumber);
}, []);
const handleSubmitComment = async () => {
if (!commentText.trim() || commentFrame === null || !version) return;
setSubmittingComment(true);
try {
const res = await fetch(`/api/client/${token}/comment`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionId: version.id,
frameNumber: commentFrame,
timestamp: commentFrame / version.fps,
text: commentText.trim(),
}),
});
if (!res.ok) throw new Error("Failed to post comment");
setCommentText("");
setCommentFrame(null);
await refreshComments(token, version.id);
toast({ title: "Comment added" });
} catch {
toast({ title: "Failed to add comment", variant: "destructive" });
} finally {
setSubmittingComment(false);
}
};
const handleSubmitApproval = async () => {
if (!approvalDialog.status || !version) return;
setSubmittingApproval(true);
try {
const res = await fetch(`/api/client/${token}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionId: version.id,
status: approvalDialog.status,
notes: approvalNotes,
}),
});
if (!res.ok) throw new Error("Failed to submit decision");
setCurrentApprovalStatus(approvalDialog.status);
setApprovalDialog({ open: false, status: null });
setApprovalNotes("");
toast({
title:
approvalDialog.status === "APPROVED"
? "Version approved!"
: approvalDialog.status === "REJECTED"
? "Version rejected"
: "Changes requested",
});
} catch {
toast({ title: "Failed to submit decision", variant: "destructive" });
} finally {
setSubmittingApproval(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (error || !version) {
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
<Film className="h-10 w-10 text-zinc-600" />
<h1 className="text-xl font-semibold text-white">Version unavailable</h1>
<p className="text-zinc-400 text-sm">{error}</p>
</div>
);
}
const approvalStyle = APPROVAL_STATUS_STYLES[currentApprovalStatus] ?? "";
return (
<div className="flex flex-col h-screen bg-zinc-950 text-white overflow-hidden">
{/* Header */}
<header className="flex items-center gap-3 px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
<Link
href={`/client/${token}`}
className="text-zinc-400 hover:text-white transition-colors flex items-center gap-1.5 text-sm"
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">All Shots</span>
</Link>
<div className="h-4 w-px bg-zinc-700" />
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-5 h-5 rounded bg-amber-500 flex items-center justify-center shrink-0">
<Film className="h-3 w-3 text-black" />
</div>
{(() => {
const project = version.shot?.project ?? version.task?.project;
const contextCode =
version.shot?.shotCode ??
version.task?.shot?.shotCode ??
version.task?.asset?.assetCode ??
null;
return (
<>
<span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span>
<span className="text-zinc-600">/</span>
{contextCode && (
<>
<span className="font-mono font-semibold">{contextCode}</span>
<span className="text-zinc-600">/</span>
</>
)}
<span className="font-mono text-sm text-zinc-300">
v{String(version.versionNumber).padStart(3, "0")}
</span>
</>
);
})()}
</div>
<span
className={cn(
"text-xs px-2.5 py-1 rounded-full border hidden sm:inline-flex items-center gap-1",
approvalStyle
)}
>
{currentApprovalStatus === "PENDING_REVIEW" ? (
<Clock className="h-3 w-3" />
) : currentApprovalStatus === "APPROVED" ? (
<CheckCircle2 className="h-3 w-3" />
) : currentApprovalStatus === "NEEDS_CHANGES" ? (
<AlertCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{currentApprovalStatus.replace(/_/g, " ")}
</span>
{/* Decision buttons */}
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="outline"
className="h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
onClick={() => setApprovalDialog({ open: true, status: "NEEDS_CHANGES" })}
>
<AlertCircle className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Needs Changes</span>
</Button>
<Button
size="sm"
className="h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
onClick={() => setApprovalDialog({ open: true, status: "APPROVED" })}
>
<CheckCircle2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Approve</span>
</Button>
</div>
</header>
{/* Main: player + comments */}
<div className="flex flex-1 overflow-hidden">
{/* Player */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
<ReviewPlayer
ref={playerRef}
videoUrl={version.fileUrl}
fps={version.fps}
comments={comments as any}
versionId={version.id}
onAddComment={handlePlayerAddComment}
/>
</div>
{/* Comment panel */}
<div className="w-72 xl:w-80 shrink-0 flex flex-col border-l border-zinc-800 bg-zinc-900">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-zinc-400" />
Notes
{comments.length > 0 && (
<span className="text-xs text-zinc-500">({comments.length})</span>
)}
</h3>
</div>
<ScrollArea className="flex-1 px-4 py-3">
{comments.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p>No notes yet.</p>
<p className="text-xs mt-1">Pause the video and add a note at any frame.</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment.id} className="space-y-2">
<div
className="rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
onClick={() => playerRef.current?.seekToFrame(comment.frameNumber)}
title={`Jump to frame ${comment.frameNumber}`}
>
<div className="flex items-center gap-2">
<Avatar className="h-5 w-5">
<AvatarFallback className="text-[9px] bg-zinc-700">
{getInitials(comment.author.name ?? comment.author.email)}
</AvatarFallback>
</Avatar>
<span className="text-xs text-zinc-300 font-medium">
{comment.author.name ?? comment.author.email.split("@")[0]}
</span>
<span className="ml-auto font-mono text-xs text-amber-400/80">
{frameToTimecode(comment.frameNumber, version.fps)}
</span>
</div>
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
</div>
{comment.replies.map((reply) => (
<div key={reply.id} className="ml-4 rounded-lg bg-zinc-800/40 border border-zinc-700/50 p-2.5">
<span className="text-xs text-zinc-400 font-medium">
{reply.author.name ?? "Reply"}
</span>
<p className="text-xs text-zinc-300 mt-1">{reply.text}</p>
</div>
))}
</div>
))}
</div>
)}
</ScrollArea>
{/* Add note */}
{commentFrame !== null ? (
<div className="border-t border-zinc-800 p-3 space-y-2">
<p className="text-xs text-zinc-500 font-mono">
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
</p>
<Textarea
placeholder="Add your note..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="min-h-[80px] text-sm resize-none"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmitComment();
if (e.key === "Escape") setCommentFrame(null);
}}
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => { setCommentFrame(null); setCommentText(""); }}
>
Cancel
</Button>
<Button
size="sm"
className="flex-1 h-8 text-xs gap-1"
onClick={handleSubmitComment}
disabled={!commentText.trim() || submittingComment}
>
<Send className="h-3 w-3" />
Send
</Button>
</div>
</div>
) : (
<div className="border-t border-zinc-800 p-3">
<Button
variant="outline"
className="w-full text-sm gap-2 h-9"
onClick={() => {
playerRef.current?.pause();
setCommentFrame(currentFrame);
}}
>
<MessageSquare className="h-4 w-4" />
Add Note at Current Frame
</Button>
</div>
)}
</div>
</div>
{/* Approval dialog */}
<Dialog
open={approvalDialog.open}
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{approvalDialog.status === "APPROVED"
? "✅ Approve this version"
: approvalDialog.status === "NEEDS_CHANGES"
? "✏️ Request changes"
: "❌ Reject this version"}
</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
{approvalDialog.status === "APPROVED"
? "Confirm you're happy with this version. You can add an optional note below."
: "Describe what needs to change so the team can action it quickly."}
</p>
<Textarea
value={approvalNotes}
onChange={(e) => setApprovalNotes(e.target.value)}
placeholder={
approvalDialog.status === "APPROVED"
? "Looks great! (optional)"
: "Please describe the changes needed..."
}
className="min-h-[100px]"
autoFocus
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setApprovalDialog({ open: false, status: null })}
>
Cancel
</Button>
<Button
onClick={handleSubmitApproval}
disabled={submittingApproval}
className={cn(
approvalDialog.status === "APPROVED"
? "bg-emerald-600 hover:bg-emerald-500 text-white"
: approvalDialog.status === "NEEDS_CHANGES"
? "bg-orange-600 hover:bg-orange-500 text-white"
: "bg-red-700 hover:bg-red-600 text-white"
)}
>
{submittingApproval
? "Submitting..."
: approvalDialog.status === "APPROVED"
? "Approve"
: approvalDialog.status === "NEEDS_CHANGES"
? "Request Changes"
: "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Dark-first CSS variables (shadcn/ui compatible) ─────────────────────── */
@layer base {
:root {
/* Zinc dark palette — always dark */
--background: 0 0% 3.9%; /* zinc-950 */
--foreground: 0 0% 98%; /* white */
--card: 0 0% 9%; /* zinc-900 */
--card-foreground: 0 0% 98%;
--popover: 0 0% 9%; /* zinc-900 */
--popover-foreground: 0 0% 98%;
/* Amber accent — brand colour */
--primary: 38 92% 50%; /* amber-500 */
--primary-foreground: 38 100% 6%;
--secondary: 0 0% 14.9%; /* zinc-800 */
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%; /* zinc-800 */
--muted-foreground: 0 0% 63.9%; /* zinc-400 */
--accent: 0 0% 14.9%; /* zinc-800 */
--accent-foreground: 0 0% 98%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%; /* zinc-800 */
--input: 0 0% 9%; /* zinc-900 */
--ring: 38 92% 50%; /* amber-500 */
--radius: 0.625rem; /* 10px */
/* Status colors */
--status-approved: 142 71% 45%;
--status-rejected: 0 72% 51%;
--status-pending: 38 92% 50%;
--status-changes: 271 76% 53%;
}
/* Force dark mode globally */
html {
color-scheme: dark;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}
}
/* ── Custom utilities ────────────────────────────────────────────────────── */
@layer utilities {
.text-balance {
text-wrap: balance;
}
/* Cinematic glow effect for review player */
.player-glow {
box-shadow: 0 0 60px rgba(0, 0, 0, 0.8), inset 0 0 120px rgba(0, 0, 0, 0.2);
}
/* Annotation canvas cursor crosshair */
.cursor-crosshair {
cursor: crosshair;
}
/* Timeline scrub cursor */
.cursor-ew-resize {
cursor: ew-resize;
}
/* Status colors */
.status-approved {
color: hsl(var(--status-approved));
}
.status-rejected {
color: hsl(var(--status-rejected));
}
.status-pending {
color: hsl(var(--status-pending));
}
.status-changes {
color: hsl(var(--status-changes));
}
.bg-status-approved {
background-color: hsl(var(--status-approved) / 0.15);
}
.bg-status-rejected {
background-color: hsl(var(--status-rejected) / 0.15);
}
.bg-status-pending {
background-color: hsl(var(--status-pending) / 0.15);
}
.bg-status-changes {
background-color: hsl(var(--status-changes) / 0.15);
}
}
/* ── Review player specific styles ──────────────────────────────────────── */
.review-player-container {
position: relative;
background: #000;
user-select: none;
}
.review-player-container video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Annotation canvas overlay */
.annotation-canvas-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-canvas-overlay.is-annotating {
pointer-events: auto;
}
/* Frame timeline */
.frame-timeline {
position: relative;
height: 48px;
background: hsl(0 0% 6%);
border-top: 1px solid hsl(0 0% 14%);
cursor: ew-resize;
overflow: hidden;
}
/* Comment badge on timeline */
.timeline-comment-marker {
position: absolute;
top: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: hsl(213 94% 68%);
transform: translateX(-50%);
pointer-events: none;
}
/* Keyframe indicator */
.playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: hsl(0 72% 51%);
pointer-events: none;
z-index: 10;
}
/* Player controls glassmorphism */
.player-controls {
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0) 100%);
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/layout/Providers";
import { Toaster } from "@/components/ui/toaster";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: {
template: "%s | FeedBack",
default: "FeedBack — VFX Review Platform",
},
description: "Frame-accurate review and approval for VFX and animation studios.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<Providers>
{children}
<Toaster />
</Providers>
</body>
</html>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
export default async function RootPage() {
const session = await auth();
if (session?.user) {
redirect("/dashboard");
} else {
redirect("/login");
}
}
+389
View File
@@ -0,0 +1,389 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
import { CommentPanel } from "@/components/comments/CommentPanel";
import { VersionList } from "@/components/versions/VersionList";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { submitApproval } from "@/actions/approvals";
import { useToast } from "@/components/ui/use-toast";
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
import {
CheckCircle2,
XCircle,
AlertCircle,
ChevronDown,
ArrowLeft,
Film,
} from "lucide-react";
interface ReviewPageClientProps {
version: {
id: string;
versionNumber: number;
fileUrl: string;
fps: number;
duration: number | null;
frameCount: number | null;
approvalStatus: string;
notes: string | null;
isClientVisible: boolean;
shot?: {
id: string;
shotCode: string;
project: { id: string; name: string; code: string };
versions: {
id: string;
versionNumber: number;
approvalStatus: string;
isLatest: boolean;
fps: number;
duration: number | null;
notes: string | null;
fileSize: number | null;
createdAt: Date;
}[];
} | null;
task?: {
id: string;
title: string;
project: { id: string; name: string; code: string };
shot?: { id: string; shotCode: string } | null;
asset?: { id: string; assetCode: string; name: string } | null;
versions: {
id: string;
versionNumber: number;
approvalStatus: string;
isLatest: boolean;
fps: number;
duration: number | null;
notes: string | null;
fileSize: number | null;
createdAt: Date;
}[];
} | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
};
comments: any[];
annotations: any[];
canApprove: boolean;
canShare: boolean;
currentUserId: string;
}
const APPROVAL_STATUS_STYLES: 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",
};
export function ReviewPageClient({
version,
comments: initialComments,
annotations: initialAnnotations,
canApprove,
canShare,
currentUserId,
}: ReviewPageClientProps) {
const [comments, setComments] = useState(initialComments);
const [annotations, setAnnotations] = useState(initialAnnotations);
const [pendingFrame, setPendingFrame] = useState<number | null>(null);
const [approvalDialog, setApprovalDialog] = useState<{
open: boolean;
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
}>({ open: false, status: null });
const [approvalNotes, setApprovalNotes] = useState("");
const [isSubmittingApproval, setIsSubmittingApproval] = useState(false);
const [showVersions, setShowVersions] = useState(false);
const playerRef = useRef<ReviewPlayerRef>(null);
const { toast } = useToast();
const router = useRouter();
const handleAddComment = useCallback(() => {
// Pause player first
playerRef.current?.pause();
const frame = playerRef.current ? undefined : undefined;
// Frame is tracked in ReviewPlayer store — CommentPanel reads from there
setPendingFrame(Date.now()); // trigger effect with timestamp trick
}, []);
const handleCommentsChange = useCallback(async () => {
try {
const res = await fetch(`/api/versions/${version.id}/comments`);
if (res.ok) {
const data = await res.json();
setComments(data.comments);
}
} catch {
// silently ignore
}
}, [version.id]);
const handleAnnotationSaved = useCallback(async () => {
// Refresh both annotations (for timeline markers) and comments (for the auto-comment)
try {
const [annRes, cmtRes] = await Promise.all([
fetch(`/api/versions/${version.id}/annotations`),
fetch(`/api/versions/${version.id}/comments`),
]);
if (annRes.ok) setAnnotations((await annRes.json()).annotations);
if (cmtRes.ok) setComments((await cmtRes.json()).comments);
} catch {
// silently ignore
}
}, [version.id]);
const handleApprovalSubmit = async () => {
if (!approvalDialog.status) return;
setIsSubmittingApproval(true);
try {
await submitApproval({
versionId: version.id,
status: approvalDialog.status,
notes: approvalNotes,
});
toast({
title:
approvalDialog.status === "APPROVED"
? "Version approved!"
: approvalDialog.status === "REJECTED"
? "Version rejected"
: "Changes requested",
});
setApprovalDialog({ open: false, status: null });
setApprovalNotes("");
router.refresh();
} catch (err) {
toast({
title: "Failed to submit approval",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setIsSubmittingApproval(false);
}
};
const openApproval = (status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES") => {
setApprovalDialog({ open: true, status });
};
// Resolve context: shot or task
const project = version.shot?.project ?? version.task?.project;
const contextCode =
version.shot?.shotCode ??
version.task?.shot?.shotCode ??
version.task?.asset?.assetCode ??
null;
const backHref = version.shot
? `/projects/${project?.id}/shots/${version.shot.id}`
: version.task
? `/tasks/${version.task.id}`
: `/dashboard`;
const versionList = version.shot?.versions ?? version.task?.versions ?? [];
return (
<div className="flex flex-col h-screen bg-background overflow-hidden">
{/* Top bar */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card shrink-0">
<Link
href={backHref}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-xs text-muted-foreground hidden sm:block">
{project?.code}
</span>
<span className="text-muted-foreground">/</span>
{contextCode && (
<>
<span className="font-mono text-sm font-semibold">{contextCode}</span>
<span className="text-muted-foreground">/</span>
</>
)}
{/* Version selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 gap-1 font-mono text-sm">
<Film className="h-3.5 w-3.5" />
v{String(version.versionNumber).padStart(3, "0")}
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{versionList.map((v) => (
<DropdownMenuItem key={v.id} asChild>
<Link href={`/review/${v.id}`}>
v{String(v.versionNumber).padStart(3, "0")}
{v.isLatest && " (latest)"}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Approval status */}
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full border hidden sm:inline-flex items-center gap-1",
APPROVAL_STATUS_STYLES[version.approvalStatus] ?? ""
)}
>
{version.approvalStatus.replace("_", " ")}
</span>
{/* Share with Client */}
{canShare && (
<ShareWithClientButton
versionId={version.id}
isAlreadyShared={version.isClientVisible}
onShared={() => {/* router.refresh() handled inside component */}}
/>
)}
{/* Approval actions */}
{canApprove && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
onClick={() => openApproval("NEEDS_CHANGES")}
>
<AlertCircle className="h-3 w-3" />
<span className="hidden sm:inline">Needs Changes</span>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 text-red-400 border-red-500/30 hover:bg-red-500/10"
onClick={() => openApproval("REJECTED")}
>
<XCircle className="h-3 w-3" />
<span className="hidden sm:inline">Reject</span>
</Button>
<Button
size="sm"
className="h-7 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
onClick={() => openApproval("APPROVED")}
>
<CheckCircle2 className="h-3 w-3" />
<span className="hidden sm:inline">Approve</span>
</Button>
</div>
)}
</div>
{/* Main content — player + comments */}
<div className="flex flex-1 overflow-hidden">
{/* Player — 70% */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
<ReviewPlayer
ref={playerRef}
videoUrl={version.fileUrl}
fps={version.fps}
comments={comments}
annotations={annotations}
versionId={version.id}
onAddComment={handleAddComment}
onAnnotationSaved={handleAnnotationSaved}
/>
</div>
{/* Comment panel — 30%, min 280px */}
<div className="w-80 xl:w-96 shrink-0 flex flex-col border-l border-border">
<CommentPanel
versionId={version.id}
fps={version.fps}
comments={comments}
onCommentsChange={handleCommentsChange}
pendingFrame={pendingFrame}
onPendingFrameCleared={() => setPendingFrame(null)}
onSeekToFrame={(frame) => playerRef.current?.seekToFrame(frame)}
/>
</div>
</div>
{/* Approval Dialog */}
<Dialog
open={approvalDialog.open}
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{approvalDialog.status === "APPROVED"
? "Approve Version"
: approvalDialog.status === "REJECTED"
? "Reject Version"
: "Request Changes"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Notes (optional)</Label>
<Textarea
value={approvalNotes}
onChange={(e) => setApprovalNotes(e.target.value)}
placeholder={
approvalDialog.status === "APPROVED"
? "Any final comments..."
: "Describe the required changes..."
}
className="min-h-[100px]"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setApprovalDialog({ open: false, status: null })}
>
Cancel
</Button>
<Button
onClick={handleApprovalSubmit}
disabled={isSubmittingApproval}
className={
approvalDialog.status === "APPROVED"
? "bg-emerald-600 hover:bg-emerald-500 text-white"
: approvalDialog.status === "REJECTED"
? "bg-red-600 hover:bg-red-500 text-white"
: "bg-orange-600 hover:bg-orange-500 text-white"
}
>
{isSubmittingApproval ? "Submitting..." : "Confirm"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { notFound } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { ReviewPageClient } from "./ReviewPageClient";
export async function generateMetadata({ params }: { params: Promise<{ versionId: string }> }) {
const { versionId } = await params;
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: { select: { shotCode: true } },
task: { select: { title: true } },
},
});
if (!version) return { title: "Not Found" };
const label = version.shot?.shotCode ?? version.task?.title ?? "Version";
return { title: `Review — ${label} v${version.versionNumber}` };
}
async function getReviewData(versionId: string) {
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: {
include: {
project: {
select: { id: true, name: true, code: true },
},
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
isLatest: true,
fps: true,
duration: true,
notes: true,
fileSize: true,
createdAt: true,
},
},
},
},
task: {
include: {
project: { select: { id: true, name: true, code: true } },
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
isLatest: true,
fps: true,
duration: true,
notes: true,
fileSize: true,
createdAt: true,
},
},
},
},
artist: {
select: { id: true, name: true, image: true, email: true },
},
},
});
if (!version) return null;
const comments = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
},
},
},
});
const annotations = await db.annotation.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
});
return { version, comments, annotations };
}
export default async function ReviewPage({
params,
}: {
params: Promise<{ versionId: string }>;
}) {
const { versionId } = await params;
const session = await auth();
if (!session?.user) {
// Already handled by middleware but be safe
return null;
}
const data = await getReviewData(versionId);
if (!data) notFound();
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(
session.user.role
);
const canShare = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
return (
<ReviewPageClient
version={data.version as any}
comments={data.comments as any}
annotations={data.annotations as any}
canApprove={canApprove}
canShare={canShare}
currentUserId={session.user.id}
/>
);
}