Board game updated pass 1

This commit is contained in:
Greg Kilmer
2026-07-06 21:50:41 -04:00
parent 7808c2ead8
commit bb4924a988
10 changed files with 997 additions and 85 deletions
+188
View File
@@ -0,0 +1,188 @@
/**
* BoardGameShelf.tsx
* ---------------------------------------------------------------------------
* Two-column "Board games" island for the TTRPGs page.
*
* Left: a list of game names, each a real <a> to its BoardGameGeek page.
* Hovering (or keyboard-focusing) a name selects it.
* Right: a tall image frame. At rest it shows Greg's board-game shelf photo;
* when a name is selected, that game's BGG cover cross-fades in on
* top of the shelf, with the game's name + note as a caption.
*
* Progressive enhancement: without JS the list is still fully usable — the
* links work and the shelf photo stays put. The cross-fade is the enhancement.
* framer-motion honors prefers-reduced-motion automatically.
*
* Data shape (src/data/boardgames.json):
* { name: string; bggId: number; note?: string; image?: string }
* `image` is optional — populated by scripts/fetch-bgg-covers.mjs. Rows without
* an `image` simply keep the shelf photo on hover (graceful fallback).
*/
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
export interface BoardGame {
name: string;
/** BoardGameGeek numeric game id; drives the link + cover lookup. */
bggId: number;
/** Optional one-line note shown as the caption. */
note?: string;
/** Optional cover image URL/path. If absent, hovering won't swap images. */
image?: string;
}
interface Props {
games: BoardGame[];
/** Public path to the default shelf photo, e.g. "/images/ttrpg-boardgame-shelf.jpg". */
shelfImage: string;
/** Alt text for the shelf photo. */
shelfAlt: string;
/** Optional caption shown at rest over the shelf photo. */
shelfCaption?: string;
}
/**
* Fixed height for BOTH columns. The list shows up to 8 rows in a scroll
* container; the image frame is pinned to this same height so the two
* columns stay aligned regardless of how many games are in the list.
* ~3.25rem per row x 8 rows.
*/
const SHELF_HEIGHT = '44rem';
const crossfade = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.35, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] },
};
export default function BoardGameShelf({
games,
shelfImage,
shelfAlt,
shelfCaption = 'The shelf',
}: Props) {
// Index of the active game, or null when at rest (showing the shelf).
const [active, setActive] = useState<number | null>(null);
const activeGame = active !== null ? games[active] : null;
// Only swap to the cover if the active game actually has an image.
const showCover = activeGame?.image;
return (
<div className="grid gap-5 sm:grid-cols-2 sm:items-stretch">
{/* LEFT: game list — capped at the shelf height with internal scroll */}
<ul
className="flex flex-col divide-y overflow-y-auto pr-1"
style={{
borderColor: 'var(--color-border)',
height: SHELF_HEIGHT,
scrollbarWidth: 'thin',
}}
>
{games.map((g, i) => {
const isActive = active === i;
return (
<li key={g.bggId}>
<a
href={`https://boardgamegeek.com/boardgame/${g.bggId}`}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onMouseLeave={() => setActive((cur) => (cur === i ? null : cur))}
onBlur={() => setActive((cur) => (cur === i ? null : cur))}
className="group flex items-center gap-3 py-3 transition-colors"
style={{
color: isActive ? 'var(--color-accent)' : 'var(--color-text)',
}}
>
<span
aria-hidden="true"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors"
style={{
background: isActive
? 'color-mix(in srgb, var(--color-accent) 20%, transparent)'
: 'color-mix(in srgb, var(--color-secondary) 16%, transparent)',
color: isActive ? 'var(--color-accent)' : 'var(--color-secondary)',
}}
>
{/* dice / playing-card style glyph, inline to stay self-contained */}
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm0 2v14h14V5H5zm4 2.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm9 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0 7a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-9 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm4.5-3.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z" />
</svg>
</span>
<span className="flex flex-col gap-0.5">
<span className="font-sans text-base leading-tight underline-offset-4 group-hover:underline group-focus:underline">
{g.name}
</span>
{g.note && (
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
{g.note}
</span>
)}
</span>
</a>
</li>
);
})}
</ul>
{/* RIGHT: tall image frame, shelf at rest, cover on hover. Pinned to the
same height as the list so the two columns line up. */}
<figure
className="relative w-full overflow-hidden rounded-xl border"
style={{
height: SHELF_HEIGHT,
borderColor: 'color-mix(in srgb, var(--color-accent) 35%, transparent)',
background: 'color-mix(in srgb, var(--color-secondary) 12%, transparent)',
}}
// When the pointer leaves the whole figure, snap back to the shelf.
onMouseLeave={() => setActive(null)}
>
{/* Base layer: shelf photo (always present; covers cross-fade on top). */}
<img
src={shelfImage}
alt={shelfAlt}
loading="lazy"
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
{/* Gradient + caption overlay for legibility. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1/3"
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)' }}
/>
<AnimatePresence>
{showCover && (
<motion.img
key={activeGame!.bggId}
src={activeGame!.image}
alt={`${activeGame!.name} cover`}
loading="lazy"
decoding="async"
className="absolute inset-0 h-full w-full object-contain"
initial={crossfade.initial}
animate={crossfade.animate}
exit={crossfade.exit}
transition={crossfade.transition}
/>
)}
</AnimatePresence>
<figcaption
className="absolute inset-x-0 bottom-0 flex flex-col gap-0.5 p-4"
style={{ color: 'var(--color-cream)' }}
>
<span className="font-sans text-lg leading-tight drop-shadow">
{activeGame?.name ?? shelfCaption}
</span>
{activeGame?.note && (
<span className="text-sm opacity-90 drop-shadow">{activeGame.note}</span>
)}
</figcaption>
</figure>
</div>
);
}