From 469d782b4fdb7885d86fbf14d6abed57a21a61e8 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:36:04 +0200 Subject: [PATCH] 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 --- app/(dashboard)/projects/page.tsx | 6 +- app/api/projects/route.ts | 5 + components/projects/ProjectCard.tsx | 206 +++++++++++++++------------- lib/project-poster.ts | 63 +++++++++ 4 files changed, 186 insertions(+), 94 deletions(-) create mode 100644 lib/project-poster.ts diff --git a/app/(dashboard)/projects/page.tsx b/app/(dashboard)/projects/page.tsx index ebd85e5..5ca9cd7 100644 --- a/app/(dashboard)/projects/page.tsx +++ b/app/(dashboard)/projects/page.tsx @@ -104,9 +104,11 @@ export default function ProjectsPage() { ) : ( -
+ // auto-rows-fr keeps every row the same height, so a project with a + // long description does not make its whole row taller than the rest. +
{visibleProjects.map((project: any) => ( -
+
))} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index fcac28a..6ebd4e0 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; +import { getProjectPosters } from "@/lib/project-poster"; export async function GET(req: Request) { const session = await auth(); @@ -53,9 +54,13 @@ export async function GET(req: Request) { statsMap.set(row.projectId, existing); } + // One still per project for the card art, chosen from the shots themselves. + const posters = await getProjectPosters(projects.map((p) => p.id)); + const projectsWithStats = projects.map((p) => ({ ...p, shotStats: statsMap.get(p.id) ?? { total: 0, approved: 0, inProgress: 0 }, + poster: posters.get(p.id) ?? null, })); return NextResponse.json({ projects: projectsWithStats }); diff --git a/components/projects/ProjectCard.tsx b/components/projects/ProjectCard.tsx index aa86aec..e1222ab 100644 --- a/components/projects/ProjectCard.tsx +++ b/components/projects/ProjectCard.tsx @@ -1,20 +1,10 @@ "use client"; import Link from "next/link"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; 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, - Users, - ArrowUpRight, -} from "lucide-react"; +import { Film, Layers, CheckCircle2, Clock, ArrowUpRight } from "lucide-react"; interface ProjectCardProps { project: { @@ -23,7 +13,9 @@ interface ProjectCardProps { code: string; status: string; description?: string | null; - deadline?: Date | 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; @@ -33,117 +25,147 @@ interface ProjectCardProps { 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 = { - ACTIVE: "bg-green-900/60 text-green-300", - ON_HOLD: "bg-yellow-900/60 text-yellow-300", - COMPLETED: "bg-blue-900/60 text-blue-300", - ARCHIVED: "bg-zinc-800 text-zinc-500", + 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 ( - - -
-
-
- {project.code} - {project.client && ( - • {project.client.company} - )} -
-

{project.name}

-
- + {/* Poster */} +
+ {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.code} + {project.client && ( + • {project.client.company} + )} +
+

+ {project.name} +

+
+
+ + {/* Body — fixed room so every card lands the same height */} +
+

+ {project.description || No description} +

+ +
+
+ Shot progress + + {total > 0 ? `${approved}/${total} approved` : "No shots yet"} + +
+
- {project.description && ( -

- {project.description} -

- )} - - - - {/* Progress */} - {total > 0 && ( -
-
- Shot progress - {approved}/{total} approved -
- -
- )} - - {/* Stats */}
-
- - {total} +
+ + {total} shots
- - {approved} + + {approved} done
- - + + {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. */} +
+
+ {project.producer ? ( + <> + + {project.producer.image && } + + {getInitials(project.producer.name)} + + + {project.producer.name ?? "Producer"} + + ) : ( + <> + + {formatRelativeDate(project.createdAt)} + + )} +
+ + {due ? ( + Due {formatRelativeDate(due as Date)} ) : ( - <> - - {formatRelativeDate(project.createdAt)} - - )} - {project.deadline && ( - - Due {formatRelativeDate(project.deadline)} - + )}
- - - - +
+ ); } 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> { + 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 }, + ]) + ); +}