feat(projects): poster art and equal-height project cards
Deploy / deploy (push) Successful in 6m27s
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>
This commit is contained in:
@@ -104,9 +104,11 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
// 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.
|
||||
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleProjects.map((project: any) => (
|
||||
<div key={project.id} onClick={saveScroll}>
|
||||
<div key={project.id} onClick={saveScroll} className="h-full">
|
||||
<ProjectCard project={project} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<Card className="group hover:border-zinc-700 transition-all flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-mono text-xs text-zinc-500">{project.code}</span>
|
||||
{project.client && (
|
||||
<span className="text-xs text-zinc-600">• {project.client.company}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-base leading-tight text-white">{project.name}</h3>
|
||||
</div>
|
||||
<span
|
||||
<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(
|
||||
"text-xs px-2 py-0.5 rounded-full border-0 font-medium shrink-0",
|
||||
STATUS_STYLES[project.status] ?? STATUS_STYLES.ACTIVE
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
{project.status.replace("_", " ")}
|
||||
</span>
|
||||
/>
|
||||
) : (
|
||||
// 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>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-zinc-400 line-clamp-2 mt-1">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-3 space-y-3">
|
||||
{/* Progress */}
|
||||
{total > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Shot progress</span>
|
||||
<span className="text-foreground font-medium">{approved}/{total} approved</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<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="h-3.5 w-3.5 text-zinc-400 mb-1" />
|
||||
<span className="font-semibold text-white">{total}</span>
|
||||
<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="h-3.5 w-3.5 text-green-400 mb-1" />
|
||||
<span className="font-semibold text-white">{approved}</span>
|
||||
<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="h-3.5 w-3.5 text-blue-400 mb-1" />
|
||||
<span className="font-semibold text-white">
|
||||
<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>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-2 border-t border-zinc-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
||||
{project.producer ? (
|
||||
<>
|
||||
<Avatar className="h-5 w-5">
|
||||
{project.producer.image && <AvatarImage src={project.producer.image} />}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(project.producer.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{project.producer.name ?? "Producer"}</span>
|
||||
</>
|
||||
{/* 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>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatRelativeDate(project.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{project.deadline && (
|
||||
<span className="text-amber-400 ml-2">
|
||||
Due {formatRelativeDate(project.deadline)}
|
||||
</span>
|
||||
<ArrowUpRight className="h-3.5 w-3.5 shrink-0 text-zinc-600 transition-colors group-hover:text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" asChild className="h-7 text-xs gap-1">
|
||||
<Link href={`/projects/${project.id}`}>
|
||||
Open
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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 },
|
||||
])
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user