Files
com.gregkilmer.www/services/site/src/pages/library/shelf.astro
T
Claude 7aa2910b12 Add light mode that follows the device setting
Dark stays the default. A device set to light gets the picks from the
light motif board: the Dawn chart sky (dawn wash over faint star chart
lines, atlas marks in the hero), deep gold line seals, Midnight, violet and
gold dust, the deep gold to teal filament and the morning sky placeholder.
Large standalone seals (404, Inquiries, Inquiry sent, the blog's lead) are
gold foil in both modes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 18:54:56 +00:00

460 lines
16 KiB
Plaintext

---
/**
* The Shelf: every board game. What is on the table (from the status log),
* then the collection, filtered by how it plays, owned, want to play and
* favorites, and sorted by last played, A to Z or recently added. Player
* counts, play times and box art sync from BoardGameGeek (a cover set in the
* note wins); no filter for them yet.
*/
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import SearchField from "@gk/ui/components/SearchField.astro";
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import FilterLayout from "../../components/library/FilterLayout.astro";
import GameCard from "../../components/library/GameCard.astro";
import LibraryHero from "../../components/library/LibraryHero.astro";
import RailGroup from "../../components/library/RailGroup.astro";
import { gameShows, minutesOf, onTheTable, playerOptions, playersOf, playStyles, searchText, shelfFilter, sortGames, sorts, timeOptions, type Game } from "../../lib/library.ts";
import { site } from "../../lib/page.ts";
import { formatDate, mediaSrc } from "../../lib/site.ts";
const s = await site();
const f = shelfFilter(Astro.url.searchParams);
const all = s.all("board-game");
const games = sortGames(all, f.sort);
const styles = playStyles(all);
const owned = all.filter((g) => g.data.owned).length;
const lede = s.pageText("/library/shelf")?.data.lede ?? "My board game collection. Filter by how it plays, sort by what hit the table last.";
const text = (g: Game) => searchText([g.title, g.data.blurb, ...(g.data.howItPlays ?? [])]);
const hasPost = (g: Game) => s.backlinks(g, { type: "post" }).length > 0;
const visible = new Set(games.filter((g) => gameShows(g, f, text(g), hasPost(g))).map((g) => g.slug));
// Players and play time come from BoardGameGeek; their pills wait for a sync.
const synced = { players: all.some((g) => playersOf(g)), time: all.some((g) => minutesOf(g) !== undefined) };
const anyPost = all.some(hasPost);
/** Games shown before "Show all", while no filter narrows the list. */
const FIRST = 12;
const narrowed = !!(f.q || f.plays.size || f.want || !f.owned || f.favorites || f.blog || f.players || f.time);
const folded = !narrowed && visible.size > FIRST;
const firstShown = new Set(games.filter((g) => visible.has(g.slug)).slice(0, FIRST).map((g) => g.slug));
// On the table: board-game statuses, then the most recent play not already listed.
const table: { label: string; game: Game; note?: string; live: boolean }[] = onTheTable(s).map(({ status, game }) => ({
label: `${status.data.kind} now`,
game,
note: status.data.note,
live: true,
}));
const recent = sortGames(all, "played").find((g) => g.data.lastPlayed && !table.some((t) => t.game.path === g.path));
if (recent) table.push({ label: "Played recently", game: recent, note: `Last played ${formatDate(recent.data.lastPlayed)}`, live: false });
const sortHref = (key: string) => {
const p = new URLSearchParams(Astro.url.searchParams);
p.set("sort", key);
return `/library/shelf?${p}`;
};
---
<PageShell title="The Shelf" family="shelf" current="library" description="Greg's board game collection: what is on the table, how each one plays, and what hit the table last.">
<Fragment slot="hero">
<LibraryHero current="shelf" lede={lede} />
<section aria-labelledby="sh-table" class="top">
<div class="count gk-featured">
<StarChartArt class="count-art" ratio="auto" seal="tria-prima" />
<GiltCorners />
<p class="count-text"><span class="num">{owned}</span><span>{owned === 1 ? "game" : "games"} on the shelf</span></p>
</div>
<div class="table">
<div class="kick">
<h2 id="sh-table"><Star size={14} />On the table</h2>
<TickScale width={150} height={10} count={30} />
</div>
{
table.length > 0 ? (
<ul class="table-list">
{table.map((t) => (
<li>
<a href={`#${t.game.slug}`} class:list={["gk-card", "table-card", { live: t.live }]}>
<StarChartArt class="table-art" ratio="1 / 1" seal="tria-prima" src={mediaSrc(t.game.data.cover)} />
<span class="table-body">
<span class="table-label">{t.label}</span>
<span class="table-title">{t.game.title}</span>
{t.note && <span class="table-note">{t.note}</span>}
</span>
</a>
</li>
))}
</ul>
) : (
<p class="quiet">Nothing on the table right now.</p>
)
}
</div>
</section>
</Fragment>
<FilterLayout action="/library/shelf" formId="shelf-filters">
<Fragment slot="rail">
<SearchField label="Search the Shelf" placeholder="Search games" value={f.q || undefined} />
<input type="hidden" name="sort" value={f.sort} />
{
styles.length > 0 && (
<RailGroup legend="How it plays">
{styles.map((p) => <label class="check"><input type="checkbox" name="plays" value={p.toLowerCase()} checked={f.plays.has(p.toLowerCase())} />{p}</label>)}
</RailGroup>
)
}
{
synced.players && (
<RailGroup legend="Players" pills>
<label class="gk-filter pill"><input type="radio" name="players" value="" checked={!f.players} class="sr" /><span class="gk-fs star"><Star size={9} /></span>Any</label>
{playerOptions.map((o) => <label class="gk-filter pill"><input type="radio" name="players" value={o.key} checked={f.players === o.key} class="sr" /><span class="gk-fs star"><Star size={9} /></span>{o.label}</label>)}
</RailGroup>
)
}
{
synced.time && (
<RailGroup legend="Play time" pills>
<label class="gk-filter pill"><input type="radio" name="time" value="" checked={!f.time} class="sr" /><span class="gk-fs star"><Star size={9} /></span>Any</label>
{timeOptions.map((o) => <label class="gk-filter pill"><input type="radio" name="time" value={o.key} checked={f.time === o.key} class="sr" /><span class="gk-fs star"><Star size={9} /></span>{o.label}</label>)}
</RailGroup>
)
}
<RailGroup legend="Show">
<label class="check"><input type="checkbox" name="owned" value="1" checked={f.owned} />Owned</label>
<label class="check"><input type="checkbox" name="want" value="1" checked={f.want} />Want to play</label>
<label class="check"><input type="checkbox" name="fav" value="1" checked={f.favorites} />Favorites only</label>
{anyPost && <label class="check"><input type="checkbox" name="blog" value="1" checked={f.blog} />Has a blog post</label>}
</RailGroup>
{(synced.players || synced.time) && <p class="rail-note">Player counts and play times come from BoardGameGeek.</p>}
</Fragment>
<section aria-label="Games" class="games">
<div class="bar">
<p class="showing" aria-live="polite">Showing <strong data-count>{visible.size}</strong> <span data-noun>{visible.size === 1 ? "game" : "games"}</span></p>
<div class="sort">
<span class="sort-label" aria-hidden="true">Sort</span>
<FilterGroup label="Sort" items={sorts.map((o) => ({ label: o.label, href: sortHref(o.key), active: o.key === f.sort, value: o.key }))} />
</div>
</div>
<ul class="grid" data-grid>
{games.map((g) => <GameCard game={g} site={s} hidden={!visible.has(g.slug) || (folded && !firstShown.has(g.slug))} />)}
</ul>
<button type="button" class="gk-text more" data-more hidden={!folded}>Show all {visible.size}</button>
<p class="quiet" data-empty hidden={visible.size > 0}>No games on the Shelf match these filters.</p>
</section>
</FilterLayout>
</PageShell>
<style>
.top {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: stretch;
padding: 0 0 8px;
}
.count {
position: relative;
flex: 1 1 380px;
min-width: 0;
min-height: 200px;
overflow: hidden;
}
.count :global(.count-art) {
position: absolute;
inset: 0;
border-radius: 0;
}
.count::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(var(--gk-sky-rgb), 0.9), rgba(var(--gk-sky-rgb), 0.2));
}
.count-text {
position: relative;
z-index: 1;
margin: 0;
padding: 28px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 15px;
font-weight: 600;
}
.num {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 56px;
line-height: 1;
color: var(--gk-gold-bright);
}
.count :global(.gk-corner) {
z-index: 1;
}
.table {
flex: 2 1 520px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.kick {
display: flex;
flex-direction: column;
gap: 6px;
}
.kick h2 {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.table-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.table-list li {
flex: 1 1 280px;
min-width: 0;
display: flex;
}
.table-card {
flex: 1;
flex-direction: row;
align-items: center;
gap: 16px;
padding: 14px 16px;
}
.table-card.live {
border-color: var(--gk-gold);
}
.table-card :global(.table-art) {
flex: 0 0 56px;
width: 56px;
border-radius: 8px;
}
.table-card :global(.table-art .mat) {
display: none;
}
.table-card :global(.table-art .seal) {
width: 34px;
height: 34px;
}
.table-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.table-label {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.table-title {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 16px;
}
.table-note,
.quiet {
margin: 0;
font-size: 13px;
color: var(--gk-muted);
}
.rail-note {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--gk-muted);
}
.sr {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.pill {
position: relative;
}
.pill:has(:checked) {
border-color: var(--gk-gold);
background: var(--gk-gold-tint);
color: var(--gk-text);
}
.pill:has(:checked) .star {
display: inline-flex;
}
.pill:has(:focus-visible) {
outline: 2px solid var(--gk-gold);
outline-offset: 3px;
}
.more {
align-self: center;
}
.games {
display: flex;
flex-direction: column;
gap: 18px;
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.showing {
margin: 0;
font-size: 14px;
color: var(--gk-muted);
}
.showing strong {
color: var(--gk-text);
}
.sort {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.sort-label {
font-size: 13px;
color: var(--gk-muted);
margin-right: 4px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
@media (max-width: 480px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
}
</style>
<script>
// Live filtering and sorting: the same rules as the server, on the rendered cards.
const form = document.getElementById("shelf-filters") as HTMLFormElement | null;
const grid = document.querySelector<HTMLElement>("[data-grid]");
if (form && grid) {
const cards = [...grid.children] as HTMLElement[];
const text = new Map(cards.map((c) => [c, (c.querySelector(".body")?.textContent ?? "").toLowerCase()]));
const title = (c: HTMLElement) => c.querySelector("h3")?.textContent?.trim() ?? "";
const sortInput = form.querySelector<HTMLInputElement>('input[name="sort"]')!;
const sortLinks = [...document.querySelectorAll<HTMLAnchorElement>(".sort .gk-filter")];
const count = document.querySelector("[data-count]");
const noun = document.querySelector("[data-noun]");
const empty = document.querySelector<HTMLElement>("[data-empty]");
form.querySelector("[data-nojs]")?.remove();
const desc = (k: "lp" | "c") => (a: HTMLElement, b: HTMLElement) => (b.dataset[k] ?? "").localeCompare(a.dataset[k] ?? "") || title(a).localeCompare(title(b));
const orders: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
played: desc("lp"),
az: (a, b) => title(a).localeCompare(title(b)),
added: desc("c"),
time: (a, b) => Number(a.dataset.t || Infinity) - Number(b.dataset.t || Infinity) || title(a).localeCompare(title(b)),
};
const FIRST = 12;
const more = document.querySelector<HTMLButtonElement>("[data-more]");
const players: Record<string, [number, number]> = { "1": [1, 1], "2": [2, 2], "3-4": [3, 4], "5+": [5, Infinity] };
const times: Record<string, [number, number]> = { short: [0, 29], mid: [30, 60], long: [61, Infinity] };
let expanded = false;
const apply = () => {
const data = new FormData(form);
const q = String(data.get("q") ?? "").trim().toLowerCase();
const plays = data.getAll("plays").map(String);
const owned = data.has("owned");
const want = data.has("want");
const fav = data.has("fav");
const blog = data.has("blog");
const seat = players[String(data.get("players") ?? "")];
const time = times[String(data.get("time") ?? "")];
const narrowed = !!(q || plays.length || want || !owned || fav || blog || seat || time);
let n = 0;
for (const c of [...grid.children] as HTMLElement[]) {
const d = c.dataset;
const [lo, hi] = (d.pl ?? "").split("-").map(Number);
const t = d.t ? Number(d.t) : NaN;
const ok =
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
(!fav || !!d.f) &&
(!blog || !!d.b) &&
(!seat || (!!d.pl && lo! <= seat[1] && hi! >= seat[0])) &&
(!time || (t >= time[0] && t <= time[1])) &&
(!plays.length || (d.p ?? "").split("|").some((p) => plays.includes(p))) &&
(!q || text.get(c)!.includes(q));
if (ok) n++;
c.hidden = !ok || (!narrowed && !expanded && n > FIRST);
}
if (more) {
more.hidden = narrowed || expanded || n <= FIRST;
more.textContent = `Show all ${n}`;
}
if (count) count.textContent = String(n);
if (noun) noun.textContent = n === 1 ? "game" : "games";
if (empty) empty.hidden = n > 0;
};
const sort = (key: string) => {
sortInput.value = key;
grid.append(...[...cards].sort(orders[key] ?? orders.played));
apply();
};
more?.addEventListener("click", () => {
expanded = true;
apply();
});
// Opening an anchor to a folded-away card unfolds the grid first.
const reveal = () => {
const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (target && target.hidden && cards.includes(target)) {
expanded = true;
apply();
target.scrollIntoView();
}
};
addEventListener("hashchange", reveal);
reveal();
form.addEventListener("input", apply);
form.addEventListener("submit", (e) => {
e.preventDefault();
apply();
});
for (const a of sortLinks) {
a.addEventListener("click", (e) => {
e.preventDefault();
const key = new URL(a.href).searchParams.get("sort") ?? "played";
sortLinks.forEach((l) => (l === a ? l.setAttribute("aria-current", "page") : l.removeAttribute("aria-current")));
history.replaceState(null, "", a.href);
sort(key);
});
}
}
</script>