+ {project.poster ? (
+
- {project.status.replace("_", " ")}
-
+ />
+ ) : (
+ // Same footprint as a real poster, so a project without stills does
+ // not leave a hole in the grid.
+
+
+
+ )}
+
+ {/* Scrim: the title has to hold on a bright frame as well as a dark one. */}
+
+
+
+ {project.status.replace("_", " ")}
+
+
+
-
-
+
+
{project.shotStats?.inProgress ?? 0}
active
-
-
-
- {project.producer ? (
- <>
-
- {project.producer.image && }
-
- {getInitials(project.producer.name)}
-
-
- {project.producer.name ?? "Producer"}
- >
+ {/* Pinned to the bottom so the footers line up across the row. */}
+
+
);
}
diff --git a/lib/project-poster.ts b/lib/project-poster.ts
new file mode 100644
index 0000000..c351f24
--- /dev/null
+++ b/lib/project-poster.ts
@@ -0,0 +1,63 @@
+import { Prisma } from "@prisma/client";
+import { db } from "@/lib/db";
+
+/**
+ * The still that stands for a project.
+ *
+ * There is no poster field on Project, and adding one would mean somebody
+ * maintaining it. The shots already carry the images, and one of them is
+ * already marked as the shot that represents the show — `isKeyShot` — so the
+ * poster is chosen rather than uploaded:
+ *
+ * 1. a key shot with a still, then
+ * 2. the most recently touched shot with a still.
+ *
+ * Within each, `thumbnailUrl` beats `generatedThumbnailUrl`, the same
+ * precedence the rest of the app displays shots by (lib/shot-thumbnail.ts):
+ * the reference frame from the brief is a deliberate choice, a promoted render
+ * frame is whatever the pipeline happened to land on.
+ *
+ * The choice is deterministic, not random — a card whose image changes between
+ * two loads of the same page reads as a glitch, and the eye stops using the
+ * image to find the project.
+ */
+export interface ProjectPoster {
+ url: string;
+ shotCode: string;
+ /** True when this is the show's marked key shot rather than a fallback. */
+ isKeyShot: boolean;
+}
+
+/**
+ * One poster per project in a single query.
+ *
+ * DISTINCT ON is Postgres-only and deliberate: the alternative is either a
+ * query per project or dragging back every shot that has a still just to keep
+ * the first of each in memory.
+ */
+export async function getProjectPosters(
+ projectIds: string[]
+): Promise