diff --git a/docs/CONTENT.md b/docs/CONTENT.md index 88739af..e9c3887 100644 --- a/docs/CONTENT.md +++ b/docs/CONTENT.md @@ -234,6 +234,39 @@ It's idempotent — run it again any time you add games to the JSON. --- +## TCGs (Trading Card Games page) + +The TCGs page (`src/pages/interests/tcgs.astro`) is hand-built, like the other +interest pages. Two parts are data-driven: + +- **The games I play** and **Proxy making** are inline frontmatter arrays of + `{ icon, title, body }` — edit them directly on the page. +- **Magic: The Gathering decks** are pulled **live from Archidekt** on the + server (the site is SSR, so no CORS or API key is needed). + +### Archidekt integration + +`src/lib/archidekt.ts` fetches Greg's public decks from: + +``` +GET https://archidekt.com/api/decks/v3/?ownerUsername= +``` + +- The username is the `ARCHIDEKT_USERNAME` export (currently `Patiun`). Change + it there to point at a different account. +- Results are cached to `node_modules/.cache/archidekt-.json` for + `CACHE_TTL_MS` (default **1 hour**) so repeat visits are instant and we're + gentle on Archidekt. Tune the TTL via that export. +- Only public, non-unlisted decks are shown. If Archidekt is unreachable, the + section falls back to a "Manage on Archidekt" link — it never breaks the page. + +Each deck renders as a `DeckCard` (featured card art, mana-color pips, card +count, format, last-updated) linking to its Archidekt page. To add or remove +decks, just change them on Archidekt — the page picks them up automatically +(within the cache window). + +--- + ## Interest pages Each interest is a hand-built page in `src/pages/interests/`. They follow the diff --git a/public/images/cards.jpg b/public/images/cards.jpg new file mode 100644 index 0000000..e9004c3 Binary files /dev/null and b/public/images/cards.jpg differ diff --git a/public/images/ttrpg-table - Copy.jpg b/public/images/ttrpg-table - Copy.jpg new file mode 100644 index 0000000..fc5d77d Binary files /dev/null and b/public/images/ttrpg-table - Copy.jpg differ diff --git a/public/images/ttrpg-table.jpg b/public/images/ttrpg-table.jpg index fc5d77d..6690174 100644 Binary files a/public/images/ttrpg-table.jpg and b/public/images/ttrpg-table.jpg differ diff --git a/src/components/cards/DeckCard.astro b/src/components/cards/DeckCard.astro new file mode 100644 index 0000000..0d87250 --- /dev/null +++ b/src/components/cards/DeckCard.astro @@ -0,0 +1,115 @@ +--- +/** + * DeckCard.astro + * --------------------------------------------------------------------------- + * Card for a single Magic: The Gathering deck pulled live from Archidekt. Shows + * the deck's featured card art (or a placeholder slab), the deck name as a link + * to its Archidekt page, a row of mana-color pips sized by color presence, and + * a meta line (card count, format, last-updated). + * + * Data shape matches ArchidektDeck in src/lib/archidekt.ts. + */ +import Icon from '@components/Icon.astro'; + +interface Props { + name: string; + href: string; + featured?: string; + colors?: { W: number; U: number; B: number; R: number; G: number }; + size?: number; + formatLabel?: string; + updatedAt?: string; +} + +const { name, href, featured, colors, size, formatLabel, updatedAt } = Astro.props; + +// MTG mana colors. Used for the presence pips; a color shows only if count > 0. +const MANA: Array<[key: 'W' | 'U' | 'B' | 'R' | 'G', hex: string]> = [ + ['W', '#f8f4e8'], + ['U', '#3b7bd4'], + ['B', '#2b2b2b'], + ['R', '#d44b3b'], + ['G', '#3f9d52'], +]; +const present = colors ? MANA.filter(([k]) => (colors[k] ?? 0) > 0) : []; + +// Friendly relative-ish date (e.g. "Jul 2026"). Falls back gracefully. +const updated = updatedAt ? new Date(updatedAt).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', +}) : ''; +--- + + +
+ { + featured ? ( + {`${name} + ) : ( + + ) + } + {/* Legibility gradient at the bottom for the meta row. */} + + + { + /* Mana color pips, top-left over the art. */ + present.length > 0 && ( +
+ {present.map(([key, hex]) => ( +
+ ) + } +
+ +
+

+ {name} +

+
+ {formatLabel && {formatLabel}} + {formatLabel && (size || updated) && } + {size ? {size} cards : null} + {updated && (size ? : null)} + {updated && {updated}} +
+
+
diff --git a/src/lib/archidekt.ts b/src/lib/archidekt.ts new file mode 100644 index 0000000..10d5a8c --- /dev/null +++ b/src/lib/archidekt.ts @@ -0,0 +1,179 @@ +/** + * archidekt.ts + * --------------------------------------------------------------------------- + * Server-side helper for pulling Greg's public MTG decks from Archidekt into + * the TCGs page. The site is SSR (output: 'server'), so this runs on the + * server — no CORS or API key required (Archidekt's decks list endpoint is + * public JSON). + * + * Endpoint: GET https://archidekt.com/api/decks/v3/?ownerUsername= + * -> { count, results: [{ id, name, featured, colors, size, deckFormat, + * updatedAt, private, unlisted, theorycrafted, ... }] } + * + * Decks are cached to a filesystem file for `CACHE_TTL_MS` (default 1 hour) so + * repeat page loads are instant and we're gentle on Archidekt. The cache is + * stale-while-revalidate: if a refresh fails, we serve the stale data rather + * than erroring. Every failure path returns an empty list — the page renders a + * fallback link and never breaks. + */ + +/** Greg's Archidekt username. Change here to point at a different account. */ +export const ARCHIDEKT_USERNAME = 'Patiun'; + +/** How long the cached deck list is considered fresh. Default: 1 hour. */ +export const CACHE_TTL_MS = 60 * 60 * 1000; + +const API_BASE = 'https://archidekt.com/api/decks/v3'; +const USER_AGENT = 'GregKilmerProfileSite/1.0 (+https://gregkilmer.com)'; + +/** Known Archidekt deckFormat ids → labels. Add as needed. */ +const FORMAT_LABELS: Record = { + 1: 'Casual', + 2: 'Modern', + 3: 'Commander', + 5: 'Standard', + 7: 'Legacy', + 10: 'Pauper', + 14: 'Historic', + 16: 'Pioneer', + 37: 'Oathbreaker', + 45: 'Brawl', +}; + +export interface ArchidektDeck { + id: number; + name: string; + /** Featured card art URL (may be empty). */ + featured: string; + /** Mana color counts present in the deck. */ + colors: { W: number; U: number; B: number; R: number; G: number }; + /** Total card count. */ + size: number; + /** Archidekt format id. */ + formatId: number; + /** Human-readable format label (e.g. "Commander"). */ + formatLabel: string; + /** ISO timestamp of last update. */ + updatedAt: string; + /** Direct link to the deck on Archidekt. */ + href: string; + theorycrafted: boolean; +} + +export const formatLabel = (id: number): string => FORMAT_LABELS[id] ?? 'Custom'; + +interface RawDeck { + id: number; + name: string; + featured?: string; + customFeatured?: string; + colors?: { W?: number; U?: number; B?: number; R?: number; G?: number }; + size?: number; + deckFormat?: number; + updatedAt?: string; + private?: boolean; + unlisted?: boolean; + theorycrafted?: boolean; +} + +const fileURL = (username: string): string => + // node_modules/.cache is gitignored and survives across dev reloads. + new URL( + `../node_modules/.cache/archidekt-${username}.json`, + import.meta.url, + ).href; + +/** + * Reads the cached decks for `username` if still fresh, else null. + * Best-effort: any read/parse error returns null (treated as a miss). + */ +async function readCache(username: string): Promise { + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(fileURL(username), 'utf8'); + const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] }; + if (!data?.fetchedAt || !Array.isArray(data.decks)) return null; + if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null; + return data.decks; + } catch { + return null; + } +} + +/** Writes the cache. Failures are non-fatal (best-effort). */ +async function writeCache(username: string, decks: ArchidektDeck[]): Promise { + try { + const { mkdir, writeFile } = await import('node:fs/promises'); + const { dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const path = fileURLToPath(fileURL(username)); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify({ fetchedAt: Date.now(), decks }, null, 2)); + } catch { + /* cache write is best-effort */ + } +} + +/** Reads stale cache regardless of TTL (used as a fallback on fetch failure). */ +async function readStale(username: string): Promise { + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(fileURL(username), 'utf8'); + const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] }; + return Array.isArray(data?.decks) ? data.decks : null; + } catch { + return null; + } +} + +/** + * Fetches Greg's public Archidekt decks, using a ~1h filesystem cache. + * Never throws — on any failure returns an empty list (page shows a fallback). + */ +export async function getDecks(username = ARCHIDEKT_USERNAME): Promise { + // 1. Fresh cache hit. + const cached = await readCache(username); + if (cached) return cached; + + // 2. Live fetch. + const url = `${API_BASE}/?ownerUsername=${encodeURIComponent(username)}`; + try { + const res = await fetch(url, { + headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, + // Don't let a slow Archidekt hang the page render. + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) throw new Error(`Archidekt responded ${res.status}`); + const data = (await res.json()) as { results?: RawDeck[] }; + const decks: ArchidektDeck[] = (data.results ?? []) + .filter((d) => !d.private && !d.unlisted) + .map((d) => ({ + id: d.id, + name: d.name, + featured: d.customFeatured || d.featured || '', + colors: { + W: d.colors?.W ?? 0, + U: d.colors?.U ?? 0, + B: d.colors?.B ?? 0, + R: d.colors?.R ?? 0, + G: d.colors?.G ?? 0, + }, + size: d.size ?? 0, + formatId: d.deckFormat ?? 0, + formatLabel: formatLabel(d.deckFormat ?? 0), + updatedAt: d.updatedAt ?? '', + href: `https://archidekt.com/fabric/decks/${d.id}`, + theorycrafted: Boolean(d.theorycrafted), + })); + + await writeCache(username, decks); + return decks; + } catch { + // 3. Stale-while-revalidate fallback, else empty. + return (await readStale(username)) ?? []; + } +} + +/** The Archidekt search-URL for the full decks list (used in fallback links). */ +export const archidektSearchUrl = (username = ARCHIDEKT_USERNAME): string => + `https://archidekt.com/search/decks?ownerUsername=${encodeURIComponent(username)}`; diff --git a/src/pages/interests/index.astro b/src/pages/interests/index.astro index fc93c89..a8d1be1 100644 --- a/src/pages/interests/index.astro +++ b/src/pages/interests/index.astro @@ -54,6 +54,17 @@ const interests = [ "Resin dice in a pressure pot, fun and functional prints on the P1s, custom TCG proxies and decks. Passion and utility.", image: "/images/making-resin.jpg", alt: "Polyhedral dice on a dark bag", + }, + { + href: "/interests/tcgs", + icon: "cards", + title: "Trading Card Games", + tagline: + "Cardboard, deckbuilding, and the eternal question: one more pack?", + blurb: + "Magic: The Gathering Commander decks, Pokémon collecting, Riftbound, and the Gundam card game. Plus the proxies and props I make to play them.", + image: "/images/cards.jpg", + alt: "Trading cards and dice on a playmat", } /* { href: '/interests/pokemon', diff --git a/src/pages/interests/making.astro b/src/pages/interests/making.astro index 74b2bc0..789c349 100644 --- a/src/pages/interests/making.astro +++ b/src/pages/interests/making.astro @@ -56,6 +56,12 @@ const related = [ icon: "book-open-page-variant", blurb: "Displays and props for the stories I love.", }, + { + href: "/interests/tcgs", + title: "Trading Card Games", + icon: "cards", + blurb: "Proxies, tokens, and props are the TCG side of the workshop.", + }, ]; --- diff --git a/src/pages/interests/tcgs.astro b/src/pages/interests/tcgs.astro new file mode 100644 index 0000000..af6b3e5 --- /dev/null +++ b/src/pages/interests/tcgs.astro @@ -0,0 +1,231 @@ +--- +/** + * tcgs.astro — Trading Card Games + * --------------------------------------------------------------------------- + * Per-interest template. Sections: overview narrative, the games I play + * (MTG, Pokémon, Riftbound, Gundam), Magic: The Gathering decks pulled live + * from Archidekt, and proxy making. Related links to Tabletop and Making. + */ +import BaseLayout from "@layouts/BaseLayout.astro"; +import Reveal from "@components/motion/Reveal.astro"; +import InterestHero from "@components/layout/InterestHero.astro"; +import InterestCard from "@components/cards/InterestCard.astro"; +import DeckCard from "@components/cards/DeckCard.astro"; +import SectionHeader from "@components/layout/SectionHeader.astro"; +import RelatedInterests from "@components/layout/RelatedInterests.astro"; +import Placeholder from "@components/layout/Placeholder.astro"; +import Icon from "@components/Icon.astro"; +import { + getDecks, + archidektSearchUrl, + ARCHIDEKT_USERNAME, +} from "@lib/archidekt"; + +const games = [ + { + icon: "cards-playing", + title: "Magic: The Gathering", + body: "The main event. Mostly Commander (EDH), with a deckbuilding obsession that Archidekt enables far too well.", + }, + { + icon: "pokeball", + title: "Pokémon TCG", + body: "Collector energy and casual play. Beautiful art pulls, deck drafts, and a soft spot for Steel/Psychic. Mostly on Pocket TCG.", + }, + { + icon: "sword-cross", + title: "Riftbound", + body: "Riot's League of Legends TCG. A new game with a lot of potential. Interested in exploring deckbuilding and the evolving meta.", + }, + { + icon: "robot", + title: "Gundam Card Game", + body: "Bandai's Gundam TCG. A new game with a lot of potential. Interested in how some of the figures will come into play with the other table top pieces for this entry.", + }, +]; + +const proxy = [ + { + icon: "content-cut", + title: "Proxy TCG cards", + body: "Designing and cutting proxy cards on the Silhouette Cameo 5a for casual play", + }, + { + icon: "scissors-cutting", + title: "Custom stickers & decals", + body: "Vinyl cut for sleeves, playmats, and storage — life counters, clan badges, and the occasional silly sticker.", + }, + { + icon: "diamond-stone", + title: "Tokens & counters", + body: "Resin-cast tokens, +1/+1 counters, and commander damage trackers cast in the pressure pot.", + }, + { + icon: "printer-3d-nozzle", + title: "3D-printed props", + body: "Life counters, dice towers, deck boxes, and display stands off.", + }, +]; + +const related = [ + { + href: "/interests/ttrpgs", + title: "TTRPGs & Tabletop", + icon: "dice-multiple", + blurb: "Cards and dice share the same shelf and the same table.", + }, + { + href: "/interests/making", + title: "Making & Creating", + icon: "palette", + blurb: "Proxies, tokens, and props are the TCG side of the workshop.", + }, +]; + +// Live Archidekt decks (server-side, cached ~1h, never throws). +const decks = await getDecks(); +const decksHref = archidektSearchUrl(); +--- + + + + +
+ {/* Overview */} +
+

+ Trading card games are where my collecting, deckbuilding, and making + instincts all collide. I primarily play Magic: The Gathering, mostly + Commander (Elder Dragon Highlander), where a hundred cards becomes a + personal expression and the brewing never really ends. But I keep a + hand in the Pokémon TCG, and I'm watching Riftbound and the new Gundam + card game closely. If it has nice cardboard, pretty art, and a + decision tree, I'm interested. +

+

+ The hobby overlaps the rest of the fun wing: the same workshop that + produces resin dice and 3D prints also turns out proxies, tokens, and + deckboxes and more. And the deckbuilding itch is a close relation of + the RPG character-builder in me. Optimizing a list tells a story about + how I want to play and the theme I build the deck around. +

+
+ + {/* The games I play */} +
+ +
+ { + games.map((g, i) => ( + + + {g.body} + + + )) + } +
+
+ + {/* Magic: The Gathering decks (live from Archidekt) */} +
+ + + + +

+ Pulled live from my Archidekt account ({ARCHIDEKT_USERNAME}). Newest + decks first; click a card to open it. +

+
+ { + decks.length > 0 ? ( +
+ {decks + .slice() + .sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt)) + .map((d, i) => ( + + + + ))} +
+ ) : ( + + + + ) + } +
+ + {/* Proxy making */} +
+ +
+ { + proxy.map((p, i) => ( + + + {p.body} + + + )) + } +
+
+ + +
+
diff --git a/src/pages/interests/ttrpgs.astro b/src/pages/interests/ttrpgs.astro index 83a245f..39da579 100644 --- a/src/pages/interests/ttrpgs.astro +++ b/src/pages/interests/ttrpgs.astro @@ -67,6 +67,12 @@ const related = [ icon: "gamepad-variant", blurb: "Digital RPGs as another expression of the same impulse.", }, + { + href: "/interests/tcgs", + title: "Trading Card Games", + icon: "cards", + blurb: "Cards and dice share the same shelf — and the same table.", + }, ]; ---