Files
vfxreview/components/projects/ProjectCard.tsx
twotalesanimation 469d782b4f
Deploy / deploy (push) Successful in 6m27s
feat(projects): poster art and equal-height project cards
A grid of twenty projects was twenty near-identical blocks of text. The cards
now lead with a still, with the code, client and title set over it.

There is no poster column on Project and adding one would mean somebody
maintaining it, so the image is chosen from the shots the project already has:
a key shot with a still first, then the most recently touched shot with one.
Within each, thumbnailUrl beats generatedThumbnailUrl - the same precedence
the rest of the app displays shots by, since the reference frame from the
brief is a deliberate choice and a promoted render frame is whatever the
pipeline landed on. The pick is deterministic rather than random: a card whose
image changes between two loads reads as a glitch, and the eye stops using the
image to find the project.

One DISTINCT ON query covers every project on the page, instead of a query
each or dragging back every shot that has a still.

Equal heights come from three things: auto-rows-fr on the grid, h-full on the
card, and fixed room for the description, which was the only part that varied.
A project with no stills gets a placeholder of the same footprint rather than
a hole in the row. The whole card is now the link, so the Open button is gone.

Also fixes a latent bug: the card read project.deadline, but the column is
dueDate, so the due date never rendered. Both are accepted now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:36:04 +02:00

172 lines
7.1 KiB
TypeScript

"use client";
import Link from "next/link";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Progress } from "@/components/ui/progress";
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
import { Film, Layers, CheckCircle2, Clock, ArrowUpRight } from "lucide-react";
interface ProjectCardProps {
project: {
id: string;
name: string;
code: string;
status: string;
description?: string | null;
/** The column is `dueDate`; `deadline` is kept for older callers. */
dueDate?: Date | string | null;
deadline?: Date | string | null;
createdAt: Date;
client?: { company: string } | null;
producer?: { name: string | null; image: string | null } | null;
_count?: { shots: number };
shotStats?: {
total: number;
approved: number;
inProgress: number;
};
/** Still chosen from the project's own shots (lib/project-poster.ts). */
poster?: { url: string; shotCode: string; isKeyShot: boolean } | null;
};
}
const STATUS_STYLES: Record<string, string> = {
ACTIVE: "bg-green-900/70 text-green-300",
ON_HOLD: "bg-yellow-900/70 text-yellow-300",
COMPLETED: "bg-blue-900/70 text-blue-300",
ARCHIVED: "bg-zinc-900/80 text-zinc-400",
};
/**
* A project card is scanned, not read: in a grid of twenty the frame is what
* the eye picks out, so the still leads and the title sits on it. Every card is
* the same height whatever it holds — a ragged grid reads as broken, and the
* description is the only part that varies, so it is given fixed room rather
* than allowed to push the rest around.
*/
export function ProjectCard({ project }: ProjectCardProps) {
const total = project.shotStats?.total ?? project._count?.shots ?? 0;
const approved = project.shotStats?.approved ?? 0;
const progress = total > 0 ? Math.round((approved / total) * 100) : 0;
const due = project.dueDate ?? project.deadline;
const isArchived = project.status === "ARCHIVED";
return (
<Link
href={`/projects/${project.id}`}
className="group flex h-full flex-col overflow-hidden rounded-lg border border-zinc-800 bg-card transition-colors hover:border-zinc-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
>
{/* Poster */}
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-zinc-900">
{project.poster ? (
<img
src={project.poster.url}
alt=""
loading="lazy"
className={cn(
"h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]",
// An archived show is still recognisable, just visibly past.
isArchived && "opacity-50 grayscale"
)}
/>
) : (
// Same footprint as a real poster, so a project without stills does
// not leave a hole in the grid.
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-900 to-zinc-950">
<Film className="h-8 w-8 text-zinc-700" />
</div>
)}
{/* Scrim: the title has to hold on a bright frame as well as a dark one. */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/35 to-black/10" />
<span
className={cn(
"absolute right-2.5 top-2.5 rounded-full px-2 py-0.5 text-[11px] font-medium backdrop-blur-sm",
STATUS_STYLES[project.status] ?? STATUS_STYLES.ACTIVE
)}
>
{project.status.replace("_", " ")}
</span>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="flex items-center gap-2 text-[11px] text-zinc-300/90">
<span className="font-mono">{project.code}</span>
{project.client && (
<span className="truncate text-zinc-400">• {project.client.company}</span>
)}
</div>
<h3 className="mt-0.5 line-clamp-2 text-base font-semibold leading-tight text-white drop-shadow-sm">
{project.name}
</h3>
</div>
</div>
{/* Body — fixed room so every card lands the same height */}
<div className="flex flex-1 flex-col gap-3 p-3">
<p className="line-clamp-2 min-h-[2.5rem] text-xs text-zinc-400">
{project.description || <span className="text-zinc-600">No description</span>}
</p>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Shot progress</span>
<span className="font-medium text-foreground tabular-nums">
{total > 0 ? `${approved}/${total} approved` : "No shots yet"}
</span>
</div>
<Progress value={progress} className="h-1.5" />
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
<Layers className="mb-1 h-3.5 w-3.5 text-zinc-400" />
<span className="font-semibold tabular-nums text-white">{total}</span>
<span className="text-zinc-400">shots</span>
</div>
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
<CheckCircle2 className="mb-1 h-3.5 w-3.5 text-green-400" />
<span className="font-semibold tabular-nums text-white">{approved}</span>
<span className="text-zinc-400">done</span>
</div>
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
<Film className="mb-1 h-3.5 w-3.5 text-blue-400" />
<span className="font-semibold tabular-nums text-white">
{project.shotStats?.inProgress ?? 0}
</span>
<span className="text-zinc-400">active</span>
</div>
</div>
{/* Pinned to the bottom so the footers line up across the row. */}
<div className="mt-auto flex items-center justify-between gap-2 border-t border-zinc-800 pt-2.5 text-xs text-zinc-400">
<div className="flex min-w-0 items-center gap-2">
{project.producer ? (
<>
<Avatar className="h-5 w-5 shrink-0">
{project.producer.image && <AvatarImage src={project.producer.image} />}
<AvatarFallback className="text-[9px]">
{getInitials(project.producer.name)}
</AvatarFallback>
</Avatar>
<span className="truncate">{project.producer.name ?? "Producer"}</span>
</>
) : (
<>
<Clock className="h-3 w-3 shrink-0" />
<span className="truncate">{formatRelativeDate(project.createdAt)}</span>
</>
)}
</div>
{due ? (
<span className="shrink-0 text-amber-400">Due {formatRelativeDate(due as Date)}</span>
) : (
<ArrowUpRight className="h-3.5 w-3.5 shrink-0 text-zinc-600 transition-colors group-hover:text-amber-400" />
)}
</div>
</div>
</Link>
);
}