Phase 5: every page of the site, the resume PDF and RSS

Landing, About, Now, Resume (with a generated PDF), Work and case studies,
Inquiries and the sent page, Library and the Shelf, Workshop and craft pages,
Tabletop, Blog with topics and posts, RSS, the Store, product lines, the
order form and the 404, all rendered from the content service's notes.

Schema: experience previousRoles, skills stack core items, page stats,
make tags and deck favorite. Icon names from notes map to brand icons in one
place, and HTML comments are dropped when Markdown renders.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 21:33:49 +00:00
parent 7025e95f89
commit 99ecdc2113
83 changed files with 11681 additions and 14 deletions
+255
View File
@@ -0,0 +1,255 @@
---
/**
* The Library: every book, series, show, film and video game. The favorites
* constellation, then the list grouped Now / Up next / Finished, filtered by
* medium, state, favorites and blog posts.
*/
import Divider from "@gk/ui/components/Divider.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import SearchField from "@gk/ui/components/SearchField.astro";
import Constellation from "../../components/library/Constellation.astro";
import FilterLayout from "../../components/library/FilterLayout.astro";
import LibraryHero from "../../components/library/LibraryHero.astro";
import LibraryRow from "../../components/library/LibraryRow.astro";
import ListHeading from "../../components/library/ListHeading.astro";
import RailGroup from "../../components/library/RailGroup.astro";
import { byStanding, constellation, libraryFilter, mediaGroupOf, mediaGroups, sectionOf, sections, searchText } from "../../lib/library.ts";
import { site } from "../../lib/page.ts";
const s = await site();
const f = libraryFilter(Astro.url.searchParams);
const items = s.all("library-item").filter((i) => sectionOf(i));
const lede = s.pageText("/library")?.data.lede ?? "What I'm on now, what's next, and what stuck with me. Short verdicts, no scores.";
const shows = (i: (typeof items)[number]) =>
(!f.type || mediaGroupOf(i) === f.type) &&
f.show.has(sectionOf(i)!) &&
(!f.favorites || !!i.data.favorite) &&
(!f.blog || s.backlinks(i, { type: "post" }).length > 0) &&
(!f.q || searchText([i.title, i.data.creator, i.data.group, i.data.verdict, i.data.note, i.data.medium, i.data.platform, i.data.year, i.data.progress]).includes(f.q));
const groups = sections.map((sec) => {
const list = items.filter((i) => sectionOf(i) === sec.key).sort(byStanding);
return { ...sec, list, visible: list.filter(shows).length };
});
const anyVisible = groups.some((g) => g.visible > 0);
const dots = { now: "live", next: "open", done: "done" } as const;
const typeHref = (key?: string) => {
const p = new URLSearchParams(Astro.url.searchParams);
if (key) p.set("type", key);
else p.delete("type");
const q = p.toString();
return q ? `/library?${q}` : "/library";
};
/** Finished rows shown before "Show everything finished". */
const FIRST = 12;
---
<PageShell title="Library" family="library" current="library" description="What Greg is reading, watching and playing now, what's next, and what stuck with him.">
<Fragment slot="hero"><LibraryHero current="library" lede={lede} /></Fragment>
<Constellation items={constellation(items)} />
<div class="divide"><Divider seed="library-list" /></div>
<FilterLayout action="/library" formId="lib-filters">
<Fragment slot="rail">
<SearchField label="Search the Library" placeholder="Search" value={f.q || undefined} />
{f.type && <input type="hidden" name="type" value={f.type} />}
<nav aria-label="Type" class="types">
<span class="types-label" aria-hidden="true">Type</span>
<a href={typeHref()} data-type="" aria-current={!f.type ? "true" : undefined}>Everything</a>
{mediaGroups.map((g) => <a href={typeHref(g.key)} data-type={g.key} aria-current={f.type === g.key ? "true" : undefined}>{g.label}</a>)}
</nav>
<RailGroup legend="Show">
{sections.map((sec) => <label class="check"><input type="checkbox" name="show" value={sec.key} checked={f.show.has(sec.key)} />{sec.label}</label>)}
<label class="check"><input type="checkbox" name="fav" value="1" checked={f.favorites} />Favorites only</label>
<label class="check"><input type="checkbox" name="blog" value="1" checked={f.blog} />Has a blog post</label>
</RailGroup>
</Fragment>
<div class="lists">
{
groups.map((g) => (
<section aria-labelledby={`lib-${g.key}`} class="group" data-group={g.key} hidden={g.visible === 0}>
<ListHeading id={`lib-${g.key}`} dot={dots[g.key]}>{g.label}</ListHeading>
<ul class="rows">
{g.list.map((item) => <LibraryRow item={item} site={s} hidden={!shows(item)} />)}
</ul>
{g.key === "done" && g.list.length > FIRST && (
<button type="button" class="gk-text more" data-more hidden>
Show everything finished ({g.list.length})
</button>
)}
</section>
))
}
<p class="empty" data-empty hidden={anyVisible}>Nothing in the Library matches these filters.</p>
</div>
</FilterLayout>
</PageShell>
<style>
.divide {
padding: 24px 0 4px;
}
.types {
display: flex;
flex-direction: column;
gap: 2px;
}
.types-label {
margin-bottom: 8px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.types a {
display: flex;
align-items: center;
min-height: 40px;
padding: 0 12px;
border-left: 2px solid transparent;
color: var(--gk-soft);
font-size: 14px;
text-decoration: none;
transition: border-color var(--gk-ease), color var(--gk-ease), background var(--gk-ease);
}
.types a:hover {
color: var(--gk-text);
border-left-color: var(--gk-gold-line);
}
.types a[aria-current] {
border-left-color: var(--gk-gold);
background: rgba(201, 162, 74, 0.08);
color: var(--gk-text);
font-weight: 600;
}
.lists {
display: flex;
flex-direction: column;
gap: 36px;
}
.group[hidden] {
display: none;
}
.rows {
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.more {
align-self: flex-start;
margin-top: 16px;
border: 0;
background-color: transparent;
font-family: var(--gk-font-body);
font-size: 15px;
cursor: pointer;
}
.group {
display: flex;
flex-direction: column;
}
.empty {
margin: 0;
color: var(--gk-muted);
}
@media (max-width: 760px) {
.types {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.types-label {
flex-basis: 100%;
margin-bottom: 2px;
}
.types a {
min-height: 36px;
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius-pill);
padding: 0 14px;
font-size: 13px;
}
.types a:hover,
.types a[aria-current] {
border-color: var(--gk-gold);
}
}
</style>
<script>
// Live filtering: the same rules as the server, applied to the rendered rows.
const form = document.getElementById("lib-filters") as HTMLFormElement | null;
if (form) {
const FIRST = 12;
const rows = [...document.querySelectorAll<HTMLElement>(".rows > li[id]")];
const groups = [...document.querySelectorAll<HTMLElement>("[data-group]")];
const more = document.querySelector<HTMLButtonElement>("[data-more]");
const empty = document.querySelector<HTMLElement>("[data-empty]");
const typeLinks = [...form.querySelectorAll<HTMLAnchorElement>("[data-type]")];
const text = new Map(rows.map((r) => [r, (r.textContent ?? "").toLowerCase()]));
let type = new URLSearchParams(location.search).get("type") ?? "";
let expanded = false;
form.querySelector("[data-nojs]")?.remove();
form.querySelector('input[name="type"]')?.remove();
const apply = () => {
const data = new FormData(form);
const show = new Set(data.getAll("show"));
const fav = data.has("fav");
const blog = data.has("blog");
const q = String(data.get("q") ?? "").trim().toLowerCase();
const narrowed = !!(type || fav || blog || q);
let doneCount = 0;
for (const r of rows) {
const d = r.dataset;
let ok = (!type || d.m === type) && show.has(d.s!) && (!fav || !!d.f) && (!blog || !!d.b) && (!q || text.get(r)!.includes(q));
if (ok && d.s === "done" && !narrowed && !expanded && ++doneCount > FIRST) ok = false;
r.hidden = !ok;
}
let any = false;
for (const g of groups) {
const visible = !!g.querySelector(".rows > li:not([hidden])");
g.hidden = !visible;
any ||= visible;
}
if (more) more.hidden = expanded || narrowed || !show.has("done") || doneCount <= FIRST;
if (empty) empty.hidden = any;
};
// Opening an anchor to a folded-away row unfolds the list first.
const reveal = () => {
const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (target && target.hidden && rows.includes(target)) {
expanded = true;
apply();
target.scrollIntoView();
}
};
form.addEventListener("input", apply);
form.addEventListener("submit", (e) => {
e.preventDefault();
apply();
});
for (const a of typeLinks) {
a.addEventListener("click", (e) => {
e.preventDefault();
type = a.dataset.type ?? "";
typeLinks.forEach((l) => (l === a ? l.setAttribute("aria-current", "true") : l.removeAttribute("aria-current")));
history.replaceState(null, "", a.href);
apply();
});
}
more?.addEventListener("click", () => {
expanded = true;
apply();
});
addEventListener("hashchange", reveal);
apply();
reveal();
}
</script>
+369
View File
@@ -0,0 +1,369 @@
---
/**
* 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 and play times come from BoardGameGeek later; no filter for them yet.
*/
import Divider from "@gk/ui/components/Divider.astro";
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, onTheTable, playStyles, searchText, shelfFilter, sortGames, sorts, 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 visible = new Set(games.filter((g) => gameShows(g, f, text(g))).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} /></Fragment>
<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>
<div class="divide"><Divider seed="shelf-list" /></div>
<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>
)
}
<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>
</RailGroup>
</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)} />)}
</ul>
<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);
}
.divide {
padding: 24px 0 4px;
}
.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"),
};
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");
let n = 0;
for (const c of cards) {
const d = c.dataset;
const ok =
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
(!fav || !!d.f) &&
(!plays.length || (d.p ?? "").split("|").some((p) => plays.includes(p))) &&
(!q || text.get(c)!.includes(q));
c.hidden = !ok;
if (ok) 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));
};
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>