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 }, + ]) + ); +}