mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Start the rebuild: note schema, vault kit and data migration
Phase 1 of the functional design plan. - Move the old site into legacy/, unchanged, for reference. It still builds from there until cutover. - Add packages/schema: the one definition of all 20 note types, with field titles and descriptions, note and vault checks, and JSON Schema export so later editing tools and the MCP server share it. - Add vault-kit: Obsidian templates generated from the schema, and the Site guide for 24.01_PersonalWebsite. - Add tools/migrate: turns the legacy JSON, collections, store data and page text into 545 Site folder notes and 84 images, and checks the result with the schema. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
---
|
||||
/**
|
||||
* about.astro — About Me
|
||||
* ---------------------------------------------------------------------------
|
||||
* The human story bridging the professional and personal sides. Three sections
|
||||
* from the spec, all copy verbatim: the short version, the longer arc, and the
|
||||
* quick facts (spec emojis mapped to mdi icons). Ends with the SplitTransition
|
||||
* fork into Professional / Interests.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import Icon from "@components/Icon.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import SplitTransition from "@components/layout/SplitTransition.astro";
|
||||
import CelestialImage from "@components/mystic/CelestialImage.astro";
|
||||
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
|
||||
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
|
||||
|
||||
// Spec "Quick Facts", emojis -> mdi icons.
|
||||
const quickFacts = [
|
||||
{ icon: "map-marker", text: "Philadelphia, PA" },
|
||||
{
|
||||
icon: "briefcase",
|
||||
text: "BTP, Cloud, and Mobile App Lead at Implico Inc.",
|
||||
},
|
||||
{ icon: "dog", text: "Dog dad to Kashi the mini dachshund" },
|
||||
{ icon: "dice-multiple", text: "Running D&D & Daggerheart campaigns" },
|
||||
{
|
||||
icon: "book-open-variant",
|
||||
text: "Currently tandem reading: Heir of Fire and Tower of Dawn by Sarah J. Maas",
|
||||
},
|
||||
{ icon: "tea", text: "Fueled by loose-leaf Chinese tea and Mountain Dew" },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About"
|
||||
description="The human story behind the resume: Greg Kilmer on curiosity, tabletop RPGs, resin dice, and a Philadelphia apartment with too many board games."
|
||||
>
|
||||
<!-- Hero — full animated Milky Way field, matching the Interests hero. -->
|
||||
<section class="on-sky relative overflow-hidden pb-8 pt-28 sm:pt-32">
|
||||
<AnimatedBackground client:idle className="vt-sky" />
|
||||
<div class="container-prose relative z-10 flex flex-col gap-8 py-8">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
tone="hero"
|
||||
kicker="About"
|
||||
icon="account-details"
|
||||
title="The person behind the projects"
|
||||
subtitle="Two wings of the same house. This page is the bridge between them."
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 1: The Short Version -->
|
||||
<section class="container-prose flex flex-col gap-8 py-10">
|
||||
<Reveal>
|
||||
<p
|
||||
class="border-l-2 pl-5 font-sans text-xl leading-relaxed sm:text-2xl"
|
||||
style="border-color: var(--color-gold); color: var(--color-text);"
|
||||
>
|
||||
I am Greg: a software developer and technical team lead based in
|
||||
Philadelphia who spends his free time running tabletop RPGs, making
|
||||
resin dice and custom cards, 3D printing anything I can, and reading his
|
||||
way through fantasy series at an alarming rate. I live with my partner
|
||||
Krystina, our mini dachshund Kashi, and more board games than any
|
||||
reasonable apartment should hold.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<!-- Section 2: The Longer Arc -->
|
||||
<section class="container-prose grid gap-10 py-10 lg:grid-cols-[1fr_18rem]">
|
||||
<Reveal class="flex flex-col gap-5">
|
||||
<h2>The longer arc</h2>
|
||||
<div
|
||||
class="flex flex-col gap-5 text-lg leading-relaxed"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
<p>
|
||||
I grew up loving books and games, and not much has changed. Technology
|
||||
pulled me into a career building cloud and mobile applications, but I
|
||||
never stopped collecting dice, designing campaigns, or losing weekends
|
||||
to Sanderson reads. I am currently expanding into SAP's Business
|
||||
Technology Platform, exploring AI and machine learning, and trying to
|
||||
convince my group that yes, we
|
||||
<em> can</em> fit one more board game into game night.
|
||||
</p>
|
||||
<p>
|
||||
I believe the best work happens when curiosity isn't
|
||||
compartmentalized. The same system-thinking that makes a good
|
||||
developer makes a good gamemaster. The patience that produces a clean
|
||||
resin cast is the same patience that debugs a stubborn deployment.
|
||||
This site is where I don't have to pretend those things are separate.
|
||||
</p>
|
||||
</div>
|
||||
<br />
|
||||
<Reveal delay={80}>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<CelestialImage
|
||||
src="/images/kashi_at_work.jpg"
|
||||
alt="Kashi, a mini dachshund, at work with his dog dad"
|
||||
aspect="4/3"
|
||||
/>
|
||||
<CelestialImage
|
||||
src="/images/tea-gaiwan.jpg"
|
||||
alt="A tea gaiwan set on a mushroom towel"
|
||||
aspect="4/3"
|
||||
/>
|
||||
</div></Reveal
|
||||
>
|
||||
</Reveal>
|
||||
|
||||
<!-- Portrait + quick facts sidebar -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<Reveal delay={60}>
|
||||
<CelestialImage
|
||||
src="/images/about-portrait.jpg"
|
||||
alt="Greg, a candid portrait"
|
||||
aspect="4/5"
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={120}>
|
||||
<aside class="card flex flex-col gap-4 lg:sticky lg:top-24">
|
||||
<h3
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
Quick facts
|
||||
</h3>
|
||||
<ul class="flex flex-col gap-3">
|
||||
{
|
||||
quickFacts.map((f) => (
|
||||
<li
|
||||
class="flex items-start gap-3 text-sm"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
<Icon
|
||||
name={f.icon}
|
||||
class="mt-0.5 h-5 w-5 shrink-0"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</aside>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Reveal>
|
||||
<div class="container-prose">
|
||||
<AlchemicalDivider
|
||||
class="mx-auto h-6 w-full max-w-sm"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<!-- Section 3: The fork -->
|
||||
<section class="container-prose flex flex-col gap-6 py-16">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="Both doors open here"
|
||||
icon="call-split"
|
||||
title="Where curiosity goes next"
|
||||
subtitle="The professional and personal sides are connected, not siloed. Pick a door."
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<SplitTransition />
|
||||
</Reveal>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* /api/contact
|
||||
* ---------------------------------------------------------------------------
|
||||
* POST endpoint that receives the contact form. Pipeline:
|
||||
* 1. Reject non-POST.
|
||||
* 2. Validate JSON body with Zod (name, email, message; honeypot must be empty).
|
||||
* 3. Rate-limit per IP (in-memory windowed counter).
|
||||
* 4. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set).
|
||||
* 5. Deliver: SMTP via nodemailer if configured, else POST to a webhook if
|
||||
* configured, else return 503 so delivery can be wired later.
|
||||
* 6. Always return JSON { ok } or { error }.
|
||||
*
|
||||
* Secrets come from env (see .env.example). Nothing secret ships to the client.
|
||||
* This runs server-side only via the @astrojs/node adapter.
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
contactTo,
|
||||
contactWebhookUrl,
|
||||
env,
|
||||
smtpFrom,
|
||||
smtpHost,
|
||||
smtpPass,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
turnstileSecretKey,
|
||||
} from '@lib/env';
|
||||
|
||||
// --- Validation schema ------------------------------------------------------
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
email: z.string().email().max(200),
|
||||
message: z.string().min(10).max(5000),
|
||||
// Honeypot: must be absent/empty. Bots tend to fill every field.
|
||||
company: z.string().max(0).optional().or(z.literal('')),
|
||||
// Turnstile token. Required when TURNSTILE_SECRET_KEY is set; ignored
|
||||
// otherwise (dev). Validated against Cloudflare's siteverify endpoint below.
|
||||
turnstileToken: z.string().optional(),
|
||||
});
|
||||
|
||||
// --- In-memory rate limiter (per worker) ------------------------------------
|
||||
// Good enough for a personal site behind a reverse proxy. For multi-instance,
|
||||
// swap this for Redis/Upstash. Window = N submissions per IP per X minutes.
|
||||
// Tunables are read per-request via the env helper so they resolve in both dev
|
||||
// (import.meta.env) and Docker (process.env).
|
||||
const hits = new Map<string, { count: number; reset: number }>();
|
||||
|
||||
function rateLimited(ip: string): boolean {
|
||||
const rateWindowMin = Number(env('CONTACT_RATE_WINDOW_MIN') ?? 15);
|
||||
const rateLimit = Number(env('CONTACT_RATE_LIMIT') ?? 5);
|
||||
const now = Date.now();
|
||||
const entry = hits.get(ip);
|
||||
if (!entry || now > entry.reset) {
|
||||
hits.set(ip, { count: 1, reset: now + rateWindowMin * 60_000 });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > rateLimit;
|
||||
}
|
||||
|
||||
// --- Helpers ----------------------------------------------------------------
|
||||
function json(body: unknown, status: number) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function clientIp(request: Request): string {
|
||||
// Cosmos/Traefik typically forwards the real client IP here.
|
||||
const fwd = request.headers.get('x-forwarded-for');
|
||||
if (fwd) return fwd.split(',')[0]!.trim();
|
||||
return request.headers.get('x-real-ip') ?? 'unknown';
|
||||
}
|
||||
|
||||
// --- Turnstile verification -------------------------------------------------
|
||||
// Skipped entirely when TURNSTILE_SECRET_KEY is unset (local dev). When set,
|
||||
// the token from the client is POSTed to Cloudflare's siteverify endpoint
|
||||
// along with the visitor's IP for fraud scoring. Only valid tokens proceed.
|
||||
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
|
||||
const secret = turnstileSecretKey();
|
||||
if (!secret) return true; // Turnstile not configured -> skip.
|
||||
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
const form = new URLSearchParams();
|
||||
form.set('secret', secret);
|
||||
form.set('response', token);
|
||||
if (ip && ip !== 'unknown') form.set('remoteip', ip);
|
||||
|
||||
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
return Boolean(data.success);
|
||||
} catch (err) {
|
||||
// Network error talking to Cloudflare. Fail closed: reject the submission
|
||||
// rather than letting a bot through on a transient outage.
|
||||
console.error('[contact] turnstile verification failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Route handler ----------------------------------------------------------
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed.' }, 405);
|
||||
}
|
||||
|
||||
// Parse + validate.
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON body.' }, 400);
|
||||
}
|
||||
const parsed = schema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return json({ error: 'Please complete all fields correctly.', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
const { name, email, message, turnstileToken } = parsed.data;
|
||||
|
||||
// Rate limit.
|
||||
const ip = clientIp(request);
|
||||
if (rateLimited(ip)) {
|
||||
return json({ error: 'Too many submissions. Please try again later.' }, 429);
|
||||
}
|
||||
|
||||
// Turnstile. Skipped when no secret is configured (dev). Fails closed on
|
||||
// network errors so a Cloudflare outage can't open the door to bots.
|
||||
const turnstileOk = await verifyTurnstile(turnstileToken, ip);
|
||||
if (!turnstileOk) {
|
||||
return json({ error: 'Bot check failed. Please refresh and try again.' }, 400);
|
||||
}
|
||||
|
||||
// Deliver.
|
||||
try {
|
||||
const delivered = await deliver({ name, email, message });
|
||||
if (!delivered) {
|
||||
// No SMTP or webhook configured. Tell the client clearly without leaking
|
||||
// server internals, but return 503 so it is visibly a config gap.
|
||||
return json({ error: 'Contact delivery is not configured yet. Please email me directly.' }, 503);
|
||||
}
|
||||
return json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error('[contact] delivery failed:', err);
|
||||
return json({ error: 'Could not send your message right now. Please try again later.' }, 502);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Delivery: SMTP first, webhook fallback ---------------------------------
|
||||
interface Message {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function deliver(msg: Message): Promise<boolean> {
|
||||
const host = smtpHost();
|
||||
const webhook = contactWebhookUrl();
|
||||
|
||||
if (host) {
|
||||
// Dynamic import keeps nodemailer out of cold-start when unused.
|
||||
const nodemailer = (await import('nodemailer')).default;
|
||||
const port = Number(smtpPort() ?? 587);
|
||||
const user = smtpUser();
|
||||
// Accept self-signed certificates when explicitly opted in. Intended for
|
||||
// internal homelab hops where the SMTP relay (e.g. Stalwart on the same
|
||||
// Docker network) presents a self-signed cert and the connection never
|
||||
// traverses an untrusted network. Defaults to false so production-grade
|
||||
// TLS verification is the default; the override is a deliberate choice.
|
||||
const allowSelfSigned = env('SMTP_ALLOW_SELFSIGNED') === 'true';
|
||||
const transport = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: user ? { user, pass: smtpPass() } : undefined,
|
||||
...(allowSelfSigned
|
||||
? { tls: { rejectUnauthorized: false } }
|
||||
: {}),
|
||||
});
|
||||
const hostname = (() => {
|
||||
try {
|
||||
return new URL(process.env.SITE_URL ?? 'https://gregkilmer.com').hostname;
|
||||
} catch {
|
||||
return 'gregkilmer.com';
|
||||
}
|
||||
})();
|
||||
await transport.sendMail({
|
||||
from: smtpFrom() ?? `Greg's Site <no-reply@${hostname}>`,
|
||||
to: contactTo() ?? 'greg@gregkilmer.com',
|
||||
replyTo: `${msg.name} <${msg.email}>`,
|
||||
subject: `New message from ${msg.name} via gregkilmer.com`,
|
||||
text: `Name: ${msg.name}\nEmail: ${msg.email}\n\n${msg.message}`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (webhook) {
|
||||
const res = await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `New contact from ${msg.name} (${msg.email}):\n\n${msg.message}`,
|
||||
// Structured fields for webhook consumers (Discord/Slack/n8n).
|
||||
name: msg.name,
|
||||
email: msg.email,
|
||||
message: msg.message,
|
||||
}),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* /api/order
|
||||
* ---------------------------------------------------------------------------
|
||||
* POST endpoint for store order requests. Pipeline mirrors /api/contact:
|
||||
* 1. Reject non-POST.
|
||||
* 2. HARD GATE: read store-availability.json; if ordersOpen is false, return
|
||||
* 403 immediately. This enforces the lock server-side so it can't be
|
||||
* bypassed by POSTing directly while the client form is hidden.
|
||||
* 3. Validate JSON body with Zod.
|
||||
* 4. Rate-limit per IP (in-memory windowed counter).
|
||||
* 5. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set).
|
||||
* 6. Deliver via SMTP (nodemailer) if configured, else webhook, else 503.
|
||||
*
|
||||
* This collects an ORDER REQUEST only — no payment is processed. The payload is
|
||||
* emailed to the configured inbox (ORDER_TO, falling back to CONTACT_TO).
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
contactWebhookUrl,
|
||||
env,
|
||||
orderTo,
|
||||
smtpFrom,
|
||||
smtpHost,
|
||||
smtpPass,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
turnstileSecretKey,
|
||||
} from '@lib/env';
|
||||
import { storeAvailability } from '@data/store-availability';
|
||||
|
||||
// --- Validation schema ------------------------------------------------------
|
||||
const orderTypeEnum = z.enum(['cards', 'deckBoxes', 'both']);
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
email: z.string().email().max(200),
|
||||
orderType: orderTypeEnum.default('cards'),
|
||||
// Filament color for printed items. Optional (only for deckBoxes/both).
|
||||
color: z.string().max(120).optional().or(z.literal('')),
|
||||
quantity: z.string().min(1).max(200),
|
||||
details: z.string().min(10).max(20000),
|
||||
notes: z.string().max(5000).optional().or(z.literal('')),
|
||||
// Policy acknowledgment checkbox must be ticked.
|
||||
agree: z.literal(true),
|
||||
// Honeypot: must be empty.
|
||||
company: z.string().max(0).optional().or(z.literal('')),
|
||||
// Turnstile token (required when TURNSTILE_SECRET_KEY is set).
|
||||
turnstileToken: z.string().optional(),
|
||||
});
|
||||
|
||||
// Human-readable label for the order type, used in the email subject/body.
|
||||
const orderTypeLabel: Record<string, string> = {
|
||||
cards: 'Proxy MTG cards',
|
||||
deckBoxes: '3D-printed deck box / counters',
|
||||
both: 'Cards + 3D prints',
|
||||
};
|
||||
|
||||
// --- In-memory rate limiter (per worker) ------------------------------------
|
||||
// Separate counter namespace from /api/contact so order submits don't share
|
||||
// the contact budget. Tunables read via env helper for dev + Docker parity.
|
||||
const hits = new Map<string, { count: number; reset: number }>();
|
||||
|
||||
function rateLimited(ip: string): boolean {
|
||||
const rateWindowMin = Number(env('ORDER_RATE_WINDOW_MIN') ?? env('CONTACT_RATE_WINDOW_MIN') ?? 15);
|
||||
const rateLimit = Number(env('ORDER_RATE_LIMIT') ?? env('CONTACT_RATE_LIMIT') ?? 5);
|
||||
const now = Date.now();
|
||||
const entry = hits.get(ip);
|
||||
if (!entry || now > entry.reset) {
|
||||
hits.set(ip, { count: 1, reset: now + rateWindowMin * 60_000 });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > rateLimit;
|
||||
}
|
||||
|
||||
// --- Helpers ----------------------------------------------------------------
|
||||
function json(body: unknown, status: number) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function clientIp(request: Request): string {
|
||||
const fwd = request.headers.get('x-forwarded-for');
|
||||
if (fwd) return fwd.split(',')[0]!.trim();
|
||||
return request.headers.get('x-real-ip') ?? 'unknown';
|
||||
}
|
||||
|
||||
// --- Turnstile verification (same logic as /api/contact) --------------------
|
||||
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
|
||||
const secret = turnstileSecretKey();
|
||||
if (!secret) return true; // Turnstile not configured -> skip.
|
||||
if (!token) return false;
|
||||
try {
|
||||
const form = new URLSearchParams();
|
||||
form.set('secret', secret);
|
||||
form.set('response', token);
|
||||
if (ip && ip !== 'unknown') form.set('remoteip', ip);
|
||||
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
return Boolean(data.success);
|
||||
} catch (err) {
|
||||
console.error('[order] turnstile verification failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Route handler ----------------------------------------------------------
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
if (request.method !== 'POST') {
|
||||
return json({ error: 'Method not allowed.' }, 405);
|
||||
}
|
||||
|
||||
// HARD GATE: orders closed -> reject before any other work. Re-reading the
|
||||
// JSON module here reflects whatever Greg committed to the file at deploy.
|
||||
if (!storeAvailability.ordersOpen) {
|
||||
return json({ error: 'Orders are currently closed. Please check back soon.' }, 403);
|
||||
}
|
||||
|
||||
// Parse + validate.
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON body.' }, 400);
|
||||
}
|
||||
const parsed = schema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return json({ error: 'Please complete all fields correctly, including the policy acknowledgment.', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
const { name, email, orderType, color, quantity, details, notes, turnstileToken } = parsed.data;
|
||||
|
||||
// Rate limit.
|
||||
const ip = clientIp(request);
|
||||
if (rateLimited(ip)) {
|
||||
return json({ error: 'Too many submissions. Please try again later.' }, 429);
|
||||
}
|
||||
|
||||
// Turnstile.
|
||||
const turnstileOk = await verifyTurnstile(turnstileToken, ip);
|
||||
if (!turnstileOk) {
|
||||
return json({ error: 'Bot check failed. Please refresh and try again.' }, 400);
|
||||
}
|
||||
|
||||
// Deliver.
|
||||
try {
|
||||
const delivered = await deliver({ name, email, orderType, color: color ?? '', quantity, details, notes: notes ?? '' });
|
||||
if (!delivered) {
|
||||
return json({ error: 'Order delivery is not configured yet. Please email me directly.' }, 503);
|
||||
}
|
||||
return json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error('[order] delivery failed:', err);
|
||||
return json({ error: 'Could not send your order right now. Please try again later.' }, 502);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Delivery: SMTP first, webhook fallback ---------------------------------
|
||||
interface OrderMessage {
|
||||
name: string;
|
||||
email: string;
|
||||
orderType: string;
|
||||
color: string;
|
||||
quantity: string;
|
||||
details: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
async function deliver(msg: OrderMessage): Promise<boolean> {
|
||||
const host = smtpHost();
|
||||
const webhook = contactWebhookUrl();
|
||||
const typeLabel = orderTypeLabel[msg.orderType] ?? msg.orderType;
|
||||
|
||||
// Plain-text body for the email.
|
||||
const text = [
|
||||
`New store order request`,
|
||||
``,
|
||||
`Name: ${msg.name}`,
|
||||
`Email: ${msg.email}`,
|
||||
`Order type: ${typeLabel}`,
|
||||
`Color: ${msg.color || '(not specified)'}`,
|
||||
`Approx quantity: ${msg.quantity}`,
|
||||
``,
|
||||
`Details:`,
|
||||
msg.details,
|
||||
``,
|
||||
msg.notes ? `Notes:\n${msg.notes}` : '',
|
||||
].join('\n');
|
||||
|
||||
if (host) {
|
||||
const nodemailer = (await import('nodemailer')).default;
|
||||
const port = Number(smtpPort() ?? 587);
|
||||
const user = smtpUser();
|
||||
const allowSelfSigned = env('SMTP_ALLOW_SELFSIGNED') === 'true';
|
||||
const transport = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: user ? { user, pass: smtpPass() } : undefined,
|
||||
...(allowSelfSigned ? { tls: { rejectUnauthorized: false } } : {}),
|
||||
});
|
||||
const hostname = (() => {
|
||||
try {
|
||||
return new URL(process.env.SITE_URL ?? 'https://gregkilmer.com').hostname;
|
||||
} catch {
|
||||
return 'gregkilmer.com';
|
||||
}
|
||||
})();
|
||||
await transport.sendMail({
|
||||
from: smtpFrom() ?? `Greg's Store <no-reply@${hostname}>`,
|
||||
to: orderTo() ?? 'greg@gregkilmer.com',
|
||||
replyTo: `${msg.name} <${msg.email}>`,
|
||||
subject: `New store order — ${typeLabel} (${msg.name})`,
|
||||
text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (webhook) {
|
||||
const res = await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `New store order from ${msg.name} (${msg.email}) — ${typeLabel}`,
|
||||
name: msg.name,
|
||||
email: msg.email,
|
||||
orderType: typeLabel,
|
||||
color: msg.color || undefined,
|
||||
quantity: msg.quantity,
|
||||
details: msg.details,
|
||||
notes: msg.notes,
|
||||
}),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
/**
|
||||
* contact.astro — Contact / Connect
|
||||
* ---------------------------------------------------------------------------
|
||||
* A single place for all ways to reach Greg, professional and social, per the
|
||||
* spec's two-column layout. Also hosts the contact form (a Node backend is
|
||||
* available, so this is the natural place to use it). Closes with the spec's
|
||||
* warm sign-off line.
|
||||
*
|
||||
* Spec items Greg still needs to fill (email, Discord, etc.) appear as
|
||||
* Placeholder slots.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import PageSeal from "@components/brand/PageSeal.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import Icon from "@components/Icon.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
import ContactForm from "@components/professional/ContactForm.tsx";
|
||||
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
|
||||
import { turnstileSiteKey as getTurnstileSiteKey } from "@lib/env";
|
||||
|
||||
// Email is rendered separately via <ObfuscatedEmail> so the plaintext address
|
||||
// never appears in the HTML source. The other entries are plain links.
|
||||
const emailAddress = "contact@gregkilmer.com";
|
||||
|
||||
const professional = [
|
||||
{
|
||||
icon: "linkedin",
|
||||
label: "LinkedIn",
|
||||
href: "https://www.linkedin.com/in/gregory-kilmer-36646bb5/",
|
||||
hint: "Gregory Kilmer",
|
||||
},
|
||||
{
|
||||
icon: "github",
|
||||
label: "GitHub",
|
||||
href: "https://github.com/patiun",
|
||||
hint: "patiun",
|
||||
},
|
||||
];
|
||||
|
||||
const social = [
|
||||
{ icon: "discord", label: "Discord", hint: "Handle or invite link." },
|
||||
{
|
||||
icon: "twitter",
|
||||
label: "Twitter / X",
|
||||
hint: "Profile link, if applicable.",
|
||||
},
|
||||
{
|
||||
icon: "paw",
|
||||
label: "Pokémon",
|
||||
hint: "Showdown or Trainer name, if shared.",
|
||||
},
|
||||
{
|
||||
icon: "cube-outline",
|
||||
label: "Foundry VTT",
|
||||
hint: "Server link, if public.",
|
||||
},
|
||||
];
|
||||
|
||||
// Turnstile site key. Public per Cloudflare (safe to ship to the client).
|
||||
// Read via the env helper so it resolves in BOTH `astro dev` (Vite loads `.env`
|
||||
// into import.meta.env) AND the Docker runtime (env_file sets process.env).
|
||||
// See src/lib/env.ts for why neither source alone covers both runtimes.
|
||||
// Undefined when Turnstile is not configured, which makes ContactForm skip the
|
||||
// widget entirely.
|
||||
const turnstileSiteKey = getTurnstileSiteKey();
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Contact"
|
||||
description="Reach Greg Kilmer for cloud development, SAP BTP, technical leadership, or just to debate the best Gundam series or find a fourth for game night."
|
||||
noindex={true}
|
||||
>
|
||||
<PageSeal name="zodiac-seal-light" />
|
||||
<section class="container-prose flex flex-col gap-10 pt-28 sm:pt-32">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="Contact"
|
||||
icon="email"
|
||||
title="Let's connect"
|
||||
subtitle="Whether you want to talk cloud architecture, debate the best Gundam series, or need a fourth for game night, reach out."
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<div class="container-prose grid gap-8 py-12 lg:grid-cols-[1fr_1.1fr]">
|
||||
{/* Left: contact methods */}
|
||||
<div class="flex flex-col gap-10">
|
||||
{/* Professional */}
|
||||
<Reveal>
|
||||
<section class="flex flex-col gap-4">
|
||||
<h2
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
Professional
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-3">
|
||||
{
|
||||
/* Email: rendered via ObfuscatedEmail so the address is not in
|
||||
the HTML source. JS decodes it into a mailto: on click. */
|
||||
}
|
||||
<li class="card flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex h-10 w-10 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="email-outline" class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span
|
||||
class="font-sans text-sm font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
Email
|
||||
</span>
|
||||
<span class="text-xs" style="color: var(--color-text-subtle);">
|
||||
<ObfuscatedEmail
|
||||
address={emailAddress}
|
||||
class="font-eyebrow uppercase tracking-[0.1em]"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
{
|
||||
professional.map((item) => (
|
||||
<li class="card flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex h-10 w-10 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={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span
|
||||
class="font-sans text-sm font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{item.hint}
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</Reveal>
|
||||
|
||||
{
|
||||
/* Social / personal
|
||||
<Reveal delay={80}>
|
||||
<section class="flex flex-col gap-4">
|
||||
<h2
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
|
||||
style="color: var(--color-secondary);"
|
||||
>
|
||||
Personal / Social
|
||||
</h2>
|
||||
<ul class="grid gap-3 sm:grid-cols-2">
|
||||
{
|
||||
social.map((item) => (
|
||||
<li class="card flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 18%, transparent); color: var(--color-secondary);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span
|
||||
class="font-sans text-sm font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
{item.hint}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</Reveal>
|
||||
*/
|
||||
}
|
||||
|
||||
{
|
||||
/* Availability note
|
||||
<Reveal delay={120}>
|
||||
<p
|
||||
class="rounded-xl border-l-2 p-4 text-sm"
|
||||
style="border-color: var(--color-gold); background: color-mix(in srgb, var(--color-accent) 8%, transparent); color: var(--color-text-muted);"
|
||||
>
|
||||
<strong style="color: var(--color-text);">Availability:</strong> Open to
|
||||
opportunities in AI/ML, app and cloud development, SAP BTP, and technical
|
||||
leadership.
|
||||
</p>
|
||||
</Reveal>*/
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Right: contact form */}
|
||||
<Reveal delay={100}>
|
||||
<section class="card flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="font-display" style="color: var(--color-text);">
|
||||
Send a message
|
||||
</h2>
|
||||
<p class="text-sm" style="color: var(--color-text-muted);">
|
||||
Prefer a form? Drop a note and it will land in my inbox.
|
||||
</p>
|
||||
</div>
|
||||
<ContactForm client:idle turnstileSiteKey={turnstileSiteKey} />
|
||||
</section>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* Closing note */}
|
||||
<section class="container-prose py-12">
|
||||
<Reveal>
|
||||
<blockquote
|
||||
class="mx-auto max-w-2xl border-l-2 pl-5 text-center font-sans text-lg italic"
|
||||
style="border-color: var(--color-gold); color: var(--color-text-muted);"
|
||||
>
|
||||
Whether you want to talk cloud architecture, debate the best Gundam
|
||||
series, or need a fourth for game night, reach out.
|
||||
</blockquote>
|
||||
</Reveal>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
/**
|
||||
* index.astro — Home (Landing)
|
||||
* ---------------------------------------------------------------------------
|
||||
* First impression page. Full-width hero with the animated background, the spec
|
||||
* headline/subheadline/intro paragraph, and the two CTA buttons. Below the fold
|
||||
* is the 3-column snapshot grid (Professional, Tabletop, Making) plus the wider
|
||||
* Reading & Media strip. All copy is verbatim from the spec.
|
||||
*
|
||||
* Emojis from the spec table are rendered as mdi icons (no emojis per spec).
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
|
||||
import Icon from "@components/Icon.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import CelestialImage from "@components/mystic/CelestialImage.astro";
|
||||
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
|
||||
import Motif from "@components/brand/Motif.astro";
|
||||
import ConstellationField from "@components/mystic/ConstellationField.tsx";
|
||||
|
||||
// Snapshot grid content from the spec, with spec emojis mapped to mdi icons.
|
||||
// Each card now carries a vibe-matched stock image (swap for real photos later).
|
||||
const cards = [
|
||||
{
|
||||
icon: "briefcase",
|
||||
title: "Professional",
|
||||
copy: "From SAP-adjacent cloud to full-stack mobile, a decade of building software that businesses actually use.",
|
||||
href: "/professional",
|
||||
image: "/images/pro-workspace.jpg",
|
||||
alt: "Developer workspace with code on screen",
|
||||
},
|
||||
{
|
||||
icon: "dice-multiple",
|
||||
title: "Tabletop",
|
||||
copy: "Currently obsessed with Spirit Island, Magic: The Gathering, and running campaigns in Foundry VTT. Always planning more; always rolling dice.",
|
||||
href: "/interests/ttrpgs",
|
||||
image: "/images/ttrpg-dice.jpg",
|
||||
alt: "Polyhedral dice on a dark surface",
|
||||
},
|
||||
{
|
||||
icon: "palette",
|
||||
title: "Making",
|
||||
copy: "Resin dice, 3D prints, TCG proxies, and whatever else catches my eye. If it involves really nice cardboard or mastering a new craft, I am in.",
|
||||
href: "/interests/making",
|
||||
image: "/images/making-resin.jpg",
|
||||
alt: "Resin crafting in progress",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
<!-- ===================== HERO ===================== -->
|
||||
<section
|
||||
class="on-sky vt-hero-sky relative flex min-h-[88vh] items-center overflow-hidden pt-20"
|
||||
>
|
||||
<AnimatedBackground client:idle className="vt-sky" />
|
||||
|
||||
<div
|
||||
class="container-prose relative z-10 flex flex-col items-start gap-8 py-20"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-10 lg:flex-row lg:items-center">
|
||||
<Reveal as="div" class="max-w-2xl grow">
|
||||
<span
|
||||
class="mb-5 inline-flex items-center gap-2 rounded-full border px-3 py-1 font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="border-color: color-mix(in srgb, var(--color-gold) 40%, transparent); color: var(--color-gold-bright); background: color-mix(in srgb, var(--color-midnight) 75%, transparent);"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full animate-pulse-glow"
|
||||
style="background: var(--color-accent);"></span>
|
||||
Developer. Maker. Storyteller.
|
||||
</span>
|
||||
|
||||
<h1 class="text-balance" style="color: var(--text);">Greg Kilmer</h1>
|
||||
|
||||
<p
|
||||
class="mt-3 font-sans text-xl font-medium sm:text-2xl"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
Software Developer · Maker · Tabletop Gamemaster
|
||||
</p>
|
||||
|
||||
<blockquote
|
||||
class="mt-7 max-w-2xl border-l-2 pl-5 text-lg leading-relaxed"
|
||||
style="border-color: var(--color-gold); color: var(--text-muted);"
|
||||
>
|
||||
I build things with code during the day and with resin, plastic, and
|
||||
imagination at night. I lead cloud and mobile development at Implico
|
||||
Inc., run D&D campaigns that span years, and fill my shelves with
|
||||
Brandon Sanderson novels, custom dice, and board games. This is the
|
||||
place where all of those worlds meet.
|
||||
</blockquote>
|
||||
|
||||
<div class="mt-9 flex flex-wrap gap-4">
|
||||
<a href="/professional" class="btn btn-primary">
|
||||
See My Work
|
||||
<Icon name="arrow-right" class="h-5 w-5" />
|
||||
</a>
|
||||
<a href="/interests" class="btn btn-secondary">
|
||||
Explore My Interests
|
||||
<Icon name="compass-outline" class="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Hero portrait — framed in celestial ornament. */}
|
||||
<Reveal as="div" delay={120} class="w-full max-w-sm shrink-0 lg:w-80">
|
||||
<CelestialImage
|
||||
src="/images/hero-portrait.jpg"
|
||||
alt="Greg at his desk"
|
||||
aspect="4/5"
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll cue -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-6 left-1/2 -translate-x-1/2 animate-float"
|
||||
style="color: var(--color-text-subtle);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="chevron-down" class="h-7 w-7" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== SNAPSHOT GRID ===================== -->
|
||||
<section class="relative overflow-hidden py-20 sm:py-28">
|
||||
{
|
||||
/* Slow-rotating constellation field behind the section — a slice of sky. */
|
||||
}
|
||||
<ConstellationField client:idle className="opacity-60" />
|
||||
{/* Faint transmutation seal behind the section (Starlight recolor). */}
|
||||
<Motif
|
||||
name="transmutation-seal-light"
|
||||
class="absolute -right-40 top-10 z-0 h-[40rem] w-[40rem]"
|
||||
style="opacity:.13;"
|
||||
/>
|
||||
<div class="container-prose relative z-10 flex flex-col gap-12">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="The two wings"
|
||||
icon="home-heart"
|
||||
title="A snapshot"
|
||||
subtitle="One house, two wings. Polished and precise on the professional side; warm and inviting on the personal side."
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-3">
|
||||
{
|
||||
cards.map((card, i) => (
|
||||
<Reveal delay={i * 90}>
|
||||
<a
|
||||
href={card.href}
|
||||
class="card card-interactive group flex h-full flex-col gap-4 overflow-hidden p-0"
|
||||
>
|
||||
<div class="aspect-[16/10] w-full overflow-hidden">
|
||||
<img
|
||||
src={card.image}
|
||||
alt={card.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 px-5 pb-5">
|
||||
<span
|
||||
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={card.icon} class="h-6 w-6" />
|
||||
</span>
|
||||
<h3 class="font-display" style="color: var(--color-text);">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p
|
||||
class="grow text-sm leading-relaxed"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{card.copy}
|
||||
</p>
|
||||
<span
|
||||
class="mt-1 inline-flex items-center gap-1 font-sans text-sm font-semibold transition-transform group-hover:translate-x-1"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
Learn more
|
||||
<Icon name="arrow-right" class="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</Reveal>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<AlchemicalDivider
|
||||
class="mx-auto h-6 w-full max-w-sm"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
{/* Wider strip: Reading & Media */}
|
||||
<Reveal delay={120}>
|
||||
<a
|
||||
href="/interests/reading"
|
||||
class="card card-interactive group flex flex-col gap-4 overflow-hidden p-0 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span class="flex items-center gap-4">
|
||||
<span
|
||||
class="hidden aspect-square w-32 shrink-0 overflow-hidden sm:block"
|
||||
>
|
||||
<img
|
||||
src="/images/fantasy-books.jpg"
|
||||
alt="Fantasy bookshelf"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex flex-col gap-1 px-5 py-4 sm:px-0 sm:py-0">
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
Reading & Media
|
||||
</span>
|
||||
<span
|
||||
class="font-sans text-lg font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
Deep in the Cosmere, catching up on anime, and always three
|
||||
books ahead on my to-read list.
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1 sm:ml-4"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
</a>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
/**
|
||||
* anime.astro — Anime
|
||||
* ---------------------------------------------------------------------------
|
||||
* The primary pillar: 150+ completed series. Favorites render as poster cards
|
||||
* up top (populated by scripts/fetch-anime-art.mjs); the full library is
|
||||
* grouped by category below. Follows the canonical interest-page template.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import InterestHero from "@components/layout/InterestHero.astro";
|
||||
import MediaCard from "@components/cards/MediaCard.astro";
|
||||
import CategoryList from "@components/media/CategoryList.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
import { getFavorites, getGrouped, mediaKinds, getCount } from "@lib/media";
|
||||
|
||||
const meta = mediaKinds.anime;
|
||||
const favorites = getFavorites("anime");
|
||||
const groups = getGrouped("anime");
|
||||
const count = getCount("anime");
|
||||
|
||||
const related = [
|
||||
{
|
||||
href: "/interests/tv",
|
||||
title: "TV Shows",
|
||||
icon: "television-classic",
|
||||
blurb: "When I step away from animation, the live-action backlog is waiting.",
|
||||
},
|
||||
{
|
||||
href: "/interests/reading",
|
||||
title: "Reading",
|
||||
icon: "book-open-page-variant",
|
||||
blurb: "Half of these have light novels and manga I'm reading too.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title={meta.title}
|
||||
description={`${count}+ completed anime series. Shonen, isekai, dark psychological. The primary pillar of my watchlist, not a side shelf.`}
|
||||
>
|
||||
<InterestHero
|
||||
icon={meta.icon}
|
||||
title={meta.title}
|
||||
tagline={meta.tagline}
|
||||
image={meta.heroImage}
|
||||
imageAlt={meta.heroImageAlt}
|
||||
/>
|
||||
|
||||
<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>{meta.overview}</p></Reveal>
|
||||
</section>
|
||||
|
||||
{/* Favorites */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="The rewatch pile"
|
||||
icon="star"
|
||||
title="Favorites"
|
||||
/></Reveal
|
||||
>
|
||||
{
|
||||
favorites.length > 0 ? (
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favorites.map((item, i) => (
|
||||
<Reveal delay={i * 70}>
|
||||
<MediaCard
|
||||
kind="anime"
|
||||
title={item.title}
|
||||
image={item.image}
|
||||
status={item.status}
|
||||
blurb={item.blurb}
|
||||
/>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Placeholder
|
||||
label="Anime favorites"
|
||||
hint='Mark items with "favorite": true in src/data/media/anime.json.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
{/* Full library */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker={`${count}+ and counting`}
|
||||
icon="format-list-bulleted"
|
||||
title="The full watch history"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={80}>
|
||||
{groups.length > 0 ? (
|
||||
<CategoryList groups={groups} pillIcon="play-circle" />
|
||||
) : (
|
||||
<Placeholder
|
||||
label="Anime library"
|
||||
hint="Add categorized items to src/data/media/anime.json."
|
||||
/>
|
||||
)}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<RelatedInterests items={related} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
/**
|
||||
* gaming.astro — Gaming
|
||||
* ---------------------------------------------------------------------------
|
||||
* Per-interest template: header + tagline, verbatim overview, platforms card
|
||||
* grid, favorite genres, currently playing placeholder, and related interests.
|
||||
*/
|
||||
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 GameCard from "@components/cards/GameCard.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 { getCollection, getEntry } from "astro:content";
|
||||
import { platformToIcon } from "@lib/platforms";
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
icon: "sony-playstation",
|
||||
title: "PlayStation 5",
|
||||
body: "Mainly for single player and exclusives.",
|
||||
},
|
||||
{
|
||||
icon: "nintendo-switch",
|
||||
title: "Nintendo Switch / Switch 2",
|
||||
body: "Handheld and docked. Pokemon, Mario, and many other Nintendo and indie gems.",
|
||||
},
|
||||
{
|
||||
icon: "steam",
|
||||
title: "Steam",
|
||||
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 = [
|
||||
{
|
||||
icon: "sword-cross",
|
||||
name: "RPGs (Western & JRPG)",
|
||||
blurb:
|
||||
"Character progression and stories I can live in for 100+ hours and then turn around and do it again.",
|
||||
},
|
||||
{
|
||||
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 = [
|
||||
/*{
|
||||
href: "/interests/pokemon",
|
||||
title: "Pokémon",
|
||||
icon: "pokeball",
|
||||
blurb: "Nintendo console overlap and a lifelong obsession.",
|
||||
},*/
|
||||
{
|
||||
href: "/interests/ttrpgs",
|
||||
title: "TTRPGs & Tabletop",
|
||||
icon: "dice-multiple",
|
||||
blurb: "Digital RPGs as another expression of the same impulse.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title="Gaming"
|
||||
description="PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten politely out of hand. RPGs, strategy, and deeply layered worlds."
|
||||
>
|
||||
<InterestHero
|
||||
icon="gamepad-variant"
|
||||
title="Gaming"
|
||||
tagline="Walking in another's shoes, escaping reality, or obsessively completing collections, one game at a time."
|
||||
image="/images/gaming-setup.jpg"
|
||||
imageAlt="A gaming setup with controller and console"
|
||||
/>
|
||||
|
||||
<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>
|
||||
Gaming has always been a passion in my life. I span console
|
||||
generations from the Sega Genesis to the modern era. I play mainly
|
||||
with a PlayStation 5, Nintendo Switch 2, and a Steam library that has
|
||||
gotten politely out of hand. I'm particularly drawn to RPGs, strategy
|
||||
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
|
||||
>
|
||||
</section>
|
||||
|
||||
{/* Platforms */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Platforms"
|
||||
icon="gamepad-variant"
|
||||
title="Where I play"
|
||||
/></Reveal
|
||||
>
|
||||
<div class="grid gap-5 sm:grid-cols-3">
|
||||
{
|
||||
platforms.map((p, i) => (
|
||||
<Reveal delay={i * 80}>
|
||||
<InterestCard icon={p.icon} title={p.title}>
|
||||
{p.body}
|
||||
</InterestCard>
|
||||
</Reveal>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Genres */}
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Tastes"
|
||||
icon="shape"
|
||||
title="Favorite genres"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={80}>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
genres.map((g, i) => (
|
||||
<Reveal delay={i * 60}>
|
||||
<article class="card card-interactive flex h-full flex-col gap-2 p-4">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<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-display 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>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Currently */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Currently"
|
||||
icon="calendar-clock"
|
||||
title="Now playing"
|
||||
/></Reveal
|
||||
>
|
||||
{
|
||||
nowPlaying.length > 0 ? (
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{nowPlaying.map((g, i) => (
|
||||
<Reveal delay={i * 60}>
|
||||
<InterestCard
|
||||
icon={platformToIcon(g.platform)}
|
||||
title={g.title}
|
||||
currently
|
||||
>
|
||||
{g.platform}
|
||||
</InterestCard>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Placeholder
|
||||
label="Currently playing"
|
||||
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>
|
||||
) : (
|
||||
<Placeholder
|
||||
label="Gaming Hall of Fame"
|
||||
hint="Add games to src/content/games/hall-of-fame/."
|
||||
/>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<RelatedInterests items={related} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
/**
|
||||
* interests/index.astro — Interests Hub
|
||||
* ---------------------------------------------------------------------------
|
||||
* The "fun wing" landing page. A warm grid of cards linking into each interest
|
||||
* sub-page, using the spec's taglines. Sets surface="interest" so the whole
|
||||
* page reads as the craft-side night sky regardless of the global light/dark toggle.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import Icon from "@components/Icon.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
|
||||
import Motif from "@components/brand/Motif.astro";
|
||||
import ConstellationField from "@components/mystic/ConstellationField.tsx";
|
||||
|
||||
const interests = [
|
||||
{
|
||||
href: "/interests/ttrpgs",
|
||||
icon: "dice-multiple",
|
||||
title: "TTRPGs & Tabletop",
|
||||
tagline: "Have dice, will roll.",
|
||||
blurb:
|
||||
"Card Games, Board Games, and Long-running D&D and Daggerheart campaigns on Foundry VTT. Worldbuilding, NPC relationships, and player-driven narrative.",
|
||||
image: "/images/ttrpg-dice.jpg",
|
||||
alt: "Boardgame Shelf",
|
||||
},
|
||||
{
|
||||
href: "/interests/gaming",
|
||||
icon: "gamepad-variant",
|
||||
title: "Gaming",
|
||||
tagline: "We do not talk about my backlog.",
|
||||
blurb:
|
||||
"PlayStation 5, Nintendo Switch 2, and a politely out-of-hand Steam library. RPGs, strategy, and anything with a deeply layered world.",
|
||||
image: "/images/gaming-setup.jpg",
|
||||
alt: "Gaming setup desk",
|
||||
},
|
||||
{
|
||||
href: "/interests/reading",
|
||||
icon: "book-open-page-variant",
|
||||
title: "Reading",
|
||||
tagline: "Perpetually three books ahead of my to-read list.",
|
||||
blurb:
|
||||
"The Cosmere, Dune, the Pendergast saga, and Throne of Glass. Plus light novels, manga, manhwa, and webtoons.",
|
||||
image: "/images/fantasy-books.jpg",
|
||||
alt: "Fantasy bookshelf",
|
||||
},
|
||||
{
|
||||
href: "/interests/anime",
|
||||
icon: "animation-play",
|
||||
title: "Anime",
|
||||
tagline: "Subbed, dubbed, and somehow still behind.",
|
||||
blurb:
|
||||
"150+ completed series. The shonen classics, the modern hits, the isekai rabbit hole, and Frieren living rent-free in my head.",
|
||||
image: "/images/anime-hero.jpg",
|
||||
alt: "A cosplayer in a colorful anime-inspired costume at a convention",
|
||||
},
|
||||
{
|
||||
href: "/interests/tv",
|
||||
icon: "television-classic",
|
||||
title: "TV Shows",
|
||||
tagline: "My rewatch rotation has a rewatch rotation.",
|
||||
blurb:
|
||||
"200+ series across every prestige drama, NBC comedy, streaming original, and the full Marvel and Star Wars rosters.",
|
||||
image: "/images/tv-hero.jpg",
|
||||
alt: "A cozy living room with a couch facing a TV",
|
||||
},
|
||||
{
|
||||
href: "/interests/movies",
|
||||
icon: "movie-open",
|
||||
title: "Movies",
|
||||
tagline: "I stayed for the post-credits scene.",
|
||||
blurb:
|
||||
"160+ films across 17+ franchises. When I love a series I commit all the way, and The Princess Bride is always within reach.",
|
||||
image: "/images/movies-hero.jpg",
|
||||
alt: "Empty red seats in a vintage movie theater",
|
||||
},
|
||||
{
|
||||
href: "/interests/making",
|
||||
icon: "palette",
|
||||
title: "Making & Creating",
|
||||
tagline: "A magnet for super glue and nice cardboard.",
|
||||
blurb:
|
||||
"Resin dice in a pressure pot, fun and functional prints on the P1s, custom TCG proxies and decks. Passion and utility.",
|
||||
image: "/images/making-resin.jpg",
|
||||
alt: "Polyhedral dice on a dark bag",
|
||||
},
|
||||
{
|
||||
href: "/interests/tcgs",
|
||||
icon: "cards",
|
||||
title: "Trading Card Games",
|
||||
tagline:
|
||||
"Cardboard, deckbuilding, and the eternal question: one more pack?",
|
||||
blurb:
|
||||
"Magic: The Gathering Commander decks, Pokémon collecting, Riftbound, and the Gundam card game. Plus the proxies and props I make to play them.",
|
||||
image: "/images/cards.jpg",
|
||||
alt: "Trading cards and dice on a playmat",
|
||||
} /*
|
||||
{
|
||||
href: '/interests/pokemon',
|
||||
icon: 'pokeball',
|
||||
title: 'Pokémon',
|
||||
tagline: 'Metagross supremacy. This is not up for debate.',
|
||||
blurb: 'A lifelong obsession from Red and Blue to modern competitive. Steel/Psychic, beautiful design, absolutely devastating in battle.',
|
||||
image: '/images/pokemon-cards.jpg',
|
||||
alt: 'Trading card collection',
|
||||
},*/,
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title="Interests"
|
||||
description="The fun wing: tabletop RPGs, gaming, reading, resin dice and 3D printing, and Pokémon. Where Greg's passions live as stories and collections."
|
||||
>
|
||||
<!-- Hero -->
|
||||
<section class="on-sky relative overflow-hidden pb-8 pt-28 sm:pt-32">
|
||||
{
|
||||
/* Full animated Milky Way field + stars, matching the Home hero treatment. */
|
||||
}
|
||||
<AnimatedBackground client:idle className="vt-sky" />
|
||||
<div
|
||||
class="container-prose relative z-10 flex flex-col items-start gap-6 py-8"
|
||||
>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
tone="hero"
|
||||
kicker="The personal wing"
|
||||
icon="heart"
|
||||
title="Interests"
|
||||
subtitle="Warmer, more visual, personality-driven. Not a list of hobbies but a collection of stories."
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Interest grid -->
|
||||
<section class="relative overflow-hidden py-12 sm:py-16">
|
||||
<ConstellationField client:idle className="opacity-45" tint="teal" />
|
||||
<Motif
|
||||
name="tria-prima-array-seal-light"
|
||||
class="absolute -left-48 bottom-0 z-0 h-[38rem] w-[38rem]"
|
||||
style="opacity:.12;"
|
||||
/>
|
||||
<div class="container-prose relative z-10">
|
||||
<div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
interests.map((item, i) => (
|
||||
<Reveal delay={i * 80}>
|
||||
<a
|
||||
href={item.href}
|
||||
class="card card-interactive group flex h-full flex-col gap-4 overflow-hidden p-0"
|
||||
>
|
||||
<div class="aspect-[16/10] w-full overflow-hidden">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 px-5 pb-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={item.icon} class="h-6 w-6" />
|
||||
</span>
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
class="h-5 w-5 transition-transform group-hover:translate-x-1"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="font-display" style="color: var(--color-text);">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p
|
||||
class="font-sans text-sm italic"
|
||||
style="color: var(--color-secondary);"
|
||||
>
|
||||
{item.tagline}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="grow text-sm leading-relaxed"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{item.blurb}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</Reveal>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Cross-link back to professional wing */}
|
||||
<Reveal delay={interests.length * 80}>
|
||||
<a
|
||||
href="/professional"
|
||||
class="card card-interactive group flex h-full flex-col justify-center gap-2 border-dashed p-5 text-center"
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);"
|
||||
>
|
||||
<Icon
|
||||
name="briefcase"
|
||||
class="mx-auto h-7 w-7"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<p
|
||||
class="font-sans font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
The other wing
|
||||
</p>
|
||||
<p class="text-sm" style="color: var(--color-text-muted);">
|
||||
See where curiosity meets the day job.
|
||||
</p>
|
||||
</a>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
/**
|
||||
* making.astro — Making & Creating
|
||||
* ---------------------------------------------------------------------------
|
||||
* 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.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import InterestHero from "@components/layout/InterestHero.astro";
|
||||
import InterestCard from "@components/cards/InterestCard.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
import Icon from "@components/Icon.astro";
|
||||
import CelestialImage from "@components/mystic/CelestialImage.astro";
|
||||
|
||||
const equipment = [
|
||||
{
|
||||
icon: "dice-multiple",
|
||||
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: "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 = [
|
||||
{
|
||||
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/tcgs",
|
||||
title: "Trading Card Games",
|
||||
icon: "cards",
|
||||
blurb: "Proxies, tokens, and props are the TCG side of the workshop.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title="Making & Creating"
|
||||
description="Resin dice in a pressure pot, miniatures and terrain on the Elegoo Mars 4 and Bambu Labs P1S. If it involves layers and precision, I am in."
|
||||
>
|
||||
<InterestHero
|
||||
icon="palette"
|
||||
title="Making & Creating"
|
||||
tagline="A magnet for super glue and nice cardboard."
|
||||
image="/images/making-print.jpg"
|
||||
imageAlt="A 3D printer producing a detailed model"
|
||||
/>
|
||||
|
||||
<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>
|
||||
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>
|
||||
|
||||
{/* Equipment */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<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">
|
||||
{
|
||||
equipment.map((e, i) => (
|
||||
<Reveal delay={i * 70}>
|
||||
<InterestCard icon={e.icon} title={e.title}>
|
||||
{e.body}
|
||||
</InterestCard>
|
||||
</Reveal>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Gallery */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Gallery"
|
||||
icon="image-multiple"
|
||||
title="Finished pieces"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={80}>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<CelestialImage
|
||||
src="/images/making-resin.jpg"
|
||||
alt="Resin dice set teal with gold leaf inclusions"
|
||||
aspect="4/3"
|
||||
/>
|
||||
<CelestialImage
|
||||
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"
|
||||
aspect="4/3"
|
||||
/>
|
||||
<CelestialImage
|
||||
src="/images/deckboxes.jpg"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Connection to TTRPGs */}
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Cross-pollination"
|
||||
icon="link-variant"
|
||||
title="Where it all connects"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={80}>
|
||||
<a
|
||||
href="/interests/ttrpgs"
|
||||
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);"
|
||||
/>
|
||||
<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 class="text-sm" style="color: var(--color-text-muted);"
|
||||
>Custom tokens, terrain, and props for Foundry VTT campaigns.</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);"
|
||||
/>
|
||||
</a>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<RelatedInterests items={related} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
/**
|
||||
* movies.astro — Movies
|
||||
* ---------------------------------------------------------------------------
|
||||
* 160+ films across 17+ franchises. Favorite films render as poster cards;
|
||||
* the full watch history below lists every film grouped by franchise (plus a
|
||||
* Standalone group for single films). Posters populated by
|
||||
* scripts/fetch-tmdb-art.mjs.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import InterestHero from "@components/layout/InterestHero.astro";
|
||||
import MediaCard from "@components/cards/MediaCard.astro";
|
||||
import CategoryList from "@components/media/CategoryList.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
import { getFavorites, getGrouped, mediaKinds, getCount } from "@lib/media";
|
||||
|
||||
const meta = mediaKinds.movies;
|
||||
const favorites = getFavorites("movies");
|
||||
const groups = getGrouped("movies");
|
||||
const count = getCount("movies");
|
||||
|
||||
const related = [
|
||||
{
|
||||
href: "/interests/tv",
|
||||
title: "TV Shows",
|
||||
icon: "television-classic",
|
||||
blurb: "The MCU and Star Wars sagas continue here.",
|
||||
},
|
||||
{
|
||||
href: "/interests/anime",
|
||||
title: "Anime",
|
||||
icon: "animation-play",
|
||||
blurb: "The animated half of my watchlist.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title={meta.title}
|
||||
description={`${count}+ films across 17+ franchises. When I love a series I commit all the way, and The Princess Bride is always within reach.`}
|
||||
>
|
||||
<InterestHero
|
||||
icon={meta.icon}
|
||||
title={meta.title}
|
||||
tagline={meta.tagline}
|
||||
image={meta.heroImage}
|
||||
imageAlt={meta.heroImageAlt}
|
||||
/>
|
||||
|
||||
<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>{meta.overview}</p></Reveal>
|
||||
</section>
|
||||
|
||||
{/* Favorites */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Stop on these any time"
|
||||
icon="star"
|
||||
title="Favorites"
|
||||
/></Reveal
|
||||
>
|
||||
{
|
||||
favorites.length > 0 ? (
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favorites.map((item, i) => (
|
||||
<Reveal delay={i * 70}>
|
||||
<MediaCard
|
||||
kind="movies"
|
||||
title={item.title}
|
||||
image={item.image}
|
||||
year={item.year}
|
||||
blurb={item.blurb}
|
||||
/>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Placeholder
|
||||
label="Movie favorites"
|
||||
hint='Mark items with "favorite": true in src/data/media/movies.json.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
{/* Full franchise watch history */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker={`${count}+ films across ${groups.length} franchises`}
|
||||
icon="filmstrip"
|
||||
title="By franchise"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={80}>
|
||||
{groups.length > 0 ? (
|
||||
<CategoryList groups={groups} pillIcon="movie-open" />
|
||||
) : (
|
||||
<Placeholder
|
||||
label="Movie library"
|
||||
hint="Add categorized films to src/data/media/movies.json."
|
||||
/>
|
||||
)}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<RelatedInterests items={related} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
/**
|
||||
* pokemon.astro — Pokémon
|
||||
* ---------------------------------------------------------------------------
|
||||
* Per-interest template: favorite callout (Metagross), games overview, a
|
||||
* Showdown-format roster placeholder (spec calls for standard Showdown text),
|
||||
* and related links to Gaming.
|
||||
*/
|
||||
import BaseLayout from '@layouts/BaseLayout.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import InterestHero from '@components/layout/InterestHero.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';
|
||||
|
||||
const related = [
|
||||
{ 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.' },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title="Pokémon"
|
||||
description="A lifelong obsession from Red and Blue to modern competitive. Metagross supremacy: Steel/Psychic, beautiful design, absolutely devastating in battle."
|
||||
>
|
||||
<InterestHero
|
||||
icon="pokeball"
|
||||
title="Pokémon"
|
||||
tagline="Metagross supremacy. This is not up for debate."
|
||||
image="/images/pokemon-cards.jpg"
|
||||
imageAlt="A collection of trading cards"
|
||||
/>
|
||||
|
||||
<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>Pokémon has been a lifelong obsession. From Red and Blue on the original Game Boy to modern competitive formats, I've followed the franchise through every generation. My favorite? Metagross, the iron leg Pokémon. Steel/Psychic, beautiful design, absolutely devastating in battle. No further questions.</p></Reveal>
|
||||
</section>
|
||||
|
||||
{/* Favorite callout */}
|
||||
<Reveal>
|
||||
<div
|
||||
class="card relative flex flex-col items-center gap-4 overflow-hidden text-center sm:flex-row sm:items-center sm:gap-6 sm:text-left"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised));"
|
||||
>
|
||||
<figure
|
||||
class="relative aspect-square w-full shrink-0 overflow-hidden sm:w-28"
|
||||
>
|
||||
<img src="/images/pokemon-cards.jpg" alt="Trading card collection" loading="lazy" decoding="async" class="h-full w-full object-cover" />
|
||||
</figure>
|
||||
<span
|
||||
class="inline-flex h-20 w-20 items-center justify-center rounded-2xl"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 22%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="robot" class="h-10 w-10" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-eyebrow text-xs uppercase tracking-[0.22em]" style="color: var(--color-accent);">Favorite Pokémon</span>
|
||||
<h2 style="color: var(--color-text);">Metagross</h2>
|
||||
<p class="text-sm" style="color: var(--color-text-muted);">Steel / Psychic. The iron leg Pokémon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Roster */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Battle team" icon="sword-cross" title="Roster" /></Reveal>
|
||||
<Reveal delay={80}>
|
||||
<p class="text-sm" style="color: var(--color-text-subtle);">
|
||||
Rosters use standard Pokémon Showdown text format.
|
||||
</p>
|
||||
<Placeholder label="Pokémon Showdown roster" hint="Paste your preferred roster in Showdown text format.">
|
||||
<pre
|
||||
class="mt-3 overflow-x-auto rounded-lg p-4 font-mono text-xs"
|
||||
style="background: var(--color-surface-sunken); color: var(--color-text-muted);"
|
||||
>## Pokémon
|
||||
[Greg's preferred roster in Showdown format]</pre>
|
||||
</Placeholder>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Games */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Across the generations" icon="gamepad-variant" title="Games" /></Reveal>
|
||||
<Reveal delay={80}>
|
||||
<Placeholder label="Mainline & spinoff titles" hint="List the mainline and spinoff titles played, if desired." />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<RelatedInterests items={related} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
/**
|
||||
* reading.astro — Reading
|
||||
* ---------------------------------------------------------------------------
|
||||
* Books, comics, light novels, webtoons, and manga/manhwa. Favorites render as
|
||||
* cover cards up top (populated by scripts/fetch-book-covers.mjs); the full
|
||||
* library is grouped by category below. Follows the canonical interest-page
|
||||
* template. The anime and movies/TV content that used to live here now has its
|
||||
* own pages under /interests/anime, /tv, and /movies.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import InterestHero from "@components/layout/InterestHero.astro";
|
||||
import MediaCard from "@components/cards/MediaCard.astro";
|
||||
import CategoryList from "@components/media/CategoryList.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
import { getFavorites, getGrouped, mediaKinds } from "@lib/media";
|
||||
|
||||
const meta = mediaKinds.reading;
|
||||
const favorites = getFavorites("reading");
|
||||
const groups = getGrouped("reading");
|
||||
|
||||
const related = [
|
||||
{
|
||||
href: "/interests/anime",
|
||||
title: "Anime",
|
||||
icon: "animation-play",
|
||||
blurb: "A lot of these light novels and manga have adaptations I've watched.",
|
||||
},
|
||||
{
|
||||
href: "/interests/making",
|
||||
title: "Making & Creating",
|
||||
icon: "palette",
|
||||
blurb: "Props and displays for the stories I love.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title={meta.title}
|
||||
description="The Cosmere, Dune, the Pendergast saga, Throne of Glass. Plus light novels, manga, manhwa, and webtoons. If a story exists, I am probably consuming it."
|
||||
>
|
||||
<InterestHero
|
||||
icon={meta.icon}
|
||||
title={meta.title}
|
||||
tagline={meta.tagline}
|
||||
image={meta.heroImage}
|
||||
imageAlt={meta.heroImageAlt}
|
||||
/>
|
||||
|
||||
<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>{meta.overview}</p></Reveal>
|
||||
</section>
|
||||
|
||||
{/* Favorites */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="On the shelf"
|
||||
icon="bookshelf"
|
||||
title="Favorites"
|
||||
/></Reveal
|
||||
>
|
||||
{
|
||||
favorites.length > 0 ? (
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favorites.map((item, i) => (
|
||||
<Reveal delay={i * 70}>
|
||||
<MediaCard
|
||||
kind="reading"
|
||||
title={item.title}
|
||||
image={item.image}
|
||||
status={item.status}
|
||||
blurb={item.blurb}
|
||||
/>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Placeholder
|
||||
label="Reading favorites"
|
||||
hint='Mark items with "favorite": true in src/data/reading.json.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
{/* Full library */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="The rest of the shelf"
|
||||
icon="format-list-bulleted"
|
||||
title="What I'm reading"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={80}>
|
||||
{groups.length > 0 ? (
|
||||
<CategoryList groups={groups} pillIcon="book-open-page-variant" />
|
||||
) : (
|
||||
<Placeholder
|
||||
label="Reading library"
|
||||
hint="Add categorized items to src/data/reading.json."
|
||||
/>
|
||||
)}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<RelatedInterests items={related} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
/**
|
||||
* ttrpgs.astro — TTRPGs & Tabletop
|
||||
* ---------------------------------------------------------------------------
|
||||
* The spec's per-interest template: Header (title + tagline), Overview
|
||||
* (narrative, verbatim), Highlights (card grid, spec emojis -> mdi), Currently,
|
||||
* then the Board Games sub-section, then RelatedInterests cross-links.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import InterestHero from "@components/layout/InterestHero.astro";
|
||||
import InterestCard from "@components/cards/InterestCard.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
import BoardGameShelf from "@components/ttrpgs/BoardGameShelf.tsx";
|
||||
import boardgames from "@/data/boardgames.json";
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
icon: "castle",
|
||||
title: "Dungeons & Dragons",
|
||||
body: "Lately 5th Edition. Long-running campaigns on and off Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative.",
|
||||
},
|
||||
{
|
||||
icon: "sword-cross",
|
||||
title: "Daggerheart",
|
||||
body: "Darrington Press's dice-building RPG. Currently running a campaign and exploring how its mechanics create different story rhythms than D&D.",
|
||||
},
|
||||
{
|
||||
icon: "target",
|
||||
title: "Draw Steel",
|
||||
body: "MCDM's fantasy tactical cinematic combat game. Interested in running it when I get a chance.",
|
||||
},
|
||||
{
|
||||
icon: "shield-moon-outline",
|
||||
title: "Cypher System",
|
||||
body: "Monte Cook's narrative-focused system. Played a few one-shots and short form campaigns.",
|
||||
},
|
||||
{
|
||||
icon: "knife-military",
|
||||
title: "Blades in the Dark",
|
||||
body: "A gritty, cinematic system for heists and criminal enterprises. Interested in playing short campaigns in it one day.",
|
||||
},
|
||||
{
|
||||
icon: "weather-windy",
|
||||
title: "Avatar the Last Airbender RPG",
|
||||
body: "A narrative-focused system set in the world of Avatar. Interested in exploring bending mechanics and character-driven stories.",
|
||||
},
|
||||
{
|
||||
icon: "monitor",
|
||||
title: "Foundry VTT",
|
||||
body: "Self-hosted on the home server. Building custom modules, maps, and automation for campaigns.",
|
||||
},
|
||||
];
|
||||
|
||||
const related = [
|
||||
{
|
||||
href: "/interests/making",
|
||||
title: "Making & Creating",
|
||||
icon: "palette",
|
||||
blurb: "Dice and terrain made for campaigns.",
|
||||
},
|
||||
{
|
||||
href: "/interests/gaming",
|
||||
title: "Gaming",
|
||||
icon: "gamepad-variant",
|
||||
blurb: "Digital RPGs as another expression of the same impulse.",
|
||||
},
|
||||
{
|
||||
href: "/interests/tcgs",
|
||||
title: "Trading Card Games",
|
||||
icon: "cards",
|
||||
blurb: "Cards and dice share the same shelf — and the same table.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title="TTRPGs & Tabletop"
|
||||
description="Long-running D&D and Daggerheart campaigns on Foundry VTT, the MCDM Crows playtest, and a growing board game collection."
|
||||
>
|
||||
<InterestHero
|
||||
icon="dice-multiple"
|
||||
title="TTRPGs & Tabletop"
|
||||
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-mini.jpg"
|
||||
imageAlt="A tabletop RPG spread with miniatures and dice"
|
||||
/>
|
||||
|
||||
<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>
|
||||
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>
|
||||
|
||||
{/* Highlights */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Highlights"
|
||||
icon="view-grid"
|
||||
title="Systems & tools"
|
||||
/></Reveal
|
||||
>
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
highlights.map((h, i) => (
|
||||
<Reveal delay={i * 70}>
|
||||
<InterestCard icon={h.icon} title={h.title}>
|
||||
{h.body}
|
||||
</InterestCard>
|
||||
</Reveal>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{
|
||||
/* Currently
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Currently" icon="calendar-clock" title="On the schedule" /></Reveal>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<Placeholder label="Campaign 1 name" hint="D&D, [day of week]s." />
|
||||
<Placeholder label="Campaign 2 name" hint="Daggerheart, [day of week]s." />
|
||||
<Placeholder label="Crows playtest" hint="Ongoing sessions." />
|
||||
</div>
|
||||
</section>
|
||||
*/
|
||||
}
|
||||
|
||||
{/* Board games */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Beyond RPGs"
|
||||
icon="cards-playing"
|
||||
title="Board games"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={80}>
|
||||
<blockquote
|
||||
class="border-l-2 pl-5 text-lg leading-relaxed"
|
||||
style="border-color: var(--color-gold); color: var(--color-text-muted);"
|
||||
>
|
||||
Beyond RPGs, I maintain a growing board and card game collection.
|
||||
Favorites span strategy, deck-building, and social deduction. Game
|
||||
night is a regular event, and yes, there's always room for one more.
|
||||
Hover a title to preview its cover; click to open it on BoardGameGeek.
|
||||
</blockquote>
|
||||
</Reveal>
|
||||
<Reveal delay={140}>
|
||||
<BoardGameShelf
|
||||
client:idle
|
||||
games={boardgames}
|
||||
shelfImage="/images/ttrpg-boardgame-shelf.jpg"
|
||||
shelfAlt="Greg's board game shelf"
|
||||
shelfCaption="The shelf"
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<RelatedInterests items={related} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
/**
|
||||
* tv.astro — TV Shows
|
||||
* ---------------------------------------------------------------------------
|
||||
* 200+ completed series across prestige drama, streaming originals, workplace
|
||||
* comedy, procedural, Marvel/Star Wars universes, animation, and reality.
|
||||
* Favorites as poster cards; full library grouped by category below.
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import InterestHero from "@components/layout/InterestHero.astro";
|
||||
import MediaCard from "@components/cards/MediaCard.astro";
|
||||
import CategoryList from "@components/media/CategoryList.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import RelatedInterests from "@components/layout/RelatedInterests.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
import { getFavorites, getGrouped, mediaKinds, getCount } from "@lib/media";
|
||||
|
||||
const meta = mediaKinds.tv;
|
||||
const favorites = getFavorites("tv");
|
||||
const groups = getGrouped("tv");
|
||||
const count = getCount("tv");
|
||||
|
||||
const related = [
|
||||
{
|
||||
href: "/interests/anime",
|
||||
title: "Anime",
|
||||
icon: "animation-play",
|
||||
blurb: "The other half of my watchlist, honestly.",
|
||||
},
|
||||
{
|
||||
href: "/interests/movies",
|
||||
title: "Movies",
|
||||
icon: "movie-open",
|
||||
blurb: "The MCU, the Matrix, and everything between.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="interest"
|
||||
title={meta.title}
|
||||
description={`${count}+ completed series across prestige drama, workplace comedy, streaming originals, and the full Marvel and Star Wars rosters.`}
|
||||
>
|
||||
<InterestHero
|
||||
icon={meta.icon}
|
||||
title={meta.title}
|
||||
tagline={meta.tagline}
|
||||
image={meta.heroImage}
|
||||
imageAlt={meta.heroImageAlt}
|
||||
/>
|
||||
|
||||
<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>{meta.overview}</p></Reveal>
|
||||
</section>
|
||||
|
||||
{/* Favorites */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="On heavy rotation"
|
||||
icon="star"
|
||||
title="Favorites"
|
||||
/></Reveal
|
||||
>
|
||||
{
|
||||
favorites.length > 0 ? (
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favorites.map((item, i) => (
|
||||
<Reveal delay={i * 70}>
|
||||
<MediaCard
|
||||
kind="tv"
|
||||
title={item.title}
|
||||
image={item.image}
|
||||
status={item.status}
|
||||
blurb={item.blurb}
|
||||
/>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Placeholder
|
||||
label="TV favorites"
|
||||
hint='Mark items with "favorite": true in src/data/media/tv.json.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
{/* Full library */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker={`${count}+ series`}
|
||||
icon="format-list-bulleted"
|
||||
title="Everything I've finished"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={80}>
|
||||
{groups.length > 0 ? (
|
||||
<CategoryList groups={groups} pillIcon="television-classic" />
|
||||
) : (
|
||||
<Placeholder
|
||||
label="TV library"
|
||||
hint="Add categorized items to src/data/media/tv.json."
|
||||
/>
|
||||
)}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<RelatedInterests items={related} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,331 @@
|
||||
---
|
||||
/**
|
||||
* professional/index.astro — Professional (Resume / Portfolio)
|
||||
* ---------------------------------------------------------------------------
|
||||
* The "polished wing". A single long-scroll page whose sections read as a
|
||||
* narrative (Summary -> Experience -> Skills -> Projects -> Education ->
|
||||
* Contact), per the spec. Uses surface="professional" for the dark slate tone.
|
||||
*
|
||||
* Experience comes from the `experience` collection; projects from the
|
||||
* `projects` collection. Both are sorted by `order`. Education is a placeholder
|
||||
* block (spec leaves it to Greg).
|
||||
*/
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import Icon from "@components/Icon.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import SectionNav from "@components/professional/SectionNav.astro";
|
||||
import Timeline from "@components/professional/Timeline.astro";
|
||||
import SkillGrid from "@components/professional/SkillGrid.astro";
|
||||
import ProjectCard from "@components/professional/ProjectCard.astro";
|
||||
import EducationHistory from "@components/professional/EducationHistory.astro";
|
||||
import Certifications from "@components/professional/Certifications.astro";
|
||||
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
|
||||
import Motif from "@components/brand/Motif.astro";
|
||||
import CircuitField from "@components/mystic/CircuitField.tsx";
|
||||
import CelestialFrame from "@components/mystic/CelestialFrame.astro";
|
||||
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const experience = (await getCollection("experience")).sort(
|
||||
(a, b) => a.data.order - b.data.order,
|
||||
);
|
||||
const projects = (await getCollection("projects")).sort(
|
||||
(a, b) => a.data.order - b.data.order,
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
surface="professional"
|
||||
title="Professional"
|
||||
description="Software developer and team lead: cloud and mobile development, SAP Business Technology Platform, AI/ML, full-stack work. Resume and portfolio for Greg Kilmer."
|
||||
>
|
||||
{
|
||||
/* Page header + overlapping headshot.
|
||||
The headshot is anchored to the HERO SECTION (not container-prose) so its
|
||||
straddle point is the true hero/next-section boundary. It's positioned on
|
||||
the right, aligned with the content column's right edge via the same
|
||||
container-prose centering math (a centered max-width column). translate-y-1/2
|
||||
puts its center on the boundary: half in the hero, half over the next
|
||||
section, in front of both (z-50). */
|
||||
}
|
||||
<section class="on-sky relative overflow-visible pt-28 pb-28 sm:pt-32 sm:pb-36">
|
||||
{
|
||||
/* Background layer — clipped to the hero so the circuit canvas/astrolabe
|
||||
don't bleed into the content below. */
|
||||
}
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<CircuitField client:idle className="vt-sky opacity-100" />
|
||||
<Motif
|
||||
name="astrolabe"
|
||||
class="absolute z-0 -right-40 -top-24 h-[38rem] w-[34rem] object-contain"
|
||||
style="opacity:.2;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* Headshot — anchored to the hero section's bottom edge (the true boundary
|
||||
with the next section), aligned right with the content column. Wrapped in
|
||||
a centered max-width box that mirrors container-prose so the headshot's
|
||||
right edge lines up with the content's right edge.
|
||||
z-40: above the hero background (z-0) and text content (z-10), but BELOW
|
||||
the fixed NavBar header (z-50) so the nav stays on top when they overlap. */
|
||||
}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 z-40 mx-auto max-w-[72rem] px-5 lg:ml-56 lg:max-w-[calc(72rem+14rem)] sm:px-8"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto ml-auto w-44 translate-y-1/2 sm:w-52 md:w-60"
|
||||
>
|
||||
<CelestialFrame rounded="2xl" emphasis="strong">
|
||||
<img
|
||||
src="/images/about-portrait.jpg"
|
||||
alt="Greg Kilmer headshot"
|
||||
class="h-full w-full object-cover"
|
||||
style="aspect-ratio: 4/5;"
|
||||
/>
|
||||
</CelestialFrame>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="container-prose relative z-10 lg:ml-56 lg:max-w-[calc(72rem+14rem)]"
|
||||
>
|
||||
<Reveal class="max-w-2xl">
|
||||
<SectionHeader
|
||||
tone="hero"
|
||||
kicker="Professional"
|
||||
icon="briefcase"
|
||||
title="Resume & Portfolio"
|
||||
subtitle="Clean, confident, skimmable."
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{
|
||||
/* Fixed left section rail. It is position:fixed so it renders once here
|
||||
and stays pinned to the viewport's left edge while the page scrolls.
|
||||
The main content column below is given lg:pl-* to clear the 14rem rail. */
|
||||
}
|
||||
<SectionNav />
|
||||
|
||||
{
|
||||
/* Content column. On lg+ the fixed left rail (w-56 = 14rem) takes the left
|
||||
edge, so this column is offset by that width via margin (NOT padding on a
|
||||
centered max-width box — that caused asymmetric overflow / broken grids).
|
||||
It keeps container-prose's max-width but lets it sit beside the rail. */
|
||||
}
|
||||
<div
|
||||
class="container-prose flex flex-col gap-20 pt-28 pb-16 sm:gap-24 sm:pt-40 sm:pb-20 lg:ml-56 lg:max-w-[calc(72rem+14rem)]"
|
||||
>
|
||||
{/* ===== 1. Summary ===== */}
|
||||
<section id="summary" class="scroll-mt-28 flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="01"
|
||||
icon="format-list-text"
|
||||
title="Professional summary"
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={80}>
|
||||
<blockquote
|
||||
class="border-l-2 pl-5 text-lg leading-relaxed"
|
||||
style="border-color: var(--color-gold); color: var(--color-text-muted);"
|
||||
>
|
||||
Software developer and team lead with deep experience in cloud and
|
||||
mobile application development, currently focused on SAP-adjacent
|
||||
solutions at Implico Inc. Expanding into SAP Business Technology
|
||||
Platform (BTP) while maintaining a broad technical foundation across
|
||||
AI/ML, full-stack development, and automation. Known for bridging
|
||||
complex technical requirements with practical, maintainable solutions.
|
||||
Strong communicator who thrives at the intersection of engineering and
|
||||
business needs.
|
||||
</blockquote>
|
||||
</Reveal>
|
||||
<Reveal delay={120}>
|
||||
<figure
|
||||
class="relative overflow-hidden rounded-2xl border"
|
||||
style="border-color: color-mix(in srgb, var(--color-gold) 70%, transparent);"
|
||||
>
|
||||
<img
|
||||
src="/images/pro-workspace.jpg"
|
||||
alt="Cloud development workspace"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-[21/9] w-full object-cover"
|
||||
/>
|
||||
<figcaption
|
||||
class="absolute bottom-0 left-0 m-3 rounded-md px-3 py-1 font-eyebrow text-[0.65rem] uppercase tracking-[0.18em] backdrop-blur-sm"
|
||||
style="background: color-mix(in srgb, var(--color-midnight) 60%, transparent); color: var(--color-starlight);"
|
||||
>
|
||||
Where the day-job work happens
|
||||
</figcaption>
|
||||
</figure>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<Reveal>
|
||||
<AlchemicalDivider
|
||||
class="mx-auto h-6 w-full max-w-sm"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
{/* ===== 2. Experience ===== */}
|
||||
<section id="experience" class="scroll-mt-28 flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="02"
|
||||
icon="timeline-text"
|
||||
title="Experience"
|
||||
subtitle="Where I've proven it. Expand any role for the detail."
|
||||
/>
|
||||
</Reveal>
|
||||
<Timeline entries={experience} />
|
||||
</section>
|
||||
|
||||
{/* ===== 3. Skills ===== */}
|
||||
<section id="skills" class="scroll-mt-28 flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="03"
|
||||
icon="chart-bar"
|
||||
title="Skills & technologies"
|
||||
subtitle="What I bring to the table, organized into three tiers."
|
||||
/>
|
||||
</Reveal>
|
||||
<SkillGrid />
|
||||
</section>
|
||||
|
||||
{/* ===== 4. Projects ===== */}
|
||||
<section id="projects" class="scroll-mt-28 flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="04"
|
||||
icon="view-grid"
|
||||
title="Projects"
|
||||
subtitle="What I've built."
|
||||
/>
|
||||
</Reveal>
|
||||
<div class="grid
|
||||
gap-5
|
||||
sm:grid-cols-2">
|
||||
{
|
||||
projects.map((project, i) => (
|
||||
<Reveal delay={i * 80}>
|
||||
<ProjectCard project={project} />
|
||||
</Reveal>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== 5. Education ===== */}
|
||||
<section id="education" class="scroll-mt-28 flex flex-col gap-8">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="05"
|
||||
icon="school"
|
||||
title="Education & certifications"
|
||||
subtitle="My foundation."
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
Education history
|
||||
</h3>
|
||||
<EducationHistory />
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={120}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
Certifications
|
||||
</h3>
|
||||
<Certifications />
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* ===== 6. Contact ===== */}
|
||||
<section id="contact" class="relative scroll-mt-28 flex flex-col gap-6">
|
||||
{/* A faint seal beside the closing call to action (the astrolabe is the
|
||||
hero's motif, so the seal sits down here rather than competing). */}
|
||||
<Motif
|
||||
name="kepler-nest-seal-light"
|
||||
class="absolute -top-20 right-0 -z-10 h-64 w-64 sm:-top-28 sm:h-96 sm:w-96"
|
||||
style="opacity:.16;"
|
||||
/>
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="06"
|
||||
icon="email"
|
||||
title="Let's talk"
|
||||
subtitle="Open to opportunities in AI/ML, mobile app, and cloud development, VR and Game Development, and technical leadership."
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={80}>
|
||||
<div class="card flex flex-col gap-6">
|
||||
<ul class="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:gap-8">
|
||||
<li class="flex items-center gap-2">
|
||||
<Icon
|
||||
name="email-outline"
|
||||
class="h-5 w-5"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<ObfuscatedEmail
|
||||
address="contact@gregkilmer.com"
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<Icon
|
||||
name="linkedin"
|
||||
class="h-5 w-5"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
><a
|
||||
href="https://www.linkedin.com/in/gregory-kilmer-36646bb5/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">LinkedIn</a
|
||||
></span
|
||||
>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<Icon
|
||||
name="github"
|
||||
class="h-5 w-5"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
><a
|
||||
href="https://github.com/patiun"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">GitHub</a
|
||||
></span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/contact" class="btn btn-primary self-start">
|
||||
Full contact page
|
||||
<Icon name="arrow-right" class="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
/**
|
||||
* /store/cards — Proxy MTG cards product line
|
||||
* ---------------------------------------------------------------------------
|
||||
* Order: back-link + title → live slot tracker → image carousel → specs →
|
||||
* pricing → double-sided callout → key requirements → CTA.
|
||||
*
|
||||
* The carousel images + labels are managed server-side in store-products.ts
|
||||
* (cardProduct.images). The specs block is image-free now; examples live in
|
||||
* the carousel only.
|
||||
*/
|
||||
import StoreLayout from '@layouts/StoreLayout.astro';
|
||||
import PageSeal from "@components/brand/PageSeal.astro";
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Carousel from '@components/store/Carousel.tsx';
|
||||
import ProductCard from '@components/store/ProductCard.astro';
|
||||
import PricingTable from '@components/store/PricingTable.astro';
|
||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
||||
import RequirementList from '@components/store/RequirementList.astro';
|
||||
import { cardProduct, requirements } from '@data/store-products';
|
||||
import { storeAvailability } from '@data/store-availability';
|
||||
|
||||
const av = storeAvailability;
|
||||
const slot = av.slots[cardProduct.slotKey];
|
||||
// The double-sided rule is important enough to surface as its own callout.
|
||||
const doubleSided = requirements.find((r) => r.title.toLowerCase().includes('double-sided'))!;
|
||||
const keyReqs = requirements.filter((r) =>
|
||||
r.title.toLowerCase().includes('card list')
|
||||
|| r.title.toLowerCase().includes('double-sided')
|
||||
|| r.title.toLowerCase().includes('proxies')
|
||||
|| r.title.toLowerCase().includes('custom'),
|
||||
);
|
||||
---
|
||||
|
||||
<StoreLayout
|
||||
title="Proxy MTG Cards"
|
||||
description="Proxy MTG cards printed to standard size with a solid white back — clearly marked proxies for casual play. Specs and pricing."
|
||||
>
|
||||
<PageSeal name="squared-circle-seal-light" />
|
||||
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
|
||||
{/* Header */}
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/store"
|
||||
class="inline-flex items-center gap-1.5 font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
Back to store
|
||||
</a>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<SectionHeader kicker="Product" icon={cardProduct.icon} title={cardProduct.title} subtitle={cardProduct.summary} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Live slot tracker for this product line (top of content). */}
|
||||
<section class="flex flex-col gap-3">
|
||||
<Reveal>
|
||||
<SlotMeter label="Card order slots" icon="cards-playing" open={slot.open} total={slot.total} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Image carousel (server-driven, swipeable). */}
|
||||
<section class="flex flex-col gap-4">
|
||||
<Reveal><SectionHeader kicker="Examples" icon="image-multiple" title={`See the ${cardProduct.title.toLowerCase()}`} /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<Carousel client:visible slides={cardProduct.images} label={`${cardProduct.title} examples`} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Specifications (no image — examples live in the carousel). */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Details" icon="ruler" title="Specifications" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<ProductCard product={cardProduct} detailed />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Pricing" icon="currency-usd" title="Card sets" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<PricingTable
|
||||
icon="cards-playing"
|
||||
caption="Per-card pricing by quantity"
|
||||
subtitle="Final pricing confirmed when your list is received. Prices are placeholders pending update."
|
||||
tiers={cardProduct.pricing}
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Double-sided callout */}
|
||||
<Reveal>
|
||||
<div
|
||||
class="flex items-start gap-4 rounded-xl border p-5"
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
|
||||
>
|
||||
<Icon name="alert" class="mt-0.5 h-6 w-6 shrink-0" style="color: var(--color-accent);" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-sans text-base font-bold" style="color: var(--color-text);">{doubleSided.title}</span>
|
||||
<span class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{doubleSided.body}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Key requirements */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal><SectionHeader kicker="Before you order" icon="clipboard-check" title="Key requirements" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<RequirementList items={keyReqs} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<Reveal>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<a href="/store/order" class="btn btn-primary">
|
||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||
{av.ordersOpen ? 'Start an order request' : 'View order page'}
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</StoreLayout>
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
/**
|
||||
* /store/deck-boxes — 3D-printed deck boxes & counters product line
|
||||
* ---------------------------------------------------------------------------
|
||||
* Order: back-link + title → live slot tracker → image carousel → specs →
|
||||
* pricing → key requirements → CTA.
|
||||
*
|
||||
* The carousel images + labels are managed server-side in store-products.ts
|
||||
* (deckBoxProduct.images). The specs block is image-free now; examples live in
|
||||
* the carousel only.
|
||||
*/
|
||||
import PageSeal from "@components/brand/PageSeal.astro";
|
||||
import StoreLayout from "@layouts/StoreLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import Icon from "@components/Icon.astro";
|
||||
import Carousel from "@components/store/Carousel.tsx";
|
||||
import ProductCard from "@components/store/ProductCard.astro";
|
||||
import PricingTable from "@components/store/PricingTable.astro";
|
||||
import SlotMeter from "@components/store/SlotMeter.astro";
|
||||
import ColorGrid from "@components/store/ColorGrid.astro";
|
||||
import RequirementList from "@components/store/RequirementList.astro";
|
||||
import { deckBoxProduct, requirements } from "@data/store-products";
|
||||
import { storeAvailability } from "@data/store-availability";
|
||||
|
||||
const av = storeAvailability;
|
||||
const slot = av.slots[deckBoxProduct.slotKey];
|
||||
const keyReqs = requirements.filter(
|
||||
(r) =>
|
||||
r.title.toLowerCase().includes("final") ||
|
||||
r.title.toLowerCase().includes("capacity") ||
|
||||
r.title.toLowerCase().includes("affiliation"),
|
||||
);
|
||||
---
|
||||
|
||||
<StoreLayout
|
||||
title="3D-Printed Deck Boxes & Counters"
|
||||
description="3D-printed deck boxes, life counters, and tokens. Specs and pricing for printed storage and trackers."
|
||||
>
|
||||
<PageSeal name="craft-seal-print-light" />
|
||||
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
|
||||
{/* Header */}
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/store"
|
||||
class="inline-flex items-center gap-1.5 font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
Back to store
|
||||
</a>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<SectionHeader
|
||||
kicker="Product"
|
||||
icon={deckBoxProduct.icon}
|
||||
title={deckBoxProduct.title}
|
||||
subtitle={deckBoxProduct.summary}
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Live slot tracker for this product line (top of content). */}
|
||||
<section class="flex flex-col gap-3">
|
||||
<Reveal>
|
||||
<SlotMeter
|
||||
label="3D-print slots"
|
||||
icon="printer-3d-nozzle"
|
||||
open={slot.open}
|
||||
total={slot.total}
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Image carousel (server-driven, swipeable). */}
|
||||
<section class="flex flex-col gap-4">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Examples"
|
||||
icon="image-multiple"
|
||||
title={`See the ${deckBoxProduct.title.toLowerCase()}`}
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={60}>
|
||||
<Carousel
|
||||
client:visible
|
||||
slides={deckBoxProduct.images}
|
||||
label={`${deckBoxProduct.title} examples`}
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Specifications (no image — examples live in the carousel). */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Details"
|
||||
icon="ruler"
|
||||
title="Specifications"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={60}>
|
||||
<ProductCard product={deckBoxProduct} detailed />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Pricing"
|
||||
icon="currency-usd"
|
||||
title="Prints"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={60}>
|
||||
<PricingTable
|
||||
icon="printer-3d-nozzle"
|
||||
caption="Per-print pricing"
|
||||
subtitle="Final pricing confirmed when your request is received. Prices are placeholders pending update."
|
||||
tiers={deckBoxProduct.pricing}
|
||||
/>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Available print colors (filament). Managed in store-products.ts. */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Colors"
|
||||
icon="palette"
|
||||
title="Available print colors"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={60}>
|
||||
<ColorGrid colors={deckBoxProduct.colors} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Key requirements */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal
|
||||
><SectionHeader
|
||||
kicker="Before you order"
|
||||
icon="clipboard-check"
|
||||
title="Key requirements"
|
||||
/></Reveal
|
||||
>
|
||||
<Reveal delay={60}>
|
||||
<RequirementList items={keyReqs} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<Reveal>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<a href="/store/order" class="btn btn-primary">
|
||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||
{av.ordersOpen ? "Start an order request" : "View order page"}
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</StoreLayout>
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
/**
|
||||
* /store — Storefront
|
||||
* ---------------------------------------------------------------------------
|
||||
* The store home: hero on the animated field, intro, the two product lines,
|
||||
* live availability (slots) from store-availability.json, the disclaimer /
|
||||
* requirements block, and a CTA to the order page.
|
||||
*
|
||||
* Reuses the main site's aesthetic (InterestHero-style hero, Reveal, cards)
|
||||
* but is rendered through StoreLayout so it gets the store-only nav and
|
||||
* noindex by default. No links back to the main site.
|
||||
*/
|
||||
import StoreLayout from "@layouts/StoreLayout.astro";
|
||||
import Reveal from "@components/motion/Reveal.astro";
|
||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
|
||||
import StarBurst from "@components/mystic/StarBurst.astro";
|
||||
import Icon from "@components/Icon.astro";
|
||||
import ProductTile from "@components/store/ProductTile.astro";
|
||||
import SlotMeter from "@components/store/SlotMeter.astro";
|
||||
import RequirementList from "@components/store/RequirementList.astro";
|
||||
import {
|
||||
cardProduct,
|
||||
deckBoxProduct,
|
||||
requirements,
|
||||
} from "@data/store-products";
|
||||
import { storeAvailability } from "@data/store-availability";
|
||||
|
||||
const av = storeAvailability;
|
||||
const orderHref = "/store/order";
|
||||
---
|
||||
|
||||
<StoreLayout title="Store — Proxy MTG Cards & 3D-Printed Deck Boxes">
|
||||
{/* --- Hero --- */}
|
||||
{/* pt-36/sm:pt-40 gives the bright hero content clear breathing room below
|
||||
the solid fixed StoreNav (64px tall), so the gold label and nav never
|
||||
visually crowd each other. */}
|
||||
<section class="on-sky relative overflow-hidden pb-8 pt-36 sm:pt-40">
|
||||
<AnimatedBackground client:idle className="vt-sky" />
|
||||
|
||||
<div
|
||||
class="container-prose relative z-10 grid items-center gap-6 py-6 sm:grid-cols-[1.4fr_1fr]"
|
||||
>
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<Reveal>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 font-eyebrow text-sm tracking-[0.06em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="store" class="h-4 w-4" />
|
||||
Store.GregKilmer.com
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<span
|
||||
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="cards" class="h-6 w-6" />
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={100}>
|
||||
<h2
|
||||
class="font-display font-bold"
|
||||
style="color: var(--color-starlight); font-size: clamp(1.75rem, 2vw + 1rem, 2.75rem); line-height: 1.15;"
|
||||
>
|
||||
Proxy MTG cards & 3D-printed deck boxes
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={140}>
|
||||
<p
|
||||
class="max-w-xl border-l-2 pl-4 font-sans text-base italic sm:text-lg"
|
||||
style="border-color: var(--color-gold); color: var(--color-starlight); opacity: 0.92;"
|
||||
>
|
||||
Clearly-marked proxies for casual play, and matching 3D-printed
|
||||
storage & counters. Hand-made in small batches.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={180}>
|
||||
<a href={orderHref} class="btn btn-primary mt-2">
|
||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||
Start an order request
|
||||
</a>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={80} class="w-full">
|
||||
<figure
|
||||
class="relative w-full overflow-hidden rounded-2xl border"
|
||||
style="border-color: color-mix(in srgb, var(--color-gold) 70%, transparent);"
|
||||
>
|
||||
<img
|
||||
src="/images/proxies.jpg"
|
||||
alt="Example printed proxy MTG cards"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute left-3 top-3 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-gold)_60%,transparent)]"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
</figure>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
|
||||
{/* --- Intro + limited capacity banner --- */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="Welcome"
|
||||
icon="store"
|
||||
title="A small workshop, not a factory"
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<p
|
||||
class="max-w-2xl text-lg leading-relaxed"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{av.maxNote}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon
|
||||
name="clock-outline"
|
||||
class="h-6 w-6 shrink-0"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<span class="text-sm sm:text-base" style="color: var(--color-text);"
|
||||
>{av.turnaround}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="pill"
|
||||
style={av.ordersOpen
|
||||
? undefined
|
||||
: "background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);"}
|
||||
>
|
||||
{av.ordersOpen ? "Orders open" : "Orders currently closed"}
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* --- Product lines --- */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="Products"
|
||||
icon="cards-outline"
|
||||
title="What I make"
|
||||
/>
|
||||
</Reveal>
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Reveal delay={60}>
|
||||
<ProductTile product={cardProduct} href="/store/cards" />
|
||||
</Reveal>
|
||||
<Reveal delay={120}>
|
||||
<ProductTile product={deckBoxProduct} href="/store/deck-boxes" />
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Live availability --- */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="Availability"
|
||||
icon="gauge"
|
||||
title="Current order slots"
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<p class="max-w-2xl text-base" style="color: var(--color-text-muted);">
|
||||
I can only take so many requests at a time. These update as I open and
|
||||
finish orders. Last updated {av.lastUpdated}.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<SlotMeter
|
||||
label="Card orders"
|
||||
icon="cards-playing"
|
||||
open={av.slots.cards.open}
|
||||
total={av.slots.cards.total}
|
||||
/>
|
||||
<SlotMeter
|
||||
label="3D-print slots"
|
||||
icon="printer-3d-nozzle"
|
||||
open={av.slots.deckBoxes.open}
|
||||
total={av.slots.deckBoxes.total}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* --- Requirements / disclaimer --- */}
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<SectionHeader
|
||||
kicker="Before you order"
|
||||
icon="clipboard-check"
|
||||
title="Requirements & policies"
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<RequirementList items={requirements} />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* --- CTA --- */}
|
||||
<section
|
||||
class="flex flex-col items-center gap-4 rounded-2xl border p-8 text-center"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<Reveal>
|
||||
<h3
|
||||
class="font-display text-2xl font-bold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
Ready to put in a request?
|
||||
</h3>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<p class="max-w-lg text-sm" style="color: var(--color-text-muted);">
|
||||
The order form collects your request and emails it to me. No payment
|
||||
is taken through the site. {
|
||||
av.ordersOpen
|
||||
? ""
|
||||
: "Orders are currently closed — check back soon."
|
||||
}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<a href={orderHref} class="btn btn-primary">
|
||||
<Icon name="clipboard-list" class="h-5 w-5" />
|
||||
Go to the order page
|
||||
</a>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
</StoreLayout>
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
/**
|
||||
* /store/order — Order request page
|
||||
* ---------------------------------------------------------------------------
|
||||
* Hard-gated by store-availability.json:
|
||||
* - ordersOpen === false => renders a "closed" notice + email link, NO form.
|
||||
* - ordersOpen === true => renders the OrderForm island + slot meters +
|
||||
* the requirements list.
|
||||
*
|
||||
* The server endpoint /api/order re-checks the same file and returns 403 when
|
||||
* closed, so the gate can't be bypassed by POSTing directly.
|
||||
*/
|
||||
import StoreLayout from '@layouts/StoreLayout.astro';
|
||||
import PageSeal from "@components/brand/PageSeal.astro";
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import ObfuscatedEmail from '@components/ObfuscatedEmail.astro';
|
||||
import OrderForm from '@components/store/OrderForm.tsx';
|
||||
import SlotMeter from '@components/store/SlotMeter.astro';
|
||||
import RequirementList from '@components/store/RequirementList.astro';
|
||||
import { requirements, deckBoxProduct } from '@data/store-products';
|
||||
import { storeAvailability } from '@data/store-availability';
|
||||
import { turnstileSiteKey } from '@lib/env';
|
||||
|
||||
const av = storeAvailability;
|
||||
const open = av.ordersOpen;
|
||||
const siteKey = turnstileSiteKey();
|
||||
---
|
||||
|
||||
<StoreLayout
|
||||
title="Order Request"
|
||||
description="Submit a proxy-card or 3D-print order request. No payment is taken through the site."
|
||||
>
|
||||
<PageSeal name="transmutation-seal-light" />
|
||||
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/store"
|
||||
class="inline-flex items-center gap-1.5 font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
Back to store
|
||||
</a>
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<SectionHeader kicker="Order" icon="clipboard-list" title="Order request" />
|
||||
</Reveal>
|
||||
<Reveal delay={100}>
|
||||
<p class="max-w-2xl text-lg leading-relaxed" style="color: var(--color-text-muted);">
|
||||
This form sends me an order request by email. No payment is taken through the site.
|
||||
{av.turnaround}
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Availability meters — shown first so visitors immediately see whether
|
||||
there's capacity before reading the rest of the page. */}
|
||||
<section class="flex flex-col gap-5">
|
||||
<Reveal><SectionHeader kicker="Availability" icon="gauge" title="Current order slots" /></Reveal>
|
||||
<Reveal delay={60}>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<SlotMeter label="Card orders" icon="cards-playing" open={av.slots.cards.open} total={av.slots.cards.total} />
|
||||
<SlotMeter label="3D-print slots" icon="printer-3d-nozzle" open={av.slots.deckBoxes.open} total={av.slots.deckBoxes.total} />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Open/closed status banner (under the meters). */}
|
||||
<Reveal delay={100}>
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
|
||||
style={open
|
||||
? 'border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);'
|
||||
: 'border-color: color-mix(in srgb, #e55 40%, transparent); background: color-mix(in srgb, #e55 10%, transparent);'}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon name={open ? 'check-circle' : 'close-circle'} class="h-6 w-6 shrink-0" style={open ? 'color: var(--color-accent);' : 'color: #e55;'} />
|
||||
<span class="text-sm sm:text-base" style="color: var(--color-text);">
|
||||
{open ? 'Orders are open. Slots are limited.' : 'Orders are currently closed.'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-eyebrow text-xs" style="color: var(--color-text-subtle);">
|
||||
Updated {av.lastUpdated}
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Either the form (open) or the closed notice (no form). */}
|
||||
{open ? (
|
||||
<section class="flex flex-col gap-8 lg:grid lg:grid-cols-[1.6fr_1fr] lg:items-start">
|
||||
<Reveal>
|
||||
<div class="card flex flex-col gap-2">
|
||||
<h3 class="font-display text-lg font-bold" style="color: var(--color-text);">Your request</h3>
|
||||
<p class="text-sm" style="color: var(--color-text-muted);">
|
||||
Fill in the details below. Remember: the card list is final once received, and
|
||||
double-sided cards count as 2 slots.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<OrderForm client:idle turnstileSiteKey={siteKey} colors={deckBoxProduct.colors} />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={80}>
|
||||
<div class="flex flex-col gap-4 lg:sticky lg:top-24">
|
||||
<RequirementList items={requirements} heading="Policies & requirements" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
) : (
|
||||
<section class="flex flex-col gap-6">
|
||||
<Reveal>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 rounded-2xl border p-8 text-center"
|
||||
style="border-color: color-mix(in srgb, #e55 40%, transparent); background: color-mix(in srgb, #e55 8%, transparent);"
|
||||
>
|
||||
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full" style="background: color-mix(in srgb, #e55 20%, transparent); color: #e55;" aria-hidden="true">
|
||||
<Icon name="lock" class="h-6 w-6" />
|
||||
</span>
|
||||
<h3 class="font-display text-xl font-bold" style="color: var(--color-text);">Orders are not being taken right now</h3>
|
||||
<p class="max-w-md text-sm" style="color: var(--color-text-muted);">
|
||||
I open a limited number of slots at a time. Check back here — the meters above
|
||||
show live availability, and the order form appears as soon as orders reopen. You can
|
||||
also email me directly with questions.
|
||||
</p>
|
||||
<div class="mt-1 flex flex-wrap items-center justify-center gap-2">
|
||||
<Icon name="email" class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
<ObfuscatedEmail address="orders@gregkilmer.com" class="font-medium" style="color: var(--color-accent);" />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<RequirementList items={requirements} heading="Policies & requirements (for when orders open)" />
|
||||
</Reveal>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</StoreLayout>
|
||||
Reference in New Issue
Block a user