diff --git a/docs/CONTENT.md b/docs/CONTENT.md index 76bd321..e9c3887 100644 --- a/docs/CONTENT.md +++ b/docs/CONTENT.md @@ -177,6 +177,96 @@ PlayStation, Xbox, and Nintendo in one place. --- +## Board games (TTRPGs page) + +The "Board games" section on the TTRPGs page reads from a JSON file and renders +an interactive shelf: a list of games on the left, and a tall image on the +right that cross-fades to a game's BGG cover when you hover its name. Clicking a +name opens the game on BoardGameGeek. + +### The list — `src/data/boardgames.json` + +A flat JSON array. Each entry needs a `name` and a `bggId`; `note` and `image` +are optional: + +```json +[ + { "name": "Wingspan", "bggId": 266192, "note": "Engine builder" }, + { "name": "Brass: Birmingham", "bggId": 224517 } +] +``` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `name` | string | yes | Display label + link text | +| `bggId` | number | yes | BoardGameGeek numeric id (from its URL) | +| `note` | string | no | One-line caption shown under the name | +| `image` | string | no | Cover path/URL; populated by the fetch tool | + +Find a game's `bggId` in its BoardGameGeek URL: +`https://boardgamegeek.com/boardgame//`. + +The shelf photo at rest is `/images/ttrpg-boardgame-shelf.jpg` — drop a real +photo of your shelf at that path. The image is a **progressive enhancement**: +without JS the list still works (links + shelf photo); games without an `image` +just keep the shelf photo on hover. + +### Fetching covers (BoardGameGeek) + +`scripts/fetch-bgg-covers.mjs` fills in the `image` field for any game missing +one, using BGG's public XML2 API (no key needed): + +```bash +# Default: download covers to public/images/boardgames/.jpg (recommended) +node scripts/fetch-bgg-covers.mjs + +# Instead hotlink BGG's CDN URLs (no download, depends on their CDN) +node scripts/fetch-bgg-covers.mjs --remote + +# Re-fetch even games that already have an image +node scripts/fetch-bgg-covers.mjs --force + +# Slow down between BGG calls (they rate-limit; default 1200ms) +node scripts/fetch-bgg-covers.mjs --sleep 2000 +``` + +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/boardgames/.gitkeep b/public/images/boardgames/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/images/boardgames/wyrmspan.jpg b/public/images/boardgames/wyrmspan.jpg new file mode 100644 index 0000000..406fef1 Binary files /dev/null and b/public/images/boardgames/wyrmspan.jpg differ 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/deckboxes.jpg b/public/images/deckboxes.jpg new file mode 100644 index 0000000..02dacea Binary files /dev/null and b/public/images/deckboxes.jpg differ diff --git a/public/images/dice_galaxy.jpg b/public/images/dice_galaxy.jpg new file mode 100644 index 0000000..8d0a4fa Binary files /dev/null and b/public/images/dice_galaxy.jpg differ diff --git a/public/images/dice_golden_moss.jpg b/public/images/dice_golden_moss.jpg new file mode 100644 index 0000000..2ee58c0 Binary files /dev/null and b/public/images/dice_golden_moss.jpg differ diff --git a/public/images/dice_scarlet.jpg b/public/images/dice_scarlet.jpg new file mode 100644 index 0000000..dbedf26 Binary files /dev/null and b/public/images/dice_scarlet.jpg differ diff --git a/public/images/dice_seastone.jpg b/public/images/dice_seastone.jpg new file mode 100644 index 0000000..bb15902 Binary files /dev/null and b/public/images/dice_seastone.jpg differ diff --git a/public/images/minis.jpg b/public/images/minis.jpg new file mode 100644 index 0000000..dfe13fa Binary files /dev/null and b/public/images/minis.jpg differ diff --git a/public/images/proxies.jpg b/public/images/proxies.jpg new file mode 100644 index 0000000..5415bc8 Binary files /dev/null and b/public/images/proxies.jpg differ diff --git a/public/images/proxies2.jpg b/public/images/proxies2.jpg new file mode 100644 index 0000000..82a937e Binary files /dev/null and b/public/images/proxies2.jpg differ diff --git a/public/images/resin_rings.jpg b/public/images/resin_rings.jpg new file mode 100644 index 0000000..0360b40 Binary files /dev/null and b/public/images/resin_rings.jpg differ diff --git a/public/images/ttrpg-boardgame-shelf.jpg b/public/images/ttrpg-boardgame-shelf.jpg new file mode 100644 index 0000000..6d33332 Binary files /dev/null and b/public/images/ttrpg-boardgame-shelf.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/public/images/xmasgifts.jpg b/public/images/xmasgifts.jpg new file mode 100644 index 0000000..b562eb1 Binary files /dev/null and b/public/images/xmasgifts.jpg differ diff --git a/scripts/fetch-bgg-covers.mjs b/scripts/fetch-bgg-covers.mjs new file mode 100644 index 0000000..826f771 --- /dev/null +++ b/scripts/fetch-bgg-covers.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * fetch-bgg-covers.mjs + * --------------------------------------------------------------------------- + * Support tool for the Board games section on the TTRPGs page. For each game + * in src/data/boardgames.json that is missing an `image`, it looks the game up + * on the BoardGameGeek XML2 API (public, no key — just a User-Agent), and + * either: + * + * (default) downloads the cover to public/images/boardgames/.jpg and + * writes the local public path back into the JSON, OR + * --remote writes BGG's remote cover URL straight into the JSON (hotlinked; + * no download, but depends on BGG's CDN staying up). + * + * Idempotent: games that already have an `image` are skipped. Be gentle on BGG + * — they rate-limit, so there's a small delay between requests. + * + * USAGE + * node scripts/fetch-bgg-covers.mjs # download covers locally + * node scripts/fetch-bgg-covers.mjs --remote # hotlink BGG URLs instead + * node scripts/fetch-bgg-covers.mjs --force # re-fetch even if image set + * node scripts/fetch-bgg-covers.mjs --sleep 1500 # ms between BGG calls + * node scripts/fetch-bgg-covers.mjs --help + * + * Edit the list itself in src/data/boardgames.json: + * { "name": "Wingspan", "bggId": 266192 } + * Find a game's bggId in its BoardGameGeek URL: + * https://boardgamegeek.com/boardgame// + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(__dirname, '..'); +const DATA_FILE = join(PROJECT_ROOT, 'src', 'data', 'boardgames.json'); +const OUT_DIR = join(PROJECT_ROOT, 'public', 'images', 'boardgames'); +const BGG_API = 'https://boardgamegeek.com/xmlapi2'; + +// --- args ------------------------------------------------------------------- +const args = process.argv.slice(2); +if (args.includes('-h') || args.includes('--help')) { + console.log(`Usage: node scripts/fetch-bgg-covers.mjs [options] + +Reads src/data/boardgames.json and fills in the "image" field for any game +missing one, using BoardGameGeek's public XML2 API (no key required). + +Options: + --remote write BGG's remote cover URL into the JSON instead of downloading + --force re-fetch even for games that already have an image + --sleep delay between BGG API calls (default 1200; BGG rate-limits) + -h, --help show this help + +Examples: + node scripts/fetch-bgg-covers.mjs + node scripts/fetch-bgg-covers.mjs --remote --sleep 2000`); + process.exit(0); +} + +const remoteMode = args.includes('--remote'); +const force = args.includes('--force'); +const sleepIdx = args.indexOf('--sleep'); +const sleepMs = sleepIdx !== -1 ? Number(args[sleepIdx + 1]) || 1200 : 1200; + +// --- helpers ---------------------------------------------------------------- +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const fail = (msg, code = 1) => { + console.error(`✖ ${msg}`); + process.exit(code); +}; + +/** Extract text of the first element from an XML string. */ +const xmlText = (xml, tagName) => { + const re = new RegExp(`<${tagName}[^>]*>([^<]*)`, 'i'); + const m = xml.match(re); + return m ? m[1].trim() : null; +}; + +// --- load data -------------------------------------------------------------- +let games; +try { + const raw = await readFile(DATA_FILE, 'utf8'); + games = JSON.parse(raw); +} catch (err) { + if (err?.code === 'ENOENT') fail(`Could not read ${DATA_FILE}. Does it exist?`); + fail(`Could not parse ${DATA_FILE}: ${err.message}`); +} +if (!Array.isArray(games)) fail(`${DATA_FILE} is not a JSON array.`); + +console.log( + `→ ${games.length} games in ${DATA_FILE.split(/[\\/]/).pop()}` + + (remoteMode ? ' (remote mode: hotlink BGG URLs)' : ' (local mode: download covers)'), +); + +// --- process each game ------------------------------------------------------ +let changed = false; +const UA = 'GregKilmerProfileSite/1.0 (board-game-cover fetcher)'; +await mkdir(OUT_DIR, { recursive: true }); + +for (let i = 0; i < games.length; i++) { + const g = games[i]; + if (typeof g.bggId !== 'number') { + console.warn(` ! "${g.name}" has no bggId; skipping.`); + continue; + } + if (g.image && !force) { + console.log(` ✓ ${g.name} — already has image, skipping.`); + continue; + } + + // BGG rate-limits; be polite between calls. + if (i > 0) await sleep(sleepMs); + + const url = `${BGG_API}/thing?id=${g.bggId}&type=boardgame`; + console.log(`→ [${i + 1}/${games.length}] ${g.name} (${url})`); + let res; + try { + res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/xml' } }); + } catch (err) { + console.warn(` ! network error: ${err?.message ?? err}; skipping.`); + continue; + } + if (res.status === 429) { + console.warn(' ! rate-limited by BGG (429). Wait a bit and re-run; skipping rest.'); + break; + } + if (!res.ok) { + console.warn(` ! BGG returned ${res.status} ${res.statusText}; skipping.`); + continue; + } + const xml = await res.text(); + const remoteImage = xmlText(xml, 'image'); // BGG's primary box-art URL + if (!remoteImage) { + console.warn(` ! no in BGG response for bggId ${g.bggId}; skipping.`); + continue; + } + + if (remoteMode) { + g.image = remoteImage; + changed = true; + console.log(` ✔ ${g.name} → ${remoteImage}`); + } else { + const ext = (remoteImage.match(/\.(jpe?g|png|webp)$/i)?.[1] ?? 'jpg').toLowerCase(); + const fileName = `${g.bggId}.${ext === 'jpeg' ? 'jpg' : ext}`; + let buf; + try { + const imgRes = await fetch(remoteImage, { headers: { 'User-Agent': UA } }); + if (!imgRes.ok) throw new Error(`${imgRes.status} ${imgRes.statusText}`); + buf = Buffer.from(await imgRes.arrayBuffer()); + } catch (err) { + console.warn(` ! image download failed (${err?.message ?? err}); skipping.`); + continue; + } + await writeFile(join(OUT_DIR, fileName), buf); + g.image = `/images/boardgames/${fileName}`; + changed = true; + console.log(` ✔ ${g.name} → ${g.image}`); + } +} + +// --- write back ------------------------------------------------------------- +if (changed) { + await writeFile(DATA_FILE, JSON.stringify(games, null, 2) + '\n', 'utf8'); + console.log(`\n✔ Updated ${DATA_FILE}`); +} else { + console.log('\n— Nothing to update (all games already had an image).'); +} 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/components/ttrpgs/BoardGameShelf.tsx b/src/components/ttrpgs/BoardGameShelf.tsx new file mode 100644 index 0000000..8139902 --- /dev/null +++ b/src/components/ttrpgs/BoardGameShelf.tsx @@ -0,0 +1,188 @@ +/** + * BoardGameShelf.tsx + * --------------------------------------------------------------------------- + * Two-column "Board games" island for the TTRPGs page. + * + * Left: a list of game names, each a real to its BoardGameGeek page. + * Hovering (or keyboard-focusing) a name selects it. + * Right: a tall image frame. At rest it shows Greg's board-game shelf photo; + * when a name is selected, that game's BGG cover cross-fades in on + * top of the shelf, with the game's name + note as a caption. + * + * Progressive enhancement: without JS the list is still fully usable — the + * links work and the shelf photo stays put. The cross-fade is the enhancement. + * framer-motion honors prefers-reduced-motion automatically. + * + * Data shape (src/data/boardgames.json): + * { name: string; bggId: number; note?: string; image?: string } + * `image` is optional — populated by scripts/fetch-bgg-covers.mjs. Rows without + * an `image` simply keep the shelf photo on hover (graceful fallback). + */ +import { useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; + +export interface BoardGame { + name: string; + /** BoardGameGeek numeric game id; drives the link + cover lookup. */ + bggId: number; + /** Optional one-line note shown as the caption. */ + note?: string; + /** Optional cover image URL/path. If absent, hovering won't swap images. */ + image?: string; +} + +interface Props { + games: BoardGame[]; + /** Public path to the default shelf photo, e.g. "/images/ttrpg-boardgame-shelf.jpg". */ + shelfImage: string; + /** Alt text for the shelf photo. */ + shelfAlt: string; + /** Optional caption shown at rest over the shelf photo. */ + shelfCaption?: string; +} + +/** + * Fixed height for BOTH columns. The list shows up to 8 rows in a scroll + * container; the image frame is pinned to this same height so the two + * columns stay aligned regardless of how many games are in the list. + * ~3.25rem per row x 8 rows. + */ +const SHELF_HEIGHT = '44rem'; + +const crossfade = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0.35, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] }, +}; + +export default function BoardGameShelf({ + games, + shelfImage, + shelfAlt, + shelfCaption = 'The shelf', +}: Props) { + // Index of the active game, or null when at rest (showing the shelf). + const [active, setActive] = useState(null); + const activeGame = active !== null ? games[active] : null; + // Only swap to the cover if the active game actually has an image. + const showCover = activeGame?.image; + + return ( +
+ {/* LEFT: game list — capped at the shelf height with internal scroll */} + + + {/* RIGHT: tall image frame, shelf at rest, cover on hover. Pinned to the + same height as the list so the two columns line up. */} +
setActive(null)} + > + {/* Base layer: shelf photo (always present; covers cross-fade on top). */} + {shelfAlt} + {/* Gradient + caption overlay for legibility. */} +
+
+ ); +} diff --git a/src/data/boardgames.json b/src/data/boardgames.json new file mode 100644 index 0000000..13570a4 --- /dev/null +++ b/src/data/boardgames.json @@ -0,0 +1,302 @@ +[ + { + "name": "Wyrmspan", + "bggId": 410201, + "note": "Engine-building with dragons. A Wingspan reimplementation with slightly more complex mechanics.", + "image": "https://cf.geekdo-images.com/original/pic7947338.png" + }, + { + "name": "Spirit Island", + "bggId": 162886, + "note": "Cooperative area control. Play as spirits defending an island from invaders. Highly complex and strategic.", + "image": "https://cf.geekdo-images.com/original/pic7013651.jpg" + }, + { + "name": "Codenames", + "bggId": 178900, + "note": "Party word game. Teams compete to find their agents using one-word clues.", + "image": "https://cf.geekdo-images.com/original/pic2856478.jpg" + }, + { + "name": "Codenames: Duet", + "bggId": 224037, + "note": "Two-player cooperative version of Codenames. Work together to find all the agents.", + "image": "https://cf.geekdo-images.com/original/pic3550436.jpg" + }, + { + "name": "Coup", + "bggId": 131357, + "note": "Deduction and bluffing game. Each player has two roles; claim actions and challenge others.", + "image": "https://cf.geekdo-images.com/original/pic1892875.jpg" + }, + { + "name": "The Resistance", + "bggId": 41114, + "note": "Social deduction hidden roles game. Resistance members vs spies trying to sabotage missions.", + "image": "https://cf.geekdo-images.com/cAPTkL2BR3snLO71dkH8rw__imagepage/img/IOY562LZXFjsmcVioY5c_r70Ebc=/fit-in/900x600/filters:no_upscale():strip_icc()/pic2576459.jpg" + }, + { + "name": "Ticket to Ride", + "bggId": 9209, + "note": "Route-building set collection. Collect train cards to claim railway routes across the country.", + "image": "https://cf.geekdo-images.com/original/pic1000656.jpg" + }, + { + "name": "7 Wonders", + "bggId": 68448, + "note": "Card-drafting civilization building. Build one of the seven wonders through three ages.", + "image": "https://cf.geekdo-images.com/original/pic869179.jpg" + }, + { + "name": "Pandemic", + "bggId": 30549, + "note": "Cooperative strategy. Work together to stop global disease outbreaks before they spread too far.", + "image": "https://cf.geekdo-images.com/original/pic398633.jpg" + }, + { + "name": "King of Tokyo", + "bggId": 70323, + "note": "Dice-rolling monster combat. Be the monster who earns 20 points or eliminates the competition.", + "image": "https://cf.geekdo-images.com/original/pic1142193.jpg" + }, + { + "name": "Carcassonne", + "bggId": 822, + "note": "Tile-placement medieval city building. Place tiles to build the city and claim features for points.", + "image": "https://cf.geekdo-images.com/original/pic830344.jpg" + }, + { + "name": "Sagrada", + "bggId": 199561, + "note": "Dice-drafting stained glass window building. Draft colored dice to complete patterns on your window.", + "image": "https://cf.geekdo-images.com/original/pic3297563.jpg" + }, + { + "name": "Small World", + "bggId": 40692, + "note": "Area control fantasy civilization. Races rise and fall as you compete for territory across the map.", + "image": "https://cf.geekdo-images.com/original/pic699500.jpg" + }, + { + "name": "Dead of Winter", + "bggId": 150376, + "note": "Cooperative survival with betrayal. Survive the zombie apocalypse while completing secret objectives.", + "image": "https://cf.geekdo-images.com/g4mV4BH-ZrhMUVgil-yV1A__imagepage/img/CJ6erNGmKfFGhR6ZbxXbBI7XaoE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3016500.jpg" + }, + { + "name": "Betrayal at the House on the Hill", + "bggId": 10547, + "note": "Traitor horror exploration. Explore a haunted mansion until one betrays the others in a unique haunt.", + "image": "https://cf.geekdo-images.com/original/pic302310.jpg" + }, + { + "name": "Betrayal at Baldur's Gate", + "bggId": 228660, + "note": "Traitor D&D horror. Betrayal set in the Forgotten Realms with fantasy-themed haunts.", + "image": "https://cf.geekdo-images.com/O6TfqmDWCkJvZPZw6pcxpg__imagepage/img/Zif6XP4Ij1qxBH2vHB6V2jBXFHk=/fit-in/900x600/filters:no_upscale():strip_icc()/pic6552099.png" + }, + { + "name": "Lords of Waterdeep", + "bggId": 110327, + "note": "Worker placement D&D city management. Assign adventurers to quests to score points for your secret lord.", + "image": "https://cf.geekdo-images.com/original/pic1204394.jpg" + }, + { + "name": "Mysterium", + "bggId": 181304, + "note": "Cooperative murder mystery. One ghost communicates clues through abstract art; others guess the murderer.", + "image": "https://cf.geekdo-images.com/1nQ3ZKudtDeAP7IiKE-kNg__imagepage/img/scbgJc5EdSpHJGxGbV9dgpxj9hQ=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8625343.jpg" + }, + { + "name": "Hanabi", + "bggId": 98778, + "note": "Cooperative card game where you see everyone's cards but your own. Work together to build the perfect firework display.", + "image": "https://cf.geekdo-images.com/original/pic1598898.jpg" + }, + { + "name": "Forbidden Island", + "bggId": 65244, + "note": "Cooperative adventure survival. Work together to collect treasures from a sinking island before it's too late.", + "image": "https://cf.geekdo-images.com/original/pic711958.jpg" + }, + { + "name": "Sushi Go Deluxe", + "bggId": 192291, + "note": "Card-drafting sushi set collection. Pick sushi cards to build the best meal; deluxe version with more cards.", + "image": "https://cf.geekdo-images.com/2f9uTicUSXkdPp2Yks6zFw__imagepage/img/BeU9AH0pcKCbXi7ViuC87zS1WfI=/fit-in/900x600/filters:no_upscale():strip_icc()/pic5885689.jpg" + }, + { + "name": "Evolution (with climate)", + "bggId": 182134, + "note": "Species evolution strategy. Build species with traits to adapt to changing food sources and climate.", + "image": "https://cf.geekdo-images.com/original/pic2696435.jpg" + }, + { + "name": "Quacks of Quedlinburg (Deluxe)", + "bggId": 244521, + "note": "Push-your luck potion brewing. Draw ingredient chips from your bag without exploding your cauldron.", + "image": "https://cf.geekdo-images.com/original/pic3953887.jpg" + }, + { + "name": "Dragonfire", + "bggId": 226501, + "note": "D&D deck-building cooperative. Build your deck and defend the North from dragons and monsters.", + "image": "https://cf.geekdo-images.com/ETvzoSRh-UuJJbdSt3ZiCA__imagepage/img/u0qsox3zSpjQpaf_PlsgFUkaJvA=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4022104.jpg" + }, + { + "name": "Mistborn: The Deckbuilding Game", + "bggId": 422780, + "note": "Cosmere deck-building strategy. Burn metals in fast-paced allomantic battles in the Final Empire.", + "image": "https://cf.geekdo-images.com/original/pic8148911.jpg" + }, + { + "name": "Doomlings", + "bggId": 324413, + "note": "Card game set collection with extinction events. Build species traits through ages until doomsday arrives.", + "image": "https://cf.geekdo-images.com/v3c8Z12rTB_0lHndnnKZIw__imagepage/img/Lagw12YQdebM_YXbyCyAUPGyo1w=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8493673.jpg" + }, + { + "name": "Dragonology Gems", + "bggId": 26617, + "note": "Dragon-themed set collection. Collect gems to complete dragon research sets.", + "image": "https://cf.geekdo-images.com/6_Bkzj3aJL-A-tDU0TsJjw__imagepage/img/mfZSMBTnW_l-qrpxSrKzxi4e3kM=/fit-in/900x600/filters:no_upscale():strip_icc()/pic174922.jpg" + }, + { + "name": "Heroes of Barcadia", + "bggId": 341027, + "note": "Dungeon crawl party game. Progress through arcade-themed dungeon rooms and drink along the way.", + "image": "https://cf.geekdo-images.com/htK1yU55RTMFIpT2ivdN2w__imagepage/img/kQBLS2DHkRtJfR0MgrhsNdvGBlU=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8505535.jpg" + }, + { + "name": "Icarus", + "bggId": 374025, + "note": "Storytelling civilization collapse. Build a civilization through history, then try to prevent its fall.", + "image": "https://cdn11.bigcommerce.com/s-kftzvkkgjv/images/stencil/1280x1280/products/172/826/Icarus_3D_2000px_x_2000px__57865.1615050805.jpg?c=1" + }, + { + "name": "Epic Spellwars of the Battle Wizards: Mt. Skullzfire", + "bggId": 112686, + "note": "Over-the-top card combat wizard battle. Combine spell cards to attack other wizards in gory absurdity.", + "image": "https://cf.geekdo-images.com/Da8TXGBq3UjKINgTRIZY9A__imagepage/img/RHaGhriDSnGO1KwYugXqWJevDRE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic1170265.jpg" + }, + { + "name": "Lost Cities: Duel", + "bggId": 50, + "note": "Two-player expedition card game. Compete to complete expeditions worth more than your opponent's.", + "image": "https://cf.geekdo-images.com/AL5D-dXabY-Lk3PqIFk_0g__imagepage/img/co0J-4Ll2G4zRgAsA6BeneHb8Oc=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4597095.jpg" + }, + { + "name": "The Fox in the Forest", + "bggId": 221965, + "note": "Two-player trick-taking. Win tricks to score points, but too many wins can hurt your total.", + "image": "https://cf.geekdo-images.com/spcy6nvsfq-hxYgwEpmidQ__imagepage/img/Z1bf4RMtxWZwVmu_WWyjQ5CBE98=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3496085.jpg" + }, + { + "name": "One Night Werewolf", + "bggId": 147949, + "note": "Fast-paced social deduction. Single night of werewolf chaos; vote to eliminate the werewolf.", + "image": "https://cf.geekdo-images.com/rqLju4uaZq-G9z4g91aPPQ__imagepage/img/dPOMB-k_9xeqFMIhWT6uut3t4ug=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8783294.png" + }, + { + "name": "What Do You Meme?", + "bggId": 226610, + "note": "Party game matching caption cards to meme photos. Create the funniest meme combinations.", + "image": "https://cf.geekdo-images.com/5Pmk8PrpMHPMg0hG8fJz9w__imagepage/img/cCVLnA23lUzKCb3FvkdHVJhN0XU=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3749720.png" + }, + { + "name": "If You Know You Know", + "bggId": 404056, + "note": "Pop culture trivia party game. Guess phrases from partial information in categories.", + "image": "https://cf.geekdo-images.com/LW2HSiE2smy8rPQCispd6A__imagepage/img/OjRMfXUc8GVaGU47N69OO5uejII=/fit-in/900x600/filters:no_upscale():strip_icc()/pic7804814.jpg" + }, + { + "name": "Cards Against Humanity", + "bggId": 50381, + "note": "Offensive humor party game. Fill in the blank with the most inappropriate card from your hand.", + "image": "https://cf.geekdo-images.com/original/pic899330.jpg" + }, + { + "name": "Hues and Cues", + "bggId": 302520, + "note": "Color communication party game. Give one- or two-word cues for others to guess a specific color.", + "image": "hhttps://cf.geekdo-images.com/jdR8WW75HkaoHGMTCIx9lA__imagepage/img/n2OeBC_N0lqSHG_0_fbta1jgeWs=/fit-in/900x600/filters:no_upscale():strip_icc()/pic5390676.jpg" + }, + { + "name": "Wavelength", + "bggId": 262543, + "note": "Telepathy party game. Give clues to help your team locate a hidden concept on a spectrum.", + "image": "https://cf.geekdo-images.com/z4fbPdmJg_5yphJEvql4ZA__imagepage/img/V_3GqWteqDmL8C3ZLRoDXbJKKRo=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4552862.png" + }, + { + "name": "Lost in Translation", + "bggId": 271305, + "note": "Word communication party game. Describe words while avoiding forbidden translations and synonyms.", + "image": "https://cf.geekdo-images.com/xLJq_px7T4YRjsnFdXsFCQ__imagepage/img/h6FAV9n-FE5lcsCUijw5X_Mpr0o=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4824635.jpg" + }, + { + "name": "Risk Legacy", + "bggId": 105134, + "note": "Legacy strategy global conquest. Write on the board, open sealed packets, and evolve the game over plays.", + "image": "https://cf.geekdo-images.com/LxKu8_oihOQftb2npGvWMw__imagepage/img/hcbhN16q2weOA-OsaD-F7iJyNtk=/fit-in/900x600/filters:no_upscale():strip_icc()/pic1196938.jpg" + }, + { + "name": "Risk", + "bggId": 181, + "note": "Classic strategy global domination. Conquer territories and eliminate opponents in military world conquest.", + "image": "https://cf.geekdo-images.com/original/pic1059899.jpg" + }, + { + "name": "Age of Mythology: The Board Game", + "bggId": 6707, + "note": "Area control mythological warfare. Recruit armies, build structures, and invoke gods' favor.", + "image": "https://cf.geekdo-images.com/1WIWJ8FPafupWGPOJzWglA__imagepage/img/0H-mabGido-zuUL8PuM56GuBYto=/fit-in/900x600/filters:no_upscale():strip_icc()/pic31542.jpg" + }, + { + "name": "Tsuro", + "bggId": 16992, + "note": "Tile placement path following. Lay tiles to create paths; stay on the board while others fall off.", + "image": "https://cf.geekdo-images.com/2V3d4ryhxkS3RoPtDrvpUw__imagepage/img/GMhJZek8dZtWNXZr9fF0OXhSC2c=/fit-in/900x600/filters:no_upscale():strip_icc()/pic875761.jpg" + }, + { + "name": "Kings Cup", + "bggId": 359076, + "note": "Drinking game with card-based rules. Draw cards and follow the associated drinking challenges.", + "image": "https://cf.geekdo-images.com/RZkEtStNVOBO18_Mnuo8mA__imagepage/img/jsXYONDRNqbgAGOOkdry3DzzBrE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic6755735.jpg" + }, + { + "name": "Pocket Farkle", + "bggId": 3181, + "note": "Dice game push your luck. Roll dice to score points, but risk losing everything if you push too far.", + "image": "https://cf.geekdo-images.com/CKRu4CRpDan3G2tgvEEVrA__imagepage/img/FddbnLCq0lP8Ww7VKCgASR1cAmE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3624966.jpg" + }, + { + "name": "Pokertiles", + "bggId": 13327, + "note": "Abstract tile strategy game. Form poker hands using numbered tiles on a board.", + "image": "https://cf.geekdo-images.com/original/pic113334.jpg" + }, + { + "name": "The Perilous Parlor Game", + "bggId": 6235, + "note": "Victorian mystery party game. Deduce the culprit through clues and interactions in a parlor setting.", + "image": "https://cf.geekdo-images.com/XwoUZLCHvSdNE2CD_qCSTA__imagepage/img/qXd6ewvgo8IyoCdm4VE6_YVCMIs=/fit-in/900x600/filters:no_upscale():strip_icc()/pic308160.jpg" + }, + { + "name": "Deception: Murder in Hong Kong", + "bggId": 156129, + "note": "Social deduction murder mystery. Forensic scientists identify the killer while one investigator secretly helps.", + "image": "https://cf.geekdo-images.com/original/pic2037351.jpg" + }, + { + "name": "Deception: Undercover Allies", + "bggId": 227748, + "note": "Expansion for Deception: Murder in Hong Kong. Adds new roles and additional investigator options.", + "image": "https://cf.geekdo-images.com/Q0ymbSxvWcA7_g55ns96Yg__imagepage/img/Jt7h-vdjkp0ZvFYWMH1p8O7ifZ4=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4199680.jpg" + }, + { + "name": "Settlers of Catan (expansion)", + "bggId": 13, + "note": "Expansion for Settlers of Catan. Adds additional gameplay content to the base Catan experience.", + "image": "https://cf.geekdo-images.com/0XODRpReiZBFUffEcqT5-Q__imagepage/img/enC7UTvCAnb6j1Uazvh0OBQjvxw=/fit-in/900x600/filters:no_upscale():strip_icc()/pic9156909.png" + } +] \ No newline at end of file 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 62fe862..a8d1be1 100644 --- a/src/pages/interests/index.astro +++ b/src/pages/interests/index.astro @@ -49,12 +49,22 @@ const interests = [ href: "/interests/making", icon: "palette", title: "Making & Creating", - tagline: - "If it involves really nice cardboard or mastering a new craft, I am in.", + tagline: "A magnet for super glue and nice cardboard.", blurb: "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..a6825b4 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.", + }, ]; --- @@ -138,17 +144,53 @@ const related = [
+ + + + + + +
diff --git a/src/pages/interests/reading.astro b/src/pages/interests/reading.astro index 4e363a3..649766b 100644 --- a/src/pages/interests/reading.astro +++ b/src/pages/interests/reading.astro @@ -5,31 +5,95 @@ * Per-interest template with the spec's reading highlights table (status pills * mapped from spec emoji statuses), anime favorites, and movies/TV note. */ -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 SectionHeader from '@components/layout/SectionHeader.astro'; -import RelatedInterests from '@components/layout/RelatedInterests.astro'; -import Icon from '@components/Icon.astro'; +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 SectionHeader from "@components/layout/SectionHeader.astro"; +import RelatedInterests from "@components/layout/RelatedInterests.astro"; +import Icon from "@components/Icon.astro"; // Spec reading highlights, with status mapped from the spec's emoji statuses. const reading = [ - { icon: 'check-decagram', title: 'Brandon Sanderson — Cosmere', status: 'Complete', body: 'Complete. Every novel, every era. The gold standard for me.' }, - { icon: 'check-decagram', title: 'Frank Herbert — Dune', status: 'Complete', body: 'Books 1-3 complete.' }, - { icon: 'autorenew', title: 'Douglas Preston & Lincoln Child — Pendergast', status: 'Rereading', body: 'Currently rereading. Up to Reliquary.' }, - { icon: 'autorenew', title: 'Sarah J. Maas — Throne of Glass', status: 'Reading', body: 'Currently on Heir of Fire (Book 3).' }, - { icon: 'new-box', title: 'Wistoria: Wand and Sword', status: 'New', body: 'Recently added to the reading list.' }, + { + icon: "check-decagram", + title: "Brandon Sanderson — Cosmere", + status: "Complete", + body: "Complete. Every novel, every era. The gold standard for me.", + }, + { + icon: "check-decagram", + title: "Frank Herbert — Dune", + status: "Complete", + body: "Books 1-3 complete.", + }, + { + icon: "bookmark", + title: "Emily Rodda — Deltora Quest", + status: "Reread-pending", + body: "Planning to listen to audiobooks to reread.", + }, + { + icon: "volume-high", + title: "J.R.R. Tolkein — The Lord of the Rings", + status: "Listening", + body: "Rereading by listening to the Andy Serkis audiobooks.", + }, + { + icon: "autorenew", + title: "Douglas Preston & Lincoln Child — Pendergast", + status: "Rereading", + body: "Currently rereading. Up to Cabinet of Curiosities.", + }, + { + icon: "autorenew", + title: "Sarah J. Maas — Throne of Glass", + status: "Reading", + body: "Currently Tandem reading Heir of Fire (Book 3) and Tower of Dawn (Book 4).", + }, + { + icon: "bookmark", + title: "Fonda Lee — Green Bone Saga", + status: "Next", + body: "Next Series to Read.", + }, + { + icon: "bookmark", + title: "That Time I Got Reincarnated as a Slime", + status: "New", + body: "Read up to Volume 13 before, planning to finish now that it is complete.", + }, + { + icon: "new-box", + title: "Wistoria: Wand and Sword", + status: "New", + body: "Recently added to the reading list. After finishing season 2", + }, ]; const anime = [ - 'One Piece', 'Bleach', 'Gundam (all series)', 'Code Geass', - 'Magi', 'That Time I Got Reincarnated as a Slime', 'Overlord', + "One Piece", + "Bleach", + "Gundam (all series)", + "Code Geass", + "Magi", + "That Time I Got Reincarnated as a Slime", + "Overlord", ]; const related = [ - { href: '/interests/ttrpgs', title: 'TTRPGs & Tabletop', icon: 'dice-multiple', blurb: 'Worldbuilding inspiration flows both ways.' }, - { href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Props and displays for the stories I love.' }, + { + href: "/interests/ttrpgs", + title: "TTRPGs & Tabletop", + icon: "dice-multiple", + blurb: "Worldbuilding inspiration flows both ways.", + }, + { + href: "/interests/making", + title: "Making & Creating", + icon: "palette", + blurb: "Props and displays for the stories I love.", + }, ]; --- @@ -48,54 +112,104 @@ const related = [
{/* Overview */} -
-

I read like it's a competitive sport. Fantasy is my native language, Brandon Sanderson's Cosmere is fully consumed and eagerly awaiting the next era. Frank Herbert's Dune trilogy shaped how I think about worldbuilding. Douglas Preston & Lincoln Child's Pendergast saga is my comfort reread. And I'm currently working through Sarah J. Maas's Throne of Glass series.

-

Beyond novels, I read light novels, comic books, manhwa, and manga. I watch anime and film extensively. If a story exists in any format, I'm probably consuming it.

+
+

+ I read like it's a competitive sport. Fantasy is my native language, + Brandon Sanderson's Cosmere is fully consumed and I am eagerly + awaiting the next era. Frank Herbert's Dune trilogy shaped how I think + about worldbuilding. Douglas Preston & Lincoln Child's Pendergast saga + showed me that a well-crafted narrative can keep me engaged outside of + a Tolkein-esque fantasy world. And I'm currently working through Sarah + J. Maas's Throne of Glass series. +

+

+ Beyond novels, I read light novels, comic books, manhwa, and manga. I + watch anime and film extensively. If a story exists in any format, I'm + probably consuming it in some capacity. +

{/* Reading highlights */}
- +
- {reading.map((r, i) => ( - - {r.body} - - ))} + { + reading.map((r, i) => ( + + + {r.body} + + + )) + }
{/* Anime */}
- +
    - {anime.map((a) => ( -
  • - - {a} -
  • - ))} + { + anime.map((a) => ( +
  • + + {a} +
  • + )) + }
{/* Movies & TV */}
- +
- Watches extensively across genres. Particularly drawn to sci-fi, fantasy, and animated film. + Watches extensively across genres. Particularly drawn to sci-fi, + fantasy, and animated film.
- Fantasy and media collection + Fantasy and media collection
diff --git a/src/pages/interests/tcgs.astro b/src/pages/interests/tcgs.astro new file mode 100644 index 0000000..d0a46bb --- /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, and precision for 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 9e022d5..eb62193 100644 --- a/src/pages/interests/ttrpgs.astro +++ b/src/pages/interests/ttrpgs.astro @@ -6,25 +6,73 @@ * (narrative, verbatim), Highlights (card grid, spec emojis -> mdi), Currently, * then the Board Games sub-section, then RelatedInterests cross-links. */ -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 SectionHeader from '@components/layout/SectionHeader.astro'; -import RelatedInterests from '@components/layout/RelatedInterests.astro'; -import Placeholder from '@components/layout/Placeholder.astro'; +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 SectionHeader from "@components/layout/SectionHeader.astro"; +import RelatedInterests from "@components/layout/RelatedInterests.astro"; +import Placeholder from "@components/layout/Placeholder.astro"; +import BoardGameShelf from "@components/ttrpgs/BoardGameShelf.tsx"; +import boardgames from "@/data/boardgames.json"; const highlights = [ - { icon: 'castle', title: 'Dungeons & Dragons', body: 'Long-running campaign on Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative.' }, - { icon: 'sword-cross', title: 'Daggerheart', body: "Darrington Press's dice-building RPG. Currently running a campaign and exploring how its mechanics create different story rhythms than D&D." }, - { icon: 'bird', title: 'Crows (Playtest)', body: "MCDM's tactical RPG in active playtest. Hex-based travel, revised travel roles, Miasma mechanics. Contributing playtest feedback." }, - { icon: 'monitor', title: 'Foundry VTT', body: 'Self-hosted on the home server (Cosmos Lenovo Tiny). Building custom modules, maps, and automation for campaigns.' }, - { icon: 'target', title: 'Draw Steel', body: "MCDM's upcoming tactical combat game. Interested in running it when it releases." }, + { + icon: "castle", + title: "Dungeons & Dragons", + body: "Lately 5th Edition. Long-running campaigns on and off Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative.", + }, + { + icon: "sword-cross", + title: "Daggerheart", + body: "Darrington Press's dice-building RPG. Currently running a campaign and exploring how its mechanics create different story rhythms than D&D.", + }, + { + icon: "target", + title: "Draw Steel", + body: "MCDM's fantasy tactical cinematic combat game. Interested in running it when I get a chance.", + }, + { + icon: "shield-moon-outline", + title: "Cypher System", + body: "Monte Cook's narrative-focused system. Played a few one-shots and short form campaigns.", + }, + { + icon: "knife-military", + title: "Blades in the Dark", + body: "A gritty, cinematic system for heists and criminal enterprises. Interested in playing short campaigns in it one day.", + }, + { + icon: "weather-windy", + title: "Avatar the Last Airbender RPG", + body: "A narrative-focused system set in the world of Avatar. Interested in exploring bending mechanics and character-driven stories.", + }, + { + icon: "monitor", + title: "Foundry VTT", + body: "Self-hosted on the home server. Building custom modules, maps, and automation for campaigns.", + }, ]; const related = [ - { href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Dice and terrain made for campaigns.' }, - { href: '/interests/gaming', title: 'Gaming', icon: 'gamepad-variant', blurb: 'Digital RPGs as another expression of the same impulse.' }, + { + href: "/interests/making", + title: "Making & Creating", + icon: "palette", + blurb: "Dice and terrain made for campaigns.", + }, + { + href: "/interests/gaming", + title: "Gaming", + 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.", + }, ]; --- @@ -36,31 +84,79 @@ const related = [
{/* Overview */} -
-

Tabletop gaming isn't a hobby for me, it's a practice. I run Dungeons & Dragons and Daggerheart campaigns that span years of real-time play, maintaining worlds, NPCs, and narrative threads that my players remember years later. I host everything on Foundry VTT, which lets me build the kind of dynamic, visually rich experiences that keep players invested session after session.

-

Right now, I'm also playtesting MCDM's Crows, Matt Colville's forthcoming RPG built around hex-crawling, faction play, and hard travel rules. The playtest has already influenced how I think about overland encounters in my own campaigns.

+
+

+ I love playing tabletop games from trading card games to role-playing + games. I run Dungeons & Dragons and Daggerheart campaigns that span + years of real-time play, maintaining worlds, NPCs, and narrative + threads that my players remember years later. I host everything on + Foundry VTT, which lets me build the kind of dynamic, visually rich + experiences that keep players invested session after session and lets + me flex my technical skills. +

+

+ I also love board games, from strategy to social deduction. My + collection is growing, and I enjoy hosting game nights with friends. I + have a particular fondness for games that challenge players to think + critically and creatively, and I appreciate the social aspect of + gaming as much as the gameplay itself. Although, I do love the ability + to play some of the engine building and deck-building games solo. + Spirit Island and Wyrmspan have been my favorites lately, and I am + always on the lookout for new games to add to my collection. +

+

+ I have played a few trading card games and collected many cards. I + have been a lifelong Pokémon and Magic: The Gathering fan, and I enjoy + the strategy and collection aspects of both games in different ways. + While I mostly enjoy looking at Pokemon cards, I spend most of my time + with Magic deck building and playing Elder Dragon Highlander (or + Commander). I recently picked up Riftbound and have had my eye on the + Gundam TCG game as well. The mechanical differences of these games are + fascinating, and I enjoy exploring the different strategies and + deck-building techniques that each game offers. +

{/* Highlights */}
- +
- {highlights.map((h, i) => ( - - {h.body} - - ))} + { + highlights.map((h, i) => ( + + + {h.body} + + + )) + }
- {/* Currently */} + { + /* Currently
@@ -69,42 +165,36 @@ const related = [
+ */ + } {/* Board games */}
- +
Beyond RPGs, I maintain a growing board and card game collection. - Favorites span strategy, deck-building, and social deduction. Game night - is a regular event, and yes, there's always room for one more. + Favorites span strategy, deck-building, and social deduction. Game + night is a regular event, and yes, there's always room for one more. + Hover a title to preview its cover; click to open it on BoardGameGeek.
-
-
- Beyond RPGs, I maintain a growing board and card game collection. - Favorites span strategy, deck-building, and social deduction. Game night - is a regular event, and yes, there's always room for one more. -
-
- Dice and game pieces -
-
-
- -