Files
com.gregkilmer.www/services/site/src/pages/library/shelf.astro
T
Claude 151bed230a Match the site to the approved designs
Denser star field, the page nebula, a favicon package, the filament banner
without its box, the About portrait, Kashi and tea photos and Scorpius, the
Store's own header, a header logo glow, a resume PDF that fills its page,
one divider on the Library and Shelf, star-chart art for coverless
favorites, Shelf Players, Play time and blog filters with a Show all fold,
make kind pills on craft pages, and full-width closing bands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:33:15 +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(14, 21, 34, 0.9), rgba(14, 21, 34, 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>