All files / src/components/projects ProjectCard.tsx

100% Statements 7/7
100% Branches 14/14
100% Functions 2/2
100% Lines 6/6

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>
	);
});