mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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:
@@ -0,0 +1,448 @@
|
||||
---
|
||||
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
|
||||
import Icon from "@gk/ui/components/Icon.astro";
|
||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||
import Star from "@gk/ui/components/Star.astro";
|
||||
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
|
||||
import StatusPill from "@gk/ui/components/StatusPill.astro";
|
||||
import PageHero from "../components/PageHero.astro";
|
||||
import NowStrip, { type NowItem } from "../components/world/NowStrip.astro";
|
||||
import WorldSection from "../components/world/WorldSection.astro";
|
||||
import type { Note } from "../lib/content.ts";
|
||||
import { plural, site } from "../lib/page.ts";
|
||||
import { slugify } from "@gk/schema";
|
||||
import { byOrder, deckUrl, makePhotos, sentence, stancePill, systemPill, systemStates } from "../lib/world.ts";
|
||||
|
||||
const s = await site();
|
||||
const text = s.pageText("/tabletop");
|
||||
const intro = s.render(text?.body);
|
||||
|
||||
const stateRank = (st: string) => systemStates.indexOf(st as (typeof systemStates)[number]);
|
||||
const systems = byOrder(s.all("tabletop-system")).sort((a, b) => stateRank(a.data.state) - stateRank(b.data.state));
|
||||
const states = systemStates.filter((st) => systems.some((x) => x.data.state === st));
|
||||
|
||||
const campaigns = [...s.all("campaign")].sort((a, b) => a.title.localeCompare(b.title));
|
||||
const atTable = campaigns.filter((c) => c.data.status === "active" || c.data.status === "playtest");
|
||||
const pastCampaigns = campaigns.filter((c) => !atTable.includes(c));
|
||||
|
||||
const games = byOrder(s.all("card-game"));
|
||||
const allDecks = [...s.all("deck")].sort((a, b) => a.title.localeCompare(b.title));
|
||||
const isFav = (d: Note<"deck">) => !!d.data.favorite;
|
||||
const decks = allDecks.some(isFav) ? allDecks.filter(isFav) : allDecks;
|
||||
const gameOf = (d: Note<"deck">) => s.link(d.data.game);
|
||||
const deckGroup = (d: Note<"deck">) => [shortGame(gameOf(d).title), d.data.format].filter(Boolean).join(" ");
|
||||
const deckGroups = [...new Set(decks.map(deckGroup))];
|
||||
|
||||
/** "Magic: The Gathering" reads as MTG in kickers, as the design does. */
|
||||
function shortGame(title: string) {
|
||||
return /^magic/i.test(title) ? "MTG" : title;
|
||||
}
|
||||
|
||||
/** A status that points at a deck makes it the deck of the moment. */
|
||||
const deckStatus = s.statuses().find((st) => s.byName(st.data.subject)?.type === "deck");
|
||||
const deckOfMoment = deckStatus ? (s.byName(deckStatus.data.subject) as Note<"deck">) : undefined;
|
||||
|
||||
const nowItems: NowItem[] = atTable.map((c) => {
|
||||
const system = c.data.system ? s.link(c.data.system) : null;
|
||||
const label = c.data.status === "playtest" ? "Playtest" : [system?.title, c.data.platform && `on ${c.data.platform}`].filter(Boolean).join(" · ");
|
||||
return {
|
||||
id: c.slug,
|
||||
label: label || "Campaign",
|
||||
title: c.title,
|
||||
note: [c.data.schedule, c.data.blurb].filter(Boolean).join(" · ") || undefined,
|
||||
};
|
||||
});
|
||||
if (deckOfMoment) {
|
||||
nowItems.push({
|
||||
label: ["Deck of the moment", deckGroup(deckOfMoment)].filter(Boolean).join(" · "),
|
||||
title: deckOfMoment.title,
|
||||
note: deckStatus?.data.note ?? deckOfMoment.data.take,
|
||||
href: `#${deckOfMoment.slug}`,
|
||||
});
|
||||
}
|
||||
|
||||
const archidekt = s.settings()?.socials.find((x) => /archidekt/i.test(x.label));
|
||||
const shelfCount = s.all("board-game").length;
|
||||
const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
|
||||
---
|
||||
<PageShell title="Tabletop" family="tabletop" current="tabletop" description={text?.data.lede ?? "Campaigns, systems, decks and card games."}>
|
||||
<Fragment slot="hero">
|
||||
<PageHero eyebrow="My world" title={text?.title ?? "Tabletop"} lede={text?.data.lede} />
|
||||
</Fragment>
|
||||
|
||||
{nowItems.length > 0 && <NowStrip id="tt-now" heading="At the table now" items={nowItems} seal="astrolabe" />}
|
||||
|
||||
{intro && <div class="gk-prose intro" set:html={intro} />}
|
||||
|
||||
{
|
||||
systems.length > 0 && (
|
||||
<WorldSection id="tt-sys" title="Systems" intro="What I run, what I have played, and what I want to try.">
|
||||
{states.length > 1 && (
|
||||
<FilterGroup
|
||||
label="Filter systems by state"
|
||||
class="tt-filter"
|
||||
items={[{ label: "All", value: "all", active: true }, ...states.map((st) => ({ label: sentence(st), value: st }))]}
|
||||
/>
|
||||
)}
|
||||
<ul class="cards systems" data-filter-target>
|
||||
{systems.map((x) => {
|
||||
const runs = campaigns.filter((c) => s.byName(c.data.system)?.path === x.path);
|
||||
return (
|
||||
<li id={x.slug} class="entry" data-value={x.data.state}>
|
||||
<div class="e-top">
|
||||
<h3 class="e-name">{x.title}</h3>
|
||||
<StatusPill kind={systemPill(x.data.state)}>{sentence(x.data.state)}</StatusPill>
|
||||
</div>
|
||||
<p class="e-blurb">{x.data.blurb}</p>
|
||||
{runs.length > 0 && (
|
||||
<p class="e-foot">
|
||||
{runs.map((c, i) => (
|
||||
<>
|
||||
{i > 0 && " · "}
|
||||
<a href={`#${c.slug}`}>{c.title}</a>
|
||||
</>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{pastCampaigns.length > 0 && (
|
||||
<div class="past">
|
||||
<h3 class="past-h">Earlier campaigns</h3>
|
||||
<ul class="past-list">
|
||||
{pastCampaigns.map((c) => (
|
||||
<li id={c.slug}>
|
||||
<Star size={9} />
|
||||
<span>{c.title}</span>
|
||||
<StatusPill kind="neutral">{sentence(c.data.status)}</StatusPill>
|
||||
{c.data.blurb && <span class="past-note">{c.data.blurb}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
decks.length > 0 && (
|
||||
<WorldSection id="tt-decks" title={allDecks.some(isFav) ? "Favorite decks" : "Decks"} intro="Magic decks pull their details from Archidekt; other games link to wherever their lists live.">
|
||||
{archidekt && (
|
||||
<a slot="action" href={archidekt.url} class="gk-text all-decks" rel="me noopener">
|
||||
All my Magic decks on Archidekt
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{deckGroups.length > 1 && (
|
||||
<FilterGroup
|
||||
label="Filter decks by game"
|
||||
class="tt-filter"
|
||||
items={[{ label: "All games", value: "all", active: true }, ...deckGroups.map((g) => ({ label: g, value: g }))]}
|
||||
/>
|
||||
)}
|
||||
<ul class="cards decks" data-filter-target>
|
||||
{decks.map((d) => {
|
||||
const url = deckUrl(d);
|
||||
const game = gameOf(d);
|
||||
return (
|
||||
<li id={d.slug} class="deck" data-value={deckGroup(d)}>
|
||||
<StarChartArt seal="astrolabe" ratio="auto" class="d-art" />
|
||||
<div class="d-txt">
|
||||
<span class="d-kick">{[shortGame(game.title), d.data.format].filter(Boolean).join(" · ")}</span>
|
||||
<div class="e-top">
|
||||
<h3 class="e-name d-name">
|
||||
{url ? (
|
||||
<a href={url} rel="noopener">
|
||||
{d.title}
|
||||
</a>
|
||||
) : (
|
||||
d.title
|
||||
)}
|
||||
{isFav(d) && <Star size={12} />}
|
||||
</h3>
|
||||
{deckOfMoment?.path === d.path && <StatusPill>Playing now</StatusPill>}
|
||||
</div>
|
||||
{d.data.take && <p class="e-blurb">{d.data.take}</p>}
|
||||
{url && (
|
||||
<a href={url} class="gk-text d-link" rel="noopener" aria-label={`${d.title} deck list`}>
|
||||
{d.data.archidektId ? "On Archidekt" : "Deck list"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
games.length > 0 && (
|
||||
<WorldSection id="tt-tcg" title="Card games" intro="Each game is one entry, so adding a new one is one note.">
|
||||
<ul class="cards games">
|
||||
{games.map((g) => {
|
||||
const n = s.all("deck").filter((d) => s.byName(d.data.game)?.path === g.path).length;
|
||||
return (
|
||||
<li id={g.slug} class="entry">
|
||||
<div class="e-top">
|
||||
<h3 class="e-name">{g.title}</h3>
|
||||
<StatusPill kind={stancePill(g.data.stance)}>{g.data.stance}</StatusPill>
|
||||
</div>
|
||||
{g.data.blurb && <p class="e-blurb">{g.data.blurb}</p>}
|
||||
{n > 0 && <p class="e-foot">{plural(n, "deck")} here</p>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</WorldSection>
|
||||
)
|
||||
}
|
||||
|
||||
<nav class="more" aria-label="More from the table">
|
||||
<a href="/library/shelf" class="gk-card more-card">
|
||||
<span class="m-icon" aria-hidden="true"><Icon name="chess" size={30} /></span>
|
||||
<span class="m-txt">
|
||||
<span class="m-name">The Shelf</span>
|
||||
<span class="m-note">{shelfCount > 0 ? `${plural(shelfCount, "board game")}, filtered by how they play` : "Board games, filtered by how they play"}</span>
|
||||
</span>
|
||||
</a>
|
||||
<a href="/workshop" class="gk-card more-card">
|
||||
{workshopImg ? <img src={workshopImg} alt="" class="m-icon" loading="lazy" /> : <span class="m-icon" aria-hidden="true"><Icon name="hammer" size={30} /></span>}
|
||||
<span class="m-txt">
|
||||
<span class="m-name">Workshop</span>
|
||||
<span class="m-note">The dice, proxies and terrain I make for the table</span>
|
||||
</span>
|
||||
</a>
|
||||
<a href={`/blog/topic/${slugify("Tabletop")}`} class="gk-card more-card">
|
||||
<span class="m-icon" aria-hidden="true"><Icon name="list-text" size={30} /></span>
|
||||
<span class="m-txt">
|
||||
<span class="m-name">Tabletop posts</span>
|
||||
<span class="m-note">Session recaps, new games and deck techs</span>
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
</PageShell>
|
||||
|
||||
<script>
|
||||
// Filter pills: show the entries whose data-value matches the pressed pill.
|
||||
document.querySelectorAll<HTMLElement>(".tt-filter").forEach((group) => {
|
||||
const list = group.parentElement?.querySelector<HTMLElement>("[data-filter-target]");
|
||||
if (!list) return;
|
||||
group.classList.add("ready");
|
||||
group.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("button[data-value]");
|
||||
if (!btn) return;
|
||||
const value = btn.dataset.value;
|
||||
group.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
|
||||
list.querySelectorAll<HTMLElement>(":scope > li").forEach((li) => {
|
||||
li.hidden = value !== "all" && li.dataset.value !== value;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
max-width: 70ch;
|
||||
padding-top: 28px;
|
||||
color: var(--gk-soft);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.intro :global(p) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
/* The filters only work with the script, which reveals them. */
|
||||
:global(.tt-filter) {
|
||||
display: none;
|
||||
}
|
||||
:global(.tt-filter.ready) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.cards > li[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.entry {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
.games {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
|
||||
}
|
||||
.entry:target,
|
||||
.deck:target {
|
||||
border-color: var(--gk-gold);
|
||||
}
|
||||
.e-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.e-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.e-blurb {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--gk-soft);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.e-foot {
|
||||
margin: auto 0 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.e-foot a {
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
|
||||
.past {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.past-h {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.past-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.past-list li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.past-note {
|
||||
color: var(--gk-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.all-decks {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.decks {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
|
||||
}
|
||||
.deck {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid var(--gk-lines);
|
||||
border-radius: var(--gk-radius);
|
||||
overflow: hidden;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
.deck :global(.d-art) {
|
||||
height: 150px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.d-txt {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.d-kick {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.d-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.d-name a {
|
||||
color: var(--gk-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.d-name a:hover {
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.d-link {
|
||||
align-self: flex-start;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding-top: 48px;
|
||||
}
|
||||
.more-card {
|
||||
flex: 1 1 300px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
.m-icon {
|
||||
flex: none;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: var(--gk-sky);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--gk-teal-bright);
|
||||
}
|
||||
.m-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.m-name {
|
||||
font-family: var(--gk-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
.m-note {
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user