469d782b4f
Deploy / deploy (push) Successful in 6m27s
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>
64 lines
2.3 KiB
TypeScript
64 lines
2.3 KiB
TypeScript
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<Map<string, ProjectPoster>> {
|
|
if (projectIds.length === 0) return new Map();
|
|
|
|
const rows = await db.$queryRaw<
|
|
{ projectId: string; url: string; shotCode: string; isKeyShot: boolean }[]
|
|
>(Prisma.sql`
|
|
SELECT DISTINCT ON (s."projectId")
|
|
s."projectId" AS "projectId",
|
|
COALESCE(NULLIF(s."thumbnailUrl", ''), NULLIF(s."generatedThumbnailUrl", '')) AS "url",
|
|
s."shotCode" AS "shotCode",
|
|
s."isKeyShot" AS "isKeyShot"
|
|
FROM "shots" s
|
|
WHERE s."projectId" IN (${Prisma.join(projectIds)})
|
|
AND COALESCE(NULLIF(s."thumbnailUrl", ''), NULLIF(s."generatedThumbnailUrl", '')) IS NOT NULL
|
|
ORDER BY s."projectId", s."isKeyShot" DESC, s."updatedAt" DESC
|
|
`);
|
|
|
|
return new Map(
|
|
rows.map((row) => [
|
|
row.projectId,
|
|
{ url: row.url, shotCode: row.shotCode, isKeyShot: row.isKeyShot },
|
|
])
|
|
);
|
|
}
|