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 | 10x 138x | import type { Item } from "@/types";
import type { UsageAnswer } from "@/lib/derive-usage";
import { hasName, itemDisplayName, itemInitial } from "@/lib/items";
import { usageChipLabel } from "@/features/items/usage-chip-copy";
import { BUCKET_LABELS } from "@/features/sorting/roundCopy";
import { Badge, Card, CardImage, PhotoViewControl, type ViewerPhoto } from "@/components/ui";
import Link from "next/link";
interface ItemCardProps {
item: Item;
collectionId: string;
heroPhotoUrl?: string;
bucket?: "top" | "high" | "medium" | "low";
/** Override the image area sizing (default a square). Triage caps height so
* the decision buttons stay above the fold. */
imageClassName?: string;
/** When set, a "view photo" control opens the zoom/rotate lightbox for the hero. */
viewerPhoto?: ViewerPhoto;
/** Epic S1: a quiet, count-free usage note ("In your life" / "Resting").
* Absent (undefined) when this item's usage is undecided — no chip renders. */
usage?: UsageAnswer;
}
const bucketDots = {
top: "●●●●",
high: "●●●",
medium: "●●",
low: "●",
};
export function ItemCard({
item,
collectionId,
heroPhotoUrl,
bucket,
imageClassName = "aspect-square",
viewerPhoto,
usage,
}: ItemCardProps) {
// Stretched-link: the navigating <Link> is an overlay sibling so the view
// control is valid DOM. `isolate` keeps the control (z-20) above the link (z-10).
return (
<Card interactive padding="none" className="relative isolate flex h-full flex-col overflow-hidden">
<div className={`bg-content-soft ${imageClassName} relative flex items-center justify-center overflow-hidden`}>
<CardImage
src={heroPhotoUrl}
alt={itemDisplayName(item)}
fallback={
<span className="font-display text-5xl text-ink-soft" aria-hidden>
{itemInitial(item)}
</span>
}
/>
</div>
<div className="flex flex-1 flex-col justify-between p-3">
<div className="space-y-2">
{/* Wraps the chip under the name when the tile is too narrow (phone) rather
than squeezing the name to a few letters. */}
<div className="flex flex-wrap items-start justify-between gap-2">
<h3 className="min-w-0 grow basis-24 text-ink line-clamp-2 break-words">{itemDisplayName(item)}</h3>
{bucket && (
<Badge variant={bucket} title={BUCKET_LABELS[bucket]} className="shrink-0">
<span role="img" aria-label={BUCKET_LABELS[bucket]}>{bucketDots[bucket]}</span>
</Badge>
)}
</div>
{hasName(item) ? (
item.brand && <p className="text-xs text-ink-soft truncate">{item.brand}</p>
) : (
<p className="text-xs text-ink-soft truncate">Add a name</p>
)}
{/* Epic S1 usage chip: a quiet, count-free note. Meaning is carried by
the words (not color), and the neutral "active" variant is AA in
both themes. No chip when usage is undecided (undefined). */}
{usage && (
<Badge variant="active" className="w-fit text-xs">
{usageChipLabel(usage)}
</Badge>
)}
</div>
</div>
<Link
href={`/collections/${collectionId}/items/${item.number}`}
className="absolute inset-0 z-10"
aria-label={itemDisplayName(item)}
/>
{viewerPhoto && <PhotoViewControl photo={viewerPhoto} className="absolute right-2 top-2 z-20" />}
</Card>
);
}
|