This commit is contained in:
2026-07-06 23:14:40 +00:00
37 changed files with 831 additions and 93 deletions
+7
View File
@@ -32,3 +32,10 @@ SITE_URL=https://gregkilmer.com
# Honeypot / basic abuse control. Maximum submissions per IP per window. # Honeypot / basic abuse control. Maximum submissions per IP per window.
CONTACT_RATE_LIMIT=5 CONTACT_RATE_LIMIT=5
CONTACT_RATE_WINDOW_MIN=15 CONTACT_RATE_WINDOW_MIN=15
# --- Gaming box-art tool ----------------------------------------------------
# Used by scripts/fetch-box-art.mjs to download box/banner art for Gaming Hall
# of Fame entries via the RAWG API. Get a free key at:
# https://rawg.io/login?forward=developer
RAWG_API_KEY=
+89 -2
View File
@@ -1,7 +1,7 @@
# Content Guide # Content Guide
How to add and edit content on the site. Two sections use **Content Collections** How to add and edit content on the site. Four sections use **Content Collections**
(typed markdown/MDX); the interest pages are hand-built `.astro` files. (typed markdown/MDX/YAML); the other interest pages are hand-built `.astro` files.
--- ---
@@ -90,6 +90,93 @@ node -e "const m=require('./node_modules/@iconify-json/mdi/icons.json'); console
--- ---
## Games (Gaming page)
The Gaming page (`src/pages/interests/gaming.astro`) has two content-driven
sections that read from collections: **Now playing** and the **Gaming Hall of
Fame**. The platforms grid and favorite-genre cards are still edited inline on
the page.
### Hall of Fame — one file per game
Each game is a markdown file in `src/content/games/hall-of-fame/`. Create a new
file (e.g. `hades.md`):
```md
---
title: 'Hades'
platform: 'Steam' # free-form; drives the icon + platform pill
year: 2020 # optional, shown as a small detail
art: '/images/games/hades.jpg' # optional, public path (see fetch tool below)
artAlt: 'Hades key art' # optional, defaults to the title
icon: 'sword-cross' # optional, overrides the platform-derived icon
blurb: 'A 2-3 sentence case for why it earned a Hall of Fame slot.'
order: 15 # lower = shown first, default 100
---
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | Game name |
| `platform` | string | yes | e.g. "PlayStation 5", "Steam", "Nintendo Switch" |
| `blurb` | string | yes | Why it's a favorite |
| `art` | string | no | `/images/games/<slug>.jpg` (public path) |
| `artAlt` | string | no | Alt text, defaults to title |
| `year` | number | no | Release year |
| `icon` | string | no | mdi icon override (else derived from platform) |
| `order` | number | no | Sort weight |
`platform` is matched case-insensitively to pick an icon: `playstation` →
`sony-playstation`, `switch` → `nintendo-switch`, `steam`/`pc` → `steam`,
`xbox` → `microsoft-xbox`, anything else → `gamepad-variant`. Override with
`icon` if you want something specific.
### Now playing — single list
Edit `src/content/games/now-playing.yaml`. This is the transient list of games
you're actively playing; each entry is a title + platform only:
```yaml
games:
- title: "Elden Ring"
platform: "PlayStation 5"
- title: "Balatro"
platform: "Steam"
```
If the list is empty, the page shows a TODO placeholder instead.
### Fetching box art (RAWG)
`scripts/fetch-box-art.mjs` downloads box/banner art from the
[RAWG API](https://rawg.io/apidocs) into `public/images/games/`, then prints the
public path to paste into a game file's `art:` field. RAWG covers Steam,
PlayStation, Xbox, and Nintendo in one place.
1. Get a free key at <https://rawg.io/login?forward=developer> and add it to
`.env` (copy from `.env.example`):
```
RAWG_API_KEY=your_key_here
```
2. Run (Node 20.6+ can auto-load `.env` via `--env-file`):
```bash
node --env-file=.env scripts/fetch-box-art.mjs "Elden Ring"
node --env-file=.env scripts/fetch-box-art.mjs "Hollow Knight" --platform pc
node --env-file=.env scripts/fetch-box-art.mjs --id 3328 # by RAWG id
node --env-file=.env scripts/fetch-box-art.mjs "Celeste" --out celeste.jpg
```
3. It prints something like:
```
✔ Saved: public/images/games/elden-ring.jpg
art: "/images/games/elden-ring.jpg"
```
Paste that `art:` value into the game file's frontmatter.
> RAWG serves **landscape** banner/screenshot art (not portrait Steam
> capsules), which matches the Hall of Fame cards (`aspect-[4/3]`).
---
## 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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 665 KiB

+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* fetch-box-art.mjs
* ---------------------------------------------------------------------------
* Support tool for the Gaming Hall of Fame. Downloads box/banner art for a
* game from the RAWG API (https://rawg.io/apidocs) and saves it into
* `public/images/games/<slug>.jpg`, then prints the public path to paste into
* a game file's `art:` field.
*
* RAWG covers Steam, PlayStation, Xbox, and Nintendo titles in one place, so
* this works regardless of platform. Note: RAWG serves landscape banner /
* screenshot art (not portrait Steam capsules), which matches the landscape
* Hall of Fame cards (aspect-[4/3]).
*
* PREREQUISITES
* 1. Get a free RAWG API key: https://rawg.io/login?forward=developer
* 2. Put it in a `.env` file at the project root:
* RAWG_API_KEY=your_key_here
* (`.env` is gitignored; `.env.example` is the template.)
*
* USAGE
* node scripts/fetch-box-art.mjs "Elden Ring"
* node scripts/fetch-box-art.mjs "Hades" --platform pc
* node scripts/fetch-box-art.mjs "Celeste" --out celeste.jpg
* node scripts/fetch-box-art.mjs --id 3328 # by RAWG id
*
* Run with `--env-file=.env` to auto-load the key (Node 20.6+):
* node --env-file=.env scripts/fetch-box-art.mjs "Elden Ring"
*
* If you don't use --env-file, export RAWG_API_KEY in your shell first.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, extname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
const OUT_DIR = join(PROJECT_ROOT, 'public', 'images', 'games');
const RAWG_BASE = 'https://api.rawg.io/api';
// RAWG parent platforms that map to the --platform flag values.
const PLATFORM_IDS = {
pc: 1,
playstation: 2,
xbox: 3,
nintendo: 7,
mac: 4,
linux: 5,
ios: 5,
android: 6,
};
// --- arg parsing ------------------------------------------------------------
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
console.log(`Usage: node scripts/fetch-box-art.mjs "<game title>" [options]
Options:
--platform <p> narrow search to a parent platform: pc, playstation, xbox, nintendo
--id <rawgId> fetch a specific game by its RAWG id (skips search)
--out <name> output filename (default: slug of the title, .jpg)
-h, --help show this help
Examples:
node scripts/fetch-box-art.mjs "Elden Ring"
node scripts/fetch-box-art.mjs "Hollow Knight" --platform pc
node scripts/fetch-box-art.mjs --id 3328
Tip: run with \`node --env-file=.env scripts/fetch-box-art.mjs ...\` to auto-load
the RAWG_API_KEY from your .env file.`);
process.exit(args.length === 0 ? 1 : 0);
}
const positional = [];
let platform = null;
let rawgId = null;
let outName = null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--platform') platform = args[++i];
else if (a === '--id') rawgId = Number(args[++i]);
else if (a === '--out') outName = args[++i];
else if (!a.startsWith('--')) positional.push(a);
}
const title = positional.join(' ').trim();
// --- helpers ----------------------------------------------------------------
const slugify = (s) =>
s
.toLowerCase()
.replace(/['’.:]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const fail = (msg) => {
console.error(`✖ ${msg}`);
process.exit(1);
};
const buildImageUrl = (game) => {
// Prefer the wide/landscape screenshot-style image, which suits the
// aspect-[4/3] Hall of Fame cards. Fall back through RAWG's image fields.
const candidates = [
game.background_image,
game.background_image_additional,
];
const url = candidates.find(Boolean);
if (!url) return null;
// RAWG serves http(s) images; force https.
return url.replace(/^http:\/\//i, 'https://');
};
// --- main -------------------------------------------------------------------
const API_KEY = process.env.RAWG_API_KEY;
if (!API_KEY) {
fail(
'RAWG_API_KEY is not set. Add it to .env (see .env.example) and run with\n' +
' node --env-file=.env scripts/fetch-box-art.mjs "..."',
);
}
let game;
try {
if (rawgId) {
console.log(`→ Fetching RAWG game id ${rawgId}…`);
const res = await fetch(`${RAWG_BASE}/games/${rawgId}?key=${API_KEY}`);
if (!res.ok) {
if (res.status === 404) fail(`No RAWG game with id ${rawgId}.`);
fail(`RAWG detail request failed: ${res.status} ${res.statusText}`);
}
game = await res.json();
} else {
if (!title) fail('No game title provided.');
const params = new URLSearchParams({ key: API_KEY, search: title, page_size: '5' });
if (platform) {
const pid = PLATFORM_IDS[platform.toLowerCase()];
if (!pid) {
fail(
`Unknown --platform "${platform}". Try one of: ${Object.keys(PLATFORM_IDS).join(', ')}`,
);
}
params.set('parent_platforms', String(pid));
}
console.log(`→ Searching RAWG for "${title}"${platform ? ` on ${platform}` : ''}…`);
const res = await fetch(`${RAWG_BASE}/games?${params}`);
if (!res.ok) fail(`RAWG search failed: ${res.status} ${res.statusText}`);
const data = await res.json();
if (!data.results?.length) fail(`No RAWG results for "${title}".`);
game = data.results[0];
console.log(` found: "${game.name}" (id ${game.id})${game.released ? `, ${game.released.slice(0, 4)}` : ''}`);
}
} catch (err) {
fail(err?.message ? `Network error: ${err.message}` : 'Network error contacting RAWG.');
}
const imageUrl = buildImageUrl(game);
if (!imageUrl) {
fail(`RAWG has no usable image for "${game.name}". Try another title or --id.`);
}
// Decide output filename + extension (keep the source extension if --out omits one).
const baseName = outName ?? `${slugify(game.name)}.jpg`;
const ext = extname(baseName) || '.jpg';
const fileName = baseName.replace(/\.[^.]+$/, '') + ext;
const outPath = join(OUT_DIR, fileName);
console.log(`→ Downloading ${imageUrl}`);
const imgRes = await fetch(imageUrl);
if (!imgRes.ok) fail(`Image download failed: ${imgRes.status} ${imgRes.statusText}`);
const buf = Buffer.from(await imgRes.arrayBuffer());
await mkdir(OUT_DIR, { recursive: true });
await writeFile(outPath, buf);
const publicPath = `/images/games/${fileName}`;
console.log(`\n✔ Saved: ${outPath}`);
console.log(` Use this in your game file's frontmatter:`);
console.log(` art: "${publicPath}"`);
+84
View File
@@ -0,0 +1,84 @@
---
/**
* GameCard.astro
* ---------------------------------------------------------------------------
* Card for a Gaming Hall of Fame entry. Shows the game's banner/box art (or a
* placeholder slab when no art is set yet), the title, a platform pill, the
* release year, and the blurb. Matches the warm "interest" surface styling and
* the existing card idiom (`.card card-interactive`).
*
* Driven by the `games` collection schema in src/content.config.ts.
*/
import Icon from '@components/Icon.astro';
import { platformToIcon } from '@lib/platforms';
interface Props {
title: string;
platform: string;
blurb: string;
/** Public path to art, e.g. "/images/games/elden-ring.jpg". */
art?: string;
/** Alt text for the art; defaults to the title. */
artAlt?: string;
/** Optional release year shown next to the platform pill. */
year?: number;
/** Optional mdi icon override; defaults to a platform-derived icon. */
icon?: string;
}
const { title, platform, blurb, art, artAlt, year, icon } = Astro.props;
const platIcon = icon ?? platformToIcon(platform);
const alt = artAlt ?? title;
---
<article class="card card-interactive flex h-full flex-col gap-0 overflow-hidden p-0">
<div class="relative aspect-[4/3] w-full overflow-hidden">
{
art ? (
<img
src={art}
alt={alt}
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
/>
) : (
<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="gamepad-variant"
class="h-12 w-12 opacity-50"
style="color: var(--color-text-muted);"
/>
</div>
)
}
</div>
<div class="flex flex-col gap-3 p-5">
<div class="flex flex-wrap items-center gap-2">
<span class="pill text-sm">
<Icon name={platIcon} class="h-3.5 w-3.5" />
{platform}
</span>
{year && (
<span
class="font-mono text-[0.65rem] uppercase tracking-[0.18em]"
style="color: var(--color-text-muted);"
>
{year}
</span>
)}
</div>
<h3 class="font-sans text-lg" style="color: var(--color-text);">{title}</h3>
<p class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
{blurb}
</p>
</div>
</article>
+6 -9
View File
@@ -36,7 +36,7 @@ const innerPx = Math.max(0, outerPx - bw);
<figure <figure
class:list={["relative", className]} class:list={["relative", className]}
style={`border-radius: ${outerPx}px; border: ${bw}px solid color-mix(in srgb, var(--color-accent) 75%, transparent); overflow: hidden;`} style={`border-radius: ${outerPx}px; border: ${bw}px solid color-mix(in srgb, var(--color-accent) 100%, transparent); `}
> >
{/* Faint sacred-geometry tint behind the image (fills the full figure). */} {/* Faint sacred-geometry tint behind the image (fills the full figure). */}
<div <div
@@ -46,14 +46,13 @@ const innerPx = Math.max(0, outerPx - bw);
> >
</div> </div>
{/* The media itself, in normal flow (defines the figure's height via the slot's {
/* The media itself, in normal flow (defines the figure's height via the slot's
aspect-ratio div). Its radius = outer radius - border width, so the image's aspect-ratio div). Its radius = outer radius - border width, so the image's
rounded corner nests PERFECTLY inside the border's rounded corner. The rounded corner nests PERFECTLY inside the border's rounded corner. The
figure's `overflow: hidden` clips the tint layer to the outer radius. */} figure's `overflow: hidden` clips the tint layer to the outer radius. */
<div }
class="relative overflow-hidden" <div class="relative overflow-hidden" style={`border-radius: ${innerPx}px;`}>
style={`border-radius: ${innerPx}px;`}
>
<slot /> <slot />
</div> </div>
@@ -77,5 +76,3 @@ const innerPx = Math.max(0, outerPx - bw);
style="color: var(--color-accent); opacity:.7;" style="color: var(--color-accent); opacity:.7;"
/> />
</figure> </figure>
+39 -1
View File
@@ -8,6 +8,8 @@
* Collections: * Collections:
* - projects: portfolio entries shown on the Professional page * - projects: portfolio entries shown on the Professional page
* - experience: work history entries shown on the Experience timeline * - experience: work history entries shown on the Experience timeline
* - games: Gaming Hall of Fame cards (one file per game)
* - nowPlaying: currently-playing games (single list file)
*/ */
import { defineCollection } from 'astro:content'; import { defineCollection } from 'astro:content';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
@@ -50,4 +52,40 @@ const experience = defineCollection({
}), }),
}); });
export const collections = { projects, experience }; const games = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/games/hall-of-fame' }),
schema: z.object({
title: z.string(),
/** Platform Greg plays it on, e.g. "PlayStation 5". Drives the platform pill + icon. */
platform: z.string(),
/** 1-3 sentence blurb on why it's a favorite. */
blurb: z.string(),
/** Optional public path to box/banner art, e.g. "/images/games/elden-ring.jpg". */
art: z.string().optional(),
/** Optional alt text for the art. Defaults to the title. */
artAlt: z.string().optional(),
/** Optional release year, shown as a small detail. */
year: z.number().optional(),
/**
* Optional mdi icon override for the card badge. Defaults to a per-platform
* icon derived from `platform` (see `platformIcon`).
*/
icon: z.string().optional(),
/** Sort order; lower comes first. Defaults to 100. */
order: z.number().default(100),
}),
});
const nowPlaying = defineCollection({
loader: glob({ pattern: 'now-playing.yaml', base: './src/content/games' }),
schema: z.object({
games: z.array(
z.object({
title: z.string(),
platform: z.string(),
}),
),
}),
});
export const collections = { projects, experience, games, nowPlaying };
@@ -0,0 +1,9 @@
---
title: "Age of Empires"
platform: "PC"
year: 1997
art: "/images/games/age-of-empires.jpg" # run: node scripts/fetch-box-art.mjs "Balatro"
blurb: >
A masterful real time strategy game using historic civilizations from cavemen to castles. My uncle showed me this game and never looked back, I would beg for my family laptop and wake up early to get time in before school. While I could say I learned history playing this game, I mostly learned to run around going "Woo-lu-loo".
order: 50
---
@@ -0,0 +1,9 @@
---
title: "Ark: Survival Evolved"
platform: "Steam"
year: 2017
art: "/images/games/ark-survival-evolved.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
blurb: >
Survival crafting game with dinosaurs. While I do not love the practices of the studio, I do love taming and surviving with dinosaurs. I found an online community here when I was not in the greatest place and learned a lot about myself in the process.
order: 50
---
@@ -0,0 +1,9 @@
---
title: "Dave the Diver"
platform: "Steam"
year: 2023
art: "/images/games/dave-the-diver.jpg" # run: node scripts/fetch-box-art.mjs "Balatro"
blurb: >
A casual, singleplayer adventure RPG featuring deep-sea exploration and fishing during the day and sushi restaurant management at night. This game had no right being as good as it was. I was addicted to this game for a weeks straight.
order: 50
---
@@ -0,0 +1,9 @@
---
title: "Elden Ring"
platform: "PlayStation 5"
year: 2022
art: "/images/games/elden-ring.jpg" # run: node scripts/fetch-box-art.mjs "Elden Ring"
blurb: >
Fantasy Action RPG. The game that made me finish a Souls-like. So good I did it twice! Arcane Blood build, RoB my darling, and an Intelligence Moonveil build, living out my Ichigo arc.
order: 40
---
+9
View File
@@ -0,0 +1,9 @@
---
title: "Knights of the Old Republic"
platform: "Steam"
year: 2003
art: "/images/games/star-wars-knights-of-the-old-republic.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
blurb: >
Fantastic RPG for peak BioWare. My own lightsaber, my own choices, my own force power and awesome companions? My nerd cup runeth over. Plus one of the greatest reveals in gaming. I wish I could replay it knowing nothing. I loved the second one and the MMO as well.
order: 60
---
@@ -0,0 +1,9 @@
---
title: "Minecraft"
platform: "PC"
year: 2010
art: "/images/games/minecraft.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
blurb: >
The crafting game. I picked up a copy of Infdev minecraft from a highschool girlfriend's cousin, and that is the part of the relationship that changed my life. I have spent untold hours building and surviving and modding an endless world of blocks and I wouldn't trade a second of it.
order: 40
---
@@ -0,0 +1,9 @@
---
title: "Pokemon"
platform: "Gameboy"
year: 1998
art: "/images/games/pok-mon-crystal-version.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
blurb: >
The definitive creature collector and battling series. Pokemon Crystal was the first video game that was mine. And I loved it. While Emerald, Platnum, and even Violet might be my more prefered versions, I am a pokemon fan through and through.
order: 10
---
@@ -0,0 +1,9 @@
---
title: "Satisfactory"
platform: "Steam"
year: 2024
art: "/images/games/satisfactory.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
blurb: >
An openworld automation and management game where the wit of your AI overseer meets the harsh realities of an alien planet. There is just something about beatifully lined up conveyer belts feeding an optimized factory with trains that actually run on schedule that I lose hours to.
order: 60
---
@@ -0,0 +1,9 @@
---
title: "Sly Cooper"
platform: "Playstation 2"
year: 2002
art: "/images/games/sly-cooper-and-the-thievius-raccoonus.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
blurb: >
A wonderful cartoony stealth game with platformer elements. The first game I ever 100% and one of my favorite game series to this day. I will happily drop everything and replay these games at almost any moment.
order: 20
---
@@ -0,0 +1,9 @@
---
title: "Stardew Valley"
platform: "Nintendo Switch"
year: 2016
art: "/images/games/stardew-valley.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
blurb: >
The coziest farming sim I have ever played. I have sunk way too many hours into being a blueberry mogal and firmly believe I could run a farm if I could operate it from a switch controller.
order: 30
---
+17
View File
@@ -0,0 +1,17 @@
# Games Greg is actively playing right now. The "Now playing" section on the
# Gaming page reads from this list. Edit freely — add, remove, or reorder.
# `platform` drives the icon shown on the card.
#
# Run `node scripts/fetch-box-art.mjs "<title>"` to pull box/banner art for
# Hall of Fame entries (this list is title + platform only).
games:
- title: "PalWorld"
platform: "Steam"
- title: "Gloomhaven"
platform: "Steam"
- title: "Pokopia"
platform: "Switch 2"
- title: "Slay the Spire 2"
platform: "Steam"
- title: "Indiana Jones and the Great Circle"
platform: "Steam"
+24
View File
@@ -0,0 +1,24 @@
/**
* platforms.ts
* ---------------------------------------------------------------------------
* Helpers for mapping a free-form platform string (as authored in the games /
* nowPlaying content) to an mdi icon name. Kept here so it can be shared
* between the gaming page and any future components without coupling to the
* content collection config.
*/
/**
* Returns an mdi icon name (without the `mdi:` prefix) for a platform string.
* Matches case-insensitively on substrings; unknown platforms fall back to a
* generic gamepad.
*
* @example platformToIcon("PlayStation 5") // "sony-playstation"
*/
export const platformToIcon = (platform: string): string => {
const p = platform.toLowerCase();
if (p.includes('playstation') || p.includes('ps5') || p.includes('ps4')) return 'sony-playstation';
if (p.includes('switch')) return 'nintendo-switch';
if (p.includes('steam') || p.includes('pc')) return 'steam';
if (p.includes('xbox')) return 'microsoft-xbox';
return 'gamepad-variant';
};
+153 -38
View File
@@ -9,45 +9,95 @@ 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 GameCard from "@components/cards/GameCard.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 Icon from "@components/Icon.astro"; import Icon from "@components/Icon.astro";
import { getCollection, getEntry } from "astro:content";
import { platformToIcon } from "@lib/platforms";
const platforms = [ const platforms = [
{ {
icon: "sony-playstation", icon: "sony-playstation",
title: "PlayStation 5", title: "PlayStation 5",
body: "Main console for AAA titles and exclusives.", body: "Mainly for single player and exclusives.",
}, },
{ {
icon: "nintendo-switch", icon: "nintendo-switch",
title: "Nintendo Switch / Switch 2", title: "Nintendo Switch / Switch 2",
body: "Handheld and docked. Nintendo first-party plus indie gems.", body: "Handheld and docked. Pokemon, Mario, and many other Nintendo and indie gems.",
}, },
{ {
icon: "steam", icon: "steam",
title: "Steam", title: "Steam",
body: "The primary PC library. Strategy, RPGs, and an ever-growing backlog.", body: "The primary game library. Strategy, RPGs, Adventure, oh my! And an ever-growing backlog.",
}, },
]; ];
/**
* Favorite genres. Each is a small card with an mdi icon and a one-line blurb
* on what Greg loves about that genre. Page-local; edit freely.
*/
const genres = [ const genres = [
"RPGs (both Western and JRPG)", {
"Strategy and tactics", icon: "sword-cross",
"Building and crafting", name: "RPGs (Western & JRPG)",
"Automation", blurb:
"Open-world exploration", "Character progression and stories I can live in for 100+ hours and then turn around and do it again.",
"Indie darlings", },
{
icon: "pokeball",
name: "Creature Collecting",
blurb: "If I can catch, train, or befriend a roster, I'm sold.",
},
{
icon: "chess-king",
name: "Strategy & Tactics",
blurb:
"Turn-based puzzles and tactical challengeswhere the right plan is oh so rewarding.",
},
{
icon: "hammer",
name: "Building & Crafting",
blurb:
"Give me a grid, resources, and a goal, and watch the evening vanish.",
},
{
icon: "factory",
name: "Automation",
blurb: "Optimizing a production line until its perfect is my zen.",
},
{
icon: "compass-outline",
name: "Open-world Exploration",
blurb:
"The map IS the point. I will try to climb every mountain and I will check every waterfall for treasure.",
},
{
icon: "star-four-points",
name: "Indie Darlings",
blurb:
"The small, weird, passionate projects that punch far above their size.",
},
]; ];
// Gaming Hall of Fame — one file per game in src/content/games/hall-of-fame/.
const hallOfFame = (await getCollection("games")).sort(
(a, b) => a.data.order - b.data.order,
);
// Currently playing — single list in src/content/games/now-playing.yaml.
const nowPlayingEntry = await getEntry("nowPlaying", "now-playing");
const nowPlaying = nowPlayingEntry?.data.games ?? [];
const related = [ const related = [
{ /*{
href: "/interests/pokemon", href: "/interests/pokemon",
title: "Pokémon", title: "Pokémon",
icon: "pokeball", icon: "pokeball",
blurb: "Nintendo console overlap and a lifelong obsession.", blurb: "Nintendo console overlap and a lifelong obsession.",
}, },*/
{ {
href: "/interests/ttrpgs", href: "/interests/ttrpgs",
title: "TTRPGs & Tabletop", title: "TTRPGs & Tabletop",
@@ -63,9 +113,9 @@ const related = [
description="PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten politely out of hand. RPGs, strategy, and deeply layered worlds." description="PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten politely out of hand. RPGs, strategy, and deeply layered worlds."
> >
<InterestHero <InterestHero
icon="sony-playstation" icon="gamepad-variant"
title="Gaming" title="Gaming"
tagline="If it has a screen or a controller, I have probably played it." tagline="Walking in another's shoes, escaping reality, or obsessively completing collections, one game at a time."
image="/images/gaming-setup.jpg" image="/images/gaming-setup.jpg"
imageAlt="A gaming setup with controller and console" imageAlt="A gaming setup with controller and console"
/> />
@@ -78,11 +128,16 @@ const related = [
> >
<Reveal <Reveal
><p> ><p>
Gaming is my other constant. I span console generations with a Gaming has always been a passion in my life. I span console
PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten generations from the Sega Genesis to the modern era. I play mainly
politely out of hand. I'm particularly drawn to RPGs, strategy games, with a PlayStation 5, Nintendo Switch 2, and a Steam library that has
and anything with a deeply layered world. If a game has lore gotten politely out of hand. I'm particularly drawn to RPGs, strategy
compendiums, I'm reading them. games, and anything with a creature to tame or a deeply layered world
to explore. But a good game loop will get me sucked in harder than the
sunk cost fallacy of a League of Legends streak. I love to play with
friends, but have found myself leaning more towards single-player
experiences. Someone should have told younger me that scheduling would
be the final boss!
</p></Reveal </p></Reveal
> >
</section> </section>
@@ -119,16 +174,37 @@ const related = [
/></Reveal /></Reveal
> >
<Reveal delay={80}> <Reveal delay={80}>
<ul class="flex flex-wrap gap-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{ {
genres.map((g) => ( genres.map((g, i) => (
<li class="pill text-sm"> <Reveal delay={i * 60}>
<Icon name="star-four-points" class="h-3.5 w-3.5" /> <article class="card card-interactive flex h-full flex-col gap-2 p-4">
{g} <div class="flex items-center gap-2.5">
</li> <span
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name={g.icon} class="h-5 w-5" />
</span>
<h3
class="font-sans text-base"
style="color: var(--color-text);"
>
{g.name}
</h3>
</div>
<p
class="text-sm leading-relaxed"
style="color: var(--color-text-muted);"
>
{g.blurb}
</p>
</article>
</Reveal>
)) ))
} }
</ul> </div>
</Reveal> </Reveal>
</section> </section>
@@ -141,24 +217,63 @@ const related = [
title="Now playing" title="Now playing"
/></Reveal /></Reveal
> >
<div class="grid gap-4 sm:grid-cols-[1fr_1fr]"> {
<figure nowPlaying.length > 0 ? (
class="relative overflow-hidden rounded-xl border" <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);" {nowPlaying.map((g, i) => (
<Reveal delay={i * 60}>
<InterestCard
icon={platformToIcon(g.platform)}
title={g.title}
currently
> >
<img {g.platform}
src="/images/gaming-setup.jpg" </InterestCard>
alt="Gaming setup" </Reveal>
loading="lazy" ))}
decoding="async" </div>
class="aspect-[4/3] w-full object-cover" ) : (
/>
</figure>
<Placeholder <Placeholder
label="Currently playing" label="Currently playing"
hint="Add the games you are actively playing right now." hint="Add games to src/content/games/now-playing.yaml."
/> />
)
}
</section>
{/* Gaming Hall of Fame */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Hall of Fame"
icon="trophy"
title="All-time favorites"
/></Reveal
>
{
hallOfFame.length > 0 ? (
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{hallOfFame.map((g, i) => (
<Reveal delay={i * 80}>
<GameCard
title={g.data.title}
platform={g.data.platform}
blurb={g.data.blurb}
art={g.data.art}
artAlt={g.data.artAlt}
year={g.data.year}
icon={g.data.icon}
/>
</Reveal>
))}
</div> </div>
) : (
<Placeholder
label="Gaming Hall of Fame"
hint="Add games to src/content/games/hall-of-fame/."
/>
)
}
</section> </section>
<RelatedInterests items={related} /> <RelatedInterests items={related} />
+1 -1
View File
@@ -27,7 +27,7 @@ const interests = [
}, },
{ {
href: "/interests/gaming", href: "/interests/gaming",
icon: "sony-playstation", icon: "gamepad-variant",
title: "Gaming", title: "Gaming",
tagline: "We do not talk about my backlog.", tagline: "We do not talk about my backlog.",
blurb: blurb:
+135 -34
View File
@@ -5,26 +5,57 @@
* Per-interest template with equipment card grid, a gallery placeholder, and the * Per-interest template with equipment card grid, a gallery placeholder, and the
* spec's connection-to-TTRPGs callout. Related links back to TTRPGs and Gaming. * spec's connection-to-TTRPGs callout. Related links back to TTRPGs and Gaming.
*/ */
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 Icon from '@components/Icon.astro'; import Icon from "@components/Icon.astro";
import CelestialImage from '@components/mystic/CelestialImage.astro'; import CelestialImage from "@components/mystic/CelestialImage.astro";
const equipment = [ const equipment = [
{ icon: 'palette', title: 'Resin Dice Making', body: 'Custom molds, pressure pot curing, pigment experimentation. Producing dice sets for personal use and gifts.' }, {
{ icon: 'printer-3d', title: 'Elegoo Mars 4', body: 'Resin 3D printer. Miniatures, terrain, and high-detail props.' }, icon: "dice-multiple",
{ icon: 'printer-3d-nozzle', title: 'Bambu Labs P1S', body: 'FDM 3D printer. Functional prints, terrain pieces, and larger prototypes.' }, title: "Resin Dice Making",
{ icon: 'wrench', title: 'General Making', body: 'Always experimenting. If there is a new technique or material, I want to try it.' }, body: "Custom molds, pressure pot curing, pigment experimentation. Producing dice sets for personal use and gifts.",
},
{
icon: "printer-3d",
title: "Elegoo Mars 4",
body: "Resin 3D printer. Miniatures, terrain, and high-detail props.",
},
{
icon: "printer-3d-nozzle",
title: "Bambu Labs P1S",
body: "FDM 3D printer. Functional prints, terrain pieces, and larger prototypes.",
},
{
icon: "scissors-cutting",
title: "Silhouette Cameo 5a",
body: "Multi material cutting machine. Silly stickers, custom vinyl decals, and custom/proxy TCG cards.",
},
{
icon: "wrench",
title: "General Making",
body: "Always experimenting and hoarding nice cardboard. If there is a new technique or material, I want to try it.",
},
]; ];
const related = [ const related = [
{ href: '/interests/ttrpgs', title: 'TTRPGs & Tabletop', icon: 'dice-multiple', blurb: 'Much of what I make ends up on the virtual table.' }, {
{ href: '/interests/reading', title: 'Reading & Media', icon: 'book-open-page-variant', blurb: 'Displays and props for the stories I love.' }, href: "/interests/ttrpgs",
title: "TTRPGs & Tabletop",
icon: "dice-multiple",
blurb: "Much of what I make ends up on the virtual table.",
},
{
href: "/interests/reading",
title: "Reading & Media",
icon: "book-open-page-variant",
blurb: "Displays and props for the stories I love.",
},
]; ];
--- ---
@@ -36,58 +67,128 @@ const related = [
<InterestHero <InterestHero
icon="palette" icon="palette"
title="Making & Creating" title="Making & Creating"
tagline="If it involves layers and precision, I am in." tagline="A magnet for super glue and nice cardboard."
image="/images/making-print.jpg" image="/images/making-print.jpg"
imageAlt="A 3D printer producing a detailed model" imageAlt="A 3D printer producing a detailed model"
/> />
<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>Making is how I decompress. I cast resin dice using pressure pots and custom molds, print miniatures and props on my Elegoo Mars 4 (resin) and Bambu Labs P1S (FDM), and generally fill my workspace with projects-in-progress. There's something deeply satisfying about taking raw material through a process and holding the finished piece, whether that's a perfect polyhedral set or a printed terrain piece for the next game session.</p></Reveal> class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal
><p>
Making is part of me. Ever since I can remember, I had been dreaming
of making inventions and things from cardboard and clay. I took things
apart to see how they worked, and rarely got them back together. But
now I have the tools and the skills to take ideas from my imagination
and physically produce them. I cast resin dice using pressure pots and
custom molds, print miniatures and props on my Elegoo Mars 4 (resin)
and Bambu Labs P1S (FDM), and generally fill my workspace with
projects-in-progress. I make beatiful cards as gifts and even make
proxy mockups of trading cards to enjoy games casually.
</p></Reveal
>
<Reveal>
<p>
There's something deeply satisfying about taking raw material through
a process and holding the finished piece, whether that's a perfect
polyhedral set or a hook that fits just right in that weird spot in
the bathroom. The ability to blend my technial skills and my creative
impulses into tangible objects is a fantasy come true my child self
only dreamed of.
</p></Reveal
>
</section> </section>
{/* Equipment */} {/* Equipment */}
<section class="flex flex-col gap-6"> <section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Equipment" icon="toolbox" title="What is on the bench" /></Reveal> <Reveal
><SectionHeader
kicker="Equipment"
icon="toolbox"
title="What is on the bench"
/></Reveal
>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-2">
{equipment.map((e, i) => ( {
equipment.map((e, i) => (
<Reveal delay={i * 70}> <Reveal delay={i * 70}>
<InterestCard icon={e.icon} title={e.title}>{e.body}</InterestCard> <InterestCard icon={e.icon} title={e.title}>
{e.body}
</InterestCard>
</Reveal> </Reveal>
))} ))
}
</div> </div>
</section> </section>
{/* Gallery */} {/* Gallery */}
<section class="flex flex-col gap-6"> <section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Gallery" icon="image-multiple" title="Finished pieces" /></Reveal> <Reveal
><SectionHeader
kicker="Gallery"
icon="image-multiple"
title="Finished pieces"
/></Reveal
>
<Reveal delay={80}> <Reveal delay={80}>
<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 src="/images/making-resin.jpg" alt="Resin dice set" aspect="4/3" /> <CelestialImage
<CelestialImage src="/images/making-print.jpg" alt="3D printed miniature" aspect="4/3" /> src="/images/making-resin.jpg"
<CelestialImage src="/images/ttrpg-dice.jpg" alt="Terrain piece and dice" aspect="4/3" /> alt="Resin dice set"
aspect="4/3"
/>
<CelestialImage
src="/images/making-print.jpg"
alt="3D printed miniature"
aspect="4/3"
/>
<CelestialImage
src="/images/ttrpg-dice.jpg"
alt="Terrain piece and dice"
aspect="4/3"
/>
</div> </div>
</Reveal> </Reveal>
<Reveal delay={120}>
<Placeholder label="Your real photos" hint="Swap these stock stand-ins with photos of your own resin casts and prints." />
</Reveal>
</section> </section>
{/* Connection to TTRPGs */} {/* Connection to TTRPGs */}
<section class="flex flex-col gap-5"> <section class="flex flex-col gap-5">
<Reveal><SectionHeader kicker="Cross-pollination" icon="link-variant" title="Where it all connects" /></Reveal> <Reveal
><SectionHeader
kicker="Cross-pollination"
icon="link-variant"
title="Where it all connects"
/></Reveal
>
<Reveal delay={80}> <Reveal delay={80}>
<a <a
href="/interests/ttrpgs" href="/interests/ttrpgs"
class="card card-interactive group flex items-center gap-4" class="card card-interactive group flex items-center gap-4"
> >
<Icon name="dice-multiple" class="h-8 w-8 shrink-0" style="color: var(--color-accent);" /> <Icon
name="dice-multiple"
class="h-8 w-8 shrink-0"
style="color: var(--color-accent);"
/>
<span class="flex flex-col gap-1"> <span class="flex flex-col gap-1">
<span class="font-sans font-semibold" style="color: var(--color-text);">Much of what I make ends up on the virtual table.</span> <span
<span class="text-sm" style="color: var(--color-text-muted);">Custom tokens, terrain, and props for Foundry VTT campaigns.</span> class="font-sans font-semibold"
style="color: var(--color-text);"
>Much of what I make ends up on the virtual table.</span
>
<span class="text-sm" style="color: var(--color-text-muted);"
>Custom tokens, terrain, and props for Foundry VTT campaigns.</span
>
</span> </span>
<Icon name="arrow-right" class="ml-auto h-5 w-5 shrink-0 transition-transform group-hover:translate-x-1" style="color: var(--color-accent);" /> <Icon
name="arrow-right"
class="ml-auto h-5 w-5 shrink-0 transition-transform group-hover:translate-x-1"
style="color: var(--color-accent);"
/>
</a> </a>
</Reveal> </Reveal>
</section> </section>
+1 -1
View File
@@ -15,7 +15,7 @@ import Placeholder from '@components/layout/Placeholder.astro';
import Icon from '@components/Icon.astro'; import Icon from '@components/Icon.astro';
const related = [ const related = [
{ href: '/interests/gaming', title: 'Gaming', icon: 'sony-playstation', blurb: 'Nintendo console overlap, from Game Boy to Switch 2.' }, { href: '/interests/gaming', title: 'Gaming', icon: 'gamepad-variant', blurb: 'Nintendo console overlap, from Game Boy to Switch 2.' },
{ href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Displays and props for collections.' }, { href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Displays and props for collections.' },
]; ];
--- ---
+1 -1
View File
@@ -24,7 +24,7 @@ const highlights = [
const related = [ const related = [
{ href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Dice and terrain made for campaigns.' }, { href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Dice and terrain made for campaigns.' },
{ href: '/interests/gaming', title: 'Gaming', icon: 'sony-playstation', blurb: 'Digital RPGs as another expression of the same impulse.' }, { href: '/interests/gaming', title: 'Gaming', icon: 'gamepad-variant', blurb: 'Digital RPGs as another expression of the same impulse.' },
]; ];
--- ---