Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | 133x 10x 2x 133x 133x 133x | import { Link } from "@tanstack/react-router";
import { Ban } from "lucide-react";
import { memo } from "react";
import type { Project } from "../../lib/api";
import { cn } from "../../lib/utils";
import { ResponsiveImage } from "../ui/ResponsiveImage";
import { StatusBadge } from "../ui/status-badge";
import { projectStatusCode } from "./project-status";
/**
* A portfolio chip — `.context/mockups/projects-list.html`, grid view.
*
* THE SHAPE
* A 4:3 cover flush to the chip's top edge, the `.punch` ring hole in the
* top-right corner, and a hairline-topped `.card-strip` carrying the printed
* status code, the title and the figures. The whole chip is one link: a chip
* is a row in grid form, so it TINTS on hover (`--tint`, 7% ink) and never
* lifts — the −3px lift is reserved for draggables.
*
* THE ONE STATE THAT GETS AN ACTION
* A draft with photos is simply unfinished, not a problem, and gets no
* prompt. A project with NO photos cannot be published at all, so that one
* chip says what the next step is and what it unlocks — and it reads as help,
* not a scold: "{n} photos and it can go live", then "Add photos ▸", with the
* whole chip pointing at the Photos tab. `{n}` is `photoMinimum` off the
* server's publish bar, so nothing here hardcodes 3.
*
* The affordance inside the chip is a `<span>` styled as a button, exactly as
* the mockup prints it: a real `<button>` nested in an `<a>` is invalid and
* gives a screen-reader user two overlapping targets for one destination.
*/
export interface ProjectCardProps {
project: Project;
/**
* The server's photo minimum (`PROJECT_PHOTO_MINIMUM`). Omitted while the
* readiness query is in flight, in which case the no-photos chip states the
* next step without a figure rather than guessing one.
*/
photoMinimum?: number;
}
/** A chip with no cover image has no photos: the cover is denormalised from the
* first room media, so its absence is the same fact, one field earlier. */
function hasNoPhotos(project: Project): boolean {
if (typeof project.photoCount === "number") return project.photoCount === 0;
return !project.coverImage;
}
export const ProjectCard = memo(function ProjectCard({
project,
photoMinimum,
}: ProjectCardProps) {
const code = projectStatusCode(project.status);
const empty = hasNoPhotos(project);
return (
<Link
to="/projects/$projectId"
params={{ projectId: project.id }}
search={empty ? { tab: "photos" } : { tab: "" }}
className={cn(
"group relative block overflow-hidden rounded-lg bg-background-elevated shadow-card",
// Rows tint on hover; they never lift.
"transition-colors hover:bg-foreground-default/7",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
)}
>
{/* The punched ring hole — one per chip, top-right on a portfolio chip. */}
<span
aria-hidden="true"
className="absolute top-2.5 right-2.5 z-10 size-3 rounded-full bg-background-base shadow-[inset_0_0_0_1px_var(--border-strong)]"
/>
<span className="relative block aspect-[4/3] overflow-hidden bg-background-muted">
{project.coverImage ? (
<ResponsiveImage
storageKey={project.coverImage}
alt={project.title}
width={600}
height={450}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="h-full w-full object-cover"
/>
) : (
<span className="absolute inset-0 grid place-content-center justify-items-center gap-1.5 p-3 text-center text-foreground-muted">
<Ban aria-hidden="true" className="size-6" />
<span className="text-xs font-semibold uppercase tracking-wider">
No photos yet
</span>
</span>
)}
</span>
<span className="block border-t border-border-default px-3.5 py-3">
<StatusBadge status={code.label} variant={code.tone} />
<span className="mt-1.5 block truncate text-base font-semibold leading-snug text-foreground-default">
{project.title}
</span>
<span className="mt-0.5 block text-xs font-semibold uppercase tracking-wider text-foreground-muted">
{typeof project.photoCount === "number" ? (
<>
<span className="tabular-nums">{project.photoCount}</span>{" "}
{project.photoCount === 1 ? "photo" : "photos"}
</>
) : (
"Portfolio project"
)}
</span>
{empty && (
<>
<span className="mt-1.5 block text-sm text-foreground-muted">
{photoMinimum
? `${photoMinimum} photos and it can go live.`
: "Add photos and it can go live."}
</span>
<span className="mt-1.5 inline-flex h-6 items-center gap-1 rounded-md border border-border-default bg-background-elevated px-1.5 text-xs font-semibold text-foreground-default group-hover:border-border-strong">
Add photos <span aria-hidden="true">▸</span>
</span>
</>
)}
</span>
</Link>
);
});
|