mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Board game updated pass 1
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user