mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
151bed230a
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
460 lines
16 KiB
Plaintext
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>
|