caaa333f02
Deploy / deploy (push) Failing after 19m11s
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>
200 lines
6.1 KiB
TypeScript
200 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useDraggable } from "@dnd-kit/core";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import Link from "next/link";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn, getInitials } from "@/lib/utils";
|
|
import {
|
|
CalendarDays,
|
|
Layers,
|
|
MessageSquare,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Clock,
|
|
GripVertical,
|
|
Film,
|
|
} from "lucide-react";
|
|
import { TaskStatus, TaskType } from "@prisma/client";
|
|
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "./TaskCard";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
|
|
|
const PRIORITY_DOT: Record<string, string> = {
|
|
LOW: "bg-zinc-500",
|
|
NORMAL: "bg-blue-500",
|
|
HIGH: "bg-amber-500",
|
|
URGENT: "bg-red-500",
|
|
};
|
|
|
|
interface KanbanTask {
|
|
id: string;
|
|
title: string;
|
|
type: TaskType;
|
|
status: TaskStatus;
|
|
priority: string;
|
|
dueDate: Date | null;
|
|
shot?: {
|
|
shotCode: string;
|
|
thumbnailUrl?: string | null;
|
|
generatedThumbnailUrl?: string | null;
|
|
} | null;
|
|
asset?: { assetCode: string; name: string } | null;
|
|
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
|
_count?: { versions: number };
|
|
versions?: {
|
|
id: string;
|
|
versionNumber: number;
|
|
approvalStatus: string;
|
|
createdAt: Date;
|
|
thumbnailUrl?: string | null;
|
|
posterUrl?: string | null;
|
|
}[];
|
|
}
|
|
|
|
interface KanbanCardProps {
|
|
task: KanbanTask;
|
|
isDragOverlay?: boolean;
|
|
}
|
|
|
|
export function KanbanCard({ task, isDragOverlay = false }: KanbanCardProps) {
|
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
|
id: task.id,
|
|
data: { task },
|
|
});
|
|
|
|
const style = transform
|
|
? { transform: CSS.Translate.toString(transform) }
|
|
: undefined;
|
|
|
|
const latestVersion = task.versions?.[0];
|
|
const isOverdue =
|
|
task.dueDate &&
|
|
new Date(task.dueDate) < new Date() &&
|
|
task.status !== "DONE";
|
|
|
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
|
|
|
// Resolve thumbnail: prefer latest version thumbnail, then the shot's own
|
|
// (reference frame first, render-derived one only as a fallback).
|
|
const thumbnail =
|
|
latestVersion?.thumbnailUrl ??
|
|
latestVersion?.posterUrl ??
|
|
shotThumbnail(task.shot) ??
|
|
null;
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={cn(
|
|
"group bg-zinc-900 border rounded-lg p-3 space-y-2.5 cursor-grab active:cursor-grabbing transition-all",
|
|
isDragging && !isDragOverlay
|
|
? "opacity-30 border-dashed border-zinc-600"
|
|
: "border-zinc-800 hover:border-zinc-600 shadow-sm",
|
|
isDragOverlay && "shadow-xl border-amber-500/30 rotate-1"
|
|
)}
|
|
{...listeners}
|
|
{...attributes}
|
|
>
|
|
{/* Header: type badge + context code */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[10px] font-medium text-zinc-500 uppercase tracking-wider">
|
|
{TASK_TYPE_LABELS[task.type]}
|
|
</span>
|
|
{contextCode && (
|
|
<span className="text-[10px] font-mono text-zinc-400 bg-zinc-800 px-1.5 py-0.5 rounded">
|
|
{contextCode}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Thumbnail */}
|
|
{thumbnail ? (
|
|
<div className="w-full aspect-video rounded-md overflow-hidden bg-zinc-800">
|
|
<img
|
|
src={thumbnail}
|
|
alt={contextCode ?? task.title}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
) : contextCode && task.shot ? (
|
|
<div className="w-full aspect-video rounded-md bg-zinc-800 flex items-center justify-center">
|
|
<Film className="h-5 w-5 text-zinc-700" />
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Title */}
|
|
<Link
|
|
href={`/tasks/${task.id}`}
|
|
className="block text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors leading-tight"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{task.title}
|
|
</Link>
|
|
|
|
{/* Latest version + approval */}
|
|
{latestVersion && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-zinc-500 font-mono">
|
|
v{latestVersion.versionNumber}
|
|
</span>
|
|
{latestVersion.approvalStatus === "APPROVED" ? (
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
|
) : latestVersion.approvalStatus === "CHANGES_REQUESTED" ? (
|
|
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
|
) : (
|
|
<Clock className="h-3.5 w-3.5 text-zinc-600" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer: due date + priority dot + artist */}
|
|
<div className="flex items-center justify-between gap-2 pt-0.5">
|
|
<div className="flex items-center gap-2">
|
|
{/* Priority dot */}
|
|
<span
|
|
className={cn(
|
|
"inline-block w-1.5 h-1.5 rounded-full shrink-0",
|
|
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
|
)}
|
|
/>
|
|
|
|
{/* Due date */}
|
|
{task.dueDate && (
|
|
<span
|
|
className={cn(
|
|
"flex items-center gap-1 text-[10px]",
|
|
isOverdue ? "text-red-400" : "text-zinc-500"
|
|
)}
|
|
>
|
|
<CalendarDays className="h-3 w-3" />
|
|
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
|
</span>
|
|
)}
|
|
|
|
{/* Version count */}
|
|
{(task._count?.versions ?? 0) > 0 && (
|
|
<span className="flex items-center gap-1 text-[10px] text-zinc-500">
|
|
<Layers className="h-3 w-3" />
|
|
{task._count!.versions}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Assignee avatar */}
|
|
{task.assignedArtist && (
|
|
<Avatar className="h-5 w-5 shrink-0">
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|