Merge branch 'master' of https://gitea.patiun.com/patiun/greg_profile_site
@@ -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/<bggId>/<slug>`.
|
||||||
|
|
||||||
|
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/<bggId>.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=<username>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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-<username>.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
|
## Interest pages
|
||||||
|
|
||||||
Each interest is a hand-built page in `src/pages/interests/`. They follow the
|
Each interest is a hand-built page in `src/pages/interests/`. They follow the
|
||||||
|
|||||||
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 615 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 371 KiB |
@@ -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/<bggId>.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/<bggId>/<slug>
|
||||||
|
*/
|
||||||
|
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 <ms> 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 <tagName> element from an XML string. */
|
||||||
|
const xmlText = (xml, tagName) => {
|
||||||
|
const re = new RegExp(`<${tagName}[^>]*>([^<]*)</${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 <image> 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).');
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}) : '';
|
||||||
|
---
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="card card-interactive group flex h-full flex-col gap-0 overflow-hidden p-0"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-[4/3] w-full overflow-hidden">
|
||||||
|
{
|
||||||
|
featured ? (
|
||||||
|
<img
|
||||||
|
src={featured}
|
||||||
|
alt={`${name} featured card`}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full items-center justify-center"
|
||||||
|
style="background: color-mix(in srgb, var(--color-secondary) 16%, transparent);"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="cards-playing"
|
||||||
|
class="h-12 w-12 opacity-50"
|
||||||
|
style="color: var(--color-text-muted);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{/* Legibility gradient at the bottom for the meta row. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute inset-x-0 bottom-0 h-1/3"
|
||||||
|
style="background: linear-gradient(to top, rgba(0,0,0,0.55), transparent);"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* Mana color pips, top-left over the art. */
|
||||||
|
present.length > 0 && (
|
||||||
|
<div class="absolute left-3 top-3 flex gap-1.5">
|
||||||
|
{present.map(([key, hex]) => (
|
||||||
|
<span
|
||||||
|
class="h-3 w-3 rounded-full border border-black/30"
|
||||||
|
style={`background:${hex};`}
|
||||||
|
title={`${key} mana`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 p-4">
|
||||||
|
<h3
|
||||||
|
class="font-sans text-base leading-tight underline-offset-4 group-hover:underline"
|
||||||
|
style="color: var(--color-text);"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2 font-mono text-[0.65rem] uppercase tracking-[0.16em]"
|
||||||
|
style="color: var(--color-text-muted);"
|
||||||
|
>
|
||||||
|
{formatLabel && <span>{formatLabel}</span>}
|
||||||
|
{formatLabel && (size || updated) && <span aria-hidden="true">·</span>}
|
||||||
|
{size ? <span>{size} cards</span> : null}
|
||||||
|
{updated && (size ? <span aria-hidden="true">·</span> : null)}
|
||||||
|
{updated && <span>{updated}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* BoardGameShelf.tsx
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Two-column "Board games" island for the TTRPGs page.
|
||||||
|
*
|
||||||
|
* Left: a list of game names, each a real <a> 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<number | null>(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 (
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2 sm:items-stretch">
|
||||||
|
{/* LEFT: game list — capped at the shelf height with internal scroll */}
|
||||||
|
<ul
|
||||||
|
className="flex flex-col divide-y overflow-y-auto pr-1"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
height: SHELF_HEIGHT,
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{games.map((g, i) => {
|
||||||
|
const isActive = active === i;
|
||||||
|
return (
|
||||||
|
<li key={g.bggId}>
|
||||||
|
<a
|
||||||
|
href={`https://boardgamegeek.com/boardgame/${g.bggId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onFocus={() => setActive(i)}
|
||||||
|
onMouseLeave={() => setActive((cur) => (cur === i ? null : cur))}
|
||||||
|
onBlur={() => setActive((cur) => (cur === i ? null : cur))}
|
||||||
|
className="group flex items-center gap-3 py-3 transition-colors"
|
||||||
|
style={{
|
||||||
|
color: isActive ? 'var(--color-accent)' : 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isActive
|
||||||
|
? 'color-mix(in srgb, var(--color-accent) 20%, transparent)'
|
||||||
|
: 'color-mix(in srgb, var(--color-secondary) 16%, transparent)',
|
||||||
|
color: isActive ? 'var(--color-accent)' : 'var(--color-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* dice / playing-card style glyph, inline to stay self-contained */}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm0 2v14h14V5H5zm4 2.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm9 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0 7a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-9 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm4.5-3.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-sans text-base leading-tight underline-offset-4 group-hover:underline group-focus:underline">
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
{g.note && (
|
||||||
|
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
{g.note}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* RIGHT: tall image frame, shelf at rest, cover on hover. Pinned to the
|
||||||
|
same height as the list so the two columns line up. */}
|
||||||
|
<figure
|
||||||
|
className="relative w-full overflow-hidden rounded-xl border"
|
||||||
|
style={{
|
||||||
|
height: SHELF_HEIGHT,
|
||||||
|
borderColor: 'color-mix(in srgb, var(--color-accent) 35%, transparent)',
|
||||||
|
background: 'color-mix(in srgb, var(--color-secondary) 12%, transparent)',
|
||||||
|
}}
|
||||||
|
// When the pointer leaves the whole figure, snap back to the shelf.
|
||||||
|
onMouseLeave={() => setActive(null)}
|
||||||
|
>
|
||||||
|
{/* Base layer: shelf photo (always present; covers cross-fade on top). */}
|
||||||
|
<img
|
||||||
|
src={shelfImage}
|
||||||
|
alt={shelfAlt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* Gradient + caption overlay for legibility. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-0 bottom-0 h-1/3"
|
||||||
|
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showCover && (
|
||||||
|
<motion.img
|
||||||
|
key={activeGame!.bggId}
|
||||||
|
src={activeGame!.image}
|
||||||
|
alt={`${activeGame!.name} cover`}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="absolute inset-0 h-full w-full object-contain"
|
||||||
|
initial={crossfade.initial}
|
||||||
|
animate={crossfade.animate}
|
||||||
|
exit={crossfade.exit}
|
||||||
|
transition={crossfade.transition}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<figcaption
|
||||||
|
className="absolute inset-x-0 bottom-0 flex flex-col gap-0.5 p-4"
|
||||||
|
style={{ color: 'var(--color-cream)' }}
|
||||||
|
>
|
||||||
|
<span className="font-sans text-lg leading-tight drop-shadow">
|
||||||
|
{activeGame?.name ?? shelfCaption}
|
||||||
|
</span>
|
||||||
|
{activeGame?.note && (
|
||||||
|
<span className="text-sm opacity-90 drop-shadow">{activeGame.note}</span>
|
||||||
|
)}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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=<username>
|
||||||
|
* -> { 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<number, string> = {
|
||||||
|
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<ArchidektDeck[] | null> {
|
||||||
|
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<void> {
|
||||||
|
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<ArchidektDeck[] | null> {
|
||||||
|
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<ArchidektDeck[]> {
|
||||||
|
// 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)}`;
|
||||||
@@ -49,12 +49,22 @@ const interests = [
|
|||||||
href: "/interests/making",
|
href: "/interests/making",
|
||||||
icon: "palette",
|
icon: "palette",
|
||||||
title: "Making & Creating",
|
title: "Making & Creating",
|
||||||
tagline:
|
tagline: "A magnet for super glue and nice cardboard.",
|
||||||
"If it involves really nice cardboard or mastering a new craft, I am in.",
|
|
||||||
blurb:
|
blurb:
|
||||||
"Resin dice in a pressure pot, fun and functional prints on the P1s, custom TCG proxies and decks. Passion and utility.",
|
"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",
|
image: "/images/making-resin.jpg",
|
||||||
alt: "Polyhedral dice on a dark bag",
|
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',
|
href: '/interests/pokemon',
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ const related = [
|
|||||||
icon: "book-open-page-variant",
|
icon: "book-open-page-variant",
|
||||||
blurb: "Displays and props for the stories I love.",
|
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 = [
|
|||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<CelestialImage
|
<CelestialImage
|
||||||
src="/images/making-resin.jpg"
|
src="/images/making-resin.jpg"
|
||||||
alt="Resin dice set"
|
alt="Resin dice set teal with gold leaf inclusions"
|
||||||
aspect="4/3"
|
aspect="4/3"
|
||||||
/>
|
/>
|
||||||
<CelestialImage
|
<CelestialImage
|
||||||
src="/images/making-print.jpg"
|
src="/images/dice_golden_moss.jpg"
|
||||||
|
alt="Resin dice set moss like pattern with gold lettering"
|
||||||
|
aspect="4/3"
|
||||||
|
/>
|
||||||
|
<CelestialImage
|
||||||
|
src="/images/dice_scarlet.jpg"
|
||||||
|
alt="Resin dice set scarlet with gold lettering"
|
||||||
|
aspect="4/3"
|
||||||
|
/>
|
||||||
|
<CelestialImage
|
||||||
|
src="/images/dice_galaxy.jpg"
|
||||||
|
alt="Resin dice set dark galaxy pattern with gold powder swirls"
|
||||||
|
aspect="4/3"
|
||||||
|
/>
|
||||||
|
<CelestialImage
|
||||||
|
src="/images/minis.jpg"
|
||||||
alt="3D printed miniature"
|
alt="3D printed miniature"
|
||||||
aspect="4/3"
|
aspect="4/3"
|
||||||
/>
|
/>
|
||||||
<CelestialImage
|
<CelestialImage
|
||||||
src="/images/ttrpg-dice.jpg"
|
src="/images/deckboxes.jpg"
|
||||||
alt="Terrain piece and dice"
|
alt="Deckboxes (Design by
|
||||||
|
@Iceenchanted on MakerWorld)"
|
||||||
|
aspect="4/3"
|
||||||
|
/>
|
||||||
|
<CelestialImage
|
||||||
|
src="/images/proxies.jpg"
|
||||||
|
alt="Proxy cards for Kynaios and Tiro deck"
|
||||||
|
aspect="4/3"
|
||||||
|
/>
|
||||||
|
<CelestialImage
|
||||||
|
src="/images/proxies2.jpg"
|
||||||
|
alt="Successful Foil Proxies MTG"
|
||||||
|
aspect="4/3"
|
||||||
|
/>
|
||||||
|
<CelestialImage
|
||||||
|
src="/images/xmasgifts.jpg"
|
||||||
|
alt="CS:GO Cases, Nightblood, Runescape Helmet, ACOTAR drawers"
|
||||||
|
aspect="4/3"
|
||||||
|
/>
|
||||||
|
<CelestialImage
|
||||||
|
src="/images/resin_rings.jpg"
|
||||||
|
alt="Resin and painted rings"
|
||||||
aspect="4/3"
|
aspect="4/3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,31 +5,95 @@
|
|||||||
* Per-interest template with the spec's reading highlights table (status pills
|
* Per-interest template with the spec's reading highlights table (status pills
|
||||||
* mapped from spec emoji statuses), anime favorites, and movies/TV note.
|
* mapped from spec emoji statuses), anime favorites, and movies/TV note.
|
||||||
*/
|
*/
|
||||||
import BaseLayout from '@layouts/BaseLayout.astro';
|
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||||
import Reveal from '@components/motion/Reveal.astro';
|
import Reveal from "@components/motion/Reveal.astro";
|
||||||
import InterestHero from '@components/layout/InterestHero.astro';
|
import InterestHero from "@components/layout/InterestHero.astro";
|
||||||
import InterestCard from '@components/cards/InterestCard.astro';
|
import InterestCard from "@components/cards/InterestCard.astro";
|
||||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||||
import RelatedInterests from '@components/layout/RelatedInterests.astro';
|
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||||
import Icon from '@components/Icon.astro';
|
import Icon from "@components/Icon.astro";
|
||||||
|
|
||||||
// Spec reading highlights, with status mapped from the spec's emoji statuses.
|
// Spec reading highlights, with status mapped from the spec's emoji statuses.
|
||||||
const reading = [
|
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: "check-decagram",
|
||||||
{ icon: 'autorenew', title: 'Douglas Preston & Lincoln Child — Pendergast', status: 'Rereading', body: 'Currently rereading. Up to Reliquary.' },
|
title: "Brandon Sanderson — Cosmere",
|
||||||
{ icon: 'autorenew', title: 'Sarah J. Maas — Throne of Glass', status: 'Reading', body: 'Currently on Heir of Fire (Book 3).' },
|
status: "Complete",
|
||||||
{ icon: 'new-box', title: 'Wistoria: Wand and Sword', status: 'New', body: 'Recently added to the reading list.' },
|
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 = [
|
const anime = [
|
||||||
'One Piece', 'Bleach', 'Gundam (all series)', 'Code Geass',
|
"One Piece",
|
||||||
'Magi', 'That Time I Got Reincarnated as a Slime', 'Overlord',
|
"Bleach",
|
||||||
|
"Gundam (all series)",
|
||||||
|
"Code Geass",
|
||||||
|
"Magi",
|
||||||
|
"That Time I Got Reincarnated as a Slime",
|
||||||
|
"Overlord",
|
||||||
];
|
];
|
||||||
|
|
||||||
const related = [
|
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 = [
|
|||||||
|
|
||||||
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
||||||
{/* Overview */}
|
{/* Overview */}
|
||||||
<section class="flex flex-col gap-5 text-lg leading-relaxed" style="color: var(--color-text-muted);">
|
<section
|
||||||
<Reveal><p>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.</p></Reveal>
|
class="flex flex-col gap-5 text-lg leading-relaxed"
|
||||||
<Reveal delay={80}><p>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.</p></Reveal>
|
style="color: var(--color-text-muted);"
|
||||||
|
>
|
||||||
|
<Reveal
|
||||||
|
><p>
|
||||||
|
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.
|
||||||
|
</p></Reveal
|
||||||
|
>
|
||||||
|
<Reveal delay={80}
|
||||||
|
><p>
|
||||||
|
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.
|
||||||
|
</p></Reveal
|
||||||
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Reading highlights */}
|
{/* Reading highlights */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal><SectionHeader kicker="On the shelf" icon="bookshelf" title="Reading highlights" /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="On the shelf"
|
||||||
|
icon="bookshelf"
|
||||||
|
title="Reading highlights"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{reading.map((r, i) => (
|
{
|
||||||
|
reading.map((r, i) => (
|
||||||
<Reveal delay={i * 70}>
|
<Reveal delay={i * 70}>
|
||||||
<InterestCard icon={r.icon} title={r.title} status={r.status}>{r.body}</InterestCard>
|
<InterestCard icon={r.icon} title={r.title} status={r.status}>
|
||||||
|
{r.body}
|
||||||
|
</InterestCard>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Anime */}
|
{/* Anime */}
|
||||||
<section class="flex flex-col gap-5">
|
<section class="flex flex-col gap-5">
|
||||||
<Reveal><SectionHeader kicker="Animated" icon="television-classic" title="Anime favorites" /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Animated"
|
||||||
|
icon="television-classic"
|
||||||
|
title="Anime favorites"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<Reveal delay={80}>
|
<Reveal delay={80}>
|
||||||
<ul class="flex flex-wrap gap-3">
|
<ul class="flex flex-wrap gap-3">
|
||||||
{anime.map((a) => (
|
{
|
||||||
|
anime.map((a) => (
|
||||||
<li class="pill text-sm">
|
<li class="pill text-sm">
|
||||||
<Icon name="play-circle" class="h-3.5 w-3.5" />
|
<Icon name="play-circle" class="h-3.5 w-3.5" />
|
||||||
{a}
|
{a}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Movies & TV */}
|
{/* Movies & TV */}
|
||||||
<section class="flex flex-col gap-5">
|
<section class="flex flex-col gap-5">
|
||||||
<Reveal><SectionHeader kicker="Beyond the page" icon="movie-open" title="Movies & TV" /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Beyond the page"
|
||||||
|
icon="movie-open"
|
||||||
|
title="Movies & TV"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<Reveal delay={80}>
|
<Reveal delay={80}>
|
||||||
<div class="grid gap-5 sm:grid-cols-[1fr_18rem]">
|
<div class="grid gap-5 sm:grid-cols-[1fr_18rem]">
|
||||||
<blockquote
|
<blockquote
|
||||||
class="border-l-2 pl-5 text-lg leading-relaxed"
|
class="border-l-2 pl-5 text-lg leading-relaxed"
|
||||||
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
||||||
>
|
>
|
||||||
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.
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<figure
|
<figure
|
||||||
class="relative overflow-hidden rounded-xl border"
|
class="relative overflow-hidden rounded-xl border"
|
||||||
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
|
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
|
||||||
>
|
>
|
||||||
<img src="/images/fantasy-books.jpg" alt="Fantasy and media collection" loading="lazy" decoding="async" class="aspect-[4/3] w-full object-cover" />
|
<img
|
||||||
|
src="/images/fantasy-books.jpg"
|
||||||
|
alt="Fantasy and media collection"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="aspect-[4/3] w-full object-cover"
|
||||||
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
surface="interest"
|
||||||
|
title="Trading Card Games"
|
||||||
|
description="Magic: The Gathering Commander decks, Pokémon collecting, Riftbound, and the Gundam card game. Plus the proxies, tokens, and props I make to play them."
|
||||||
|
>
|
||||||
|
<InterestHero
|
||||||
|
icon="cards"
|
||||||
|
title="Trading Card Games"
|
||||||
|
tagline="Cardboard, deckbuilding, and the eternal question: one more pack?"
|
||||||
|
image="/images/cards.jpg"
|
||||||
|
imageAlt="Trading cards and dice on a playmat"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
||||||
|
{/* Overview */}
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-5 text-lg leading-relaxed"
|
||||||
|
style="color: var(--color-text-muted);"
|
||||||
|
>
|
||||||
|
<Reveal
|
||||||
|
><p>
|
||||||
|
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.
|
||||||
|
</p></Reveal
|
||||||
|
>
|
||||||
|
<Reveal delay={80}
|
||||||
|
><p>
|
||||||
|
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.
|
||||||
|
</p></Reveal
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* The games I play */}
|
||||||
|
<section class="flex flex-col gap-6">
|
||||||
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Collection"
|
||||||
|
icon="cards-outline"
|
||||||
|
title="The games I play"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-2">
|
||||||
|
{
|
||||||
|
games.map((g, i) => (
|
||||||
|
<Reveal delay={i * 70}>
|
||||||
|
<InterestCard icon={g.icon} title={g.title}>
|
||||||
|
{g.body}
|
||||||
|
</InterestCard>
|
||||||
|
</Reveal>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Magic: The Gathering decks (live from Archidekt) */}
|
||||||
|
<section class="flex flex-col gap-6">
|
||||||
|
<Reveal>
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<SectionHeader kicker="Magic" icon="magic-staff" title="MTG decks" />
|
||||||
|
<a
|
||||||
|
href={decksHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm transition-colors hover:underline"
|
||||||
|
style="color: var(--color-accent);"
|
||||||
|
>
|
||||||
|
Manage on Archidekt
|
||||||
|
<Icon name="open-in-new" class="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={40}>
|
||||||
|
<p class="text-sm" style="color: var(--color-text-muted);">
|
||||||
|
Pulled live from my Archidekt account ({ARCHIDEKT_USERNAME}). Newest
|
||||||
|
decks first; click a card to open it.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
{
|
||||||
|
decks.length > 0 ? (
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{decks
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt))
|
||||||
|
.map((d, i) => (
|
||||||
|
<Reveal delay={Math.min(i * 50, 400)}>
|
||||||
|
<DeckCard
|
||||||
|
name={d.name}
|
||||||
|
href={d.href}
|
||||||
|
featured={d.featured}
|
||||||
|
colors={d.colors}
|
||||||
|
size={d.size}
|
||||||
|
formatLabel={d.formatLabel}
|
||||||
|
updatedAt={d.updatedAt}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Reveal delay={80}>
|
||||||
|
<Placeholder
|
||||||
|
label="Archidekt decks"
|
||||||
|
hint={`Couldn't reach Archidekt right now. View the full list at ${decksHref}.`}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Proxy making */}
|
||||||
|
<section class="flex flex-col gap-6">
|
||||||
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Workshop"
|
||||||
|
icon="auto-fix"
|
||||||
|
title="Proxy making & printed props"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-2">
|
||||||
|
{
|
||||||
|
proxy.map((p, i) => (
|
||||||
|
<Reveal delay={i * 70}>
|
||||||
|
<InterestCard icon={p.icon} title={p.title}>
|
||||||
|
{p.body}
|
||||||
|
</InterestCard>
|
||||||
|
</Reveal>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<RelatedInterests items={related} />
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
@@ -6,25 +6,73 @@
|
|||||||
* (narrative, verbatim), Highlights (card grid, spec emojis -> mdi), Currently,
|
* (narrative, verbatim), Highlights (card grid, spec emojis -> mdi), Currently,
|
||||||
* then the Board Games sub-section, then RelatedInterests cross-links.
|
* then the Board Games sub-section, then RelatedInterests cross-links.
|
||||||
*/
|
*/
|
||||||
import BaseLayout from '@layouts/BaseLayout.astro';
|
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||||
import Reveal from '@components/motion/Reveal.astro';
|
import Reveal from "@components/motion/Reveal.astro";
|
||||||
import InterestHero from '@components/layout/InterestHero.astro';
|
import InterestHero from "@components/layout/InterestHero.astro";
|
||||||
import InterestCard from '@components/cards/InterestCard.astro';
|
import InterestCard from "@components/cards/InterestCard.astro";
|
||||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||||
import RelatedInterests from '@components/layout/RelatedInterests.astro';
|
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||||
import Placeholder from '@components/layout/Placeholder.astro';
|
import Placeholder from "@components/layout/Placeholder.astro";
|
||||||
|
import BoardGameShelf from "@components/ttrpgs/BoardGameShelf.tsx";
|
||||||
|
import boardgames from "@/data/boardgames.json";
|
||||||
|
|
||||||
const highlights = [
|
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: "castle",
|
||||||
{ icon: 'bird', title: 'Crows (Playtest)', body: "MCDM's tactical RPG in active playtest. Hex-based travel, revised travel roles, Miasma mechanics. Contributing playtest feedback." },
|
title: "Dungeons & Dragons",
|
||||||
{ icon: 'monitor', title: 'Foundry VTT', body: 'Self-hosted on the home server (Cosmos Lenovo Tiny). Building custom modules, maps, and automation for campaigns.' },
|
body: "Lately 5th Edition. Long-running campaigns on and off Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative.",
|
||||||
{ icon: 'target', title: 'Draw Steel', body: "MCDM's upcoming tactical combat game. Interested in running it when it releases." },
|
},
|
||||||
|
{
|
||||||
|
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 = [
|
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 = [
|
|||||||
<InterestHero
|
<InterestHero
|
||||||
icon="dice-multiple"
|
icon="dice-multiple"
|
||||||
title="TTRPGs & Tabletop"
|
title="TTRPGs & Tabletop"
|
||||||
tagline="Three campaigns, two systems, one rolling table."
|
tagline="Minis and dice sets all sat round the table, card games commander all gathered when able. Games about dragons and spanning their wings, These are a few of my favorite things."
|
||||||
image="/images/ttrpg-table.jpg"
|
image="/images/ttrpg-table.jpg"
|
||||||
imageAlt="A tabletop RPG spread with miniatures and dice"
|
imageAlt="A tabletop RPG spread with miniatures and dice"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
||||||
{/* Overview */}
|
{/* Overview */}
|
||||||
<section class="flex flex-col gap-5 text-lg leading-relaxed" style="color: var(--color-text-muted);">
|
<section
|
||||||
<Reveal><p>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.</p></Reveal>
|
class="flex flex-col gap-5 text-lg leading-relaxed"
|
||||||
<Reveal delay={80}><p>Right now, I'm also playtesting MCDM's <em>Crows</em>, 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.</p></Reveal>
|
style="color: var(--color-text-muted);"
|
||||||
|
>
|
||||||
|
<Reveal
|
||||||
|
><p>
|
||||||
|
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.
|
||||||
|
</p></Reveal
|
||||||
|
>
|
||||||
|
<Reveal delay={60}
|
||||||
|
><p>
|
||||||
|
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.
|
||||||
|
</p></Reveal
|
||||||
|
>
|
||||||
|
<Reveal delay={100}
|
||||||
|
><p>
|
||||||
|
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.
|
||||||
|
</p></Reveal
|
||||||
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Highlights */}
|
{/* Highlights */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal><SectionHeader kicker="Highlights" icon="view-grid" title="Systems & tools" /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Highlights"
|
||||||
|
icon="view-grid"
|
||||||
|
title="Systems & tools"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{highlights.map((h, i) => (
|
{
|
||||||
|
highlights.map((h, i) => (
|
||||||
<Reveal delay={i * 70}>
|
<Reveal delay={i * 70}>
|
||||||
<InterestCard icon={h.icon} title={h.title}>{h.body}</InterestCard>
|
<InterestCard icon={h.icon} title={h.title}>
|
||||||
|
{h.body}
|
||||||
|
</InterestCard>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Currently */}
|
{
|
||||||
|
/* Currently
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal><SectionHeader kicker="Currently" icon="calendar-clock" title="On the schedule" /></Reveal>
|
<Reveal><SectionHeader kicker="Currently" icon="calendar-clock" title="On the schedule" /></Reveal>
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
@@ -69,42 +165,36 @@ const related = [
|
|||||||
<Placeholder label="Crows playtest" hint="Ongoing sessions." />
|
<Placeholder label="Crows playtest" hint="Ongoing sessions." />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
{/* Board games */}
|
{/* Board games */}
|
||||||
<section class="flex flex-col gap-6">
|
<section class="flex flex-col gap-6">
|
||||||
<Reveal><SectionHeader kicker="Beyond RPGs" icon="cards-playing" title="Board games" /></Reveal>
|
<Reveal
|
||||||
|
><SectionHeader
|
||||||
|
kicker="Beyond RPGs"
|
||||||
|
icon="cards-playing"
|
||||||
|
title="Board games"
|
||||||
|
/></Reveal
|
||||||
|
>
|
||||||
<Reveal delay={80}>
|
<Reveal delay={80}>
|
||||||
<blockquote
|
<blockquote
|
||||||
class="border-l-2 pl-5 text-lg leading-relaxed"
|
class="border-l-2 pl-5 text-lg leading-relaxed"
|
||||||
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
||||||
>
|
>
|
||||||
Beyond RPGs, I maintain a growing board and card game collection.
|
Beyond RPGs, I maintain a growing board and card game collection.
|
||||||
Favorites span strategy, deck-building, and social deduction. Game night
|
Favorites span strategy, deck-building, and social deduction. Game
|
||||||
is a regular event, and yes, there's always room for one more.
|
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.
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={140}>
|
<Reveal delay={140}>
|
||||||
<div class="grid gap-4 sm:grid-cols-[2fr_1fr]">
|
<BoardGameShelf
|
||||||
<blockquote
|
client:idle
|
||||||
class="border-l-2 pl-5 text-lg leading-relaxed"
|
games={boardgames}
|
||||||
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
|
shelfImage="/images/ttrpg-boardgame-shelf.jpg"
|
||||||
>
|
shelfAlt="Greg's board game shelf"
|
||||||
Beyond RPGs, I maintain a growing board and card game collection.
|
shelfCaption="The shelf"
|
||||||
Favorites span strategy, deck-building, and social deduction. Game night
|
|
||||||
is a regular event, and yes, there's always room for one more.
|
|
||||||
</blockquote>
|
|
||||||
<figure
|
|
||||||
class="relative overflow-hidden rounded-xl border"
|
|
||||||
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
|
|
||||||
>
|
|
||||||
<img src="/images/ttrpg-dice.jpg" alt="Dice and game pieces" loading="lazy" decoding="async" class="h-full w-full object-cover" />
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
<Reveal delay={200}>
|
|
||||||
<Placeholder
|
|
||||||
label="Board game shelf"
|
|
||||||
hint="Replace this with a real photo of your game shelf once you have one."
|
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||