Files
vfxreview/components/projects/ProjectSettingsTab.tsx
twotalesanimation caaa333f02
Deploy / deploy (push) Failing after 19m11s
feat(pipeline): render-derived shot thumbnails, and bulk shot delete
Two independent features landing together:

Generated shot thumbnails — a shot can now carry a thumbnail promoted
from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom)
alongside the existing brief-supplied thumbnailUrl, which always takes
display precedence (lib/shot-thumbnail.ts) so a promoted frame can only
ever fill a gap, never overwrite the brief. Shows with
pipelineConfig.autoShotThumbnail promote automatically on every
finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/
clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for
everyone else. Worker-side seek logic for picking the promoted frame is
covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs.

Bulk shot delete — the shot-status page's selection bar gets a "Bulk
Actions" dropdown (alongside the existing Change Due Date) with Delete
Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather
than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT
by design, so a shot with renders or deliveries on record is protected
and gets skipped and reported by name rather than aborting the whole
selection or being silently destroyed along with its history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:06:55 +02:00

468 lines
17 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } 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 { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { updateProject } from "@/actions/projects";
import { useToast } from "@/components/ui/use-toast";
import { Settings, Building2, Users, Webhook, Clapperboard } from "lucide-react";
const settingsSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
code: z.string().min(1, "Code is required").max(20).regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore"),
showId: z.string().min(1, "Show ID is required").max(10).regex(/^[A-Z0-9_]+$/i, "Letters, numbers, underscore only"),
projectType: z.enum(["STANDARD", "EPISODIC"]),
description: z.string().optional(),
status: z.enum(["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"]),
clientId: z.string().optional(),
producerId: z.string().optional(),
supervisorId: z.string().optional(),
dueDate: z.string().optional(),
startDate: z.string().optional(),
slackWebhook: z.string().optional(),
slackChannel: z.string().optional(),
// Pipeline — see the Pipeline section for why only paths are required here.
templateAep: z.string().optional(),
rendersRoot: z.string().optional(),
overlayComp: z.string().optional(),
previewComp: z.string().optional(),
lutComp: z.string().optional(),
autoShotThumbnail: z.boolean().optional(),
});
type SettingsFormValues = z.infer<typeof settingsSchema>;
interface Client {
id: string;
company: string;
}
interface TeamMember {
id: string;
name: string | null;
email: string;
role: string;
}
interface ProjectSettingsTabProps {
project: {
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;
pipelineConfig: {
templateAep?: string | null;
rendersRoot?: string | null;
overlayComp?: string | null;
previewComp?: string | null;
lutComp?: string | null;
autoShotThumbnail?: boolean | null;
} | null;
};
clients: Client[];
teamMembers: TeamMember[];
}
const NONE = "__none__";
function toDateInput(d: Date | null | undefined) {
if (!d) return "";
return new Date(d).toISOString().substring(0, 10);
}
export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSettingsTabProps) {
const { toast } = useToast();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const producers = teamMembers.filter((m) => ["ADMIN", "PRODUCER"].includes(m.role));
const supervisors = teamMembers.filter((m) => ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(m.role));
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isDirty },
} = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
defaultValues: {
name: project.name,
code: project.code,
showId: project.showId,
projectType: project.projectType,
description: project.description ?? "",
status: project.status as SettingsFormValues["status"],
clientId: project.clientId ?? NONE,
producerId: project.producerId ?? NONE,
supervisorId: project.supervisorId ?? NONE,
dueDate: toDateInput(project.dueDate),
startDate: toDateInput(project.startDate),
slackWebhook: project.slackWebhook ?? "",
slackChannel: project.slackChannel ?? "",
templateAep: project.pipelineConfig?.templateAep ?? "",
rendersRoot: project.pipelineConfig?.rendersRoot ?? "",
overlayComp: project.pipelineConfig?.overlayComp ?? "",
previewComp: project.pipelineConfig?.previewComp ?? "",
lutComp: project.pipelineConfig?.lutComp ?? "",
autoShotThumbnail: project.pipelineConfig?.autoShotThumbnail ?? false,
},
});
// Blank comp-name fields mean "use the convention", so they are shown as
// placeholders rather than pre-filled: a project that follows the pattern
// should have nothing stored, and inherit any future change to it.
const showId = watch("showId") || project.showId;
const onSubmit = async (data: SettingsFormValues) => {
setIsSubmitting(true);
try {
await updateProject({
id: project.id,
name: data.name,
code: data.code,
showId: data.showId,
projectType: data.projectType,
description: data.description || null,
status: data.status as any,
clientId: data.clientId === NONE ? null : data.clientId || null,
producerId: data.producerId === NONE ? null : data.producerId || null,
supervisorId: data.supervisorId === NONE ? null : data.supervisorId || null,
dueDate: data.dueDate || null,
startDate: data.startDate || null,
slackWebhook: data.slackWebhook || null,
slackChannel: data.slackChannel || null,
pipelineConfig: {
templateAep: data.templateAep?.trim() || null,
rendersRoot: data.rendersRoot?.trim() || null,
overlayComp: data.overlayComp?.trim() || null,
previewComp: data.previewComp?.trim() || null,
lutComp: data.lutComp?.trim() || null,
autoShotThumbnail: data.autoShotThumbnail ?? false,
},
});
toast({ title: "Project settings saved" });
router.refresh();
} catch (err) {
toast({
title: "Failed to save settings",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 max-w-2xl">
{/* ── General ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">General</h2>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="name">Project Name</Label>
<Input id="name" {...register("name")} />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="code">Project Code</Label>
<Input id="code" className="uppercase font-mono" {...register("code")} />
{errors.code && <p className="text-xs text-destructive">{errors.code.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Select
defaultValue={project.status}
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"], { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="ON_HOLD">On Hold</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
<SelectItem value="ARCHIVED">Archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 col-span-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" className="min-h-[80px]" {...register("description")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="startDate">Start Date</Label>
<Input id="startDate" type="date" {...register("startDate")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" {...register("dueDate")} />
</div>
</div>
</section>
{/* ── Shot Naming ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">Shot Naming</h2>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="showId">Show ID</Label>
<Input id="showId" className="uppercase font-mono" maxLength={10} {...register("showId")} />
{errors.showId && <p className="text-xs text-destructive">{errors.showId.message}</p>}
<p className="text-xs text-muted-foreground">Used in shot codes, e.g. SHOWID_SC010_0010</p>
</div>
<div className="space-y-1.5">
<Label>Project Type</Label>
<Select
defaultValue={project.projectType}
onValueChange={(v) => setValue("projectType", v as "STANDARD" | "EPISODIC", { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="STANDARD">Standard</SelectItem>
<SelectItem value="EPISODIC">Episodic</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Episodic adds an episode segment to shot codes</p>
</div>
</div>
</section>
{/* ── Client & Team ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">Client & Team</h2>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label>Client</Label>
<Select
defaultValue={project.clientId ?? NONE}
onValueChange={(v) => setValue("clientId", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No client assigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.company}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Producer</Label>
<Select
defaultValue={project.producerId ?? NONE}
onValueChange={(v) => setValue("producerId", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Not assigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Not assigned</SelectItem>
{producers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name ?? m.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Supervisor</Label>
<Select
defaultValue={project.supervisorId ?? NONE}
onValueChange={(v) => setValue("supervisorId", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Not assigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Not assigned</SelectItem>
{supervisors.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name ?? m.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</section>
{/* ── Integrations ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Webhook className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">Slack Integration</h2>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="slackWebhook">Webhook URL</Label>
<Input
id="slackWebhook"
type="url"
placeholder="https://hooks.slack.com/services/..."
{...register("slackWebhook")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slackChannel">Channel</Label>
<Input
id="slackChannel"
placeholder="#vfx-pipeline"
{...register("slackChannel")}
/>
</div>
</div>
</section>
{/* ── Pipeline ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Clapperboard className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">Pipeline</h2>
</div>
<Separator />
<p className="text-xs text-zinc-500">
Where this show&apos;s After Effects template lives. Used by the connector panel and by the
render farm&apos;s preview build alike, so both work from the same template — and, above
all, the same show LUT.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="templateAep">Template project (.aep)</Label>
<Input
id="templateAep"
placeholder="X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep"
{...register("templateAep")}
/>
<p className="text-xs text-zinc-500">
Holds the overlay, the slate/export template and the show LUT. Imported by
Initialise Workspace and by every headless preview build.
</p>
</div>
<div className="space-y-1.5 col-span-2">
<Label htmlFor="rendersRoot">Renders root</Label>
<Input
id="rendersRoot"
placeholder="X:/shared_projects_2026/UNGO_VFX/production/renders"
{...register("rendersRoot")}
/>
<p className="text-xs text-zinc-500">
Where per-shot pre-renders live; the panel appends the shot code. Used by Import Renders.
</p>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs text-zinc-400">
Comp names default to the convention below and only need filling in when this show&apos;s
template departs from it. Leave blank to follow the convention.
</p>
<div className="grid grid-cols-3 gap-4 pt-1">
<div className="space-y-1.5">
<Label htmlFor="overlayComp">Overlay comp</Label>
<Input id="overlayComp" placeholder={`${showId}_VFX_OVERLAY`} {...register("overlayComp")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="previewComp">Slate / export comp</Label>
<Input id="previewComp" placeholder={`${showId}_EXPORT_TEMPLATE`} {...register("previewComp")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="lutComp">Show LUT comp</Label>
<Input id="lutComp" placeholder="_SHOW LUT" {...register("lutComp")} />
</div>
</div>
<div className="pt-4 mt-4 border-t border-border/60">
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
id="autoShotThumbnail"
type="checkbox"
className="accent-amber-500 h-3.5 w-3.5 mt-0.5"
{...register("autoShotThumbnail")}
/>
<span className="space-y-1">
<span className="block text-sm group-hover:text-foreground transition-colors">
Use render frames as shot thumbnails
</span>
<span className="block text-xs text-muted-foreground">
Every finished export sets its shot&apos;s thumbnail from the render.
A thumbnail supplied with the brief always takes priority and is
never overwritten &mdash; turn this on for shows that arrive with no
stills at all, where the alternative is an empty grid.
</span>
</span>
</label>
</div>
</div>
</section>
{/* Save */}
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting || !isDirty} className="min-w-[120px]">
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
);
}