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,55 @@
|
||||
---
|
||||
/**
|
||||
* Icon.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Inline icon renderer. Names resolve to the Patiun brand icon set first
|
||||
* (src/lib/brand-icons.ts: stroke icons with a gold star accent), via either a
|
||||
* brand name ("dice") or the mdi name the call sites already use ("dice-multiple").
|
||||
* Anything not in the brand set, which is only third-party logos like GitHub or
|
||||
* LinkedIn, falls back to mdi (Material Design Icons) path data. Either way the
|
||||
* output is one inline <svg> that inherits `currentColor`.
|
||||
*
|
||||
* Usage: <Icon name="briefcase" class="h-6 w-6" />
|
||||
*/
|
||||
import mdi from '@iconify-json/mdi/icons.json';
|
||||
import { brandIconBody } from '@lib/brand-icons';
|
||||
|
||||
interface Props {
|
||||
/** mdi icon name WITHOUT prefix, e.g. "briefcase". */
|
||||
name: string;
|
||||
/** Accessible label. Set when the icon conveys meaning alone (not decorative). */
|
||||
title?: string;
|
||||
class?: string;
|
||||
/** Override default 1em sizing (rarely needed; prefer class utilities). */
|
||||
size?: number | string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const { name, title, class: className, size, style } = Astro.props;
|
||||
|
||||
const brand = brandIconBody(name);
|
||||
const icon = brand
|
||||
? undefined
|
||||
: (mdi as { icons: Record<string, { body: string }> }).icons[name];
|
||||
if (!brand && !icon) {
|
||||
// Surface missing-icon names during dev so typos are caught immediately.
|
||||
console.warn(`[Icon] unknown mdi icon: "${name}"`);
|
||||
}
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size ?? '1em'}
|
||||
height={size ?? '1em'}
|
||||
viewBox="0 0 24 24"
|
||||
fill={brand ? 'none' : undefined}
|
||||
stroke={brand ? 'currentColor' : undefined}
|
||||
stroke-width={brand ? 1.6 : undefined}
|
||||
stroke-linecap={brand ? 'round' : undefined}
|
||||
stroke-linejoin={brand ? 'round' : undefined}
|
||||
class={className}
|
||||
style={style}
|
||||
aria-hidden={title ? undefined : 'true'}
|
||||
role={title ? 'img' : undefined}
|
||||
set:html={(title ? `<title>${title}</title>` : '') + (brand ?? icon?.body ?? '')}
|
||||
/>
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
/**
|
||||
* ObfuscatedEmail.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders an email address as a clickable link without ever putting the
|
||||
* plaintext address into the HTML source. The address is base64-encoded at
|
||||
* render time and stored in a data attribute; a tiny inline script decodes it
|
||||
* on click and builds the mailto: href at that moment. Scrapers that simply
|
||||
* grep the HTML for `@` or `mailto:` see nothing useful.
|
||||
*
|
||||
* This is not cryptographically secure obfuscation (base64 is trivially
|
||||
* reversible), but it defeats the vast majority of dumb harvesters that don't
|
||||
* run JS. Combined with robots.txt + noindex on /contact, it raises the cost
|
||||
* of harvesting enough to be worthwhile.
|
||||
*
|
||||
* <noscript> fallback shows a non-clickable prompt directing visitors to the
|
||||
* contact form, so the page isn't broken for the rare no-JS visitor.
|
||||
*
|
||||
* Usage: <ObfuscatedEmail address="contact@gregkilmer.com" />
|
||||
*/
|
||||
interface Props {
|
||||
address: string;
|
||||
/** Optional visible label; defaults to the address (decoded client-side). */
|
||||
label?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const { address, label, class: className, style } = Astro.props;
|
||||
|
||||
// Encode at render time. btoa needs a binary-safe string; email addresses are
|
||||
// ASCII so this is fine. We also reverse the string first so even a base64
|
||||
// decoder doesn't immediately yield the address — a small extra speed bump.
|
||||
const encoded = btoa(address.split("").reverse().join(""));
|
||||
const displayLabel = label ?? " "; // placeholder; JS fills the real address
|
||||
---
|
||||
|
||||
<a
|
||||
href="#"
|
||||
data-mail={encoded}
|
||||
class={className}
|
||||
style={style}
|
||||
onclick="return false;"
|
||||
aria-label="Email address (decoded on click)"
|
||||
>
|
||||
{displayLabel}
|
||||
</a>
|
||||
<noscript>
|
||||
<span class={className} style={style}>
|
||||
Enable JavaScript to see the email address, or use the contact form.
|
||||
</span>
|
||||
</noscript>
|
||||
|
||||
<script is:inline>
|
||||
// Decode and wire up every obfuscated email link on the page. Runs on initial
|
||||
// load and again after Astro View Transitions swap the DOM (astro:page-load).
|
||||
function initObfuscatedEmail() {
|
||||
document.querySelectorAll("a[data-mail]").forEach((el) => {
|
||||
const a = el;
|
||||
const encoded = a.getAttribute("data-mail");
|
||||
if (!encoded) return;
|
||||
// Reverse the base64 we encoded at render time, then decode.
|
||||
const reversed = atob(encoded).split("").reverse().join("");
|
||||
a.setAttribute("href", "mailto:" + reversed);
|
||||
// If no explicit label was provided, show the address as the link text.
|
||||
if (!a.textContent || a.textContent.trim() === "") {
|
||||
a.textContent = reversed;
|
||||
}
|
||||
});
|
||||
}
|
||||
initObfuscatedEmail();
|
||||
document.addEventListener("astro:page-load", initObfuscatedEmail);
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* BrandIcon.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* React twin of Icon.astro for islands: renders a Patiun brand icon (by brand
|
||||
* name or mdi alias) as an inline stroke SVG that inherits currentColor.
|
||||
*/
|
||||
import { brandIconBody } from '@lib/brand-icons';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** Accessible label; omit when the icon sits beside visible text. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function BrandIcon({ name, size = 20, className, title }: Props) {
|
||||
const body = brandIconBody(name) ?? '';
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
role={title ? 'img' : undefined}
|
||||
aria-hidden={title ? undefined : true}
|
||||
aria-label={title}
|
||||
dangerouslySetInnerHTML={{ __html: body }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
/**
|
||||
* Motif.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A Patiun brand motif ("The Alchemist's Instrument") as a decorative image.
|
||||
* Files live in public/motifs/ and are copied from branding/motifs/svg with
|
||||
* their ring text and symbols outlined to paths, so they render without the
|
||||
* Cinzel or Noto Sans Symbols fonts. `-light` files are the Starlight-ground
|
||||
* recolor (deep teal, deep gold, Midnight symbols); the rest are drawn for the
|
||||
* night sky.
|
||||
*
|
||||
* Brand rules: one focal motif per surface, and keep motifs faint behind
|
||||
* content (they are texture, not illustration).
|
||||
*
|
||||
* Usage:
|
||||
* <Motif name="transmutation-seal-light" class="absolute -right-40 top-10 h-[40rem] w-[40rem]" style="opacity:.1" />
|
||||
*/
|
||||
export interface Props {
|
||||
name:
|
||||
| "layered-seal"
|
||||
| "astrolabe"
|
||||
| "transmutation-seal-light"
|
||||
| "tria-prima-array-seal-light"
|
||||
| "zodiac-seal-light"
|
||||
| "craft-seal-print-light"
|
||||
| "squared-circle-seal-light"
|
||||
| "kepler-nest-seal-light";
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { name, class: className, style } = Astro.props;
|
||||
---
|
||||
|
||||
<img
|
||||
src={`/motifs/${name}.svg`}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
class:list={["pointer-events-none select-none", className]}
|
||||
style={style}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
/**
|
||||
* PageSeal.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A faint Patiun seal behind the top-right of a page header, for pages
|
||||
* without a night-sky hero (those already carry the turning layered seal).
|
||||
* Sits behind content (-z-10) and is clipped so it never widens the page.
|
||||
*
|
||||
* Usage (first thing inside the page): <PageSeal name="zodiac-seal-light" />
|
||||
*/
|
||||
import Motif from "@components/brand/Motif.astro";
|
||||
import type { Props as MotifProps } from "@components/brand/Motif.astro";
|
||||
|
||||
interface Props {
|
||||
name: MotifProps["name"];
|
||||
}
|
||||
const { name } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[46rem] overflow-hidden"
|
||||
>
|
||||
<Motif
|
||||
name={name}
|
||||
class="absolute -right-40 top-20 h-[30rem] w-[30rem] sm:-right-24 sm:top-24 sm:h-[36rem] sm:w-[36rem] lg:right-[max(-6rem,calc((100vw-72rem)/2-12rem))]"
|
||||
style="opacity:.14;"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
/**
|
||||
* DeckCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a single Magic: The Gathering deck pulled live from Archidekt. Shows
|
||||
* the deck's featured card art (or a placeholder slab), the deck name as a link
|
||||
* to its Archidekt page, a row of mana-color pips sized by color presence, and
|
||||
* a meta line (card count, format, last-updated).
|
||||
*
|
||||
* Data shape matches ArchidektDeck in src/lib/archidekt.ts.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
href: string;
|
||||
featured?: string;
|
||||
colors?: { W: number; U: number; B: number; R: number; G: number };
|
||||
size?: number;
|
||||
formatLabel?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const { name, href, featured, colors, size, formatLabel, updatedAt } = Astro.props;
|
||||
|
||||
// MTG mana colors. Used for the presence pips; a color shows only if count > 0.
|
||||
const MANA: Array<[key: 'W' | 'U' | 'B' | 'R' | 'G', hex: string]> = [
|
||||
['W', '#f8f4e8'],
|
||||
['U', '#3b7bd4'],
|
||||
['B', '#2b2b2b'],
|
||||
['R', '#d44b3b'],
|
||||
['G', '#3f9d52'],
|
||||
];
|
||||
const present = colors ? MANA.filter(([k]) => (colors[k] ?? 0) > 0) : [];
|
||||
|
||||
// Friendly relative-ish date (e.g. "Jul 2026"). Falls back gracefully.
|
||||
const updated = updatedAt ? new Date(updatedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) : '';
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card card-interactive group flex h-full flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<div class="relative aspect-[4/3] w-full overflow-hidden">
|
||||
{
|
||||
featured ? (
|
||||
<img
|
||||
src={featured}
|
||||
alt={`${name} featured card`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 16%, transparent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name="cards-playing"
|
||||
class="h-12 w-12 opacity-50"
|
||||
style="color: var(--color-text-muted);"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Legibility gradient at the bottom for the meta row. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-x-0 bottom-0 h-1/3"
|
||||
style="background: linear-gradient(to top, rgba(0,0,0,0.55), transparent);"
|
||||
></div>
|
||||
|
||||
{
|
||||
/* Mana color pips, top-left over the art. */
|
||||
present.length > 0 && (
|
||||
<div class="absolute left-3 top-3 flex gap-1.5">
|
||||
{present.map(([key, hex]) => (
|
||||
<span
|
||||
class="h-3 w-3 rounded-full border border-black/30"
|
||||
style={`background:${hex};`}
|
||||
title={`${key} mana`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<h3
|
||||
class="font-display text-base leading-tight underline-offset-4 group-hover:underline"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 font-eyebrow text-[0.65rem] uppercase tracking-[0.16em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{formatLabel && <span>{formatLabel}</span>}
|
||||
{formatLabel && (size || updated) && <span aria-hidden="true">·</span>}
|
||||
{size ? <span>{size} cards</span> : null}
|
||||
{updated && (size ? <span aria-hidden="true">·</span> : null)}
|
||||
{updated && <span>{updated}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
/**
|
||||
* GameCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a Gaming Hall of Fame entry. Shows the game's banner/box art (or a
|
||||
* placeholder slab when no art is set yet), the title, a platform pill, the
|
||||
* release year, and the blurb. Matches the warm "interest" surface styling and
|
||||
* the existing card idiom (`.card card-interactive`).
|
||||
*
|
||||
* Driven by the `games` collection schema in src/content.config.ts.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import { platformToIcon } from '@lib/platforms';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
platform: string;
|
||||
blurb: string;
|
||||
/** Public path to art, e.g. "/images/games/elden-ring.jpg". */
|
||||
art?: string;
|
||||
/** Alt text for the art; defaults to the title. */
|
||||
artAlt?: string;
|
||||
/** Optional release year shown next to the platform pill. */
|
||||
year?: number;
|
||||
/** Optional mdi icon override; defaults to a platform-derived icon. */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const { title, platform, blurb, art, artAlt, year, icon } = Astro.props;
|
||||
|
||||
const platIcon = icon ?? platformToIcon(platform);
|
||||
const alt = artAlt ?? title;
|
||||
---
|
||||
|
||||
<article class="card card-interactive flex h-full flex-col gap-0 overflow-hidden p-0">
|
||||
<div class="relative aspect-[4/3] w-full overflow-hidden">
|
||||
{
|
||||
art ? (
|
||||
<img
|
||||
src={art}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 16%, transparent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name="gamepad-variant"
|
||||
class="h-12 w-12 opacity-50"
|
||||
style="color: var(--color-text-muted);"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 p-5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="pill text-sm">
|
||||
<Icon name={platIcon} class="h-3.5 w-3.5" />
|
||||
{platform}
|
||||
</span>
|
||||
{year && (
|
||||
<span
|
||||
class="font-eyebrow text-[0.65rem] uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 class="font-display text-lg" style="color: var(--color-text);">{title}</h3>
|
||||
|
||||
<p class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
{blurb}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
/**
|
||||
* InterestCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Generic highlight card used across interest pages (the spec's "Highlights
|
||||
* (Card Grid)" sections). Each card: an mdi icon, a bold title, and a body.
|
||||
* Spec emojis in the highlight tables are mapped to mdi icon names at the call
|
||||
* site.
|
||||
*
|
||||
* Optional `currently` prop restyles the card as a "what's active now" item
|
||||
* with a pulse dot, matching the spec's "Currently" sections.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
/** Optional small status pill text, e.g. "Complete", "Reading". */
|
||||
status?: string;
|
||||
currently?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { icon, title, status, currently = false, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
'card card-interactive flex h-full flex-col gap-3',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl"
|
||||
style={currently
|
||||
? 'background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent);'
|
||||
: 'background: color-mix(in srgb, var(--color-secondary) 18%, transparent); color: var(--color-secondary);'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={icon} class="h-6 w-6" />
|
||||
</span>
|
||||
{currently && (
|
||||
<span class="inline-flex items-center gap-1.5 font-eyebrow text-[0.65rem] uppercase tracking-[0.18em]" style="color: var(--color-accent);">
|
||||
<span class="h-2 w-2 rounded-full animate-pulse-glow" style="background: var(--color-accent);"></span>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{status && !currently && (
|
||||
<span class="pill">{status}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 class="font-display text-lg" style="color: var(--color-text);">{title}</h3>
|
||||
<div class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
<slot />
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* MediaCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a media favorite (anime / tv / movies / reading). Mirrors the
|
||||
* GameCard / InterestCard idiom (`.card card-interactive`): a poster-style art
|
||||
* slot with a kind-derived icon fallback, the title, an optional status pill,
|
||||
* an optional year/count detail, and a blurb body.
|
||||
*
|
||||
* Driven by the `MediaItem` shape in src/lib/media.ts. Populate the optional
|
||||
* `image` field via the matching `scripts/fetch-*-art.mjs` tool; until then the
|
||||
* icon fallback slab keeps the grid looking intentional.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { MediaKind } from '@lib/media';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/** Which medium — drives the fallback poster icon + accent. */
|
||||
kind: MediaKind;
|
||||
/** Local public path to poster/cover art, e.g. "/images/anime/demon-slayer.jpg". */
|
||||
image?: string;
|
||||
/** Alt text for the art; defaults to the title. */
|
||||
imageAlt?: string;
|
||||
/** Optional status pill text, e.g. "Completed", "Watching". */
|
||||
status?: string;
|
||||
/** Optional release year shown next to the status pill. */
|
||||
year?: number;
|
||||
/** Optional span/count for franchise cards, e.g. "All 4 films". */
|
||||
count?: string;
|
||||
/** Long-form blurb shown as the card body. */
|
||||
blurb?: string;
|
||||
}
|
||||
|
||||
const { title, kind, image, imageAlt, status, year, count, blurb } = Astro.props;
|
||||
|
||||
const fallbackIcon: Record<MediaKind, string> = {
|
||||
anime: 'animation-play',
|
||||
tv: 'television-classic',
|
||||
movies: 'movie-open',
|
||||
reading: 'book-open-page-variant',
|
||||
};
|
||||
|
||||
const alt = imageAlt ?? title;
|
||||
const detail = [year ? String(year) : null, count].filter(Boolean).join(' \u00b7 ');
|
||||
---
|
||||
|
||||
<article class="card card-interactive flex h-full flex-col gap-0 overflow-hidden p-0">
|
||||
<div class="relative aspect-[3/4] w-full overflow-hidden">
|
||||
{
|
||||
image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 16%, transparent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name={fallbackIcon[kind]}
|
||||
class="h-12 w-12 opacity-50"
|
||||
style="color: var(--color-text-muted);"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Favorite star badge, always present on these cards. */}
|
||||
<span
|
||||
class="absolute right-2.5 top-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full backdrop-blur-sm"
|
||||
style="background: color-mix(in srgb, var(--color-surface) 70%, transparent); color: var(--color-accent);"
|
||||
aria-label="Favorite"
|
||||
>
|
||||
<Icon name="star" class="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="font-display text-lg leading-tight" style="color: var(--color-text);">
|
||||
{title}
|
||||
</h3>
|
||||
{status && <span class="pill shrink-0">{status}</span>}
|
||||
</div>
|
||||
{
|
||||
detail && (
|
||||
<span
|
||||
class="font-eyebrow text-[0.65rem] uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
blurb && (
|
||||
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
{blurb}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* AnimatedBackground.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* The hero background island. Layers:
|
||||
*
|
||||
* 1. A CSS-driven night-sky gradient (deep blue, darker at top → lighter near
|
||||
* horizon) plus a diagonal Milky Way galaxy band and teal/violet nebula
|
||||
* clouds. GPU-friendly, animated via `animate-gradient-shift` keyframes.
|
||||
* 2. The Patiun layered magic seal, faint and slowly turning in the top-left
|
||||
* corner (the surface's one focal motif).
|
||||
* 3. An optional <canvas> star field: a dense field of slowly-twinkling stars
|
||||
* with thin constellation lines between near neighbors, plus the occasional
|
||||
* shooting star. The WHOLE field rotates together very slowly around the
|
||||
* field center — reading as a slice of sky turning as the planet rotates.
|
||||
* We render at devicePixelRatio and cap the count for perf. It pauses when
|
||||
* off-screen and is fully skipped when prefers-reduced-motion is set.
|
||||
*
|
||||
* The component is presentational only; it reads CSS variables for colors so it
|
||||
* adapts to the section surface tone automatically.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Show the canvas star field. Off on interest pages for a calmer feel. */
|
||||
particles?: boolean;
|
||||
/**
|
||||
* Tailwind/className passthrough for the root. The CALL SITE (in .astro) is
|
||||
* responsible for including `vt-sky` here when this element should carry the
|
||||
* `view-transition-name: sky` morph. We intentionally do NOT compute the
|
||||
* transition class inside this React component: any conditional className
|
||||
* interpolation causes a server/client hydration attribute mismatch under
|
||||
* Astro's client:idle. Passing a static, fully-formed string guarantees the
|
||||
* server-rendered and hydrated class attributes are byte-identical.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AnimatedBackground({
|
||||
particles = true,
|
||||
className = '',
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduce || !particles) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
interface Star {
|
||||
// Position in sky coordinates (pre-rotation). The whole field rotates.
|
||||
rx: number; // offset from field center (cx, cy)
|
||||
ry: number;
|
||||
r: number; // base radius
|
||||
tw: number; // twinkle phase
|
||||
tws: number; // twinkle speed (slow)
|
||||
baseA: number; // base alpha
|
||||
anchor: boolean; // brighter "anchor" star used for constellations
|
||||
hue: 'white' | 'gold' | 'violet' | 'teal';
|
||||
}
|
||||
|
||||
interface Shooter {
|
||||
x: number;
|
||||
y: number;
|
||||
len: number;
|
||||
speed: number;
|
||||
angle: number;
|
||||
life: number;
|
||||
}
|
||||
|
||||
let stars: Star[] = [];
|
||||
let shooters: Shooter[] = [];
|
||||
let nextShooterAt = 0;
|
||||
let t0 = performance.now();
|
||||
let angle = 0; // current field rotation (radians)
|
||||
|
||||
// Resolve star colors from CSS variables so the field matches the theme.
|
||||
const gold =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-gold').trim() ||
|
||||
'#c9a24a';
|
||||
// Nebula Violet is background light only, so violet stars stay sparse; teal stars use the on-dark teal so they read at dot size.
|
||||
const violet = '#6a2fe0';
|
||||
const teal =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-teal-bright').trim() ||
|
||||
'#2bc2b7';
|
||||
const white =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-starlight').trim() ||
|
||||
'#f4f2ed';
|
||||
|
||||
const hueColor = (h: Star['hue']) => (h === 'gold' ? gold : h === 'violet' ? violet : h === 'teal' ? teal : white);
|
||||
|
||||
// Max distance for two stars to be connected by a constellation line.
|
||||
const LINK_DIST = 168;
|
||||
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
||||
|
||||
// Rotation speed: a full turn takes ~6 minutes, so the drift is felt but
|
||||
// never distracting — "as the planet rotates".
|
||||
const ROT_SPEED = (Math.PI * 2) / (6 * 60); // radians per second
|
||||
|
||||
function pickHue(): Star['hue'] {
|
||||
const n = Math.random();
|
||||
// Mostly white stars, with occasional colored ones for richness.
|
||||
if (n < 0.7) return 'white';
|
||||
if (n < 0.85) return 'gold';
|
||||
if (n < 0.94) return 'violet';
|
||||
return 'teal';
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!canvas || !ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Dense field tuned for ~40% constellation coverage on any slice. Stars
|
||||
// live in a sky disk large enough that rotation never reveals an empty
|
||||
// edge. The disk is centered on the PIVOT (the top-right corner) so the
|
||||
// whole field rotates around that corner like a star chart turning about
|
||||
// its corner mount; maxR reaches the far (bottom-left) corner.
|
||||
const cx = width;
|
||||
const cy = 0;
|
||||
const maxR = Math.sqrt(cx * cx + height * height) + 40;
|
||||
|
||||
// Ultra-dense starfield: ~5x the previous density for a richly packed sky.
|
||||
// One star per ~220 px^2 of the disk, capped for perf (~17% anchors).
|
||||
const diskArea = Math.PI * maxR * maxR;
|
||||
const count = Math.min(3000, Math.floor(diskArea / 220));
|
||||
stars = Array.from({ length: count }, (_, i) => {
|
||||
const anchor = i % 6 === 0; // ~17% anchors -> very dense constellation backbone
|
||||
// Uniform-ish distribution across the disk via sqrt on radius.
|
||||
const rr = maxR * Math.sqrt(Math.random());
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = anchor ? 1.4 + Math.random() * 1.4 : 0.35 + Math.random() * 1.1;
|
||||
return {
|
||||
rx: cx + rr * Math.cos(a),
|
||||
ry: cy + rr * Math.sin(a),
|
||||
r,
|
||||
tw: Math.random() * Math.PI * 2,
|
||||
// Slow twinkle: 0.15–0.5 cycles/sec.
|
||||
tws: 0.15 + Math.random() * 0.35,
|
||||
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.16 + Math.random() * 0.4,
|
||||
anchor,
|
||||
hue: anchor ? (Math.random() < 0.5 ? 'gold' : 'white') : pickHue(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function spawnShooter(now: number) {
|
||||
const startX = Math.random() * width * 0.8;
|
||||
const startY = Math.random() * height * 0.5;
|
||||
const a = Math.PI * (0.18 + Math.random() * 0.12);
|
||||
shooters.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
len: 90 + Math.random() * 120,
|
||||
speed: 5.5 + Math.random() * 3.5,
|
||||
angle: a,
|
||||
life: 1,
|
||||
});
|
||||
nextShooterAt = now + 5000 + Math.random() * 8000;
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!ctx) return;
|
||||
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||
t0 = now;
|
||||
angle += ROT_SPEED * dt; // whole sky rotates together
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Pivot = top-right corner (matches the disk center used in resize()).
|
||||
const cx = width;
|
||||
const cy = 0;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
|
||||
// Rotate each sky point about the field center into screen space.
|
||||
const project = (rx: number, ry: number): [number, number] => [
|
||||
cx + (rx - cx) * cosA - (ry - cy) * sinA,
|
||||
cy + (rx - cx) * sinA + (ry - cy) * cosA,
|
||||
];
|
||||
|
||||
// Project every star's sky position into screen space once.
|
||||
// Each entry carries the source star + its rotated (x, y).
|
||||
const proj = stars.map((s) => {
|
||||
const [x, y] = project(s.rx, s.ry);
|
||||
return { s, x, y };
|
||||
});
|
||||
|
||||
// 1) Constellation lines (behind stars) — connect near anchor pairs.
|
||||
ctx.lineWidth = 0.6;
|
||||
for (let i = 0; i < proj.length; i++) {
|
||||
const A = proj[i];
|
||||
if (!A.s.anchor) continue;
|
||||
for (let j = i + 1; j < proj.length; j++) {
|
||||
const B = proj[j];
|
||||
if (!B.s.anchor) continue;
|
||||
const ddx = A.x - B.x;
|
||||
const ddy = A.y - B.y;
|
||||
const d2 = ddx * ddx + ddy * ddy;
|
||||
if (d2 > LINK_DIST_SQ) continue;
|
||||
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7);
|
||||
const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse);
|
||||
ctx.strokeStyle = `rgba(201, 162, 74, ${alpha.toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(A.x, A.y);
|
||||
ctx.lineTo(B.x, B.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Stars (slow twinkle; positions already rotated).
|
||||
for (const p of proj) {
|
||||
const s = p.s;
|
||||
s.tw += s.tws * dt;
|
||||
// Cull stars rotated off-screen for perf.
|
||||
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
|
||||
|
||||
const twinkle = 0.6 + 0.4 * Math.sin(s.tw);
|
||||
const alpha = Math.max(0, Math.min(1, s.baseA * twinkle));
|
||||
const color = hueColor(s.hue);
|
||||
|
||||
if (s.anchor) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = alpha * 0.5;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = color;
|
||||
ctx.arc(p.x, p.y, s.r * 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.shadowBlur = s.anchor ? 6 : 3;
|
||||
ctx.shadowColor = color;
|
||||
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 3) Shooting stars (screen-space; not part of the rotating sky).
|
||||
if (now >= nextShooterAt) spawnShooter(now);
|
||||
for (let i = shooters.length - 1; i >= 0; i--) {
|
||||
const sh = shooters[i];
|
||||
sh.x += Math.cos(sh.angle) * sh.speed;
|
||||
sh.y += Math.sin(sh.angle) * sh.speed;
|
||||
sh.life -= dt * 0.5;
|
||||
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
|
||||
shooters.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const tailX = sh.x - Math.cos(sh.angle) * sh.len;
|
||||
const tailY = sh.y - Math.sin(sh.angle) * sh.len;
|
||||
const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);
|
||||
const headA = Math.max(0, Math.min(1, sh.life));
|
||||
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`);
|
||||
grad.addColorStop(0.4, `rgba(201, 162, 74, ${(headA * 0.6).toFixed(3)})`);
|
||||
grad.addColorStop(1, 'rgba(201, 162, 74, 0)');
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sh.x, sh.y);
|
||||
ctx.lineTo(tailX, tailY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = gold;
|
||||
ctx.arc(sh.x, sh.y, 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resize();
|
||||
t0 = performance.now();
|
||||
nextShooterAt = t0 + 3000 + Math.random() * 4000;
|
||||
raf = requestAnimationFrame(frame);
|
||||
const onResize = () => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Pause when off-screen to save cycles.
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (!raf) {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
} else {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
io.disconnect();
|
||||
};
|
||||
}, [particles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Layer 1: night-sky gradient — deep blue, darker at top, lighter toward
|
||||
a low horizon. Evokes a true clear night sky rather than a flat color. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, #080c14 0%, #0b111c 32%, #0e1522 58%, #131c2b 82%, #1a2a3a 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Milky Way galaxy band — a soft diagonal luminous streak with a denser
|
||||
core, blending teal + violet + faint gold. Sits over the base sky. */}
|
||||
<div
|
||||
className="animate-gradient-shift absolute inset-0 opacity-90"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(38% 70% at 68% 38%, rgba(20, 163, 154, 0.22), transparent 62%),
|
||||
radial-gradient(30% 55% at 72% 44%, rgba(106, 47, 224, 0.26), transparent 60%),
|
||||
radial-gradient(22% 40% at 76% 50%, rgba(201, 162, 74, 0.12), transparent 60%),
|
||||
linear-gradient(115deg, transparent 30%, rgba(236, 233, 226, 0.06) 45%, rgba(236, 233, 226, 0.09) 52%, rgba(236, 233, 226, 0.05) 60%, transparent 72%)
|
||||
`,
|
||||
backgroundSize: '200% 200%',
|
||||
}}
|
||||
/>
|
||||
{/* Nebula clouds near the corners for color depth. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(45% 55% at 12% 12%, rgba(106, 47, 224, 0.18), transparent 60%),
|
||||
radial-gradient(40% 50% at 88% 88%, rgba(20, 163, 154, 0.16), transparent 62%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{/* The layered magic seal, turning slowly in the top-left corner: the
|
||||
one focal motif on the night sky. Its ring text and symbols are
|
||||
outlined to paths (public/motifs/), so no symbol font is needed. */}
|
||||
<img
|
||||
src="/motifs/layered-seal.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow select-none opacity-[0.2]"
|
||||
/>
|
||||
|
||||
{/* Subtle vignette for text legibility. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(120% 80% at 50% 30%, transparent 40%, rgba(8, 12, 20, 0.7) 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Canvas star field (skipped under reduced motion). */}
|
||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
/**
|
||||
* Footer.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Minimal footer on a deep night-sky surface: a faint static starfield accent,
|
||||
* the Patiun seal beside the name, social links, the copyright with the
|
||||
* "a Patiun LLC company" line, and
|
||||
* the spec microcopy "Built with intention. Powered by caffeine." No emojis.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import StarField from '@components/mystic/StarField.astro';
|
||||
import { socials, footerMicrocopy, siteIdentity } from '@lib/site';
|
||||
const year = new Date().getFullYear();
|
||||
const socialList = Object.values(socials);
|
||||
---
|
||||
|
||||
<footer
|
||||
class="relative overflow-hidden border-t"
|
||||
style="border-color: color-mix(in srgb, var(--color-gold) 55%, transparent); background: var(--color-surface-sunken);"
|
||||
>
|
||||
{/* Faint static starfield — carries the night-sky treatment into the footer. */}
|
||||
<StarField
|
||||
class="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style="color: var(--color-accent); opacity:.35;"
|
||||
density="sparse"
|
||||
/>
|
||||
<div class="container-prose relative z-10 flex flex-col items-center gap-6 py-10 text-center sm:flex-row sm:justify-between sm:text-left">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/brand/patiun-seal.svg" alt="" width="72" height="72" class="h-[72px] w-[72px] shrink-0" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-display text-sm font-bold uppercase tracking-[0.14em]" style="color: var(--color-text);">
|
||||
{siteIdentity.name}
|
||||
</p>
|
||||
<p class="font-eyebrow text-xs italic" style="color: var(--color-text-subtle);">
|
||||
{footerMicrocopy}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="flex items-center gap-2">
|
||||
{socialList.map((s) => (
|
||||
<li>
|
||||
<a
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={s.label}
|
||||
title={s.label}
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-lg border transition-colors hover:bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)]"
|
||||
style="color: var(--color-text-muted); border-color: var(--color-border);"
|
||||
>
|
||||
<Icon name={s.icon} class="h-5 w-5" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="container-prose relative z-10 pb-8 text-center text-xs" style="color: var(--color-text-subtle);">
|
||||
© {year} {siteIdentity.name} · {siteIdentity.lockupLine}. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
/**
|
||||
* InterestHero.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Shared header for every interest sub-page. Compact hero on the animated
|
||||
* Milky Way field: a back-to-hub breadcrumb, a small icon mark, the title, and
|
||||
* the italic tagline.
|
||||
*
|
||||
* Tuned for the dark starfield: text uses bright Starlight tones (not the dim
|
||||
* --color-text-subtle/--color-text which vanish on the deep sky). The title is
|
||||
* h2-sized (the page is a sub-page, not Home) so it doesn't dominate. An
|
||||
* optional banner image sits above the heading.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
|
||||
import StarBurst from '@components/mystic/StarBurst.astro';
|
||||
|
||||
interface Props {
|
||||
/** mdi icon used as the decorative mark. */
|
||||
icon: string;
|
||||
title: string;
|
||||
/** The italicized pull-quote tagline from the spec. */
|
||||
tagline: string;
|
||||
/** Back-to-hub breadcrumb label. */
|
||||
eyebrow?: string;
|
||||
/** Optional hero banner image (vibe-matched stock; swap for real photos). */
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
tagline,
|
||||
eyebrow = 'Interests',
|
||||
image,
|
||||
imageAlt = '',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section class="on-sky relative overflow-hidden pb-8 pt-28 sm:pt-32">
|
||||
<!-- Full animated Milky Way field + stars (matches the Home/Interests hero).
|
||||
Carries the per-page morph name (one .vt-sky per page snapshot). -->
|
||||
<AnimatedBackground client:idle className="vt-sky" />
|
||||
|
||||
{/* Two-column layout: text on the left, image on the right. Putting the image
|
||||
BESIDE the text (instead of stacked above it) means the hero height is
|
||||
driven by the text column and stays consistent across interest pages
|
||||
regardless of image presence. On small screens it stacks (image below). */}
|
||||
<div class="container-prose relative z-10 grid items-center gap-6 py-6 sm:grid-cols-[1.4fr_1fr]">
|
||||
{/* --- Left: text column --- */}
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/interests"
|
||||
class="inline-flex items-center gap-1.5 font-eyebrow text-xs uppercase tracking-[0.18em] transition-colors"
|
||||
style="color: var(--color-starlight); opacity: 0.8;"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
{eyebrow}
|
||||
</a>
|
||||
</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={icon} 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.5rem); line-height: 1.15;"
|
||||
>
|
||||
{title}
|
||||
</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;"
|
||||
>
|
||||
{tagline}
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* --- Right: image column (only when an image is provided). A fixed
|
||||
aspect keeps every interest hero the same height. --- */}
|
||||
{image && (
|
||||
<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={image}
|
||||
alt={imageAlt}
|
||||
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>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
/**
|
||||
* Placeholder.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Visual marker for content Greg still needs to provide (the spec's many
|
||||
* "[Greg to fill]" items). Renders a dashed slot with a small mdi pencil badge
|
||||
* and a "TODO" label so the structure is visible and demoable, but it's obvious
|
||||
* what needs swapping. The chosen content approach is "verbatim spec copy, leave
|
||||
* gaps", so these mark every gap rather than inventing fake content.
|
||||
*
|
||||
* Usage:
|
||||
* <Placeholder label="Project name" hint="Add 3-6 portfolio entries" />
|
||||
* <Placeholder label="Email"><p>Inline hint...</p></Placeholder>
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
/** Short name of what's missing, e.g. "Email". */
|
||||
label: string;
|
||||
/** Optional longer hint shown as muted text. */
|
||||
hint?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { label, hint, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'flex items-start gap-3 rounded-xl border border-dashed p-4',
|
||||
className,
|
||||
]}
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); background: color-mix(in srgb, var(--color-accent) 6%, transparent);"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
||||
style="background: var(--color-accent); color: var(--color-accent-contrast);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="pencil" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-eyebrow text-[0.65rem] font-bold uppercase tracking-[0.18em]" style="color: var(--color-accent);">
|
||||
TODO · {label}
|
||||
</span>
|
||||
{hint && <p class="text-sm" style="color: var(--color-text-muted);">{hint}</p>}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
/**
|
||||
* RelatedInterests.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The "You might also like..." cross-link block that ends each interest page,
|
||||
* per the spec's Interests Page Flow. Pass an array of related slugs/titles and
|
||||
* this renders attractive teaser cards.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
export interface RelatedLink {
|
||||
href: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: RelatedLink[];
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="mt-16">
|
||||
<h3 class="mb-5 font-eyebrow text-xs uppercase tracking-[0.22em]" style="color: var(--color-accent);">
|
||||
You might also like
|
||||
</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<a href={item.href} class="card card-interactive group flex items-start gap-4">
|
||||
<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-1">
|
||||
<span class="flex items-center gap-1 font-sans font-semibold" style="color: var(--color-text);">
|
||||
{item.title}
|
||||
<Icon name="arrow-right" class="h-4 w-4 transition-transform group-hover:translate-x-1" style="color: var(--color-accent);" />
|
||||
</span>
|
||||
<span class="text-sm" style="color: var(--color-text-muted);">{item.blurb}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
/**
|
||||
* SectionHeader.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Reusable section title block: a mono "kicker" (e.g. "02"), an h2 heading, and
|
||||
* an optional supporting line. Keeps section openers visually consistent.
|
||||
*
|
||||
* `tone` controls contrast:
|
||||
* - "default" for headers on lighter CONTENT surfaces (uses --color-text /
|
||||
* --color-text-muted, which are tuned for the page body).
|
||||
* - "hero" for headers that sit on a dark hero / starfield background: forces
|
||||
* near-white title + bright subtitle + a glowing kicker so the text stands
|
||||
* out against the deep Milky Way field.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
|
||||
interface Props {
|
||||
/** Monospace kicker, e.g. "02" or "Experience". */
|
||||
kicker?: string;
|
||||
title: string;
|
||||
/** Optional subtitle shown under the title. */
|
||||
subtitle?: string;
|
||||
/** Optional mdi icon shown beside the kicker. */
|
||||
icon?: string;
|
||||
class?: string;
|
||||
align?: "left" | "center";
|
||||
/** "hero" = bright text for dark backgrounds; "default" = body content. */
|
||||
tone?: "default" | "hero";
|
||||
}
|
||||
|
||||
const {
|
||||
kicker,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
class: className = "",
|
||||
align = "left",
|
||||
tone = "default",
|
||||
} = Astro.props;
|
||||
|
||||
const isHero = tone === "hero";
|
||||
const titleColor = isHero ? "var(--color-starlight)" : undefined;
|
||||
const subtitleColor = isHero ? "var(--color-starlight)" : "var(--color-text-muted)";
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
"flex flex-col gap-3",
|
||||
align === "center" && "items-center text-center",
|
||||
className,
|
||||
]}
|
||||
>
|
||||
{
|
||||
kicker && (
|
||||
<span
|
||||
class="inline-flex items-center gap-2 font-eyebrow text-xs uppercase tracking-[0.22em]"
|
||||
style={
|
||||
isHero
|
||||
? "color: var(--color-accent); text-shadow: 0 0 14px color-mix(in srgb, var(--color-accent) 45%, transparent);"
|
||||
: "color: var(--color-secondary);"
|
||||
}
|
||||
>
|
||||
{icon && <Icon name={icon} class="h-4 w-4" />}
|
||||
{kicker}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<h2 style={titleColor ? `color: ${titleColor};` : undefined}>{title}</h2>
|
||||
{
|
||||
subtitle && (
|
||||
<p
|
||||
class="max-w-2xl text-base sm:text-lg"
|
||||
style={`color: ${subtitleColor};${isHero ? " opacity: 0.92;" : ""}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
/**
|
||||
* SplitTransition.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The "fork" used at the end of the About page (and reusable elsewhere) that
|
||||
* reinforces the spec's "two wings of the same house" idea: two side-by-side
|
||||
* paths inviting the visitor into Professional or Interests.
|
||||
*
|
||||
* Left -> Professional (polished tone)
|
||||
* Right -> Interests (warm tone)
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
|
||||
interface Props {
|
||||
/** Optional override copy for each side. Defaults to spec phrasing. */
|
||||
leftHref?: string;
|
||||
rightHref?: string;
|
||||
leftText?: string;
|
||||
rightText?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
leftHref = "/professional",
|
||||
rightHref = "/interests",
|
||||
leftText = "See where I apply that curiosity professionally",
|
||||
rightText = "See where it takes me outside of work",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href={leftHref}
|
||||
class="card card-interactive group flex items-center justify-between gap-4"
|
||||
>
|
||||
<Icon
|
||||
name="arrow-left"
|
||||
class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<span class="flex flex-col gap-1">
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle); text-align:right;"
|
||||
>
|
||||
The professional wing
|
||||
</span>
|
||||
<span
|
||||
class="font-sans text-lg font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{leftText}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={rightHref}
|
||||
class="card card-interactive group flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="flex flex-col gap-1">
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
The personal wing
|
||||
</span>
|
||||
<span
|
||||
class="font-sans text-lg font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{rightText}
|
||||
</span>
|
||||
</span>
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
/**
|
||||
* CategoryList.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders a media kind's full library grouped by category: for each group a
|
||||
* small subheader (the category name + item count), then a wrapping row of
|
||||
* pills (title + optional status). Reuses the existing `.pill` + `<Icon>`
|
||||
* pattern already established on the old reading page.
|
||||
*
|
||||
* Pass the output of `getGrouped(kind)` directly. When the list is empty the
|
||||
* caller should fall back to `<Placeholder>` (see the pages); this component
|
||||
* assumes at least one group.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { MediaGroup } from '@lib/media';
|
||||
|
||||
interface Props {
|
||||
groups: MediaGroup[];
|
||||
/** mdi icon shown before each pill. */
|
||||
pillIcon?: string;
|
||||
}
|
||||
|
||||
const { groups, pillIcon = 'play-circle' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
{
|
||||
groups.map((group) => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-baseline gap-2.5">
|
||||
<h3
|
||||
class="font-display text-lg"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{group.category}
|
||||
</h3>
|
||||
<span
|
||||
class="font-eyebrow text-[0.65rem] uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="flex flex-wrap gap-2.5">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
class="pill inline-flex items-center gap-1.5 text-sm"
|
||||
title={item.note ?? ''}
|
||||
>
|
||||
<Icon name={pillIcon} class="h-3.5 w-3.5" />
|
||||
<span>{item.title}</span>
|
||||
{item.status && (
|
||||
<span
|
||||
class="font-eyebrow text-[0.6rem] uppercase tracking-[0.14em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
)}
|
||||
{item.year && (
|
||||
<span
|
||||
class="font-eyebrow text-[0.6rem] tracking-[0.04em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{item.year}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/**
|
||||
* Reveal.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zero-dependency scroll-reveal wrapper. Adds the `reveal` class (defined in
|
||||
* global.css, which handles the transition + the reduced-motion override) and
|
||||
* toggles `is-visible` when the element enters the viewport.
|
||||
*
|
||||
* Why not Framer Motion here? Scroll reveals happen on potentially dozens of
|
||||
* elements per page; using IntersectionObserver + a CSS class keeps this free
|
||||
* of client JS. Framer Motion is reserved for genuinely interactive islands.
|
||||
*
|
||||
* This component renders a <div> wrapper. When you need a different semantic
|
||||
* element (e.g. an <li> or <section>), apply the `reveal` class and the
|
||||
* `data-reveal` attribute to that element directly instead of using Reveal.
|
||||
*
|
||||
* Usage:
|
||||
* <Reveal delay={100}><h2>...</h2></Reveal>
|
||||
* <li class="reveal" data-reveal style="transition-delay:80ms">...</li>
|
||||
*/
|
||||
interface Props {
|
||||
delay?: number;
|
||||
class?: string;
|
||||
style?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const { delay = 0, class: className = '', style = '', ...rest } = Astro.props;
|
||||
const delayStyle = delay ? `transition-delay:${delay}ms;` : '';
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={['reveal', className]}
|
||||
style={`${delayStyle}${style}`}
|
||||
data-reveal
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
/**
|
||||
* AlchemicalDivider.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Section break in the Patiun star-line style: a dotted (faint) run leading
|
||||
* into a solid (main) run on each side, each stopping short of the stars, with
|
||||
* a gold four-point star at the center flanked by two small ones. Inherits
|
||||
* currentColor; call sites pass Alchemist Gold.
|
||||
*
|
||||
* Usage:
|
||||
* <AlchemicalDivider class="mx-auto h-6 w-full max-w-sm" style="color: var(--color-gold)" />
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { class: className = '', style = '' } = Astro.props;
|
||||
---
|
||||
<div class={`flex items-center justify-center ${className}`} style={style} aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 200 24"
|
||||
class="h-full w-full"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
{/* Faint links: dotted runs at the outer ends. */}
|
||||
<path d="M6 12h30M164 12h30" stroke-width="1" stroke-dasharray="0.1 4" />
|
||||
{/* Main figure: solid runs that stop short of the stars. */}
|
||||
<path d="M42 12h38M120 12h38" stroke-width="1" />
|
||||
<polygon points="86.50,8.80 87.18,11.32 89.70,12.00 87.18,12.68 86.50,15.20 85.82,12.68 83.30,12.00 85.82,11.32" fill="currentColor" stroke="none" />
|
||||
<polygon points="113.50,8.80 114.18,11.32 116.70,12.00 114.18,12.68 113.50,15.20 112.82,12.68 110.30,12.00 112.82,11.32" fill="currentColor" stroke="none" />
|
||||
<polygon points="100.00,3.00 101.91,10.09 109.00,12.00 101.91,13.91 100.00,21.00 98.09,13.91 91.00,12.00 98.09,10.09" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
/**
|
||||
* CelestialFrame.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* An ornamental frame for imagery: rounded image container with a gold border
|
||||
* + small star-burst flourishes in the corners and a faint sacred-geometry tint
|
||||
* behind. Wraps an <img> (or any media) passed via the default slot.
|
||||
*
|
||||
* CORNER FIT: the gold border lives on the outer <figure>. The media container
|
||||
* is INSET by exactly the border width (via --frame-bw) and its corner radius
|
||||
* is reduced by the same amount, so the image nests flush inside the border at
|
||||
* every corner — no gap, no mismatch. Adjust --frame-bw here in one place if
|
||||
* the border width changes.
|
||||
*/
|
||||
import StarBurst from "./StarBurst.astro";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Corner rounding. */
|
||||
rounded?: "lg" | "xl" | "2xl" | "none";
|
||||
/** Stroke emphasis on the frame border. */
|
||||
emphasis?: "subtle" | "strong";
|
||||
}
|
||||
const {
|
||||
class: className = "",
|
||||
rounded = "2xl",
|
||||
emphasis = "subtle",
|
||||
} = Astro.props;
|
||||
|
||||
// Outer radius (px) per option. The inner media radius is outer - border width
|
||||
// so the image corner nests perfectly inside the border's corner.
|
||||
const outerPx = { none: 0, lg: 8, xl: 12, "2xl": 16 }[rounded];
|
||||
const bw = emphasis === "strong" ? 4 : 3; // border width in px (matches border-2 / 3px)
|
||||
const innerPx = Math.max(0, outerPx - bw);
|
||||
---
|
||||
|
||||
<figure
|
||||
class:list={["relative", className]}
|
||||
style={`border-radius: ${outerPx}px; border: ${bw}px solid var(--color-gold); `}
|
||||
>
|
||||
{/* Faint sacred-geometry tint behind the image (fills the full figure). */}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style={`border-radius: ${outerPx}px; background: color-mix(in srgb, var(--color-violet, var(--color-accent)) 14%, var(--color-surface-sunken));`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* The media itself, in normal flow (defines the figure's height via the slot's
|
||||
aspect-ratio div). Its radius = outer radius - border width, so the image's
|
||||
rounded corner nests PERFECTLY inside the border's rounded corner. The
|
||||
figure's `overflow: hidden` clips the tint layer to the outer radius. */
|
||||
}
|
||||
<div class="relative overflow-hidden" style={`border-radius: ${innerPx}px;`}>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{/* Corner four-point stars in Alchemist Gold (sit on the border corners). */}
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute -left-3 -top-3 h-6 w-6 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-gold)_60%,transparent)]"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute -bottom-5 -right-5 h-7 w-7 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-gold)_60%,transparent)]"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
<StarBurst
|
||||
variant="outline"
|
||||
class="pointer-events-none absolute -right-2 -top-2 h-3 w-3"
|
||||
style="color: var(--color-gold); opacity:.7;"
|
||||
/>
|
||||
<StarBurst
|
||||
variant="outline"
|
||||
class="pointer-events-none absolute -left-2 -bottom-2 h-3 w-3"
|
||||
style="color: var(--color-gold); opacity:.7;"
|
||||
/>
|
||||
</figure>
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
/**
|
||||
* CelestialImage.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The standard way to put an image on the site: an <img> with sensible defaults
|
||||
* (lazy, async decode, object-cover, explicit aspect via prop) wrapped in a
|
||||
* CelestialFrame so every photo reads as part of the mystical system.
|
||||
*
|
||||
* Falls back to a themed SVG placeholder (starfield on the surface tone) when
|
||||
* `src` is empty or the path is known to be missing — so the layout never
|
||||
* shows a broken-image icon while stock photos are being swapped for real ones.
|
||||
*
|
||||
* Usage:
|
||||
* <CelestialImage src="/images/hero.jpg" alt="Greg at his desk" aspect="4/3" />
|
||||
* <CelestialImage src="/images/about.jpg" alt="Greg" aspect="3/4" rounded="xl" />
|
||||
*/
|
||||
import CelestialFrame from "./CelestialFrame.astro";
|
||||
import StarField from "./StarField.astro";
|
||||
|
||||
interface Props {
|
||||
src?: string;
|
||||
alt: string;
|
||||
/** Aspect ratio, e.g. "4/3", "3/4", "16/9". */
|
||||
aspect?: string;
|
||||
/** Image fit. object-cover by default. */
|
||||
fit?: "cover" | "contain";
|
||||
rounded?: "lg" | "xl" | "2xl" | "none";
|
||||
class?: string;
|
||||
/** When true, render only the frame + placeholder (no <img>). */
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
aspect = "4/3",
|
||||
fit = "cover",
|
||||
rounded = "2xl",
|
||||
class: className = "",
|
||||
placeholder = false,
|
||||
} = Astro.props;
|
||||
|
||||
const showPlaceholder = placeholder || !src;
|
||||
const objectFit = fit === "contain" ? "object-contain" : "object-cover";
|
||||
---
|
||||
|
||||
<CelestialFrame rounded={rounded} class:list={["w-full", className]}>
|
||||
<div class="w-full" style={`aspect-ratio: ${aspect};`}>
|
||||
{
|
||||
showPlaceholder ? (
|
||||
// Themed SVG placeholder: starfield + moon glyph over the surface tone.
|
||||
<div
|
||||
class="relative h-full w-full"
|
||||
style="background: radial-gradient(120% 90% at 30% 20%, color-mix(in srgb, var(--color-violet, var(--color-accent)) 22%, transparent), transparent 60%), var(--color-surface-sunken);"
|
||||
>
|
||||
<StarField
|
||||
class="absolute inset-0 h-full w-full"
|
||||
style="color: var(--color-accent); opacity:.5;"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-10 w-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
style="color: var(--color-accent); opacity:.55;"
|
||||
>
|
||||
{/* Crescent moon + small star as the placeholder glyph. */}
|
||||
<path d="M16 12a7 7 0 1 1-6.4-7 5.5 5.5 0 0 0 0 14A7 7 0 0 0 16 12z" />
|
||||
<path
|
||||
d="M18.5 5.5c.3 1.8 1 3.1 2.5 3.5-1.5.3-2.2 1.7-2.5 3.5-.3-1.8-1-3.2-2.5-3.5 1.5-.4 2.2-1.7 2.5-3.5z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 font-eyebrow text-[0.6rem] uppercase tracking-[0.2em]"
|
||||
style="color: var(--color-text-subtle); opacity:.8;"
|
||||
>
|
||||
{alt}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class:list={["h-full w-full", objectFit]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</CelestialFrame>
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* CircuitField.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* The Professional page's counterpart to ConstellationField. It draws a faint
|
||||
* circuit-board pattern — orthogonal traces connecting node pads, with a slow
|
||||
* "signal pulse" traveling the traces — instead of stars and constellations.
|
||||
*
|
||||
* Crucially it carries the SAME `view-transition-name` ("sky") as the star
|
||||
* fields on other pages. So when you navigate from, say, the Home hero
|
||||
* (ConstellationField / AnimatedBackground) to the Professional page, the
|
||||
* browser's View Transition morphs the star field into the circuit field as
|
||||
* one element — constellations visibly transform into circuitry.
|
||||
*
|
||||
* Honors prefers-reduced-motion (renders a static board) and pauses off-screen.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Tailwind/className passthrough. The .astro call site includes `vt-sky` when
|
||||
* this element should carry the view-transition morph. Not computed here to
|
||||
* avoid a server/client hydration mismatch (see AnimatedBackground).
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Trace {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
interface Node {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export default function CircuitField({
|
||||
className = '',
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let t0 = performance.now();
|
||||
|
||||
let traces: Trace[] = [];
|
||||
let nodes: Node[] = [];
|
||||
|
||||
const gold = '#c9a24a';
|
||||
const violet = '#6a2fe0';
|
||||
const teal = '#2bc2b7';
|
||||
|
||||
function build() {
|
||||
if (!canvas || !ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Build a clean, ALIGNED orthogonal circuit on a grid: pads sit exactly at
|
||||
// grid intersections (no jitter) and traces connect them in straight
|
||||
// right-angle lines. Reads as an orderly schematic / PCB layout.
|
||||
const step = 64;
|
||||
const cols = Math.ceil(width / step) + 1;
|
||||
const rows = Math.ceil(height / step) + 1;
|
||||
const present: boolean[][] = [];
|
||||
nodes = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
present[r] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
// ~62% of intersections get a pad.
|
||||
present[r][c] = Math.random() < 0.62;
|
||||
if (present[r][c]) {
|
||||
// Snapped to the grid — no jitter, so traces land flush on pads.
|
||||
nodes.push({ x: c * step, y: r * step });
|
||||
}
|
||||
}
|
||||
}
|
||||
traces = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (!present[r][c]) continue;
|
||||
const here = { x: c * step, y: r * step };
|
||||
// Right neighbor.
|
||||
if (c + 1 < cols && present[r][c + 1] && Math.random() < 0.7) {
|
||||
traces.push({ x1: here.x, y1: here.y, x2: (c + 1) * step, y2: r * step });
|
||||
}
|
||||
// Down neighbor.
|
||||
if (r + 1 < rows && present[r + 1][c] && Math.random() < 0.7) {
|
||||
traces.push({ x1: here.x, y1: here.y, x2: c * step, y2: (r + 1) * step });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!ctx) return;
|
||||
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||
t0 = now;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Traces. A signal pulse brightens a moving window along each trace, with
|
||||
// traces staggered so pulses are distributed across the board over time.
|
||||
for (let i = 0; i < traces.length; i++) {
|
||||
const t = traces[i];
|
||||
// Base trace — clearly visible violet conductor line.
|
||||
ctx.strokeStyle = 'rgba(43, 194, 183, 0.55)';
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(t.x1, t.y1);
|
||||
ctx.lineTo(t.x2, t.y2);
|
||||
ctx.stroke();
|
||||
|
||||
// Pulse: a bright gold "signal" traveling the trace. position 0..1,
|
||||
// looping, with traces staggered so pulses spread across the board.
|
||||
const period = 6 + (i % 7);
|
||||
const phase = ((now / 1000) % period) / period;
|
||||
const px = t.x1 + (t.x2 - t.x1) * phase;
|
||||
const py = t.y1 + (t.y2 - t.y1) * phase;
|
||||
ctx.strokeStyle = gold;
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = gold;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px - (t.x2 - t.x1) * 0.08, py - (t.y2 - t.y1) * 0.08);
|
||||
ctx.lineTo(px, py);
|
||||
ctx.stroke();
|
||||
// Bright pulse head dot.
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#fbf3dc';
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Node pads — solid teal fill with a brighter violet ring (solder pad).
|
||||
for (const n of nodes) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = teal;
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.strokeStyle = violet;
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, 4.5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
void dt;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function drawStatic() {
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
for (const t of traces) {
|
||||
ctx.strokeStyle = 'rgba(43, 194, 183, 0.5)';
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(t.x1, t.y1);
|
||||
ctx.lineTo(t.x2, t.y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (const n of nodes) {
|
||||
ctx.fillStyle = teal;
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
build();
|
||||
if (reduce) {
|
||||
drawStatic();
|
||||
} else {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
const onResize = () => {
|
||||
build();
|
||||
if (reduce) drawStatic();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (reduce) return;
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (!raf) {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
} else {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
io.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// Root container mirrors AnimatedBackground's structure (gradient layers +
|
||||
// canvas all INSIDE the named element) so the view-transition morph from a
|
||||
// star hero into this circuit field is element-to-element equivalent and
|
||||
// nothing flashes separately. The `vt-sky`/opacity classes are passed via
|
||||
// className from the .astro call site.
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Base gradient — IDENTICAL colors to AnimatedBackground's night sky so
|
||||
the morph between star field and circuit field is color-consistent.
|
||||
No nebula radials here (the user asked to keep the circuit style only);
|
||||
just the clean deep-blue sky base. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, #080c14 0%, #0b111c 32%, #0e1522 58%, #131c2b 82%, #1a2a3a 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Circuit traces + nodes + signal pulses. */}
|
||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* ConstellationField.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* A calmer, reusable star-field island for SECTION backgrounds (everything that
|
||||
* isn't the hero). Compared to AnimatedBackground it is:
|
||||
* - sparser and dimmer (so foreground content stays readable),
|
||||
* - slow-blink only (no shooting stars / nebula),
|
||||
* - the whole field rotates together very slowly, reading as a slice of sky
|
||||
* turning as the planet rotates.
|
||||
*
|
||||
* Carries a `view-transition-name` (default "sky") so that when the browser
|
||||
* morphs between pages via Astro's <ClientRouter />, the hero's star field and
|
||||
* a section's constellation field cross-fade/translate as one continuous sky.
|
||||
* On the Professional page we render CircuitField instead, which shares the
|
||||
* same transition name — so constellations visibly MORPH into circuit traces.
|
||||
*
|
||||
* Honors prefers-reduced-motion (renders a static field) and pauses when the
|
||||
* section is off-screen.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Density multiplier. 1 = default; lower for quieter sections. */
|
||||
density?: number;
|
||||
/**
|
||||
* Tailwind/className passthrough. The .astro call site is responsible for
|
||||
* including `vt-sky` when this element should carry the view-transition
|
||||
* morph. We do NOT compute the transition class here: conditional className
|
||||
* interpolation triggers a server/client hydration attribute mismatch under
|
||||
* Astro's client:idle. A static, fully-formed string keeps SSR and hydration
|
||||
* byte-identical.
|
||||
*/
|
||||
className?: string;
|
||||
/** Star color theme. 'gold' leans warm, 'violet' leans mystic. */
|
||||
tint?: 'gold' | 'violet' | 'teal';
|
||||
}
|
||||
|
||||
export default function ConstellationField({
|
||||
density = 1,
|
||||
className = '',
|
||||
tint = 'gold',
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let angle = 0;
|
||||
let t0 = performance.now();
|
||||
|
||||
interface Star {
|
||||
rx: number;
|
||||
ry: number;
|
||||
r: number;
|
||||
tw: number;
|
||||
tws: number;
|
||||
baseA: number;
|
||||
anchor: boolean;
|
||||
}
|
||||
let stars: Star[] = [];
|
||||
|
||||
const gold = '#c9a24a';
|
||||
const violet = '#6a2fe0';
|
||||
const teal = '#2bc2b7';
|
||||
const white = '#f4f2ed';
|
||||
const accent = tint === 'violet' ? violet : tint === 'teal' ? teal : gold;
|
||||
|
||||
// One full rotation every ~10 minutes — barely perceptible, "planet turning".
|
||||
const ROT_SPEED = (Math.PI * 2) / (10 * 60);
|
||||
const LINK_DIST = 175;
|
||||
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
||||
|
||||
function resize() {
|
||||
if (!canvas || !ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Pivot = top-right corner. The disk is centered there so the field
|
||||
// rotates around that corner; maxR reaches the far (bottom-left) corner.
|
||||
const cx = width;
|
||||
const cy = 0;
|
||||
const maxR = Math.sqrt(cx * cx + height * height) + 40;
|
||||
// Ultra-dense: ~5x. One star per ~300 px^2, scaled by density; ~17%
|
||||
// anchors for a very dense constellation backbone.
|
||||
const count = Math.min(
|
||||
3000,
|
||||
Math.floor(((Math.PI * maxR * maxR) / 300) * density),
|
||||
);
|
||||
stars = Array.from({ length: count }, (_, i) => {
|
||||
const anchor = i % 7 === 0;
|
||||
const rr = maxR * Math.sqrt(Math.random());
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
return {
|
||||
rx: cx + rr * Math.cos(a),
|
||||
ry: cy + rr * Math.sin(a),
|
||||
r: anchor ? 1.1 + Math.random() * 1.0 : 0.3 + Math.random() * 0.9,
|
||||
tw: Math.random() * Math.PI * 2,
|
||||
tws: 0.1 + Math.random() * 0.25,
|
||||
baseA: anchor ? 0.4 + Math.random() * 0.3 : 0.1 + Math.random() * 0.3,
|
||||
anchor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function drawStatic() {
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
for (const s of stars) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = s.anchor ? accent : white;
|
||||
ctx.globalAlpha = s.baseA;
|
||||
ctx.arc(s.rx, s.ry, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
void cx;
|
||||
void cy;
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!ctx) return;
|
||||
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||
t0 = now;
|
||||
angle += ROT_SPEED * dt;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Pivot = top-right corner (matches the disk center used in resize()).
|
||||
const cx = width;
|
||||
const cy = 0;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
const project = (rx: number, ry: number): [number, number] => [
|
||||
cx + (rx - cx) * cosA - (ry - cy) * sinA,
|
||||
cy + (rx - cx) * sinA + (ry - cy) * cosA,
|
||||
];
|
||||
|
||||
const proj = stars.map((s) => {
|
||||
const [x, y] = project(s.rx, s.ry);
|
||||
return { s, x, y };
|
||||
});
|
||||
|
||||
// Constellation lines between near anchors.
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i < proj.length; i++) {
|
||||
const A = proj[i];
|
||||
if (!A.s.anchor) continue;
|
||||
for (let j = i + 1; j < proj.length; j++) {
|
||||
const B = proj[j];
|
||||
if (!B.s.anchor) continue;
|
||||
const ddx = A.x - B.x;
|
||||
const ddy = A.y - B.y;
|
||||
const d2 = ddx * ddx + ddy * ddy;
|
||||
if (d2 > LINK_DIST_SQ) continue;
|
||||
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(now / 1600 + (i + j) * 0.7);
|
||||
const alpha = proximity * 0.24 * (0.55 + 0.45 * pulse);
|
||||
ctx.strokeStyle = `rgba(201, 162, 74, ${alpha.toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(A.x, A.y);
|
||||
ctx.lineTo(B.x, B.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Slow-blink stars.
|
||||
for (const p of proj) {
|
||||
const s = p.s;
|
||||
s.tw += s.tws * dt;
|
||||
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
|
||||
const blink = 0.55 + 0.45 * Math.sin(s.tw);
|
||||
const alpha = Math.max(0, Math.min(1, s.baseA * blink));
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = s.anchor ? accent : white;
|
||||
ctx.globalAlpha = alpha;
|
||||
if (s.anchor) {
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.shadowColor = accent;
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resize();
|
||||
if (reduce) {
|
||||
drawStatic();
|
||||
} else {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
const onResize = () => {
|
||||
resize();
|
||||
if (reduce) drawStatic();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (reduce) return;
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (!raf) {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
} else {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
io.disconnect();
|
||||
};
|
||||
}, [density, tint]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`pointer-events-none absolute inset-0 h-full w-full ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
/**
|
||||
* StarBurst.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A small decorative four-point "sparkle" star (alchemical/star-chart style),
|
||||
* drawn as inline SVG so it inherits `currentColor`. Used as an accent mark
|
||||
* next to headings, in dividers, and as corner flourishes on frames.
|
||||
*
|
||||
* Variants:
|
||||
* - `solid` : filled sparkle (default)
|
||||
* - `outline`: stroke-only, lighter
|
||||
*
|
||||
* Usage:
|
||||
* <StarBurst class="h-4 w-4" style="color: var(--color-gold)" />
|
||||
* <StarBurst variant="outline" class="h-6 w-6" />
|
||||
*/
|
||||
interface Props {
|
||||
variant?: 'solid' | 'outline';
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { variant = 'solid', class: className = '', style = '' } = Astro.props;
|
||||
const fill = variant === 'outline' ? 'none' : 'currentColor';
|
||||
const sw = variant === 'outline' ? 1.4 : 0;
|
||||
---
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class={className}
|
||||
style={style}
|
||||
fill={fill}
|
||||
stroke="currentColor"
|
||||
stroke-width={sw}
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* The Patiun four-point star: thin straight arms (inner radius 0.3 of the
|
||||
outer), the same star used on the seal, the icons and the favicon. */}
|
||||
<polygon points="12,1 14.33,9.67 23,12 14.33,14.33 12,23 9.67,14.33 1,12 9.67,9.67" />
|
||||
</svg>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
/**
|
||||
* StarField.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A static field of tiny scattered stars + a few brighter sparkles, as inline
|
||||
* SVG. Use as an ambient, non-animated decorative layer behind hero blocks or
|
||||
* inside frames — the *animated* star field lives in AnimatedBackground.tsx.
|
||||
*
|
||||
* Stars are deterministic (hard-coded positions) so the layout is stable and
|
||||
* cheap. Pointer-events disabled; inherits `currentColor`.
|
||||
*
|
||||
* Usage:
|
||||
* <StarField class="absolute inset-0 h-full w-full" style="color: var(--color-gold); opacity:.5" />
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
/** Seed for how many sparkles; keeps the markup compact. */
|
||||
density?: 'sparse' | 'default' | 'dense';
|
||||
}
|
||||
const { class: className = '', style = '', density = 'default' } = Astro.props;
|
||||
|
||||
// Pre-baked star positions (cx, cy, r) on a 100x100 viewBox; scaled by density.
|
||||
const baseDots: [number, number, number][] = [
|
||||
[8, 12, 0.5], [22, 6, 0.7], [34, 18, 0.4], [47, 9, 0.6], [61, 14, 0.5],
|
||||
[73, 5, 0.8], [86, 16, 0.4], [94, 8, 0.5], [14, 28, 0.4], [29, 34, 0.6],
|
||||
[43, 27, 0.5], [57, 36, 0.4], [68, 29, 0.7], [81, 38, 0.5], [92, 31, 0.4],
|
||||
[6, 46, 0.6], [19, 52, 0.4], [31, 47, 0.5], [44, 55, 0.4], [58, 49, 0.7],
|
||||
[71, 54, 0.5], [84, 47, 0.4], [96, 55, 0.5], [12, 66, 0.4], [26, 72, 0.6],
|
||||
[39, 64, 0.5], [52, 73, 0.4], [65, 67, 0.5], [78, 74, 0.7], [90, 68, 0.4],
|
||||
[9, 84, 0.5], [23, 90, 0.4], [36, 86, 0.6], [49, 92, 0.4], [62, 85, 0.5],
|
||||
[75, 91, 0.7], [88, 88, 0.4], [16, 40, 0.4], [50, 22, 0.4], [80, 60, 0.4],
|
||||
];
|
||||
// Brighter 4-point sparkles (cx, cy, size).
|
||||
const sparkles: [number, number, number][] = [
|
||||
[20, 20, 2.2], [70, 30, 1.8], [40, 60, 2.4], [85, 75, 1.6], [12, 70, 1.4],
|
||||
];
|
||||
|
||||
const step = density === 'sparse' ? 2 : density === 'dense' ? 1 : 1;
|
||||
const dots = baseDots.filter((_, i) => i % step === 0);
|
||||
---
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class={className}
|
||||
style={style}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{dots.map(([x, y, r]) => (
|
||||
<circle cx={x} cy={y} r={r} />
|
||||
))}
|
||||
{sparkles.map(([x, y, s]) => (
|
||||
<path
|
||||
d={`M${x} ${y - s}c${s * 0.18} ${s * 0.6} ${s * 0.4} ${s * 0.82} ${s} ${s}c${-s * 0.6}${s * 0.18} ${-s * 0.82}${s * 0.4} ${-s}${s}c${-s * 0.18}${-s * 0.6} ${-s * 0.4}${-s * 0.82} ${-s}${-s}c${s * 0.6}${-s * 0.18} ${s * 0.82}${-s * 0.4} ${s}${-s}z`}
|
||||
opacity="0.9"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* MobileMenu.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* React island that powers the hamburger menu on small screens. Renders the
|
||||
* same two-group nav structure as the desktop bar (imported via a data prop so
|
||||
* the source of truth stays in site.ts).
|
||||
*
|
||||
* Accessibility: the button is aria-expanded/aria-controls wired to the panel,
|
||||
* Escape closes, clicking a link closes, and focus is returned to the toggle.
|
||||
* Animations respect prefers-reduced-motion (Framer Motion reads the media query
|
||||
* automatically when we pass the transition).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
export interface MobileNavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
export interface MobileNavGroup {
|
||||
items: MobileNavItem[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
groups: MobileNavGroup[];
|
||||
/** Current path for active link highlighting. */
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
// Patiun brand icons, resolved by the same names the nav config uses.
|
||||
function Icon({ name, className }: { name: string; className?: string }) {
|
||||
return <BrandIcon name={name} className={className} />;
|
||||
}
|
||||
|
||||
function isActive(href: string, path: string): boolean {
|
||||
if (href === '/') return path === '/';
|
||||
return path === href || path.startsWith(href + '/');
|
||||
}
|
||||
|
||||
export default function MobileMenu({ groups, currentPath }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Escape closes the menu; lock body scroll while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={open ? 'Close menu' : 'Open menu'}
|
||||
className="relative z-[60] inline-flex h-10 w-10 items-center justify-center rounded-lg border"
|
||||
style={{ color: 'var(--color-starlight)', borderColor: 'color-mix(in srgb, var(--color-gold) 45%, transparent)' }}
|
||||
>
|
||||
{open ? (
|
||||
<BrandIcon name="close" size={22} />
|
||||
) : (
|
||||
<BrandIcon name="menu" size={22} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
|
||||
/>
|
||||
{/* Panel */}
|
||||
<motion.nav
|
||||
id="mobile-menu"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'tween', duration: 0.28 }}
|
||||
className="fixed right-0 top-0 z-50 flex h-[100dvh] w-[min(82vw,22rem)] flex-col gap-6 overflow-y-auto p-6 pt-20 shadow-2xl"
|
||||
style={{
|
||||
// Night-sky drawer to match the nav it opens from.
|
||||
backgroundColor: 'var(--color-midnight)',
|
||||
borderLeft: '1px solid color-mix(in srgb, var(--color-gold) 45%, transparent)',
|
||||
}}
|
||||
>
|
||||
{groups.map((group, gi) => (
|
||||
<ul key={gi} className="flex flex-col gap-1">
|
||||
{group.items.map((item) => {
|
||||
const active = isActive(item.href, currentPath);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 text-lg font-medium transition-colors"
|
||||
style={{
|
||||
color: active ? 'var(--color-teal-bright)' : 'var(--color-starlight)',
|
||||
backgroundColor: active
|
||||
? 'color-mix(in srgb, var(--color-teal-bright) 14%, transparent)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
))}
|
||||
</motion.nav>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
/**
|
||||
* NavBar.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Fixed top navigation. Two visual groups separated by a subtle divider, per
|
||||
* the spec: left = "who I am" (Home, About), right = "what I do" (Professional,
|
||||
* Interests, Contact). The logo block shows the name + tagline.
|
||||
*
|
||||
* On scroll a tiny inline script adds a `scrolled` class -> backdrop blur + a
|
||||
* hairline border + subtle shadow, so content stays readable under the bar.
|
||||
* Mobile renders a hamburger (MobileMenu.tsx) with the same two-group structure.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
import MobileMenu from "./MobileMenu.tsx";
|
||||
import { navGroups, siteIdentity } from "@lib/site";
|
||||
|
||||
const path = Astro.url.pathname;
|
||||
const isActive = (href: string) =>
|
||||
href === "/" ? path === "/" : path === href || path.startsWith(href + "/");
|
||||
|
||||
interface Props {
|
||||
/** True on the Home page. When false, the nav carries the hero-sky transition
|
||||
* name so the Home hero can morph into / out of it (expand/collapse). */
|
||||
isHome?: boolean;
|
||||
}
|
||||
const { isHome = false } = Astro.props;
|
||||
// On non-Home pages the NavBar is the morph target for the Home hero.
|
||||
const heroSkyClass = isHome ? "" : "vt-hero-sky";
|
||||
---
|
||||
|
||||
<header
|
||||
id="site-nav"
|
||||
data-nav
|
||||
class:list={[
|
||||
"on-sky fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300",
|
||||
heroSkyClass,
|
||||
]}
|
||||
>
|
||||
<div
|
||||
class="border-b border-transparent transition-colors duration-300"
|
||||
data-nav-inner
|
||||
>
|
||||
<nav
|
||||
class="container-prose flex h-20 items-center justify-between gap-4"
|
||||
aria-label="Primary"
|
||||
>
|
||||
<!-- Logo / identity: the Patiun seal (glyph in the gold degree ring) beside
|
||||
the Cinzel name and the site tagline. The "a Patiun LLC company" line
|
||||
lives in the footer with the copyright. -->
|
||||
<a
|
||||
href="/"
|
||||
class="group flex items-center gap-3 leading-tight"
|
||||
aria-label={`${siteIdentity.name} home`}
|
||||
>
|
||||
<img
|
||||
src="/brand/patiun-seal.svg"
|
||||
alt=""
|
||||
width="72"
|
||||
height="72"
|
||||
class="h-[72px] w-[72px] shrink-0 transition-transform duration-700 group-hover:rotate-[30deg]"
|
||||
/>
|
||||
<span class="flex flex-col">
|
||||
<span
|
||||
class="font-display text-base font-bold uppercase tracking-[0.14em]"
|
||||
style="color: var(--color-starlight);"
|
||||
>
|
||||
{siteIdentity.name}
|
||||
</span>
|
||||
<span
|
||||
class="hidden font-eyebrow text-[0.62rem] uppercase tracking-[0.18em] sm:block"
|
||||
style="color: var(--color-gold-bright);"
|
||||
>
|
||||
{siteIdentity.tagline}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav: two groups with a divider -->
|
||||
<div class="hidden items-center gap-5 md:flex">
|
||||
{
|
||||
navGroups.map((group, gi) => (
|
||||
<div
|
||||
class:list={[
|
||||
"flex items-center gap-1",
|
||||
{ "border-l pl-5": gi > 0 },
|
||||
]}
|
||||
style={gi > 0 ? "border-color: color-mix(in srgb, var(--color-gold) 45%, transparent);" : ""}
|
||||
>
|
||||
{group.items.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
"inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
]}
|
||||
style={
|
||||
active
|
||||
? "color: var(--color-accent);"
|
||||
: "color: var(--color-starlight); opacity: 0.85;"
|
||||
}
|
||||
onmouseover="this.style.opacity='1'"
|
||||
onmouseout="this.style.opacity='0.85'"
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon name={item.icon} class="h-4 w-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
<div class="ml-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile controls (theme toggle removed: dark mode is forced for now) -->
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<MobileMenu client:idle groups={navGroups} currentPath={path} />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* The nav bar ALWAYS carries the dark purple/blue nebula treatment + lighter
|
||||
text, so it reads as a continuous band of night sky across the top of every
|
||||
page (matching the hero fields). The scrolled state just adds blur + a
|
||||
hairline border + shadow for definition over content. */
|
||||
[data-nav-inner] {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(14, 21, 34, 0.92) 0%,
|
||||
rgba(19, 28, 43, 0.86) 100%
|
||||
);
|
||||
}
|
||||
/* Scrolled state: stronger blur + hairline border + shadow. */
|
||||
[data-nav].scrolled [data-nav-inner] {
|
||||
backdrop-filter: saturate(150%) blur(12px);
|
||||
-webkit-backdrop-filter: saturate(150%) blur(12px);
|
||||
border-bottom-color: color-mix(in srgb, var(--color-gold) 35%, transparent);
|
||||
box-shadow: 0 8px 30px -20px rgba(8, 12, 20, 0.7);
|
||||
}
|
||||
/* A subtle violet/gold nebula glow behind the nav, fixed to the top. */
|
||||
[data-nav]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
60% 120% at 85% -20%,
|
||||
rgba(106, 47, 224, 0.22),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
50% 120% at 10% -10%,
|
||||
rgba(201, 162, 74, 0.1),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Add the `scrolled` class once the page has scrolled a few px. Re-bind on
|
||||
* View Transitions navigations so it keeps working across page changes.
|
||||
*/
|
||||
function setupNavScroll() {
|
||||
const nav = document.getElementById("site-nav");
|
||||
if (!nav) return;
|
||||
const onScroll = () => nav.classList.toggle("scrolled", window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
}
|
||||
setupNavScroll();
|
||||
document.addEventListener("astro:page-load", setupNavScroll);
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* ThemeToggle.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* React island that toggles `data-theme` on <html> between 'light' and 'dark'
|
||||
* and persists the choice to localStorage (key matches theme-init.ts).
|
||||
*
|
||||
* Renders mdi sun/moon SVGs inline (no extra requests). The initial state is
|
||||
* read from the DOM at mount so it always matches what theme-init set, even
|
||||
* across View Transitions navigations.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'gk-theme';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
function currentTheme(): Theme {
|
||||
if (typeof document === 'undefined') return 'light';
|
||||
return (document.documentElement.getAttribute('data-theme') as Theme) ?? 'light';
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Read the real current theme on mount and after View Transitions navigations.
|
||||
useEffect(() => {
|
||||
setTheme(currentTheme());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* ignore private-mode write failures */
|
||||
}
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border transition-colors hover:bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)]"
|
||||
style={{
|
||||
color: 'var(--color-text)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{/* Sun (shown in dark mode -> click to go light) */}
|
||||
{mounted && isDark && (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm0-5a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1zm0 17a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1zM3 11h2a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2zm16 0h2a1 1 0 1 1 0 2h-2a1 1 0 1 1 0-2zM5.64 5.64a1 1 0 0 1 1.42 0l1.4 1.42a1 1 0 1 1-1.4 1.4l-1.42-1.4a1 1 0 0 1 0-1.42zm9.9 9.9a1 1 0 0 1 1.42 0l1.4 1.42a1 1 0 0 1-1.4 1.4l-1.42-1.4a1 1 0 0 1 0-1.42zm1.42-9.9a1 1 0 0 1 0 1.42l-1.42 1.4a1 1 0 1 1-1.4-1.4l1.4-1.42a1 1 0 0 1 1.42 0zm-9.9 9.9a1 1 0 0 1 0 1.42l-1.42 1.4a1 1 0 0 1-1.4-1.4l1.4-1.42a1 1 0 0 1 1.42 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Moon (shown in light mode -> click to go dark) */}
|
||||
{mounted && !isDark && (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Placeholder keeps button sized before mount to avoid layout shift. */}
|
||||
{!mounted && <span style={{ width: 20, height: 20, display: 'inline-block' }} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
/**
|
||||
* Certifications.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card grid for the Certifications section of the Professional page. Each entry
|
||||
* is a cert with name, issuer, year, and optional status. Seeded with SAP/cloud
|
||||
* placeholders (Greg to fill); swap the array below.
|
||||
*
|
||||
* Card aesthetic matches EducationHistory / ProjectCard.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
|
||||
interface CertEntry {
|
||||
icon: string;
|
||||
name: string;
|
||||
issuer: string;
|
||||
year: string;
|
||||
status?: "Active" | "Expired" | "In progress";
|
||||
}
|
||||
|
||||
// Placeholder entries — replace with Greg's real certifications.
|
||||
const entries: CertEntry[] = [
|
||||
/*{
|
||||
icon: "microsoft-azure-devops",
|
||||
name: "SAP BTP Certification",
|
||||
issuer: "SAP",
|
||||
year: "[Year]",
|
||||
status: "Active",
|
||||
},*/
|
||||
{
|
||||
icon: "cloud",
|
||||
name: "Planet 9 Devlopment",
|
||||
issuer: "Neptune",
|
||||
year: "2019",
|
||||
status: "Active",
|
||||
},
|
||||
/*{
|
||||
icon: "robot",
|
||||
name: "[AI / ML cert]",
|
||||
issuer: "[Issuer]",
|
||||
year: "[Year]",
|
||||
},*/
|
||||
];
|
||||
---
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
entries.map((c) => (
|
||||
<article class="card flex flex-col gap-3">
|
||||
<div class="flex items-start justify-between 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={c.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
{c.status && <span class="pill">{c.status}</span>}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="font-display text-base" style="color: var(--color-text);">
|
||||
{c.name}
|
||||
</h3>
|
||||
<p class="text-sm font-medium" style="color: var(--color-accent);">
|
||||
{c.issuer}
|
||||
</p>
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
{c.year}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* ContactForm.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Accessible contact form island. Posts to /api/contact, which either sends an
|
||||
* email via SMTP or fires a webhook. Manages idle / submitting / success / error
|
||||
* states, does lightweight client validation, and includes a honeypot field for
|
||||
* crude spam defense (the server also rate-limits and verifies a Turnstile
|
||||
* token when configured).
|
||||
*
|
||||
* The form is keyboard-accessible, labels every field, and announces status
|
||||
* changes via an aria-live region.
|
||||
*
|
||||
* Turnstile: when `turnstileSiteKey` is provided, Cloudflare's invisible
|
||||
* challenge is rendered. The token it produces is sent with the submission and
|
||||
* verified server-side. If no key is configured (local dev), Turnstile is
|
||||
* skipped entirely so the form still works.
|
||||
*/
|
||||
import { useState, useRef, useEffect, type FormEvent } from 'react';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// Declared by the Turnstile script on window once loaded.
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: string | HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
|
||||
turnstileSiteKey?: string;
|
||||
}
|
||||
|
||||
export default function ContactForm({ turnstileSiteKey }: Props) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
|
||||
// Holds the live Turnstile token between renders (cleared on reset/expiry).
|
||||
const turnstileTokenRef = useRef<string>('');
|
||||
// Widget ID returned by turnstile.render(); used to reset on failure.
|
||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||
// Container div the widget mounts into.
|
||||
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Load the Turnstile script once when a site key is configured, then render
|
||||
// the widget once it's ready. Two effects coordinate via a script onload
|
||||
// callback + a readiness poll, because the script loads async and
|
||||
// window.turnstile isn't defined the moment the script tag is inserted.
|
||||
useEffect(() => {
|
||||
if (!turnstileSiteKey) return;
|
||||
|
||||
// If the script is already present (e.g. HMR / re-mount), just render.
|
||||
if (window.turnstile) {
|
||||
renderWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid injecting a second script tag on re-mount.
|
||||
let script = document.getElementById(
|
||||
'cf-turnstile-script',
|
||||
) as HTMLScriptElement | null;
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = 'cf-turnstile-script';
|
||||
script.src =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
// No SRI `integrity` attribute here — deliberate. Cloudflare rotates the
|
||||
// Turnstile loader contents (so a pinned hash would break the challenge
|
||||
// on rotation), the loader itself fetches further challenge resources
|
||||
// that SRI can't cover, and Cloudflare does not publish official SRI
|
||||
// hashes. SRI on Turnstile is industry-standard NOT to use; the risk is
|
||||
// mitigated by serving the site over HTTPS and by the CSP (which
|
||||
// restricts script-src to this one Cloudflare origin). See
|
||||
// src/middleware.ts.
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// When the script finishes loading, render the widget. onload fires only
|
||||
// once even if multiple components share the same script tag.
|
||||
script.addEventListener('load', renderWidget);
|
||||
return () => {
|
||||
script?.removeEventListener('load', renderWidget);
|
||||
};
|
||||
}, [turnstileSiteKey]);
|
||||
|
||||
function renderWidget() {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (!window.turnstile || !turnstileContainerRef.current) return;
|
||||
// Avoid double-rendering if this fires twice (StrictMode / onload + poll).
|
||||
if (turnstileWidgetIdRef.current) return;
|
||||
|
||||
turnstileWidgetIdRef.current = window.turnstile.render(
|
||||
turnstileContainerRef.current,
|
||||
{
|
||||
sitekey: turnstileSiteKey,
|
||||
// Called by Turnstile once it has a token. For invisible/Managed mode
|
||||
// this fires automatically once the challenge resolves.
|
||||
callback: (token: string) => {
|
||||
turnstileTokenRef.current = token;
|
||||
},
|
||||
// On expiry/error, clear the token so the server rejects the stale
|
||||
// submission — but DON'T re-disable the button, because an invisible
|
||||
// widget gives the user nothing to click to re-solve it. Instead we
|
||||
// reset the widget, which re-runs the challenge and fires callback
|
||||
// again with a fresh token.
|
||||
// On token expiry: clear it. The next submit will detect the empty
|
||||
// token and reset the widget for a fresh challenge. We don't auto-reset
|
||||
// here to keep behavior simple and avoid any retry churn.
|
||||
'expired-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
// On widget error: clear the token but DO NOT call turnstile.reset().
|
||||
// Resetting from inside error-callback causes Turnstile to immediately
|
||||
// retry, hit the same error (e.g. 110200 domain-not-authorized), and
|
||||
// fire error-callback again — an infinite loop that manifests as the
|
||||
// widget rapidly flashing. Instead we leave the widget in its errored
|
||||
// state; the next form submission will reset it for a fresh attempt.
|
||||
// The error surfaces as the token being empty, which onSubmit reports.
|
||||
'error-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
theme: 'dark',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function validate(values: { name: string; email: string; message: string }): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
if (!values.name.trim()) e.name = 'Please enter your name.';
|
||||
if (!values.email.trim()) e.email = 'Please enter your email.';
|
||||
else if (!EMAIL_RE.test(values.email)) e.email = 'That email address looks off.';
|
||||
if (!values.message.trim()) e.message = 'Please enter a message.';
|
||||
else if (values.message.trim().length < 10) e.message = 'A little more detail, please (10+ characters).';
|
||||
return e;
|
||||
}
|
||||
|
||||
async function onSubmit(ev: FormEvent<HTMLFormElement>) {
|
||||
ev.preventDefault();
|
||||
const form = ev.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const values = {
|
||||
name: String(data.get('name') ?? '').trim(),
|
||||
email: String(data.get('email') ?? '').trim(),
|
||||
message: String(data.get('message') ?? '').trim(),
|
||||
// Honeypot: bots fill this, humans never see it.
|
||||
company: String(data.get('company') ?? ''),
|
||||
// Turnstile token (empty string if Turnstile is not configured).
|
||||
turnstileToken: turnstileTokenRef.current,
|
||||
};
|
||||
|
||||
const found = validate(values);
|
||||
// If Turnstile is configured, require a token before we even hit the server.
|
||||
if (turnstileSiteKey && !values.turnstileToken) {
|
||||
found.message = found.message ?? 'Please complete the bot check and try again.';
|
||||
}
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) return;
|
||||
|
||||
setStatus('submitting');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
// Reset the Turnstile widget so a fresh challenge runs. The widget's
|
||||
// callback will populate the token ref with a new value when it
|
||||
// resolves; the button is never disabled on Turnstile state, so there's
|
||||
// no UI flag to manage here.
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
throw new Error(body.error || 'Something went wrong. Please try again.');
|
||||
}
|
||||
setStatus('success');
|
||||
form.reset();
|
||||
// Fresh challenge for the next submission.
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]';
|
||||
const labelBase = 'mb-1.5 block font-sans text-sm font-medium';
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
|
||||
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BrandIcon name="check" size={24} />
|
||||
</span>
|
||||
<h3 className="font-display text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Message sent.
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Thanks for reaching out. I will get back to you soon.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('idle')}
|
||||
className="btn btn-secondary mt-2"
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
|
||||
{/* Honeypot: visually hidden, not aria-hidden so screen readers can skip it. */}
|
||||
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
|
||||
<label htmlFor="company">Company (leave blank)</label>
|
||||
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="name" className={labelBase} style={{ color: 'var(--color-text)' }}>Name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.name ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
/>
|
||||
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="email" className={labelBase} style={{ color: 'var(--color-text)' }}>Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.email ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={errors.email ? 'email-error' : undefined}
|
||||
/>
|
||||
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="message" className={labelBase} style={{ color: 'var(--color-text)' }}>Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.message ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.message)}
|
||||
aria-describedby={errors.message ? 'message-error' : undefined}
|
||||
/>
|
||||
{errors.message && <p id="message-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="rounded-lg p-3 text-sm" style={{ background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', color: 'var(--color-accent)' }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Cloudflare Turnstile widget. Rendered by the script via the ref.
|
||||
Hidden container; Turnstile injects its iframe here. Skipped entirely
|
||||
when no site key is configured (turnstileSiteKey is undefined). */}
|
||||
{turnstileSiteKey && (
|
||||
<div ref={turnstileContainerRef} className="min-h-[65px]" aria-label="Bot check" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
|
||||
{/* Screen-reader status announcements. */}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{status === 'submitting' ? 'Sending your message.' : ''}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
/**
|
||||
* EducationHistory.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card grid for the Education section of the Professional page. Each entry is a
|
||||
* degree/diploma with institution, period, and optional detail. Data is seeded
|
||||
* with placeholder rows (Greg to fill); swap the array below with real entries.
|
||||
*
|
||||
* Mirrors the Timeline/ProjectCard card aesthetic: raised surface, hairline
|
||||
* border, gold accent for the credential, mono period label.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
|
||||
interface EduEntry {
|
||||
icon: string;
|
||||
credential: string;
|
||||
institution: string;
|
||||
period: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
// Placeholder entries — replace with Greg's real education history.
|
||||
const entries: EduEntry[] = [
|
||||
{
|
||||
icon: "school",
|
||||
credential: "B.S., Computer Science",
|
||||
institution: "Drexel University",
|
||||
period: "2014 – 2018",
|
||||
detail:
|
||||
"Foundational coursework in software engineering, algorithms, and systems. Focuses in AI and human-computer interaction.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{
|
||||
entries.map((e) => (
|
||||
<article class="card flex flex-col gap-3">
|
||||
<div class="flex items-start 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={e.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="font-display text-lg" style="color: var(--color-text);">
|
||||
{e.credential}
|
||||
</h3>
|
||||
<p
|
||||
class="font-sans font-semibold"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
{e.institution}
|
||||
</p>
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
{e.period}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{e.detail && (
|
||||
<p
|
||||
class="text-sm leading-relaxed"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{e.detail}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
/**
|
||||
* ProjectCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a single project entry from the `projects` collection. Renders title,
|
||||
* role, tech pills, summary, and links (repo/demo/case study). Placeholder
|
||||
* entries (frontmatter `placeholder: true`) get a subtle badge so they read as
|
||||
* "to be filled" rather than real work, per the chosen content approach.
|
||||
*/
|
||||
import { type CollectionEntry } from 'astro:content';
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
project: CollectionEntry<'projects'>;
|
||||
}
|
||||
const { project } = Astro.props;
|
||||
const d = project.data;
|
||||
|
||||
const links = [
|
||||
d.repo && { href: d.repo, label: 'GitHub', icon: 'github' },
|
||||
d.demo && { href: d.demo, label: 'Live demo', icon: 'open-in-new' },
|
||||
d.caseStudy && { href: d.caseStudy, label: 'Case study', icon: 'file-document-outline' },
|
||||
].filter(Boolean) as { href: string; label: string; icon: string }[];
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
'card card-interactive flex h-full flex-col gap-4',
|
||||
d.placeholder && 'border-dashed',
|
||||
]}
|
||||
style={d.placeholder ? 'border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);' : ''}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 style="color: var(--color-text);">{d.title}</h3>
|
||||
<p class="font-eyebrow text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||
{d.role}
|
||||
</p>
|
||||
</div>
|
||||
{d.placeholder && (
|
||||
<span
|
||||
class="pill"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="pencil" class="h-3.5 w-3.5" />
|
||||
Placeholder
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{d.tech.length > 0 && (
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{d.tech.map((t) => <li class="pill">{t}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
{d.summary}
|
||||
</p>
|
||||
|
||||
{links.length > 0 && (
|
||||
<ul class="mt-1 flex flex-wrap gap-4">
|
||||
{links.map((l) => (
|
||||
<li>
|
||||
<a
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 font-sans text-sm font-semibold transition-colors"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name={l.icon} class="h-4 w-4" />
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
/**
|
||||
* SectionNav.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Fixed LEFT-SIDE vertical rail for the Professional page. Six sections as a
|
||||
* stacked icon+label list; highlights the current section; click to smooth-
|
||||
* scroll. A vertical progress line LEFT of the icons fills as you scroll.
|
||||
*
|
||||
* Three bugs previously fixed here:
|
||||
* 1. Contrast: the rail used surface-relative tokens (--color-surface-raised /
|
||||
* --color-starlight) which collapsed to the same tone on light surfaces (the
|
||||
* interest-surface cream made icons/text invisible). The rail now uses
|
||||
* FIXED dark colors regardless of the page's surface tone.
|
||||
* 2. Progress line clipping: the line sat at the icon column's horizontal
|
||||
* center, running THROUGH the circles. It now sits in a dedicated left
|
||||
* gutter, clear of the icons.
|
||||
* 3. Last section (Contact) not activating: the IntersectionObserver band was
|
||||
* too thin for the short final section to land in. The rootMargin is
|
||||
* widened + click handlers set active immediately so feedback is instant.
|
||||
*
|
||||
* Hidden below `lg`.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
const sections = [
|
||||
{ id: "summary", label: "Summary", icon: "format-list-text" },
|
||||
{ id: "experience", label: "Experience", icon: "timeline-text" },
|
||||
{ id: "skills", label: "Skills", icon: "chart-bar" },
|
||||
{ id: "projects", label: "Projects", icon: "view-grid" },
|
||||
{ id: "education", label: "Education", icon: "school" },
|
||||
{ id: "contact", label: "Contact", icon: "email" },
|
||||
];
|
||||
---
|
||||
|
||||
<nav
|
||||
aria-label="Professional sections"
|
||||
class="pro-section-nav fixed left-4 top-1/2 z-40 hidden -translate-y-1/2 flex-col lg:flex"
|
||||
data-pro-nav
|
||||
>
|
||||
{/* Rail container. FIXED dark colors (not surface tokens) so it's always
|
||||
legible regardless of the page's data-surface tone. Layout: a left gutter
|
||||
for the progress line, then the icon+label list. */}
|
||||
<div
|
||||
class="relative flex gap-2 rounded-2xl border p-3 shadow-2xl"
|
||||
style="border-color: rgba(201, 162, 74, 0.22); background: linear-gradient(180deg, rgba(19, 28, 43, 0.97), rgba(14, 21, 34, 0.97)); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 18px 40px -24px rgba(8, 12, 20, 0.7);"
|
||||
>
|
||||
{/* --- Left gutter: the progress track + fill. Sits LEFT of the icons. --- */}
|
||||
<div class="relative my-1 w-2 shrink-0">
|
||||
{/* Track (full height of the gutter). */}
|
||||
<div
|
||||
class="pointer-events-none absolute left-1/2 top-0 h-full w-px -translate-x-1/2"
|
||||
style="background: rgba(255, 255, 255, 0.12);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
{/* Fill — height set by JS. Anchored to the top of the track. */}
|
||||
<div
|
||||
class="pointer-events-none absolute left-1/2 top-0 w-px -translate-x-1/2 origin-top"
|
||||
style="background: var(--color-accent); height: 0%; box-shadow: 0 0 8px color-mix(in srgb, var(--color-accent) 60%, transparent);"
|
||||
data-pro-fill
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- The icon+label list. --- */}
|
||||
<div class="flex flex-col gap-1">
|
||||
{sections.map((s) => (
|
||||
<a
|
||||
href={`#${s.id}`}
|
||||
data-section-link={s.id}
|
||||
class="group relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 font-sans text-sm font-semibold transition-all"
|
||||
style="color: #ece9e2;"
|
||||
>
|
||||
<span
|
||||
class="relative z-10 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors"
|
||||
style="background: rgba(201, 162, 74, 0.14); color: #d8b35a;"
|
||||
data-section-dot={s.id}
|
||||
>
|
||||
<Icon name={s.icon} class="h-[1.15rem] w-[1.15rem]" />
|
||||
</span>
|
||||
<span class="relative z-10 tracking-wide">{s.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
/* Inactive items: readable but slightly dimmed. */
|
||||
.pro-section-nav a[data-section-link] {
|
||||
opacity: 0.82;
|
||||
}
|
||||
.pro-section-nav a[data-section-link]:hover {
|
||||
opacity: 1;
|
||||
background: rgba(201, 162, 74, 0.08);
|
||||
}
|
||||
/* Active link: bright accent text + accent wash. */
|
||||
.pro-section-nav a[data-active="true"] {
|
||||
opacity: 1 !important;
|
||||
color: var(--color-accent) !important;
|
||||
background: rgba(201, 162, 74, 0.14);
|
||||
}
|
||||
/* Active dot: filled accent, dark icon, glow ring. */
|
||||
.pro-section-nav a[data-active="true"] [data-section-dot] {
|
||||
background: var(--color-accent) !important;
|
||||
color: #131c2b !important;
|
||||
box-shadow: 0 0 12px rgba(201, 162, 74, 0.65);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function setupSectionSpy() {
|
||||
const nav = document.querySelector<HTMLElement>("[data-pro-nav]");
|
||||
if (!nav) return;
|
||||
|
||||
const links = Array.from(
|
||||
document.querySelectorAll<HTMLAnchorElement>("[data-section-link]"),
|
||||
);
|
||||
if (!links.length) return;
|
||||
const sections = links
|
||||
.map((l) => document.getElementById(l.dataset.sectionLink!))
|
||||
.filter((el): el is HTMLElement => Boolean(el));
|
||||
const fill = nav.querySelector<HTMLElement>("[data-pro-fill]");
|
||||
|
||||
const setActive = (id: string) => {
|
||||
for (const l of links) {
|
||||
l.setAttribute("data-active", l.dataset.sectionLink === id ? "true" : "false");
|
||||
}
|
||||
};
|
||||
|
||||
// Manual-scroll suppression. When the user clicks a nav link we set active
|
||||
// immediately AND suppress IO updates until the smooth-scroll settles. This
|
||||
// is what stops the flicker: without it, the IO callback and the scroll
|
||||
// handler fight during the animated scroll, toggling active state rapidly.
|
||||
let suppressIo = false;
|
||||
let suppressTimer: number | undefined;
|
||||
|
||||
// Click → set active immediately + suppress IO until scrolling settles.
|
||||
for (const l of links) {
|
||||
l.addEventListener("click", () => {
|
||||
const id = l.dataset.sectionLink!;
|
||||
setActive(id);
|
||||
suppressIo = true;
|
||||
window.clearTimeout(suppressTimer);
|
||||
// Re-enable IO once the smooth-scroll has had time to finish.
|
||||
suppressTimer = window.setTimeout(() => {
|
||||
suppressIo = false;
|
||||
}, 900);
|
||||
});
|
||||
}
|
||||
|
||||
// The IntersectionObserver is the SINGLE source of truth for the active
|
||||
// section during free scrolling. (Previously the scroll handler also set
|
||||
// active, which caused the two systems to thrash.)
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (suppressIo) return;
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
|
||||
if (visible) setActive(visible.target.id);
|
||||
},
|
||||
// Band spans roughly the middle 50% of the viewport vertically.
|
||||
{ rootMargin: "-25% 0px -25% 0px", threshold: [0, 0.1, 0.25, 0.5] },
|
||||
);
|
||||
sections.forEach((s) => io.observe(s));
|
||||
|
||||
// Progress fill ONLY. The scroll handler must never set active state — that
|
||||
// was the other half of the flicker. Last-section-at-bottom is handled by
|
||||
// a dedicated, debounced check below instead of on every scroll tick.
|
||||
const updateFill = () => {
|
||||
if (!fill) return;
|
||||
const track = fill.parentElement; // the .w-2 gutter
|
||||
if (!track) return;
|
||||
const doc = document.documentElement;
|
||||
const max = doc.scrollHeight - doc.clientHeight;
|
||||
const frac = max > 0 ? Math.max(0, Math.min(1, window.scrollY / max)) : 0;
|
||||
fill.style.height = `${(frac * track.clientHeight).toFixed(1)}px`;
|
||||
};
|
||||
|
||||
// Debounced at-bottom check: pin the last section active when truly at the
|
||||
// bottom, but only after scrolling has paused (not on every tick).
|
||||
let bottomTimer: number | undefined;
|
||||
const checkBottom = () => {
|
||||
window.clearTimeout(bottomTimer);
|
||||
bottomTimer = window.setTimeout(() => {
|
||||
if (suppressIo) return;
|
||||
const doc = document.documentElement;
|
||||
const atBottom =
|
||||
window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
|
||||
if (atBottom && sections.length) {
|
||||
setActive(sections[sections.length - 1].id);
|
||||
}
|
||||
}, 120);
|
||||
};
|
||||
|
||||
updateFill();
|
||||
window.addEventListener("scroll", () => {
|
||||
updateFill();
|
||||
checkBottom();
|
||||
}, { passive: true });
|
||||
window.addEventListener("resize", updateFill);
|
||||
}
|
||||
setupSectionSpy();
|
||||
document.addEventListener("astro:page-load", setupSectionSpy);
|
||||
</script>
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
/**
|
||||
* SkillGrid.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders the spec's three skill tiers (Core Competencies, Technical Stack,
|
||||
* Emerging & Exploring) as categorized cards. The spec lists some items as
|
||||
* "[Fill in]" for languages/frameworks/tools; those are surfaced as Placeholder
|
||||
* slots rather than invented content.
|
||||
*
|
||||
* Items map to mdi icons for visual texture. The Core tier uses a subtle bar
|
||||
* treatment (decorative, not a proficiency claim) to feel like the spec's
|
||||
* "visual skill bars" suggestion without implying false metrics.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
|
||||
const core = [
|
||||
{ icon: "cloud", label: "Cloud Architecture & Development" },
|
||||
{ icon: "cellphone-link", label: "Mobile Application Development" },
|
||||
{ icon: "server-network", label: "SAP Business Technology Platform (BTP)" },
|
||||
{ icon: "account-group", label: "Team Leadership & Technical Mentorship" },
|
||||
{ icon: "translate", label: "Cross-functional Communication" },
|
||||
];
|
||||
|
||||
const emerging = [
|
||||
{ icon: "chart-line", label: "SAP BTP deep-dive (active expansion)" },
|
||||
{ icon: "robot", label: "Cutting edge AI-powered development tooling" },
|
||||
{
|
||||
icon: "cog-refresh",
|
||||
label: "Advanced automation and workflow engineering",
|
||||
},
|
||||
];
|
||||
|
||||
// Technical stack mixes known items (from spec context) with explicit gaps.
|
||||
const techStack = [
|
||||
{
|
||||
icon: "code-braces",
|
||||
label: "Languages",
|
||||
value:
|
||||
"JavaScript, TypeScript, Java, Python, C#, C, SQL, HTML, CSS, and many more.",
|
||||
hint: "JavaScript, TypeScript, Java, Python, C#, C, SQL, HTML, CSS, and many more.",
|
||||
},
|
||||
{
|
||||
icon: "cube-outline",
|
||||
label: "Frameworks",
|
||||
value: "React, Vue, Astro, Flask",
|
||||
hint: "Add your primary frameworks here.",
|
||||
},
|
||||
{
|
||||
icon: "microsoft-azure-devops",
|
||||
label: "Cloud Platforms",
|
||||
value: "SAP BTP, Google Cloud Platform, AWS",
|
||||
hint: "List the cloud platforms you ship on.",
|
||||
},
|
||||
{
|
||||
icon: "git",
|
||||
label: "Tools",
|
||||
value:
|
||||
"PostgreSQL, VSCode, Unity, XCode, Android Studio, Git, Gitea, CI/CD pipelines",
|
||||
hint: "Add the rest of your toolchain.",
|
||||
},
|
||||
{
|
||||
icon: "brain",
|
||||
label: "AI/ML",
|
||||
value:
|
||||
"Hands-on experience with machine learning frameworks and LLM integration and agent harnesses like ClaudeCode, Hermes, and OpenClaw",
|
||||
hint: null,
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- Core Competencies -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-display" style="color: var(--color-text);">
|
||||
Core Competencies
|
||||
</h3>
|
||||
<ul class="grid gap-3 sm:grid-cols-2">
|
||||
{
|
||||
core.map((item, i) => (
|
||||
<li
|
||||
class="reveal card flex items-center gap-3"
|
||||
data-reveal
|
||||
style={`transition-delay:${i * 60}ms`}
|
||||
>
|
||||
<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="text-sm font-medium" style="color: var(--color-text);">
|
||||
{item.label}
|
||||
</span>
|
||||
{/* Decorative bar; intentionally not a % so we never imply false metrics. */}
|
||||
<span
|
||||
class="ml-auto hidden h-1.5 w-16 overflow-hidden rounded-full sm:block"
|
||||
style="background: var(--color-border);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="block h-full w-full"
|
||||
style="background: var(--color-accent);"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Technical Stack -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-display" style="color: var(--color-text);">Technical Stack</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{
|
||||
techStack.map((item) => (
|
||||
<div class="card flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex h-9 w-9 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="font-sans text-sm font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
{item.value ? (
|
||||
<p class="text-sm" style="color: var(--color-text-muted);">
|
||||
{item.value}
|
||||
</p>
|
||||
) : (
|
||||
<Placeholder
|
||||
label={item.label}
|
||||
hint={item.hint ?? ""}
|
||||
class="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emerging & Exploring -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-display" style="color: var(--color-text);">
|
||||
Emerging & Exploring
|
||||
</h3>
|
||||
<ul class="grid gap-3 sm:grid-cols-3">
|
||||
{
|
||||
emerging.map((item, i) => (
|
||||
<li
|
||||
class="reveal card flex items-start gap-3"
|
||||
data-reveal
|
||||
style={`transition-delay:${i * 70}ms`}
|
||||
>
|
||||
<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={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="text-sm" style="color: var(--color-text);">
|
||||
{item.label}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
/**
|
||||
* Timeline.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Vertical timeline for the Experience section. Each entry has a marker (mdi
|
||||
* icon), a period label, company/title, focus, and rendered markdown body (the
|
||||
* bullet list from the experience collection entry).
|
||||
*
|
||||
* The vertical line is drawn with a pseudo-element; markers sit on it. Entry
|
||||
* bodies are expandable detail (the spec asks for "expandable detail"). On
|
||||
* small screens the marker line stays but spacing tightens.
|
||||
*
|
||||
* Astro 7 content layer: `render(entry)` returns `{ Content }` (an Astro
|
||||
* component), which we render with `<Content />` rather than stringifying.
|
||||
*/
|
||||
import { type CollectionEntry, render } from 'astro:content';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
|
||||
interface Props {
|
||||
entries: CollectionEntry<'experience'>[];
|
||||
}
|
||||
|
||||
const { entries } = Astro.props;
|
||||
|
||||
// Pre-render each entry to get its Content component (Astro 7 API).
|
||||
const rendered = await Promise.all(
|
||||
entries.map(async (e) => ({
|
||||
entry: e,
|
||||
Content: (await render(e)).Content,
|
||||
})),
|
||||
);
|
||||
---
|
||||
|
||||
<ol class="flex flex-col gap-10">
|
||||
{rendered.map(({ entry, Content }, i) => (
|
||||
<li class="relative">
|
||||
<Reveal delay={i * 80}>
|
||||
|
||||
<div class="card flex flex-col gap-3">
|
||||
{/* Header row: icon + company name inline, period on the right. */}
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
<h3 class="flex items-center gap-3" style="color: var(--color-text);">
|
||||
<span
|
||||
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={entry.data.icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
</span>
|
||||
{entry.data.company}
|
||||
</h3>
|
||||
<span class="font-eyebrow text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||
{entry.data.period}
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-sans font-semibold" style="color: var(--color-accent);">
|
||||
{entry.data.title}
|
||||
</p>
|
||||
{entry.data.focus && (
|
||||
<p class="text-sm italic" style="color: var(--color-text-muted);">
|
||||
Focus: {entry.data.focus}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<details class="group mt-1">
|
||||
<summary
|
||||
class="flex cursor-pointer list-none items-center gap-2 font-sans text-sm font-semibold transition-colors"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="chevron-down" class="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Show details
|
||||
</summary>
|
||||
<div
|
||||
class="mt-4 flex flex-col gap-3 text-sm leading-relaxed [&_ul]:flex [&_ul]:flex-col [&_ul]:gap-2 [&_li]:ml-4 [&_li]:list-disc [&_strong]:font-semibold [&_strong]:text-[var(--color-text)] [&_p:first-child]:mt-0"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
<Content />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Carousel.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Server-driven image carousel for store product pages. The list of slides
|
||||
* comes from the product's `images` array (defined in store-products.ts), so
|
||||
* you manage what's shown purely from the data file.
|
||||
*
|
||||
* Features:
|
||||
* - Swipe / drag via pointer events (works for touch + mouse).
|
||||
* - A caption (label) rendered on each slide.
|
||||
* - Prev/next buttons + dot indicators.
|
||||
* - Keyboard accessible (buttons + left/right arrows when focused).
|
||||
* - Respects prefers-reduced-motion (no slide animation, instant swap).
|
||||
*
|
||||
* The carousel is presentation-only on the image; each slide's alt text
|
||||
* carries the meaning for screen readers, and the caption is rendered as
|
||||
* visible text (not aria-hidden) so it's announced.
|
||||
*/
|
||||
import { useState, useRef, useCallback, type PointerEvent } from 'react';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
export interface CarouselSlide {
|
||||
src: string;
|
||||
alt: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
slides: CarouselSlide[];
|
||||
/** Optional aria-label for the carousel region. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function Carousel({ slides, label = 'Product examples' }: Props) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// Horizontal drag delta in px accumulated during the current gesture.
|
||||
const dragDeltaRef = useRef(0);
|
||||
const startXRef = useRef(0);
|
||||
// Live offset applied to the track during a drag (px), for visual feedback.
|
||||
const [liveOffset, setLiveOffset] = useState(0);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const count = slides.length;
|
||||
|
||||
const goTo = useCallback(
|
||||
(i: number) => {
|
||||
if (count === 0) return;
|
||||
const next = (i + count) % count;
|
||||
setIndex(next);
|
||||
},
|
||||
[count],
|
||||
);
|
||||
|
||||
const next = useCallback(() => goTo(index + 1), [goTo, index]);
|
||||
const prev = useCallback(() => goTo(index - 1), [goTo, index]);
|
||||
|
||||
// --- Pointer (touch + mouse) drag handling --------------------------------
|
||||
// Threshold in px before a drag counts as a swipe. Below it, the gesture is
|
||||
// treated as a click (ignored), so taps on the image don't advance.
|
||||
const SWIPE_THRESHOLD = 40;
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
// Only react to primary button / touch.
|
||||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
||||
setDragging(true);
|
||||
startXRef.current = e.clientX;
|
||||
dragDeltaRef.current = 0;
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
const delta = e.clientX - startXRef.current;
|
||||
dragDeltaRef.current = delta;
|
||||
setLiveOffset(delta);
|
||||
};
|
||||
|
||||
const finishDrag = () => {
|
||||
if (!dragging) return;
|
||||
const delta = dragDeltaRef.current;
|
||||
setDragging(false);
|
||||
setLiveOffset(0);
|
||||
if (delta <= -SWIPE_THRESHOLD) {
|
||||
next();
|
||||
} else if (delta >= SWIPE_THRESHOLD) {
|
||||
prev();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
prev();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
const slide = slides[index]!;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
role="group"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl border select-none"
|
||||
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface-sunken)' }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={finishDrag}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Slides track. We translate by -index*100% and apply the live drag
|
||||
offset for feedback. touch-action: pan-y lets vertical scroll pass
|
||||
through while we capture horizontal swipes. */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: `translateX(calc(${-index * 100}% + ${liveOffset}px))`,
|
||||
transitionDuration: dragging ? '0ms' : undefined,
|
||||
touchAction: 'pan-y',
|
||||
}}
|
||||
>
|
||||
{slides.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative w-full shrink-0"
|
||||
aria-hidden={i !== index}
|
||||
aria-roledescription="slide"
|
||||
aria-label={`Slide ${i + 1} of ${count}`}
|
||||
>
|
||||
<img
|
||||
src={s.src}
|
||||
alt={s.alt}
|
||||
draggable={false}
|
||||
loading={i === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
className="aspect-[16/9] w-full object-cover"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
{/* Caption gradient + label, always visible for the active slide. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end p-4"
|
||||
style={{ background: 'linear-gradient(to top, rgba(14,21,34,0.85), transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="font-sans text-sm font-semibold sm:text-base"
|
||||
style={{ color: 'var(--color-starlight)' }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Prev / next controls. */}
|
||||
{count > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
aria-label="Previous slide"
|
||||
className="absolute left-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||
style={{
|
||||
color: 'var(--color-starlight)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 40%, transparent)',
|
||||
background: 'rgba(14,21,34,0.5)',
|
||||
}}
|
||||
>
|
||||
<BrandIcon name="chevron-left" size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
aria-label="Next slide"
|
||||
className="absolute right-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||
style={{
|
||||
color: 'var(--color-starlight)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 40%, transparent)',
|
||||
background: 'rgba(14,21,34,0.5)',
|
||||
}}
|
||||
>
|
||||
<BrandIcon name="chevron-right" size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slide counter (top-right). */}
|
||||
<span
|
||||
className="pointer-events-none absolute right-3 top-3 rounded-full px-2.5 py-1 font-eyebrow text-xs"
|
||||
style={{ color: 'var(--color-starlight)', background: 'rgba(14,21,34,0.6)' }}
|
||||
>
|
||||
{index + 1} / {count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dot indicators. */}
|
||||
{count > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{slides.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => goTo(i)}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
aria-current={i === index}
|
||||
className="h-2 rounded-full transition-all"
|
||||
style={{
|
||||
width: i === index ? '1.75rem' : '0.5rem',
|
||||
background:
|
||||
i === index
|
||||
? 'var(--color-accent)'
|
||||
: 'color-mix(in srgb, var(--color-text-subtle) 50%, transparent)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* sr-only live region announcing the active slide's label. */}
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{slide.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
/**
|
||||
* ColorGrid.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders the available print/filament colors for a product as a grid of
|
||||
* swatches. Each swatch shows the color, its name, and an In-stock / Out-of-
|
||||
* stock pill.
|
||||
*
|
||||
* The data comes from the product's `colors` array in store-products.ts. To
|
||||
* manage colors, edit that array (add/remove entries, toggle `inStock`).
|
||||
*
|
||||
* If `colors` is omitted or empty, this component renders nothing.
|
||||
*/
|
||||
import type { ColorOption } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
colors?: ColorOption[];
|
||||
}
|
||||
|
||||
const { colors = [] } = Astro.props;
|
||||
const inStockCount = colors.filter((c) => c.inStock).length;
|
||||
---
|
||||
|
||||
{colors.length > 0 && (
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span class="text-sm" style="color: var(--color-text-muted);">
|
||||
Choose from the available filament colors.
|
||||
</span>
|
||||
<span class="font-eyebrow text-xs" style="color: var(--color-text-subtle);">
|
||||
{inStockCount} of {colors.length} in stock
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{colors.map((c) => (
|
||||
<li
|
||||
class:list={[
|
||||
'flex items-center gap-3 rounded-xl border p-3',
|
||||
!c.inStock && 'opacity-60',
|
||||
]}
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
{/* Swatch. A hairline ring keeps light colors (e.g. bone white)
|
||||
visible against the raised surface. */}
|
||||
<span
|
||||
class="h-9 w-9 shrink-0 rounded-full"
|
||||
style={`background: ${c.hex}; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 18%, transparent);`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-sans text-sm font-semibold" style="color: var(--color-text);">
|
||||
{c.name}
|
||||
</span>
|
||||
<span
|
||||
class="pill text-[0.7rem]"
|
||||
style={c.inStock
|
||||
? undefined
|
||||
: 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'}
|
||||
>
|
||||
{c.inStock ? 'In stock' : 'Out of stock'}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* OrderForm.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Store order-request form island. Modeled on ContactForm.tsx: same
|
||||
* idle/submitting/success/error states, honeypot, optional Turnstile, aria-live
|
||||
* status, and identical input styling. Posts to /api/order.
|
||||
*
|
||||
* This collects an ORDER REQUEST only — no payment fields, no checkout. The
|
||||
* submission is emailed to Greg. The server endpoint (/api/order) independently
|
||||
* re-checks store-availability.json and 403s if orders are closed, so the gate
|
||||
* can't be bypassed from the client.
|
||||
*
|
||||
* The form requires an explicit acknowledgment of the store policies (checkbox)
|
||||
* before it can be submitted.
|
||||
*/
|
||||
import { useState, useRef, useEffect, type FormEvent } from 'react';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
||||
type OrderType = 'cards' | 'deckBoxes' | 'both';
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
details?: string;
|
||||
quantity?: string;
|
||||
agree?: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: string | HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ColorOption {
|
||||
name: string;
|
||||
hex: string;
|
||||
inStock: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
|
||||
turnstileSiteKey?: string;
|
||||
/**
|
||||
* Available filament colors for printed items, from store-products.ts. Only
|
||||
* in-stock colors are offered in the color dropdown. Omit to hide the color
|
||||
* selector entirely (e.g. if no colors are configured).
|
||||
*/
|
||||
colors?: ColorOption[];
|
||||
}
|
||||
|
||||
export default function OrderForm({ turnstileSiteKey, colors = [] }: Props) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
// Tracks the order type so the color selector can show/hide. Mirrors the
|
||||
// <select name="orderType"> so the two stay in sync.
|
||||
const [orderType, setOrderType] = useState<OrderType>('cards');
|
||||
|
||||
// Colors currently offered = in-stock only. Empty when none configured.
|
||||
const availableColors = colors.filter((c) => c.inStock);
|
||||
const showColors = availableColors.length > 0 && (orderType === 'deckBoxes' || orderType === 'both');
|
||||
|
||||
const turnstileTokenRef = useRef<string>('');
|
||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Same Turnstile bootstrap as ContactForm.
|
||||
useEffect(() => {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (window.turnstile) {
|
||||
renderWidget();
|
||||
return;
|
||||
}
|
||||
let script = document.getElementById('cf-turnstile-script') as HTMLScriptElement | null;
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = 'cf-turnstile-script';
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
script.addEventListener('load', renderWidget);
|
||||
return () => {
|
||||
script?.removeEventListener('load', renderWidget);
|
||||
};
|
||||
}, [turnstileSiteKey]);
|
||||
|
||||
function renderWidget() {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (!window.turnstile || !turnstileContainerRef.current) return;
|
||||
if (turnstileWidgetIdRef.current) return;
|
||||
turnstileWidgetIdRef.current = window.turnstile.render(turnstileContainerRef.current, {
|
||||
sitekey: turnstileSiteKey,
|
||||
callback: (token: string) => {
|
||||
turnstileTokenRef.current = token;
|
||||
},
|
||||
'expired-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
'error-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
theme: 'dark',
|
||||
});
|
||||
}
|
||||
|
||||
interface OrderValues {
|
||||
name: string;
|
||||
email: string;
|
||||
orderType: OrderType;
|
||||
color: string;
|
||||
quantity: string;
|
||||
details: string;
|
||||
notes: string;
|
||||
agree: boolean;
|
||||
company: string;
|
||||
turnstileToken: string;
|
||||
}
|
||||
|
||||
function validate(v: OrderValues): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
if (!v.name.trim()) e.name = 'Please enter your name.';
|
||||
if (!v.email.trim()) e.email = 'Please enter your email.';
|
||||
else if (!EMAIL_RE.test(v.email)) e.email = 'That email address looks off.';
|
||||
if (!v.details.trim()) e.details = 'Please describe your order (card list or deck-box spec).';
|
||||
else if (v.details.trim().length < 10) e.details = 'A little more detail, please (10+ characters).';
|
||||
if (!v.quantity.trim()) e.quantity = 'Please enter an approximate quantity.';
|
||||
if (!v.agree) e.agree = 'You must acknowledge the policies to continue.';
|
||||
return e;
|
||||
}
|
||||
|
||||
async function onSubmit(ev: FormEvent<HTMLFormElement>) {
|
||||
ev.preventDefault();
|
||||
const form = ev.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const values: OrderValues = {
|
||||
name: String(data.get('name') ?? '').trim(),
|
||||
email: String(data.get('email') ?? '').trim(),
|
||||
orderType: (String(data.get('orderType') ?? 'cards') as OrderType),
|
||||
color: String(data.get('color') ?? '').trim(),
|
||||
quantity: String(data.get('quantity') ?? '').trim(),
|
||||
details: String(data.get('details') ?? '').trim(),
|
||||
notes: String(data.get('notes') ?? '').trim(),
|
||||
agree: data.get('agree') === 'on',
|
||||
company: String(data.get('company') ?? ''),
|
||||
turnstileToken: turnstileTokenRef.current,
|
||||
};
|
||||
|
||||
const found = validate(values);
|
||||
if (turnstileSiteKey && !values.turnstileToken) {
|
||||
found.agree = found.agree ?? 'Please complete the bot check and try again.';
|
||||
}
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) return;
|
||||
|
||||
setStatus('submitting');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
throw new Error(body.error || 'Something went wrong. Please try again.');
|
||||
}
|
||||
setStatus('success');
|
||||
form.reset();
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]';
|
||||
const labelBase = 'mb-1.5 block font-sans text-sm font-medium';
|
||||
const fieldColor = { color: 'var(--color-text)' };
|
||||
const borderFor = (k: keyof FieldErrors) => (errors[k] ? 'var(--color-accent)' : 'var(--color-border)');
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
|
||||
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BrandIcon name="check" size={24} />
|
||||
</span>
|
||||
<h3 className="font-display text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Order request sent.
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Thanks! I'll review your request and reply by email. No payment is taken through the site.
|
||||
</p>
|
||||
<button type="button" onClick={() => setStatus('idle')} className="btn btn-secondary mt-2">
|
||||
Submit another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
|
||||
{/* Honeypot */}
|
||||
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
|
||||
<label htmlFor="company">Company (leave blank)</label>
|
||||
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="name" className={labelBase} style={fieldColor}>Name</label>
|
||||
<input id="name" name="name" type="text" autoComplete="name" className={inputBase} style={{ borderColor: borderFor('name'), ...fieldColor }} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'name-error' : undefined} />
|
||||
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="email" className={labelBase} style={fieldColor}>Email</label>
|
||||
<input id="email" name="email" type="email" autoComplete="email" className={inputBase} style={{ borderColor: borderFor('email'), ...fieldColor }} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'email-error' : undefined} />
|
||||
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="orderType" className={labelBase} style={fieldColor}>Order type</label>
|
||||
<select
|
||||
id="orderType"
|
||||
name="orderType"
|
||||
value={orderType}
|
||||
onChange={(e) => setOrderType(e.target.value as OrderType)}
|
||||
className={inputBase}
|
||||
style={{ borderColor: 'var(--color-border)', ...fieldColor }}
|
||||
>
|
||||
<option value="cards">Proxy MTG cards</option>
|
||||
<option value="deckBoxes">3D-printed deck box / counters</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Color selector — only for printed items, only when colors are
|
||||
configured and in stock. The list comes from store-products.ts. */}
|
||||
{showColors && (
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="color" className={labelBase} style={fieldColor}>Print color</label>
|
||||
<select id="color" name="color" defaultValue="" className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor }}>
|
||||
<option value="" disabled>Select a color…</option>
|
||||
{availableColors.map((c) => (
|
||||
<option key={c.name} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
||||
Colors shown are what’s loaded right now. See the deck-boxes page for swatches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="quantity" className={labelBase} style={fieldColor}>Approximate quantity</label>
|
||||
<input id="quantity" name="quantity" type="text" placeholder="e.g. 75 cards, or 1 deck box + 4 counters" className={inputBase} style={{ borderColor: borderFor('quantity'), ...fieldColor }} aria-invalid={Boolean(errors.quantity)} aria-describedby={errors.quantity ? 'quantity-error' : undefined} />
|
||||
{errors.quantity && <p id="quantity-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.quantity}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="details" className={labelBase} style={fieldColor}>Card list / deck-box spec</label>
|
||||
<textarea
|
||||
id="details"
|
||||
name="details"
|
||||
rows={6}
|
||||
className={inputBase}
|
||||
style={{ borderColor: borderFor('details'), ...fieldColor, resize: 'vertical' }}
|
||||
placeholder={'For cards: paste your full card list (one per line). Remember double-sided cards count as 2 slots.\nFor deck boxes: describe capacity, color, and any counters.'}
|
||||
aria-invalid={Boolean(errors.details)}
|
||||
aria-describedby={errors.details ? 'details-error' : undefined}
|
||||
/>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
||||
The card list is final once received — cards cannot be changed for that order.
|
||||
</p>
|
||||
{errors.details && <p id="details-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.details}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="notes" className={labelBase} style={fieldColor}>Notes <span className="font-normal" style={{ color: 'var(--color-text-subtle)' }}>(optional)</span></label>
|
||||
<textarea id="notes" name="notes" rows={3} className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor, resize: 'vertical' }} />
|
||||
</div>
|
||||
|
||||
{/* Policy acknowledgment */}
|
||||
<label className="flex items-start gap-3 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<input
|
||||
id="agree"
|
||||
name="agree"
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 shrink-0"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span>
|
||||
I understand these are clearly-marked proxies (not counterfeits), the card list is final
|
||||
once received, double-sided cards count as 2 slots, and all sales are final. I am not
|
||||
requesting custom or offensive art.
|
||||
</span>
|
||||
</label>
|
||||
{errors.agree && <p className="-mt-2 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.agree}</p>}
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="rounded-lg p-3 text-sm" style={{ background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', color: 'var(--color-accent)' }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div ref={turnstileContainerRef} className="min-h-[65px]" aria-label="Bot check" />
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={status === 'submitting'} className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{status === 'submitting' ? 'Sending…' : 'Send order request'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
||||
No payment is taken through this site. Your request is emailed to Greg, who will reply to
|
||||
confirm details and availability.
|
||||
</p>
|
||||
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{status === 'submitting' ? 'Sending your order request.' : ''}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
/**
|
||||
* PricingTable.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Generic quantity/pricing table. Takes a caption + a list of tiers
|
||||
* ({ band, price, note? }) and renders a clean three-column table. Used for
|
||||
* both the card-set tiers and the deck-box print tiers.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Tier {
|
||||
band: string;
|
||||
price: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** mdi icon shown beside the caption. */
|
||||
icon?: string;
|
||||
caption: string;
|
||||
tiers: Tier[];
|
||||
/** Column header for the price column, e.g. "Price". */
|
||||
priceHeader?: string;
|
||||
/** Optional supporting line under the caption. */
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const { icon, caption, tiers, priceHeader = 'Price', subtitle } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
{icon && <Icon name={icon} class="h-5 w-5" style="color: var(--color-accent);" />}
|
||||
{caption}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span class="text-sm" style="color: var(--color-text-muted);">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border" style="border-color: var(--color-border);">
|
||||
<table class="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr style="background: var(--color-surface-sunken);">
|
||||
<th scope="col" class="px-4 py-3 font-sans text-xs font-semibold uppercase tracking-wider" style="color: var(--color-text-subtle);">
|
||||
Quantity
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-sans text-xs font-semibold uppercase tracking-wider" style="color: var(--color-text-subtle);">
|
||||
{priceHeader}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tiers.map((tier, i) => (
|
||||
<tr style={i > 0 ? 'border-top: 1px solid var(--color-border);' : undefined}>
|
||||
<td class="px-4 py-3" style="color: var(--color-text);">
|
||||
<span class="font-medium">{tier.band}</span>
|
||||
{tier.note && (
|
||||
<span class="ml-2 text-xs" style="color: var(--color-text-subtle);">({tier.note})</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-semibold" style="color: var(--color-accent);">
|
||||
{tier.price}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
/**
|
||||
* ProductCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Product specification block: a title + summary and a spec definition list
|
||||
* (size, thickness, finishes, white back, etc.). Example IMAGES live in the
|
||||
* product-page Carousel, not here, so this component is specs-only now.
|
||||
*
|
||||
* Used on the product detail pages (cards / deck-boxes). On the storefront we
|
||||
* use the compact ProductTile instead (image + title + summary).
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Product } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
/** When true, show the full spec list (detail pages). When false, just
|
||||
* title + summary. */
|
||||
detailed?: boolean;
|
||||
/** Optional link wrapped around the compact card (storefront tiles). */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const { product, detailed = false, href } = Astro.props;
|
||||
const Wrapper = href ? 'a' : 'div';
|
||||
---
|
||||
|
||||
<Wrapper
|
||||
href={href}
|
||||
class:list={[
|
||||
'group flex flex-col gap-4 rounded-2xl border p-4',
|
||||
href && 'card-interactive',
|
||||
]}
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
<Icon name={product.icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{product.title}
|
||||
</span>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{product.summary}</p>
|
||||
</div>
|
||||
|
||||
{detailed && (
|
||||
<dl class="flex flex-col gap-0 rounded-xl border text-sm" style="border-color: var(--color-border);">
|
||||
{product.specs.map((spec, i) => (
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-3 sm:flex-row sm:items-baseline sm:gap-4"
|
||||
style={i > 0 ? 'border-top: 1px solid var(--color-border);' : undefined}
|
||||
>
|
||||
<dt class="font-sans text-xs font-semibold uppercase tracking-wider sm:w-40 sm:shrink-0" style="color: var(--color-text-subtle);">
|
||||
{spec.label}
|
||||
</dt>
|
||||
<dd style="color: var(--color-text);">{spec.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</Wrapper>
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
/**
|
||||
* ProductTile.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Compact storefront tile for a product line: the first example image (from the
|
||||
* product's `images` array), the title, the summary, and a "View details" hint.
|
||||
* Wraps the whole tile in a link to the product page.
|
||||
*
|
||||
* The full image carousel lives on the product page; this is just the storefront
|
||||
* teaser. ProductCard.astro (specs-only) is used on the detail pages.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Product } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const { product, href } = Astro.props;
|
||||
const cover = product.images[0];
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class="group card-interactive flex flex-col gap-4 rounded-2xl border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
{cover && (
|
||||
<figure class="relative w-full overflow-hidden rounded-xl" style="border: 1px solid var(--color-border);">
|
||||
<img
|
||||
src={cover.src}
|
||||
alt={cover.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-[4/3] w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
{product.images.length > 1 && (
|
||||
<span
|
||||
class="pointer-events-none absolute right-2 top-2 inline-flex items-center gap-1 rounded-full px-2 py-1 font-eyebrow text-xs"
|
||||
style="color: var(--color-starlight); background: rgba(14,21,34,0.6);"
|
||||
>
|
||||
<Icon name="gallery" class="h-3.5 w-3.5" />
|
||||
{product.images.length}
|
||||
</span>
|
||||
)}
|
||||
</figure>
|
||||
)}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
<Icon name={product.icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{product.title}
|
||||
</span>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{product.summary}</p>
|
||||
<span class="inline-flex items-center gap-1 text-sm font-medium transition-colors" style="color: var(--color-accent);">
|
||||
View details
|
||||
<Icon name="arrow-right" class="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
/**
|
||||
* RequirementList.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders the store rules / requirements. Each item has an mdi icon that
|
||||
* signals tone: 'check-decagram' / 'information' = neutral rule,
|
||||
* 'alert' = caution, 'close-circle' = hard no / final.
|
||||
*
|
||||
* Used on the storefront and repeated on the order page; the OrderForm also
|
||||
* requires an explicit acknowledgment of these.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Requirement } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
items: Requirement[];
|
||||
/** Optional heading shown above the list. */
|
||||
heading?: string;
|
||||
}
|
||||
|
||||
const { items, heading } = Astro.props;
|
||||
|
||||
// Map the requirement icon to a tone color.
|
||||
function toneColor(icon: Requirement['icon']): string {
|
||||
switch (icon) {
|
||||
case 'alert':
|
||||
return '#d8b35a'; // gold — caution
|
||||
case 'close-circle':
|
||||
return '#e5655a'; // red — hard no
|
||||
case 'check-decagram':
|
||||
case 'information':
|
||||
default:
|
||||
return 'var(--color-accent)';
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
{heading && (
|
||||
<h3 class="font-display text-xl font-bold" style="color: var(--color-text);">{heading}</h3>
|
||||
)}
|
||||
|
||||
<ul class="flex flex-col gap-3">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
class="flex gap-3 rounded-lg border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<span class="mt-0.5 shrink-0" style={`color: ${toneColor(item.icon)};`}>
|
||||
<Icon name={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-sans text-sm font-semibold" style="color: var(--color-text);">{item.title}</span>
|
||||
<span class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{item.body}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
/**
|
||||
* SlotMeter.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Visual "X of Y slots open" capacity meter for a store category. Reads a slot
|
||||
* status (open + total) and renders a progress-style bar plus an open/closed
|
||||
* pill. The fill width reflects open/total; when open === 0 it shows CLOSED.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
/** Category label, e.g. "Card orders" or "3D-print slots". */
|
||||
label: string;
|
||||
/** mdi icon for the row. */
|
||||
icon: string;
|
||||
/** Number of currently open slots. */
|
||||
open: number;
|
||||
/** Total slots for the category. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
const { label, icon, open, total } = Astro.props;
|
||||
const closed = open <= 0;
|
||||
const pct = total > 0 ? Math.max(0, Math.min(100, (open / total) * 100)) : 0;
|
||||
---
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-sm font-semibold" style="color: var(--color-text);">
|
||||
<Icon name={icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
class="pill"
|
||||
style={closed
|
||||
? 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'
|
||||
: undefined}
|
||||
>
|
||||
{closed ? 'Closed' : 'Open'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm" style="color: var(--color-text-muted);">
|
||||
<span>{open} of {total} slots open</span>
|
||||
<span class="font-eyebrow text-xs" style="color: var(--color-text-subtle);">{closed ? '0%' : `${Math.round(pct)}%`}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full"
|
||||
style="background: color-mix(in srgb, var(--color-border) 60%, transparent);"
|
||||
role="meter"
|
||||
aria-valuenow={open}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={total}
|
||||
aria-label={`${label}: ${open} of ${total} slots open`}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-500"
|
||||
style={`width: ${pct}%; background: ${closed ? '#e55' : 'var(--color-accent)'};`}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
/**
|
||||
* StoreNav.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Store-only top navigation. Deliberately separate from the main NavBar: this
|
||||
* renders ONLY store links (Storefront, Cards, Deck Boxes, Order) and contains
|
||||
* NO links back to the main gregkilmer.com site, per Greg's request that the
|
||||
* two sites not be clearly linked yet.
|
||||
*
|
||||
* Visually it mirrors NavBar.astro's treatment — the dark nebula band, the
|
||||
* scroll blur, and the Patiun seal — so it reads as the same house while
|
||||
* being unmistakably a store (a "Store" wordmark + the store.gregkilmer.com
|
||||
* domain label).
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import MobileMenu from '@components/nav/MobileMenu.tsx';
|
||||
|
||||
interface NavEntry {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// Single flat group (no "two wings" divider — the store is one thing).
|
||||
const storeNav: NavEntry[] = [
|
||||
{ label: 'Store', href: '/store', icon: 'store' },
|
||||
{ label: 'Cards', href: '/store/cards', icon: 'cards-playing' },
|
||||
{ label: 'Deck Boxes', href: '/store/deck-boxes', icon: 'printer-3d-nozzle' },
|
||||
{ label: 'Order', href: '/store/order', icon: 'clipboard-list' },
|
||||
];
|
||||
|
||||
const path = Astro.url.pathname;
|
||||
const isActive = (href: string) =>
|
||||
href === '/store' ? path === '/store' : path === href || path.startsWith(href + '/');
|
||||
|
||||
// MobileMenu expects the { items: [] }[] shape.
|
||||
const groups = [{ items: storeNav }];
|
||||
---
|
||||
<header
|
||||
id="store-nav"
|
||||
data-nav
|
||||
class="on-sky fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300"
|
||||
>
|
||||
<div class="border-b border-transparent transition-colors duration-300" data-nav-inner>
|
||||
<nav class="container-prose flex h-20 items-center justify-between gap-4" aria-label="Store">
|
||||
<!-- Wordmark: "Greg Kilmer · Store" + the store domain label -->
|
||||
<a href="/store" class="group flex items-center gap-3 leading-tight" aria-label="Greg Kilmer Store — home">
|
||||
<img
|
||||
src="/brand/patiun-seal.svg"
|
||||
alt=""
|
||||
width="72"
|
||||
height="72"
|
||||
class="h-[72px] w-[72px] shrink-0 transition-transform duration-700 group-hover:rotate-[30deg]"
|
||||
/>
|
||||
<span class="flex flex-col">
|
||||
<span class="font-display text-base font-bold uppercase tracking-[0.12em]" style="color: var(--color-starlight);">
|
||||
Greg Kilmer · Store
|
||||
</span>
|
||||
<span
|
||||
class="hidden font-eyebrow text-[0.7rem] tracking-[0.08em] sm:block"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
Store.GregKilmer.com
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
{storeNav.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors"
|
||||
style={active ? 'color: var(--color-accent);' : 'color: var(--color-starlight); opacity: 0.85;'}
|
||||
onmouseover="this.style.opacity='1'"
|
||||
onmouseout="this.style.opacity='0.85'"
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<Icon name={item.icon} class="h-4 w-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<MobileMenu client:idle groups={groups} currentPath={path} />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* The store nav uses a SOLID opaque band (not the main NavBar's translucent
|
||||
gradient), because the store index hero renders a bright animated field
|
||||
plus a gold label right at the top edge. A translucent nav let that hero
|
||||
content bleed through, making the nav read as if it sat behind the hero.
|
||||
Solid + a hairline border keeps it a clearly distinct bar at all times. */
|
||||
[data-nav-inner] {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(14, 21, 34) 0%,
|
||||
rgb(19, 28, 43) 100%
|
||||
);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
}
|
||||
[data-nav].scrolled [data-nav-inner] {
|
||||
backdrop-filter: saturate(150%) blur(12px);
|
||||
-webkit-backdrop-filter: saturate(150%) blur(12px);
|
||||
border-bottom-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 30%,
|
||||
var(--color-border)
|
||||
);
|
||||
box-shadow: 0 8px 30px -20px rgba(8, 12, 20, 0.7);
|
||||
}
|
||||
[data-nav]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(60% 120% at 85% -20%, rgba(106, 47, 224, 0.22), transparent 70%),
|
||||
radial-gradient(50% 120% at 10% -10%, rgba(201, 162, 74, 0.1), transparent 70%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Mirror NavBar's scroll handler. Re-bind on View Transitions navigations.
|
||||
function setupNavScroll() {
|
||||
const nav = document.getElementById('store-nav');
|
||||
if (!nav) return;
|
||||
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
setupNavScroll();
|
||||
document.addEventListener('astro:page-load', setupNavScroll);
|
||||
</script>
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* BoardGameShelf.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Two-column "Board games" island for the TTRPGs page.
|
||||
*
|
||||
* Left: a list of game names, each a real <a> to its BoardGameGeek page.
|
||||
* Hovering (or keyboard-focusing) a name selects it.
|
||||
* Right: a tall image frame. At rest it shows Greg's board-game shelf photo;
|
||||
* when a name is selected, that game's BGG cover cross-fades in on
|
||||
* top of the shelf, with the game's name + note as a caption.
|
||||
*
|
||||
* Progressive enhancement: without JS the list is still fully usable — the
|
||||
* links work and the shelf photo stays put. The cross-fade is the enhancement.
|
||||
* framer-motion honors prefers-reduced-motion automatically.
|
||||
*
|
||||
* Data shape (src/data/boardgames.json):
|
||||
* { name: string; bggId: number; note?: string; image?: string }
|
||||
* `image` is optional — populated by scripts/fetch-bgg-covers.mjs. Rows without
|
||||
* an `image` simply keep the shelf photo on hover (graceful fallback).
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
export interface BoardGame {
|
||||
name: string;
|
||||
/** BoardGameGeek numeric game id; drives the link + cover lookup. */
|
||||
bggId: number;
|
||||
/** Optional one-line note shown as the caption. */
|
||||
note?: string;
|
||||
/** Optional cover image URL/path. If absent, hovering won't swap images. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
games: BoardGame[];
|
||||
/** Public path to the default shelf photo, e.g. "/images/ttrpg-boardgame-shelf.jpg". */
|
||||
shelfImage: string;
|
||||
/** Alt text for the shelf photo. */
|
||||
shelfAlt: string;
|
||||
/** Optional caption shown at rest over the shelf photo. */
|
||||
shelfCaption?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed height for BOTH columns. The list shows up to 8 rows in a scroll
|
||||
* container; the image frame is pinned to this same height so the two
|
||||
* columns stay aligned regardless of how many games are in the list.
|
||||
* ~3.25rem per row x 8 rows.
|
||||
*/
|
||||
const SHELF_HEIGHT = '44rem';
|
||||
|
||||
const crossfade = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
transition: { duration: 0.35, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] },
|
||||
};
|
||||
|
||||
export default function BoardGameShelf({
|
||||
games,
|
||||
shelfImage,
|
||||
shelfAlt,
|
||||
shelfCaption = 'The shelf',
|
||||
}: Props) {
|
||||
// Index of the active game, or null when at rest (showing the shelf).
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const activeGame = active !== null ? games[active] : null;
|
||||
// Only swap to the cover if the active game actually has an image.
|
||||
const showCover = activeGame?.image;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 sm:grid-cols-2 sm:items-stretch">
|
||||
{/* LEFT: game list — capped at the shelf height with internal scroll */}
|
||||
<ul
|
||||
className="flex flex-col divide-y overflow-y-auto pr-1"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
height: SHELF_HEIGHT,
|
||||
scrollbarWidth: 'thin',
|
||||
}}
|
||||
>
|
||||
{games.map((g, i) => {
|
||||
const isActive = active === i;
|
||||
return (
|
||||
<li key={g.bggId}>
|
||||
<a
|
||||
href={`https://boardgamegeek.com/boardgame/${g.bggId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onFocus={() => setActive(i)}
|
||||
onMouseLeave={() => setActive((cur) => (cur === i ? null : cur))}
|
||||
onBlur={() => setActive((cur) => (cur === i ? null : cur))}
|
||||
className="group flex items-center gap-3 py-3 transition-colors"
|
||||
style={{
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors"
|
||||
style={{
|
||||
background: isActive
|
||||
? 'color-mix(in srgb, var(--color-accent) 20%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-secondary) 16%, transparent)',
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--color-secondary)',
|
||||
}}
|
||||
>
|
||||
{/* Patiun dice icon */}
|
||||
<BrandIcon name="dice" size={16} />
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="font-sans text-base leading-tight underline-offset-4 group-hover:underline group-focus:underline">
|
||||
{g.name}
|
||||
</span>
|
||||
{g.note && (
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{g.note}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* RIGHT: tall image frame, shelf at rest, cover on hover. Pinned to the
|
||||
same height as the list so the two columns line up. */}
|
||||
<figure
|
||||
className="relative w-full overflow-hidden rounded-xl border"
|
||||
style={{
|
||||
height: SHELF_HEIGHT,
|
||||
borderColor: 'color-mix(in srgb, var(--color-gold) 70%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--color-secondary) 12%, transparent)',
|
||||
}}
|
||||
// When the pointer leaves the whole figure, snap back to the shelf.
|
||||
onMouseLeave={() => setActive(null)}
|
||||
>
|
||||
{/* Base layer: shelf photo (always present; covers cross-fade on top). */}
|
||||
<img
|
||||
src={shelfImage}
|
||||
alt={shelfAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
{/* Gradient + caption overlay for legibility. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-1/3"
|
||||
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)' }}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{showCover && (
|
||||
<motion.img
|
||||
key={activeGame!.bggId}
|
||||
src={activeGame!.image}
|
||||
alt={`${activeGame!.name} cover`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
initial={crossfade.initial}
|
||||
animate={crossfade.animate}
|
||||
exit={crossfade.exit}
|
||||
transition={crossfade.transition}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<figcaption
|
||||
className="absolute inset-x-0 bottom-0 flex flex-col gap-0.5 p-4"
|
||||
style={{ color: 'var(--color-starlight)' }}
|
||||
>
|
||||
<span className="font-sans text-lg leading-tight drop-shadow">
|
||||
{activeGame?.name ?? shelfCaption}
|
||||
</span>
|
||||
{activeGame?.note && (
|
||||
<span className="text-sm opacity-90 drop-shadow">{activeGame.note}</span>
|
||||
)}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* content.config.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Astro Content Collections schemas. Adding a project or experience entry is as
|
||||
* simple as dropping a new MDX/markdown file in the matching folder; the type
|
||||
* system and Astro will validate it. See docs/CONTENT.md for the authoring guide.
|
||||
*
|
||||
* Collections:
|
||||
* - projects: portfolio entries shown on the Professional page
|
||||
* - experience: work history entries shown on the Experience timeline
|
||||
* - games: Gaming Hall of Fame cards (one file per game)
|
||||
* - nowPlaying: currently-playing games (single list file)
|
||||
*/
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { z } from 'zod/v4';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const projects = defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/projects' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
role: z.string().default('Developer'),
|
||||
/** Short tag list rendered as pills. */
|
||||
tech: z.array(z.string()).default([]),
|
||||
/** 2-3 sentence summary. */
|
||||
summary: z.string(),
|
||||
/** Optional links. */
|
||||
repo: z.string().url().optional(),
|
||||
demo: z.string().url().optional(),
|
||||
caseStudy: z.string().url().optional(),
|
||||
/** Optional cover image (public path). */
|
||||
cover: z.string().optional(),
|
||||
/** Sort order; lower comes first. Defaults to 100. */
|
||||
order: z.number().default(100),
|
||||
/** True for the placeholder used until Greg adds real projects. */
|
||||
placeholder: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
const experience = defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/experience' }),
|
||||
schema: z.object({
|
||||
company: z.string(),
|
||||
title: z.string(),
|
||||
/** Free-form date label, e.g. "2020 - Present". */
|
||||
period: z.string().optional(),
|
||||
focus: z.string().optional(),
|
||||
/** mdi icon for the timeline marker. */
|
||||
icon: z.string().default('briefcase'),
|
||||
/** Sort order; lower = more recent / first shown. */
|
||||
order: z.number().default(100),
|
||||
}),
|
||||
});
|
||||
|
||||
const games = defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/games/hall-of-fame' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
/** Platform Greg plays it on, e.g. "PlayStation 5". Drives the platform pill + icon. */
|
||||
platform: z.string(),
|
||||
/** 1-3 sentence blurb on why it's a favorite. */
|
||||
blurb: z.string(),
|
||||
/** Optional public path to box/banner art, e.g. "/images/games/elden-ring.jpg". */
|
||||
art: z.string().optional(),
|
||||
/** Optional alt text for the art. Defaults to the title. */
|
||||
artAlt: z.string().optional(),
|
||||
/** Optional release year, shown as a small detail. */
|
||||
year: z.number().optional(),
|
||||
/**
|
||||
* Optional mdi icon override for the card badge. Defaults to a per-platform
|
||||
* icon derived from `platform` (see `platformIcon`).
|
||||
*/
|
||||
icon: z.string().optional(),
|
||||
/** Sort order; lower comes first. Defaults to 100. */
|
||||
order: z.number().default(100),
|
||||
}),
|
||||
});
|
||||
|
||||
const nowPlaying = defineCollection({
|
||||
loader: glob({ pattern: 'now-playing.yaml', base: './src/content/games' }),
|
||||
schema: z.object({
|
||||
games: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
platform: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { projects, experience, games, nowPlaying };
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
# Experience entry for Implico Inc.
|
||||
# This seeds the Experience timeline. The narrative bullets below are the spec's
|
||||
# template bullets; Greg should replace the bracketed placeholders with real
|
||||
# outcomes, metrics, and dates. See docs/CONTENT.md for how to add more roles.
|
||||
company: "Comcast"
|
||||
title: "Co-op - Junior Developer"
|
||||
period: "Sep 2014 - Sep 2015"
|
||||
focus: "Worked on inventory management systems and Natural Language Processing tools."
|
||||
icon: "briefcase"
|
||||
order: 20
|
||||
---
|
||||
|
||||
Key contributions:
|
||||
|
||||
- Developed and maintained NLP based data gathering system and associated databases using Java, Groovy, Grails, Elasticsearch, and MySQL
|
||||
- Led and managed team of senior interns and a full time developer using Rally and GitHub
|
||||
- Developed and maintained features for internal logistics web platform using PHP and MySQL
|
||||
- Worked in weeklong sprints using an Agile workflow
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# Experience entry for Implico Inc.
|
||||
# This seeds the Experience timeline. The narrative bullets below are the spec's
|
||||
# template bullets; Greg should replace the bracketed placeholders with real
|
||||
# outcomes, metrics, and dates. See docs/CONTENT.md for how to add more roles.
|
||||
company: "Drexel University College of Computing and Informatics"
|
||||
title: "Assistant System Administrator"
|
||||
period: "Sep 2016 - Mar 2017"
|
||||
focus: "Assisted students and teachers with technical issues and supported the college's technical infrastructure."
|
||||
icon: "briefcase"
|
||||
order: 15
|
||||
---
|
||||
|
||||
Key contributions:
|
||||
|
||||
- Maintained and improved college wide linux server network using Ansible, Bash, Python, and MySQL
|
||||
- Used ceph storage system to backup and protect college wide data
|
||||
- Handled technical issues for faculty and staff
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# Experience entry for Implico Inc.
|
||||
# This seeds the Experience timeline. The narrative bullets below are the spec's
|
||||
# template bullets; Greg should replace the bracketed placeholders with real
|
||||
# outcomes, metrics, and dates. See docs/CONTENT.md for how to add more roles.
|
||||
company: "Implico Inc."
|
||||
title: "Software Developer / Lead Developer / BTP Cloud and Mobile App Lead"
|
||||
period: "Mar 2019 - Present"
|
||||
focus: "BTP, Cloud, and Mobile application development, SAP-adjacent solutions."
|
||||
icon: "briefcase"
|
||||
order: 10
|
||||
---
|
||||
|
||||
Current expansion: **SAP Business Technology Platform (BTP)**.
|
||||
|
||||
Key contributions:
|
||||
|
||||
- Lead development on Carrier Connect, delivering a robust 3rd party carrier logistics tool.
|
||||
- Architect and maintain multiple carrier integration systems, supporting over 1000 active users and 100 companies.
|
||||
- Collaborate with cross-functional teams to translate business requirements into technical solutions.
|
||||
- Mentor junior developers and establish coding standards and review practices.
|
||||
- Drive adoption of new technologies and frameworks within the team.
|
||||
- Maintain responsible AI usage guidelines in an every changing ecosystem.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Age of Empires"
|
||||
platform: "PC"
|
||||
year: 1997
|
||||
art: "/images/games/age-of-empires.jpg" # run: node scripts/fetch-box-art.mjs "Balatro"
|
||||
blurb: >
|
||||
A masterful real time strategy game using historic civilizations from cavemen to castles. My uncle showed me this game and never looked back, I would beg for my family laptop and wake up early to get time in before school. While I could say I learned history playing this game, I mostly learned to run around going "Woo-lu-loo".
|
||||
order: 50
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Ark: Survival Evolved"
|
||||
platform: "Steam"
|
||||
year: 2017
|
||||
art: "/images/games/ark-survival-evolved.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
|
||||
blurb: >
|
||||
Survival crafting game with dinosaurs. While I do not love the practices of the studio, I do love taming and surviving with dinosaurs. I found an online community here when I was not in the greatest place and learned a lot about myself in the process.
|
||||
order: 50
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Dave the Diver"
|
||||
platform: "Steam"
|
||||
year: 2023
|
||||
art: "/images/games/dave-the-diver.jpg" # run: node scripts/fetch-box-art.mjs "Balatro"
|
||||
blurb: >
|
||||
A casual, singleplayer adventure RPG featuring deep-sea exploration and fishing during the day and sushi restaurant management at night. This game had no right being as good as it was. I was addicted to this game for a weeks straight.
|
||||
order: 50
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Elden Ring"
|
||||
platform: "PlayStation 5"
|
||||
year: 2022
|
||||
art: "/images/games/elden-ring.jpg" # run: node scripts/fetch-box-art.mjs "Elden Ring"
|
||||
blurb: >
|
||||
Fantasy Action RPG. The game that made me finish a Souls-like. So good I did it twice! Arcane Blood build, RoB my darling, and an Intelligence Moonveil build, living out my Ichigo arc.
|
||||
order: 40
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Granblue Fantasy: Relink"
|
||||
platform: "Steam"
|
||||
year: 2024
|
||||
art: "/images/games/granblue-relink.jpg" # run: node scripts/fetch-box-art.mjs "Elden Ring"
|
||||
blurb: >
|
||||
Fantasy Action RPG that I lost months of my life to. The gameplay loop of awesome characters with swords fighting boss raids from giant dragons to mech world ending creatures had me hooked. Mastering the dodgeing and ability combinations of some characters was so rewarding. Once you start attack canceling across the battlefield as Siegfried to gurantee your big hits, its hard to stop.
|
||||
order: 10
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Knights of the Old Republic"
|
||||
platform: "Steam"
|
||||
year: 2003
|
||||
art: "/images/games/star-wars-knights-of-the-old-republic.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
|
||||
blurb: >
|
||||
Fantastic RPG for peak BioWare. My own lightsaber, my own choices, my own force power and awesome companions? My nerd cup runeth over. Plus one of the greatest reveals in gaming. I wish I could replay it knowing nothing. I loved the second one and the MMO as well.
|
||||
order: 60
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Minecraft"
|
||||
platform: "PC"
|
||||
year: 2010
|
||||
art: "/images/games/minecraft.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
|
||||
blurb: >
|
||||
The crafting game. I picked up a copy of Infdev minecraft from a highschool girlfriend's cousin, and that is the part of the relationship that changed my life. I have spent untold hours building and surviving and modding an endless world of blocks and I wouldn't trade a second of it.
|
||||
order: 40
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Pokemon"
|
||||
platform: "Gameboy"
|
||||
year: 1998
|
||||
art: "/images/games/pok-mon-crystal-version.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
|
||||
blurb: >
|
||||
The definitive creature collector and battling series. Pokemon Crystal was the first video game that was mine. And I loved it. While Emerald, Platnum, and even Violet might be my more prefered versions, I am a pokemon fan through and through.
|
||||
order: 10
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Satisfactory"
|
||||
platform: "Steam"
|
||||
year: 2024
|
||||
art: "/images/games/satisfactory.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
|
||||
blurb: >
|
||||
An openworld automation and management game where the wit of your AI overseer meets the harsh realities of an alien planet. There is just something about beatifully lined up conveyer belts feeding an optimized factory with trains that actually run on schedule that I lose hours to.
|
||||
order: 60
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Sly Cooper"
|
||||
platform: "Playstation 2"
|
||||
year: 2002
|
||||
art: "/images/games/sly-cooper-and-the-thievius-raccoonus.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
|
||||
blurb: >
|
||||
A wonderful cartoony stealth game with platformer elements. The first game I ever 100% and one of my favorite game series to this day. I will happily drop everything and replay these games at almost any moment.
|
||||
order: 20
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Stardew Valley"
|
||||
platform: "Nintendo Switch"
|
||||
year: 2016
|
||||
art: "/images/games/stardew-valley.jpg" # run: node scripts/fetch-box-art.mjs "Stardew Valley"
|
||||
blurb: >
|
||||
The coziest farming sim I have ever played. I have sunk way too many hours into being a blueberry mogal and firmly believe I could run a farm if I could operate it from a switch controller.
|
||||
order: 30
|
||||
---
|
||||
@@ -0,0 +1,17 @@
|
||||
# Games Greg is actively playing right now. The "Now playing" section on the
|
||||
# Gaming page reads from this list. Edit freely — add, remove, or reorder.
|
||||
# `platform` drives the icon shown on the card.
|
||||
#
|
||||
# Run `node scripts/fetch-box-art.mjs "<title>"` to pull box/banner art for
|
||||
# Hall of Fame entries (this list is title + platform only).
|
||||
games:
|
||||
- title: "PalWorld"
|
||||
platform: "Steam"
|
||||
- title: "Gloomhaven"
|
||||
platform: "Steam"
|
||||
- title: "Pokopia"
|
||||
platform: "Switch 2"
|
||||
- title: "Slay the Spire 2"
|
||||
platform: "Steam"
|
||||
- title: "Indiana Jones and the Great Circle"
|
||||
platform: "Steam"
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# Placeholder project card.
|
||||
# This exists so the Projects grid has structure to show until Greg adds real
|
||||
# entries. To replace it: copy this file, edit the frontmatter + body, and bump
|
||||
# `placeholder: false`. Or just delete this file and add your own. See
|
||||
# docs/CONTENT.md. The schema lives in src/content.config.ts.
|
||||
title: "Carrier Connect"
|
||||
role: "Lead Developer and Architect"
|
||||
tech: ["JavaScript", "Node.js", "SAP BTP", "SAP CAP", "PostgreSQL"]
|
||||
summary: >
|
||||
A complex suite of CAP applications and associated mobile application endpoints hosted on SAP BTP to enable 3rd party carrier integration into proprietary logistics software. Includes order and trip delegation, BOL and Delivery record tracking, image processing, location inventory polling, geofencing, exception management, CSV upload and download, and RestfulAPIs.
|
||||
order: 105
|
||||
placeholder: false
|
||||
---
|
||||
|
||||
Drop in a real project writeup here. Markdown is supported, including headings,
|
||||
lists, code blocks, and links. Keep the frontmatter summary short (2-3
|
||||
sentences) and use this body for the longer story if you want case-study depth.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# Placeholder project card.
|
||||
# This exists so the Projects grid has structure to show until Greg adds real
|
||||
# entries. To replace it: copy this file, edit the frontmatter + body, and bump
|
||||
# `placeholder: false`. Or just delete this file and add your own. See
|
||||
# docs/CONTENT.md. The schema lives in src/content.config.ts.
|
||||
title: "Cosmos"
|
||||
role: "Lead Developer / Architect / Admin"
|
||||
tech:
|
||||
[
|
||||
"OpenClaw",
|
||||
"Node.js",
|
||||
"Docker",
|
||||
"Python",
|
||||
"Nginx Proxy Manager",
|
||||
"LLM",
|
||||
"Discord",
|
||||
"Next.js",
|
||||
"NextCloud",
|
||||
"Kavita",
|
||||
"Gitea",
|
||||
"Home Assistant",
|
||||
"PiHole",
|
||||
]
|
||||
summary: >
|
||||
Converted a Lenovo Tiny workstation computer into a homelab server hosting multiple containerized tools from PiHole to Home Assistant. Supports a suite of 17 AI Agents managed utilizing OpenClaw and a shared knowledge repository to handle personal assistant, research, and coding tasks.
|
||||
order: 101
|
||||
placeholder: false
|
||||
---
|
||||
|
||||
Drop in a real project writeup here. Markdown is supported, including headings,
|
||||
lists, code blocks, and links. Keep the frontmatter summary short (2-3
|
||||
sentences) and use this body for the longer story if you want case-study depth.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# Placeholder project card.
|
||||
# This exists so the Projects grid has structure to show until Greg adds real
|
||||
# entries. To replace it: copy this file, edit the frontmatter + body, and bump
|
||||
# `placeholder: false`. Or just delete this file and add your own. See
|
||||
# docs/CONTENT.md. The schema lives in src/content.config.ts.
|
||||
title: "Driver App"
|
||||
role: "Lead Developer / Architect"
|
||||
tech: ["React", "React Native", "SAP BTP"]
|
||||
summary: >
|
||||
A react native mobile application designed for use with the Carrier Connect suite. Accepts order and trip delegation from the back end suite, handles geofencing, exception management, delivery and order progress tracking, image and signature capture and upload. Designed to fit into existing driver workflows.
|
||||
order: 110
|
||||
placeholder: false
|
||||
---
|
||||
|
||||
Drop in a real project writeup here. Markdown is supported, including headings,
|
||||
lists, code blocks, and links. Keep the frontmatter summary short (2-3
|
||||
sentences) and use this body for the longer story if you want case-study depth.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# Placeholder project card.
|
||||
# This exists so the Projects grid has structure to show until Greg adds real
|
||||
# entries. To replace it: copy this file, edit the frontmatter + body, and bump
|
||||
# `placeholder: false`. Or just delete this file and add your own. See
|
||||
# docs/CONTENT.md. The schema lives in src/content.config.ts.
|
||||
title: "OmniTracker"
|
||||
role: "Lead Developer / Architect"
|
||||
tech: ["TypeScript", "Node.js", "NFC tags", "Tasker", "PostgreSQL"]
|
||||
summary: >
|
||||
A responsive web application designed to track beverage, supplement, and medication intake for personal needs with minimal friction of data entry. Tracks and estimate caffeine levels, medication timing, and supplement schedules. Calculates calories, caffeine, sugar, sodium, and other nutrional information utilizing public endpoints and configurable item cataloges. Utilizes NFC tags to log events throughout personal environment along with endpoints and AI MCP configuration for personal assistant AI integration.
|
||||
order: 120
|
||||
placeholder: false
|
||||
---
|
||||
|
||||
Drop in a real project writeup here. Markdown is supported, including headings,
|
||||
lists, code blocks, and links. Keep the frontmatter summary short (2-3
|
||||
sentences) and use this body for the longer story if you want case-study depth.
|
||||
@@ -0,0 +1,302 @@
|
||||
[
|
||||
{
|
||||
"name": "Wyrmspan",
|
||||
"bggId": 410201,
|
||||
"note": "Engine-building with dragons. A Wingspan reimplementation with slightly more complex mechanics.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic7947338.png"
|
||||
},
|
||||
{
|
||||
"name": "Spirit Island",
|
||||
"bggId": 162886,
|
||||
"note": "Cooperative area control. Play as spirits defending an island from invaders. Highly complex and strategic.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic7013651.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Codenames",
|
||||
"bggId": 178900,
|
||||
"note": "Party word game. Teams compete to find their agents using one-word clues.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic2856478.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Codenames: Duet",
|
||||
"bggId": 224037,
|
||||
"note": "Two-player cooperative version of Codenames. Work together to find all the agents.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic3550436.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Coup",
|
||||
"bggId": 131357,
|
||||
"note": "Deduction and bluffing game. Each player has two roles; claim actions and challenge others.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic1892875.jpg"
|
||||
},
|
||||
{
|
||||
"name": "The Resistance",
|
||||
"bggId": 41114,
|
||||
"note": "Social deduction hidden roles game. Resistance members vs spies trying to sabotage missions.",
|
||||
"image": "https://cf.geekdo-images.com/cAPTkL2BR3snLO71dkH8rw__imagepage/img/IOY562LZXFjsmcVioY5c_r70Ebc=/fit-in/900x600/filters:no_upscale():strip_icc()/pic2576459.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Ticket to Ride",
|
||||
"bggId": 9209,
|
||||
"note": "Route-building set collection. Collect train cards to claim railway routes across the country.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic1000656.jpg"
|
||||
},
|
||||
{
|
||||
"name": "7 Wonders",
|
||||
"bggId": 68448,
|
||||
"note": "Card-drafting civilization building. Build one of the seven wonders through three ages.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic869179.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Pandemic",
|
||||
"bggId": 30549,
|
||||
"note": "Cooperative strategy. Work together to stop global disease outbreaks before they spread too far.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic398633.jpg"
|
||||
},
|
||||
{
|
||||
"name": "King of Tokyo",
|
||||
"bggId": 70323,
|
||||
"note": "Dice-rolling monster combat. Be the monster who earns 20 points or eliminates the competition.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic1142193.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Carcassonne",
|
||||
"bggId": 822,
|
||||
"note": "Tile-placement medieval city building. Place tiles to build the city and claim features for points.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic830344.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Sagrada",
|
||||
"bggId": 199561,
|
||||
"note": "Dice-drafting stained glass window building. Draft colored dice to complete patterns on your window.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic3297563.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Small World",
|
||||
"bggId": 40692,
|
||||
"note": "Area control fantasy civilization. Races rise and fall as you compete for territory across the map.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic699500.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dead of Winter",
|
||||
"bggId": 150376,
|
||||
"note": "Cooperative survival with betrayal. Survive the zombie apocalypse while completing secret objectives.",
|
||||
"image": "https://cf.geekdo-images.com/g4mV4BH-ZrhMUVgil-yV1A__imagepage/img/CJ6erNGmKfFGhR6ZbxXbBI7XaoE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3016500.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Betrayal at the House on the Hill",
|
||||
"bggId": 10547,
|
||||
"note": "Traitor horror exploration. Explore a haunted mansion until one betrays the others in a unique haunt.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic302310.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Betrayal at Baldur's Gate",
|
||||
"bggId": 228660,
|
||||
"note": "Traitor D&D horror. Betrayal set in the Forgotten Realms with fantasy-themed haunts.",
|
||||
"image": "https://cf.geekdo-images.com/O6TfqmDWCkJvZPZw6pcxpg__imagepage/img/Zif6XP4Ij1qxBH2vHB6V2jBXFHk=/fit-in/900x600/filters:no_upscale():strip_icc()/pic6552099.png"
|
||||
},
|
||||
{
|
||||
"name": "Lords of Waterdeep",
|
||||
"bggId": 110327,
|
||||
"note": "Worker placement D&D city management. Assign adventurers to quests to score points for your secret lord.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic1204394.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Mysterium",
|
||||
"bggId": 181304,
|
||||
"note": "Cooperative murder mystery. One ghost communicates clues through abstract art; others guess the murderer.",
|
||||
"image": "https://cf.geekdo-images.com/1nQ3ZKudtDeAP7IiKE-kNg__imagepage/img/scbgJc5EdSpHJGxGbV9dgpxj9hQ=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8625343.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hanabi",
|
||||
"bggId": 98778,
|
||||
"note": "Cooperative card game where you see everyone's cards but your own. Work together to build the perfect firework display.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic1598898.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Forbidden Island",
|
||||
"bggId": 65244,
|
||||
"note": "Cooperative adventure survival. Work together to collect treasures from a sinking island before it's too late.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic711958.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Sushi Go Deluxe",
|
||||
"bggId": 192291,
|
||||
"note": "Card-drafting sushi set collection. Pick sushi cards to build the best meal; deluxe version with more cards.",
|
||||
"image": "https://cf.geekdo-images.com/2f9uTicUSXkdPp2Yks6zFw__imagepage/img/BeU9AH0pcKCbXi7ViuC87zS1WfI=/fit-in/900x600/filters:no_upscale():strip_icc()/pic5885689.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Evolution (with climate)",
|
||||
"bggId": 182134,
|
||||
"note": "Species evolution strategy. Build species with traits to adapt to changing food sources and climate.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic2696435.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Quacks of Quedlinburg (Deluxe)",
|
||||
"bggId": 244521,
|
||||
"note": "Push-your luck potion brewing. Draw ingredient chips from your bag without exploding your cauldron.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic3953887.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dragonfire",
|
||||
"bggId": 226501,
|
||||
"note": "D&D deck-building cooperative. Build your deck and defend the North from dragons and monsters.",
|
||||
"image": "https://cf.geekdo-images.com/ETvzoSRh-UuJJbdSt3ZiCA__imagepage/img/u0qsox3zSpjQpaf_PlsgFUkaJvA=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4022104.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Mistborn: The Deckbuilding Game",
|
||||
"bggId": 422780,
|
||||
"note": "Cosmere deck-building strategy. Burn metals in fast-paced allomantic battles in the Final Empire.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic8148911.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Doomlings",
|
||||
"bggId": 324413,
|
||||
"note": "Card game set collection with extinction events. Build species traits through ages until doomsday arrives.",
|
||||
"image": "https://cf.geekdo-images.com/v3c8Z12rTB_0lHndnnKZIw__imagepage/img/Lagw12YQdebM_YXbyCyAUPGyo1w=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8493673.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dragonology Gems",
|
||||
"bggId": 26617,
|
||||
"note": "Dragon-themed set collection. Collect gems to complete dragon research sets.",
|
||||
"image": "https://cf.geekdo-images.com/6_Bkzj3aJL-A-tDU0TsJjw__imagepage/img/mfZSMBTnW_l-qrpxSrKzxi4e3kM=/fit-in/900x600/filters:no_upscale():strip_icc()/pic174922.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Heroes of Barcadia",
|
||||
"bggId": 341027,
|
||||
"note": "Dungeon crawl party game. Progress through arcade-themed dungeon rooms and drink along the way.",
|
||||
"image": "https://cf.geekdo-images.com/htK1yU55RTMFIpT2ivdN2w__imagepage/img/kQBLS2DHkRtJfR0MgrhsNdvGBlU=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8505535.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Icarus",
|
||||
"bggId": 374025,
|
||||
"note": "Storytelling civilization collapse. Build a civilization through history, then try to prevent its fall.",
|
||||
"image": "https://cdn11.bigcommerce.com/s-kftzvkkgjv/images/stencil/1280x1280/products/172/826/Icarus_3D_2000px_x_2000px__57865.1615050805.jpg?c=1"
|
||||
},
|
||||
{
|
||||
"name": "Epic Spellwars of the Battle Wizards: Mt. Skullzfire",
|
||||
"bggId": 112686,
|
||||
"note": "Over-the-top card combat wizard battle. Combine spell cards to attack other wizards in gory absurdity.",
|
||||
"image": "https://cf.geekdo-images.com/Da8TXGBq3UjKINgTRIZY9A__imagepage/img/RHaGhriDSnGO1KwYugXqWJevDRE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic1170265.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Lost Cities: Duel",
|
||||
"bggId": 50,
|
||||
"note": "Two-player expedition card game. Compete to complete expeditions worth more than your opponent's.",
|
||||
"image": "https://cf.geekdo-images.com/AL5D-dXabY-Lk3PqIFk_0g__imagepage/img/co0J-4Ll2G4zRgAsA6BeneHb8Oc=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4597095.jpg"
|
||||
},
|
||||
{
|
||||
"name": "The Fox in the Forest",
|
||||
"bggId": 221965,
|
||||
"note": "Two-player trick-taking. Win tricks to score points, but too many wins can hurt your total.",
|
||||
"image": "https://cf.geekdo-images.com/spcy6nvsfq-hxYgwEpmidQ__imagepage/img/Z1bf4RMtxWZwVmu_WWyjQ5CBE98=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3496085.jpg"
|
||||
},
|
||||
{
|
||||
"name": "One Night Werewolf",
|
||||
"bggId": 147949,
|
||||
"note": "Fast-paced social deduction. Single night of werewolf chaos; vote to eliminate the werewolf.",
|
||||
"image": "https://cf.geekdo-images.com/rqLju4uaZq-G9z4g91aPPQ__imagepage/img/dPOMB-k_9xeqFMIhWT6uut3t4ug=/fit-in/900x600/filters:no_upscale():strip_icc()/pic8783294.png"
|
||||
},
|
||||
{
|
||||
"name": "What Do You Meme?",
|
||||
"bggId": 226610,
|
||||
"note": "Party game matching caption cards to meme photos. Create the funniest meme combinations.",
|
||||
"image": "https://cf.geekdo-images.com/5Pmk8PrpMHPMg0hG8fJz9w__imagepage/img/cCVLnA23lUzKCb3FvkdHVJhN0XU=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3749720.png"
|
||||
},
|
||||
{
|
||||
"name": "If You Know You Know",
|
||||
"bggId": 404056,
|
||||
"note": "Pop culture trivia party game. Guess phrases from partial information in categories.",
|
||||
"image": "https://cf.geekdo-images.com/LW2HSiE2smy8rPQCispd6A__imagepage/img/OjRMfXUc8GVaGU47N69OO5uejII=/fit-in/900x600/filters:no_upscale():strip_icc()/pic7804814.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Cards Against Humanity",
|
||||
"bggId": 50381,
|
||||
"note": "Offensive humor party game. Fill in the blank with the most inappropriate card from your hand.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic899330.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hues and Cues",
|
||||
"bggId": 302520,
|
||||
"note": "Color communication party game. Give one- or two-word cues for others to guess a specific color.",
|
||||
"image": "hhttps://cf.geekdo-images.com/jdR8WW75HkaoHGMTCIx9lA__imagepage/img/n2OeBC_N0lqSHG_0_fbta1jgeWs=/fit-in/900x600/filters:no_upscale():strip_icc()/pic5390676.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Wavelength",
|
||||
"bggId": 262543,
|
||||
"note": "Telepathy party game. Give clues to help your team locate a hidden concept on a spectrum.",
|
||||
"image": "https://cf.geekdo-images.com/z4fbPdmJg_5yphJEvql4ZA__imagepage/img/V_3GqWteqDmL8C3ZLRoDXbJKKRo=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4552862.png"
|
||||
},
|
||||
{
|
||||
"name": "Lost in Translation",
|
||||
"bggId": 271305,
|
||||
"note": "Word communication party game. Describe words while avoiding forbidden translations and synonyms.",
|
||||
"image": "https://cf.geekdo-images.com/xLJq_px7T4YRjsnFdXsFCQ__imagepage/img/h6FAV9n-FE5lcsCUijw5X_Mpr0o=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4824635.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Risk Legacy",
|
||||
"bggId": 105134,
|
||||
"note": "Legacy strategy global conquest. Write on the board, open sealed packets, and evolve the game over plays.",
|
||||
"image": "https://cf.geekdo-images.com/LxKu8_oihOQftb2npGvWMw__imagepage/img/hcbhN16q2weOA-OsaD-F7iJyNtk=/fit-in/900x600/filters:no_upscale():strip_icc()/pic1196938.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Risk",
|
||||
"bggId": 181,
|
||||
"note": "Classic strategy global domination. Conquer territories and eliminate opponents in military world conquest.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic1059899.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Age of Mythology: The Board Game",
|
||||
"bggId": 6707,
|
||||
"note": "Area control mythological warfare. Recruit armies, build structures, and invoke gods' favor.",
|
||||
"image": "https://cf.geekdo-images.com/1WIWJ8FPafupWGPOJzWglA__imagepage/img/0H-mabGido-zuUL8PuM56GuBYto=/fit-in/900x600/filters:no_upscale():strip_icc()/pic31542.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Tsuro",
|
||||
"bggId": 16992,
|
||||
"note": "Tile placement path following. Lay tiles to create paths; stay on the board while others fall off.",
|
||||
"image": "https://cf.geekdo-images.com/2V3d4ryhxkS3RoPtDrvpUw__imagepage/img/GMhJZek8dZtWNXZr9fF0OXhSC2c=/fit-in/900x600/filters:no_upscale():strip_icc()/pic875761.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kings Cup",
|
||||
"bggId": 359076,
|
||||
"note": "Drinking game with card-based rules. Draw cards and follow the associated drinking challenges.",
|
||||
"image": "https://cf.geekdo-images.com/RZkEtStNVOBO18_Mnuo8mA__imagepage/img/jsXYONDRNqbgAGOOkdry3DzzBrE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic6755735.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Pocket Farkle",
|
||||
"bggId": 3181,
|
||||
"note": "Dice game push your luck. Roll dice to score points, but risk losing everything if you push too far.",
|
||||
"image": "https://cf.geekdo-images.com/CKRu4CRpDan3G2tgvEEVrA__imagepage/img/FddbnLCq0lP8Ww7VKCgASR1cAmE=/fit-in/900x600/filters:no_upscale():strip_icc()/pic3624966.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Pokertiles",
|
||||
"bggId": 13327,
|
||||
"note": "Abstract tile strategy game. Form poker hands using numbered tiles on a board.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic113334.jpg"
|
||||
},
|
||||
{
|
||||
"name": "The Perilous Parlor Game",
|
||||
"bggId": 6235,
|
||||
"note": "Victorian mystery party game. Deduce the culprit through clues and interactions in a parlor setting.",
|
||||
"image": "https://cf.geekdo-images.com/XwoUZLCHvSdNE2CD_qCSTA__imagepage/img/qXd6ewvgo8IyoCdm4VE6_YVCMIs=/fit-in/900x600/filters:no_upscale():strip_icc()/pic308160.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Deception: Murder in Hong Kong",
|
||||
"bggId": 156129,
|
||||
"note": "Social deduction murder mystery. Forensic scientists identify the killer while one investigator secretly helps.",
|
||||
"image": "https://cf.geekdo-images.com/original/pic2037351.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Deception: Undercover Allies",
|
||||
"bggId": 227748,
|
||||
"note": "Expansion for Deception: Murder in Hong Kong. Adds new roles and additional investigator options.",
|
||||
"image": "https://cf.geekdo-images.com/Q0ymbSxvWcA7_g55ns96Yg__imagepage/img/Jt7h-vdjkp0ZvFYWMH1p8O7ifZ4=/fit-in/900x600/filters:no_upscale():strip_icc()/pic4199680.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Settlers of Catan (expansion)",
|
||||
"bggId": 13,
|
||||
"note": "Expansion for Settlers of Catan. Adds additional gameplay content to the base Catan experience.",
|
||||
"image": "https://cf.geekdo-images.com/0XODRpReiZBFUffEcqT5-Q__imagepage/img/enC7UTvCAnb6j1Uazvh0OBQjvxw=/fit-in/900x600/filters:no_upscale():strip_icc()/pic9156909.png"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,683 @@
|
||||
[
|
||||
{
|
||||
"title": "Demon Slayer",
|
||||
"favorite": true,
|
||||
"blurb": "Ufotable's animation is on another planet and the Tanjiro/Nezuko bond at the center actually earns the spectacle. Every fight is a showcase. All four seasons down and I'd rewatch tomorrow.",
|
||||
"image": "/images/anime/kimetsu-no-yaiba.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Demon Slayer",
|
||||
"category": "Major Modern Hits",
|
||||
"status": "Completed",
|
||||
"note": "All 4 seasons. Ufotable's animation is legendary. Tanjiro and Nezuko's relationship is the emotional core."
|
||||
},
|
||||
{
|
||||
"title": "Jujutsu Kaisen",
|
||||
"category": "Major Modern Hits",
|
||||
"status": "Completed",
|
||||
"note": "Through Season 2. The Shibuya Incident arc redefined what shonen anime can do: dark, unpredictable, devastating."
|
||||
},
|
||||
{
|
||||
"title": "Attack on Titan",
|
||||
"category": "Major Modern Hits",
|
||||
"status": "Completed",
|
||||
"note": "Full series. Dark fantasy with political themes and shocking twists. The basement reveal redefined the series."
|
||||
},
|
||||
{
|
||||
"title": "My Hero Academia",
|
||||
"category": "Major Modern Hits",
|
||||
"favorite": true,
|
||||
"status": "Caught up",
|
||||
"note": "Through Season 6. Character-driven with incredible growth arcs. The war arc is darker.",
|
||||
"image": "/images/anime/my-hero-academia.jpg",
|
||||
"blurb": "The superhero school I didn't know I wanted. Deku's growth is the engine, and the war arc goes hard. Caught up through Season 6 and still rooting for these kids."
|
||||
},
|
||||
{
|
||||
"title": "Chainsaw Man",
|
||||
"category": "Major Modern Hits",
|
||||
"status": "Completed",
|
||||
"note": "Full series. Dark, chaotic, unique. Denji's journey from poverty to hope is compelling."
|
||||
},
|
||||
{
|
||||
"title": "Black Clover",
|
||||
"category": "Major Modern Hits",
|
||||
"status": "Completed",
|
||||
"note": "Full series. Underdog shonen that gets better as it develops."
|
||||
},
|
||||
{
|
||||
"title": "Dragon Ball",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Original series."
|
||||
},
|
||||
{
|
||||
"title": "Dragon Ball Z",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Legendary shonen. Saiyans, Frieza, Cell, Buu."
|
||||
},
|
||||
{
|
||||
"title": "Naruto",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Ninja, friendship, power."
|
||||
},
|
||||
{
|
||||
"title": "Naruto Shippuden",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Mature sequel with war arc."
|
||||
},
|
||||
{
|
||||
"title": "YuYu Hakusho",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Classic 90s shonen with Yusuke becoming Spirit Detective."
|
||||
},
|
||||
{
|
||||
"title": "Sailor Moon",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Classic magical girl."
|
||||
},
|
||||
{
|
||||
"title": "Death Note",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Detective thriller with supernatural death note."
|
||||
},
|
||||
{
|
||||
"title": "Cowboy Bebop",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Space western bounty hunters. Style, jazz, melancholy. Watched both anime and Netflix live-action."
|
||||
},
|
||||
{
|
||||
"title": "One Piece",
|
||||
"category": "Classics",
|
||||
"status": "Caught up",
|
||||
"note": "Caught up through current episodes. Epic adventure spanning 1,100+ episodes."
|
||||
},
|
||||
{
|
||||
"title": "Bleach",
|
||||
"category": "Classics",
|
||||
"favorite": true,
|
||||
"status": "Completed",
|
||||
"note": "Completed including Thousand-Year Blood War arc.",
|
||||
"image": "/images/anime/bleach.jpg",
|
||||
"blurb": "My comfort shonen. The Soul Society arc is peak, and the Thousand-Year Blood War comeback proved this series still had it. Completed the whole run."
|
||||
},
|
||||
{
|
||||
"title": "Gundam (All Series)",
|
||||
"category": "Classics",
|
||||
"favorite": true,
|
||||
"status": "Completed",
|
||||
"note": "All timelines, all series. War, politics, humanity through mecha.",
|
||||
"image": "/images/anime/gundam-all-series.jpg",
|
||||
"blurb": "Every timeline, every series. I'm a sucker for the politics, the moral gray, and the mecha spectacle. Picking a favorite timeline feels like picking a favorite kid."
|
||||
},
|
||||
{
|
||||
"title": "Evangelion",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Psychological mecha deconstruction. Trauma, depression, identity."
|
||||
},
|
||||
{
|
||||
"title": "Code Geass",
|
||||
"category": "Classics",
|
||||
"favorite": true,
|
||||
"status": "Completed",
|
||||
"note": "Strategy, rebellion, moral questions. The ending is legendary.",
|
||||
"image": "/images/anime/code-geass.jpg",
|
||||
"blurb": "Lelouch is the scheming antihero blueprint. The chess battles, the twists, and that ending. THE ending. Still the gold standard for me."
|
||||
},
|
||||
{
|
||||
"title": "Fullmetal Alchemist: Brotherhood",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Perfection in shonen storytelling."
|
||||
},
|
||||
{
|
||||
"title": "Hunter x Hunter",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Complex battle shonen with Nen power system. The Chimera Ant arc is philosophical."
|
||||
},
|
||||
{
|
||||
"title": "Saint Seiya",
|
||||
"category": "Classics",
|
||||
"status": "Completed",
|
||||
"note": "Classic shonen with saints and cloths."
|
||||
},
|
||||
{
|
||||
"title": "That Time I Got Reincarnated as a Slime",
|
||||
"category": "Isekai & Fantasy",
|
||||
"favorite": true,
|
||||
"status": "Completed",
|
||||
"note": "Nation-building focus over battles. Also reading light novels (21/23 volumes).",
|
||||
"image": "/images/anime/that-time-i-got-reincarnated-as-a-slime.jpg",
|
||||
"blurb": "Started as a joke premise and turned into the best nation-building power fantasy in the genre. Rimuru's crew is pure comfort. Also 21/23 light novels deep."
|
||||
},
|
||||
{
|
||||
"title": "Overlord",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Protagonist becomes actual overlord. Political intrigue and power fantasy. Also reading light novels (16/18 volumes)."
|
||||
},
|
||||
{
|
||||
"title": "Re:Zero",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Time-loop psychological thriller. Subaru's growth is compelling. Also reading light novels (26-28/44+ volumes)."
|
||||
},
|
||||
{
|
||||
"title": "Ascendance of a Bookworm",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Unique isekai with commerce and innovation focus rather than combat."
|
||||
},
|
||||
{
|
||||
"title": "Solo Leveling",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Korean webnovel turned anime/manhwa. Protagonist gains power through system. Consumed in all formats."
|
||||
},
|
||||
{
|
||||
"title": "Log Horizon",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Thousands trapped in MMORPG become reality. Focus on politics and society building."
|
||||
},
|
||||
{
|
||||
"title": "No Game No Life",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Everything decided by games. Vibrant colors, clever strategies."
|
||||
},
|
||||
{
|
||||
"title": "Yona of the Dawn",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Princess exiled, gathers allies. Character-driven fantasy romance."
|
||||
},
|
||||
{
|
||||
"title": "Kakegurui",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Gambling school where status determined by gambling. Psychological, dark humor."
|
||||
},
|
||||
{
|
||||
"title": "Rising of the Shield Hero",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Summoned as Shield Hero, faces betrayal. Darker isekai."
|
||||
},
|
||||
{
|
||||
"title": "How a Realist Hero Rebuilt the Kingdom",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Isekai focusing on kingdom management and economics."
|
||||
},
|
||||
{
|
||||
"title": "The Strongest Sage with the Weakest Crest",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Reincarnated with low stats, technique overcomes limits."
|
||||
},
|
||||
{
|
||||
"title": "Plunderer",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Post-apocalyptic fantasy with unique power system."
|
||||
},
|
||||
{
|
||||
"title": "A Returner's Magic Should Be Special",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Magical academy with regression trope."
|
||||
},
|
||||
{
|
||||
"title": "Knights and Magic",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Mecha fantasy."
|
||||
},
|
||||
{
|
||||
"title": "The Unwanted Undead Adventurer",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Supporting character becomes main. Short, sweet, complete."
|
||||
},
|
||||
{
|
||||
"title": "Black Summoner",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Dungeon fantasy with overpowered protagonist."
|
||||
},
|
||||
{
|
||||
"title": "The Irregular at Magic High School",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Magic school where student has uncommon power."
|
||||
},
|
||||
{
|
||||
"title": "Fire Force",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Firefighters fighting flames from human combustion."
|
||||
},
|
||||
{
|
||||
"title": "Psycho-Pass",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Cyberpunk dystopia where potential for crime measured. Philosophical, dark."
|
||||
},
|
||||
{
|
||||
"title": "Parasyte",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Alien parasites taking over human bodies. Body horror, philosophy."
|
||||
},
|
||||
{
|
||||
"title": "World Trigger",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Invaders and defense force using \"triggers.\" Tactical, strategic."
|
||||
},
|
||||
{
|
||||
"title": "Aldnoah.Zero",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Mecha war between Earth and Vers. Political, tactical."
|
||||
},
|
||||
{
|
||||
"title": "Blood Blockade Battlefront",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Supernatural city with unusual powers. Stylish, action-packed."
|
||||
},
|
||||
{
|
||||
"title": "Hellsing",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Vampire hunters fighting vampires. Over-the-top, violent."
|
||||
},
|
||||
{
|
||||
"title": "Goblin Slayer",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Dark fantasy adventurer whose focus is killing goblins. Trauma-informed."
|
||||
},
|
||||
{
|
||||
"title": "Berserk of Gluttony",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Fantasy with unique power system involving eating."
|
||||
},
|
||||
{
|
||||
"title": "Blue Exorcist",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Protagonist is son of Satan but becomes exorcist."
|
||||
},
|
||||
{
|
||||
"title": "Fate/Stay Night: Unlimited Blade Works",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Visual novel adaptation with Holy Grail War."
|
||||
},
|
||||
{
|
||||
"title": "Fate/Zero",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Prequel exploring previous Holy Grail War. Complex, philosophical."
|
||||
},
|
||||
{
|
||||
"title": "The God of High School",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Tournament fighting. Watched in anime and webtoon."
|
||||
},
|
||||
{
|
||||
"title": "Noblesse",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Vampire nobility in modern setting. Watched in anime and webtoon."
|
||||
},
|
||||
{
|
||||
"title": "Tower of God",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Climbing mysterious tower. Watched in anime and manhwa."
|
||||
},
|
||||
{
|
||||
"title": "Vinland Saga",
|
||||
"category": "Battle & Action",
|
||||
"status": "Completed",
|
||||
"note": "Vikings, war, pacifism, redemption. Thorfinn's journey is complex and mature."
|
||||
},
|
||||
{
|
||||
"title": "Magi: The Labyrinth of Magic",
|
||||
"category": "Isekai & Fantasy",
|
||||
"favorite": true,
|
||||
"status": "Completed",
|
||||
"note": "Adventure fantasy inspired by the Arabian Nights. Dungeons, djinn, and a sprawling world that grows more political as it goes.",
|
||||
"image": "/images/anime/magi-the-labyrinth-of-magic.jpg",
|
||||
"blurb": "Arabian Nights inspired adventure that grows up as it goes. Dungeons, djinn, and a world that opens into real politics. Doesn't get enough love."
|
||||
},
|
||||
{
|
||||
"title": "Witch Hat Atelier",
|
||||
"category": "Isekai & Fantasy",
|
||||
"status": "Completed",
|
||||
"note": "Beautifully drawn fantasy about a girl who discovers magic is drawn, not born. The art alone is worth it."
|
||||
},
|
||||
{
|
||||
"title": "Erased",
|
||||
"category": "Dark & Psychological",
|
||||
"status": "Completed",
|
||||
"note": "Time travel to prevent murders. Emotional, compelling."
|
||||
},
|
||||
{
|
||||
"title": "Death Parade",
|
||||
"category": "Dark & Psychological",
|
||||
"status": "Completed",
|
||||
"note": "Afterlife bar where games determine souls. Emotional, philosophical."
|
||||
},
|
||||
{
|
||||
"title": "So I'm a Spider, So What?",
|
||||
"category": "Dark & Psychological",
|
||||
"status": "Completed",
|
||||
"note": "Isekai from spider perspective. Clever, funny, surprisingly deep."
|
||||
},
|
||||
{
|
||||
"title": "Tokyo Ghoul",
|
||||
"category": "Dark & Psychological",
|
||||
"status": "Completed",
|
||||
"note": "Half-ghoul navigating two worlds. Dark, violent, explores prejudice."
|
||||
},
|
||||
{
|
||||
"title": "Bungo Stray Dogs",
|
||||
"category": "Dark & Psychological",
|
||||
"status": "Completed",
|
||||
"note": "Detectives with powers based on literary figures."
|
||||
},
|
||||
{
|
||||
"title": "86",
|
||||
"category": "Dark & Psychological",
|
||||
"status": "Completed",
|
||||
"note": "Discriminated soldiers fighting war. Mature, powerful, heartbreaking."
|
||||
},
|
||||
{
|
||||
"title": "Akame ga Kill!",
|
||||
"category": "Dark & Psychological",
|
||||
"status": "Completed",
|
||||
"note": "Dark fantasy rebellion with high casualties."
|
||||
},
|
||||
{
|
||||
"title": "Tanya the Evil",
|
||||
"category": "Dark & Psychological",
|
||||
"status": "Completed",
|
||||
"note": "Salaryman reincarnated as magical girl officer in WWI. (Also known as Youjo Senki.)"
|
||||
},
|
||||
{
|
||||
"title": "Your Lie in April",
|
||||
"category": "Character & Romance",
|
||||
"status": "Completed",
|
||||
"note": "Music and romance. Emotional, beautiful, devastating."
|
||||
},
|
||||
{
|
||||
"title": "Spy x Family",
|
||||
"category": "Character & Romance",
|
||||
"status": "Caught up",
|
||||
"note": "Spy, assassin, telepath form fake family. Charming comedy with heart."
|
||||
},
|
||||
{
|
||||
"title": "Ancient Magus Bride",
|
||||
"category": "Character & Romance",
|
||||
"status": "Completed",
|
||||
"note": "Mature apprentice learning from ancient mage. Beautiful world-building."
|
||||
},
|
||||
{
|
||||
"title": "To Your Eternity",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Immortal being learning humanity across time. Beautiful, devastating."
|
||||
},
|
||||
{
|
||||
"title": "Ranking of Kings",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Fantasy kingdom with deaf prince protagonist."
|
||||
},
|
||||
{
|
||||
"title": "Dr. Stone",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Stone petrification, scientific reconstruction, kingdom building."
|
||||
},
|
||||
{
|
||||
"title": "The Promised Neverland",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Children's orphanage hiding dark secret. First arc excellent."
|
||||
},
|
||||
{
|
||||
"title": "Food Wars",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Cooking battles with elaborate descriptions and fanservice."
|
||||
},
|
||||
{
|
||||
"title": "Wise Man's Grandchild",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Isekai with powerful but socially inept protagonist."
|
||||
},
|
||||
{
|
||||
"title": "Reincarnated as the World's Strongest Exorcist",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Fantasy exorcism."
|
||||
},
|
||||
{
|
||||
"title": "Noragami",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Minor god doing odd jobs. Character-driven fantasy."
|
||||
},
|
||||
{
|
||||
"title": "Lord of Mysteries",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Cthulhu-esque mystery, power system."
|
||||
},
|
||||
{
|
||||
"title": "Fruits Basket",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Sohma family curse, emotional, heartwarming."
|
||||
},
|
||||
{
|
||||
"title": "Kuroko's Basketball",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Sports anime with shadow player and light player partnership."
|
||||
},
|
||||
{
|
||||
"title": "Haikyu!!",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Volleyball anime with incredible team dynamics. Character work elevates beyond sports."
|
||||
},
|
||||
{
|
||||
"title": "Digimon Adventure Tri",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Digimon reboot/remix."
|
||||
},
|
||||
{
|
||||
"title": "Zatch Bell",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Battle mamodo with spell books. (Also known as Zak Bell.)"
|
||||
},
|
||||
{
|
||||
"title": "Konosuba",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Isekai comedy with dysfunctional party."
|
||||
},
|
||||
{
|
||||
"title": "Guilty Crown",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Apocalypse, music, power systems."
|
||||
},
|
||||
{
|
||||
"title": "Record of Grancrest War",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Fantasy war and unions."
|
||||
},
|
||||
{
|
||||
"title": "SerVamp",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Vampires and servants."
|
||||
},
|
||||
{
|
||||
"title": "Prince of Tennis",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Sports anime."
|
||||
},
|
||||
{
|
||||
"title": "MAR",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Adventure fantasy."
|
||||
},
|
||||
{
|
||||
"title": "Tokyo Revengers",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Time gang time travel. Watched anime, finished manga as well."
|
||||
},
|
||||
{
|
||||
"title": "Darling in the FranXX",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Mecha fantasy romance."
|
||||
},
|
||||
{
|
||||
"title": "SK8 the Infinity",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Skateboard racing anime."
|
||||
},
|
||||
{
|
||||
"title": "Soul Eater",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Weapon/meister academy."
|
||||
},
|
||||
{
|
||||
"title": "Wistoria: Wand and Sword",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Magic academy anime."
|
||||
},
|
||||
{
|
||||
"title": "Reign of the Seven Spellblades",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Magic academy."
|
||||
},
|
||||
{
|
||||
"title": "The Unaware Atelier Meister",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Isekai atelier fantasy."
|
||||
},
|
||||
{
|
||||
"title": "Kabaneri of the Iron Fortress",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Zombies with trains."
|
||||
},
|
||||
{
|
||||
"title": "Classroom of the Elite",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Elite school mystery with hidden genius protagonist."
|
||||
},
|
||||
{
|
||||
"title": "Charlotte",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Students with powers."
|
||||
},
|
||||
{
|
||||
"title": "Sword Art Online",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "All seasons and movies."
|
||||
},
|
||||
{
|
||||
"title": "The King's Avatar",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Pro gamer returns. Watched fully, read light novel, seen all animation and live-action shows. Completed all formats."
|
||||
},
|
||||
{
|
||||
"title": "Fairy Tail",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Guild-based fantasy."
|
||||
},
|
||||
{
|
||||
"title": "Bofuri",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Defensive build in VRMMO."
|
||||
},
|
||||
{
|
||||
"title": "Kaiju No. 8",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Kaiju defense forces."
|
||||
},
|
||||
{
|
||||
"title": "Welcome to Demon School! Iruma-Kun",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Demon school fantasy."
|
||||
},
|
||||
{
|
||||
"title": "JoJo's Bizarre Adventure",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Up to Steel Ball Run."
|
||||
},
|
||||
{
|
||||
"title": "Black Butler",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Demon butler."
|
||||
},
|
||||
{
|
||||
"title": "Frieren: Beyond Journey's End",
|
||||
"category": "Unique & Specialized",
|
||||
"status": "Completed",
|
||||
"note": "Post-adventure elf fantasy."
|
||||
},
|
||||
{
|
||||
"title": "One Piece",
|
||||
"favorite": true,
|
||||
"category": "Long-Running Ongoing",
|
||||
"status": "Caught up",
|
||||
"note": "Caught up through current episodes (1,100+ episodes and ongoing).",
|
||||
"image": "/images/anime/one-piece.jpg",
|
||||
"blurb": "The commitment is the point. 1,100+ episodes and still catching me off guard. Oda planted seeds decades ago that pay off today. Fully caught up."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,792 @@
|
||||
[
|
||||
{
|
||||
"title": "The Princess Bride",
|
||||
"category": "Standalone",
|
||||
"year": 1987,
|
||||
"favorite": true,
|
||||
"blurb": "The most quotable movie ever made and I will fight anyone who disagrees. Fantasy, romance, comedy, and Rodents Of Unusual Size. As you wish.",
|
||||
"image": "/images/movies/the-princess-bride.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Contact",
|
||||
"category": "Standalone",
|
||||
"year": 1997,
|
||||
"favorite": true,
|
||||
"blurb": "Sci-fi that sits with the big questions instead of dodging them. The faith versus reason thread stuck with me long after the credits. Underrated favorite.",
|
||||
"image": "/images/movies/contact.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Howl's Moving Castle",
|
||||
"category": "Ghibli",
|
||||
"year": 2004,
|
||||
"favorite": true,
|
||||
"blurb": "Peak Ghibli warmth and Miyazaki doing what he does best: building a world you want to live in. The art alone is worth it.",
|
||||
"image": "/images/movies/howls-moving-castle.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Matrix",
|
||||
"category": "The Matrix",
|
||||
"year": 1999,
|
||||
"favorite": true,
|
||||
"blurb": "Red pill, bullet time, green digital rain. It bent my brain as a kid and it still holds up. The philosophy isn't window dressing, it's the whole movie.",
|
||||
"image": "/images/movies/the-matrix.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Matrix Reloaded",
|
||||
"category": "The Matrix",
|
||||
"year": 2003
|
||||
},
|
||||
{
|
||||
"title": "The Matrix Revolutions",
|
||||
"category": "The Matrix",
|
||||
"year": 2003
|
||||
},
|
||||
{
|
||||
"title": "The Matrix Resurrections",
|
||||
"category": "The Matrix",
|
||||
"year": 2021
|
||||
},
|
||||
{
|
||||
"title": "Kiki's Delivery Service",
|
||||
"category": "Ghibli",
|
||||
"year": 1989
|
||||
},
|
||||
{
|
||||
"title": "Spirited Away",
|
||||
"category": "Ghibli",
|
||||
"year": 2001
|
||||
},
|
||||
{
|
||||
"title": "My Neighbor Totoro",
|
||||
"category": "Ghibli",
|
||||
"year": 1988
|
||||
},
|
||||
{
|
||||
"title": "Porco Rosso",
|
||||
"category": "Ghibli",
|
||||
"year": 1992
|
||||
},
|
||||
{
|
||||
"title": "Iron Man",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2008
|
||||
},
|
||||
{
|
||||
"title": "The Incredible Hulk",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2008
|
||||
},
|
||||
{
|
||||
"title": "Iron Man 2",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2010
|
||||
},
|
||||
{
|
||||
"title": "Thor",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2011
|
||||
},
|
||||
{
|
||||
"title": "Captain America: The First Avenger",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2011
|
||||
},
|
||||
{
|
||||
"title": "The Avengers",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2012
|
||||
},
|
||||
{
|
||||
"title": "Iron Man 3",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2013
|
||||
},
|
||||
{
|
||||
"title": "Thor: The Dark World",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2013
|
||||
},
|
||||
{
|
||||
"title": "Captain America: The Winter Soldier",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2014
|
||||
},
|
||||
{
|
||||
"title": "Guardians of the Galaxy",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2014
|
||||
},
|
||||
{
|
||||
"title": "Avengers: Age of Ultron",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2015
|
||||
},
|
||||
{
|
||||
"title": "Ant-Man",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2015
|
||||
},
|
||||
{
|
||||
"title": "Captain America: Civil War",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2016
|
||||
},
|
||||
{
|
||||
"title": "Doctor Strange",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2016
|
||||
},
|
||||
{
|
||||
"title": "Spider-Man: Homecoming",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2017
|
||||
},
|
||||
{
|
||||
"title": "Thor: Ragnarok",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2017
|
||||
},
|
||||
{
|
||||
"title": "Black Panther",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Avengers: Infinity War",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2018,
|
||||
"favorite": true,
|
||||
"blurb": "The MCU at its operatic peak. Thanos is the engine and that ending is still a gut punch. Ten years of setup, paid off in one movie.",
|
||||
"image": "/images/movies/avengers-infinity-war.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Ant-Man and the Wasp",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Captain Marvel",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2019
|
||||
},
|
||||
{
|
||||
"title": "Avengers: Endgame",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2019,
|
||||
"favorite": true,
|
||||
"blurb": "The decade-long payoff. The Portals scene is the closest thing my generation has to a shared cultural moment. Worth every post-credit scene it took to get there.",
|
||||
"image": "/images/movies/avengers-endgame.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Spider-Man: Far From Home",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2019
|
||||
},
|
||||
{
|
||||
"title": "Black Widow",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2021
|
||||
},
|
||||
{
|
||||
"title": "Shang-Chi and the Legend of the Ten Rings",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2021
|
||||
},
|
||||
{
|
||||
"title": "Eternals",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2021
|
||||
},
|
||||
{
|
||||
"title": "Spider-Man: No Way Home",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2021
|
||||
},
|
||||
{
|
||||
"title": "Doctor Strange in the Multiverse of Madness",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2022
|
||||
},
|
||||
{
|
||||
"title": "Thor: Love and Thunder",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2022
|
||||
},
|
||||
{
|
||||
"title": "Black Panther: Wakanda Forever",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2022
|
||||
},
|
||||
{
|
||||
"title": "Ant-Man and the Wasp: Quantumania",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "Guardians of the Galaxy Vol. 3",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "The Marvels",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "Deadpool & Wolverine",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2024
|
||||
},
|
||||
{
|
||||
"title": "Captain America: Brave New World",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2025
|
||||
},
|
||||
{
|
||||
"title": "Thunderbolts*",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2025
|
||||
},
|
||||
{
|
||||
"title": "The Fantastic Four: First Steps",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2025
|
||||
},
|
||||
{
|
||||
"title": "Avengers: Doomsday",
|
||||
"category": "Marvel Cinematic Universe",
|
||||
"year": 2026
|
||||
},
|
||||
{
|
||||
"title": "Avatar",
|
||||
"category": "Avatar",
|
||||
"year": 2009
|
||||
},
|
||||
{
|
||||
"title": "Avatar: The Way of Water",
|
||||
"category": "Avatar",
|
||||
"year": 2022
|
||||
},
|
||||
{
|
||||
"title": "Pacific Rim",
|
||||
"category": "Pacific Rim",
|
||||
"year": 2013
|
||||
},
|
||||
{
|
||||
"title": "Pacific Rim: Uprising",
|
||||
"category": "Pacific Rim",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Knives Out",
|
||||
"category": "Knives Out",
|
||||
"year": 2019
|
||||
},
|
||||
{
|
||||
"title": "Glass Onion: A Knives Out Mystery",
|
||||
"category": "Knives Out",
|
||||
"year": 2022
|
||||
},
|
||||
{
|
||||
"title": "Wake Up Dead Man: A Knives Out Mystery",
|
||||
"category": "Knives Out",
|
||||
"year": 2025
|
||||
},
|
||||
{
|
||||
"title": "The Fast and the Furious",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2001
|
||||
},
|
||||
{
|
||||
"title": "2 Fast 2 Furious",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2003
|
||||
},
|
||||
{
|
||||
"title": "The Fast and the Furious: Tokyo Drift",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2006
|
||||
},
|
||||
{
|
||||
"title": "Fast & Furious",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2009
|
||||
},
|
||||
{
|
||||
"title": "Fast Five",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2011
|
||||
},
|
||||
{
|
||||
"title": "Fast & Furious 6",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2013
|
||||
},
|
||||
{
|
||||
"title": "Furious 7",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2015
|
||||
},
|
||||
{
|
||||
"title": "The Fate of the Furious",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2017
|
||||
},
|
||||
{
|
||||
"title": "F9: The Fast Saga",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2021
|
||||
},
|
||||
{
|
||||
"title": "Fast X",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "Hobbs & Shaw",
|
||||
"category": "Fast & Furious",
|
||||
"year": 2019
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode IV - A New Hope",
|
||||
"category": "Star Wars",
|
||||
"year": 1977
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode V - The Empire Strikes Back",
|
||||
"favorite": true,
|
||||
"category": "Star Wars",
|
||||
"year": 1980,
|
||||
"image": "/images/movies/star-wars-episode-v-the-empire-strikes-back.jpg",
|
||||
"blurb": "The one that made Star Wars mean something. The twist, the cliffhanger, Hoth, the training. The blueprint for a great sequel."
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode VI - Return of the Jedi",
|
||||
"category": "Star Wars",
|
||||
"year": 1983
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode I - The Phantom Menace",
|
||||
"category": "Star Wars",
|
||||
"year": 1999
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode II - Attack of the Clones",
|
||||
"category": "Star Wars",
|
||||
"year": 2002
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode III - Revenge of the Sith",
|
||||
"category": "Star Wars",
|
||||
"year": 2005
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode VII - The Force Awakens",
|
||||
"category": "Star Wars",
|
||||
"year": 2015
|
||||
},
|
||||
{
|
||||
"title": "Rogue One: A Star Wars Story",
|
||||
"category": "Star Wars",
|
||||
"year": 2016
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode VIII - The Last Jedi",
|
||||
"category": "Star Wars",
|
||||
"year": 2017
|
||||
},
|
||||
{
|
||||
"title": "Solo: A Star Wars Story",
|
||||
"category": "Star Wars",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Star Wars: Episode IX - The Rise of Skywalker",
|
||||
"category": "Star Wars",
|
||||
"year": 2019
|
||||
},
|
||||
{
|
||||
"title": "Saw",
|
||||
"category": "Saw",
|
||||
"year": 2004
|
||||
},
|
||||
{
|
||||
"title": "Saw II",
|
||||
"category": "Saw",
|
||||
"year": 2005
|
||||
},
|
||||
{
|
||||
"title": "Saw III",
|
||||
"category": "Saw",
|
||||
"year": 2006
|
||||
},
|
||||
{
|
||||
"title": "Saw IV",
|
||||
"category": "Saw",
|
||||
"year": 2007
|
||||
},
|
||||
{
|
||||
"title": "Saw V",
|
||||
"category": "Saw",
|
||||
"year": 2008
|
||||
},
|
||||
{
|
||||
"title": "Saw VI",
|
||||
"category": "Saw",
|
||||
"year": 2009
|
||||
},
|
||||
{
|
||||
"title": "Saw 3D: The Final Chapter",
|
||||
"category": "Saw",
|
||||
"year": 2010
|
||||
},
|
||||
{
|
||||
"title": "Jigsaw",
|
||||
"category": "Saw",
|
||||
"year": 2017
|
||||
},
|
||||
{
|
||||
"title": "Spiral: From the Book of Saw",
|
||||
"category": "Saw",
|
||||
"year": 2021
|
||||
},
|
||||
{
|
||||
"title": "Saw X",
|
||||
"category": "Saw",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "Mission: Impossible",
|
||||
"category": "Mission: Impossible",
|
||||
"year": 1996
|
||||
},
|
||||
{
|
||||
"title": "Mission: Impossible 2",
|
||||
"category": "Mission: Impossible",
|
||||
"year": 2000
|
||||
},
|
||||
{
|
||||
"title": "Mission: Impossible III",
|
||||
"category": "Mission: Impossible",
|
||||
"year": 2006
|
||||
},
|
||||
{
|
||||
"title": "Mission: Impossible - Ghost Protocol",
|
||||
"category": "Mission: Impossible",
|
||||
"year": 2011
|
||||
},
|
||||
{
|
||||
"title": "Mission: Impossible - Rogue Nation",
|
||||
"category": "Mission: Impossible",
|
||||
"year": 2015
|
||||
},
|
||||
{
|
||||
"title": "Mission: Impossible - Fallout",
|
||||
"category": "Mission: Impossible",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Mission: Impossible - Dead Reckoning",
|
||||
"category": "Mission: Impossible",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "Mission: Impossible - The Final Reckoning",
|
||||
"category": "Mission: Impossible",
|
||||
"year": 2025
|
||||
},
|
||||
{
|
||||
"title": "Jurassic Park",
|
||||
"favorite": true,
|
||||
"category": "Jurassic Park / World",
|
||||
"year": 1993,
|
||||
"image": "/images/movies/jurassic-park.jpg",
|
||||
"blurb": "The movie that made dinosaurs feel real. The T. rex paddock scene is flawless and the John Williams score lives in my head rent-free. Saw it too young and never recovered."
|
||||
},
|
||||
{
|
||||
"title": "The Lost World: Jurassic Park",
|
||||
"category": "Jurassic Park / World",
|
||||
"year": 1997
|
||||
},
|
||||
{
|
||||
"title": "Jurassic Park III",
|
||||
"category": "Jurassic Park / World",
|
||||
"year": 2001
|
||||
},
|
||||
{
|
||||
"title": "Jurassic World",
|
||||
"category": "Jurassic Park / World",
|
||||
"year": 2015
|
||||
},
|
||||
{
|
||||
"title": "Jurassic World: Fallen Kingdom",
|
||||
"category": "Jurassic Park / World",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Jurassic World Dominion",
|
||||
"category": "Jurassic Park / World",
|
||||
"year": 2022
|
||||
},
|
||||
{
|
||||
"title": "Transformers",
|
||||
"category": "Transformers",
|
||||
"favorite": true,
|
||||
"year": 2007,
|
||||
"image": "/images/movies/transformers.jpg",
|
||||
"blurb": "Giant robots punching each other and somehow it rules. Optimus Prime on screen for the first time hit younger me hard. Pure spectacle, zero apologies. One of very few movies I saw in theatres more than once"
|
||||
},
|
||||
{
|
||||
"title": "Transformers: Revenge of the Fallen",
|
||||
"category": "Transformers",
|
||||
"year": 2009
|
||||
},
|
||||
{
|
||||
"title": "Transformers: Dark of the Moon",
|
||||
"category": "Transformers",
|
||||
"year": 2011
|
||||
},
|
||||
{
|
||||
"title": "Transformers: Age of Extinction",
|
||||
"category": "Transformers",
|
||||
"year": 2014
|
||||
},
|
||||
{
|
||||
"title": "Transformers: The Last Knight",
|
||||
"category": "Transformers",
|
||||
"year": 2017
|
||||
},
|
||||
{
|
||||
"title": "Bumblebee",
|
||||
"category": "Transformers",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Transformers: Rise of the Beasts",
|
||||
"category": "Transformers",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "Spider-Man",
|
||||
"category": "Spider-Man (All Versions)",
|
||||
"year": 2002
|
||||
},
|
||||
{
|
||||
"title": "Spider-Man 2",
|
||||
"category": "Spider-Man (All Versions)",
|
||||
"year": 2004
|
||||
},
|
||||
{
|
||||
"title": "Spider-Man 3",
|
||||
"category": "Spider-Man (All Versions)",
|
||||
"year": 2007
|
||||
},
|
||||
{
|
||||
"title": "The Amazing Spider-Man",
|
||||
"category": "Spider-Man (All Versions)",
|
||||
"year": 2012
|
||||
},
|
||||
{
|
||||
"title": "The Amazing Spider-Man 2",
|
||||
"category": "Spider-Man (All Versions)",
|
||||
"year": 2014
|
||||
},
|
||||
{
|
||||
"title": "Spider-Man: Into the Spider-Verse",
|
||||
"category": "Spider-Man (All Versions)",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Spider-Man: Across the Spider-Verse",
|
||||
"category": "Spider-Man (All Versions)",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "Batman Begins",
|
||||
"category": "The Dark Knight Trilogy",
|
||||
"year": 2005
|
||||
},
|
||||
{
|
||||
"title": "The Dark Knight",
|
||||
"category": "The Dark Knight Trilogy",
|
||||
"year": 2008
|
||||
},
|
||||
{
|
||||
"title": "The Dark Knight Rises",
|
||||
"category": "The Dark Knight Trilogy",
|
||||
"year": 2012
|
||||
},
|
||||
{
|
||||
"title": "X-Men",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2000
|
||||
},
|
||||
{
|
||||
"title": "X2: X-Men United",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2003
|
||||
},
|
||||
{
|
||||
"title": "X-Men: The Last Stand",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2006
|
||||
},
|
||||
{
|
||||
"title": "X-Men Origins: Wolverine",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2009
|
||||
},
|
||||
{
|
||||
"title": "X-Men: First Class",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2011
|
||||
},
|
||||
{
|
||||
"title": "The Wolverine",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2013
|
||||
},
|
||||
{
|
||||
"title": "X-Men: Days of Future Past",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2014
|
||||
},
|
||||
{
|
||||
"title": "Deadpool",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2016
|
||||
},
|
||||
{
|
||||
"title": "X-Men: Apocalypse",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2016
|
||||
},
|
||||
{
|
||||
"title": "Logan",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2017
|
||||
},
|
||||
{
|
||||
"title": "Deadpool 2",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "X-Men: Dark Phoenix",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2019
|
||||
},
|
||||
{
|
||||
"title": "The New Mutants",
|
||||
"category": "X-Men / Fox Marvel",
|
||||
"year": 2020
|
||||
},
|
||||
{
|
||||
"title": "Raiders of the Lost Ark",
|
||||
"category": "Indiana Jones",
|
||||
"year": 1981
|
||||
},
|
||||
{
|
||||
"title": "Indiana Jones and the Temple of Doom",
|
||||
"category": "Indiana Jones",
|
||||
"year": 1984
|
||||
},
|
||||
{
|
||||
"title": "Indiana Jones and the Last Crusade",
|
||||
"category": "Indiana Jones",
|
||||
"year": 1989
|
||||
},
|
||||
{
|
||||
"title": "Indiana Jones and the Kingdom of the Crystal Skull",
|
||||
"category": "Indiana Jones",
|
||||
"year": 2008
|
||||
},
|
||||
{
|
||||
"title": "Indiana Jones and the Dial of Destiny",
|
||||
"category": "Indiana Jones",
|
||||
"year": 2023
|
||||
},
|
||||
{
|
||||
"title": "Godzilla",
|
||||
"category": "MonsterVerse",
|
||||
"year": 2014
|
||||
},
|
||||
{
|
||||
"title": "Kong: Skull Island",
|
||||
"category": "MonsterVerse",
|
||||
"year": 2017
|
||||
},
|
||||
{
|
||||
"title": "Godzilla: King of the Monsters",
|
||||
"category": "MonsterVerse",
|
||||
"year": 2019
|
||||
},
|
||||
{
|
||||
"title": "Godzilla vs. Kong",
|
||||
"category": "MonsterVerse",
|
||||
"year": 2021
|
||||
},
|
||||
{
|
||||
"title": "Godzilla x Kong: The New Empire",
|
||||
"category": "MonsterVerse",
|
||||
"year": 2024
|
||||
},
|
||||
{
|
||||
"title": "The Bourne Identity",
|
||||
"category": "Bourne",
|
||||
"year": 2002
|
||||
},
|
||||
{
|
||||
"title": "The Bourne Supremacy",
|
||||
"category": "Bourne",
|
||||
"year": 2004
|
||||
},
|
||||
{
|
||||
"title": "The Bourne Ultimatum",
|
||||
"category": "Bourne",
|
||||
"year": 2007
|
||||
},
|
||||
{
|
||||
"title": "The Bourne Legacy",
|
||||
"category": "Bourne",
|
||||
"year": 2012
|
||||
},
|
||||
{
|
||||
"title": "Jason Bourne",
|
||||
"category": "Bourne",
|
||||
"year": 2016
|
||||
},
|
||||
{
|
||||
"title": "Ocean's Eleven",
|
||||
"favorite": true,
|
||||
"category": "Ocean's",
|
||||
"year": 2001,
|
||||
"image": "/images/movies/oceans-eleven.jpg",
|
||||
"blurb": "The smoothest movie ever made. The cast, the plan, the vibe. Everything a heist movie should be and impossibly rewatchable."
|
||||
},
|
||||
{
|
||||
"title": "Ocean's Twelve",
|
||||
"category": "Ocean's",
|
||||
"year": 2004
|
||||
},
|
||||
{
|
||||
"title": "Ocean's Thirteen",
|
||||
"category": "Ocean's",
|
||||
"year": 2007
|
||||
},
|
||||
{
|
||||
"title": "Ocean's 8",
|
||||
"category": "Ocean's",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Venom",
|
||||
"category": "Venom",
|
||||
"year": 2018
|
||||
},
|
||||
{
|
||||
"title": "Venom: Let There Be Carnage",
|
||||
"category": "Venom",
|
||||
"year": 2021
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,607 @@
|
||||
[
|
||||
{
|
||||
"title": "Breaking Bad",
|
||||
"favorite": true,
|
||||
"blurb": "The character study of a decade. Watching Walter White transform is devastating, and the craftsmanship is meticulous top to bottom. One of the best shows ever made, full stop.",
|
||||
"image": "/images/tv/breaking-bad.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Brooklyn Nine-Nine",
|
||||
"favorite": true,
|
||||
"blurb": "The found-family comedy with the perfect ensemble. Andre Braugher's deadpan Captain Holt is comedy royalty. Rewatches never get old.",
|
||||
"image": "/images/tv/brooklyn-nine-nine.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Parks and Recreation",
|
||||
"favorite": true,
|
||||
"blurb": "A show about people who actually care. Leslie Knope is everything, and the optimism is genuine, not forced. My comfort rewatch.",
|
||||
"image": "/images/tv/parks-and-recreation.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Bear",
|
||||
"favorite": true,
|
||||
"blurb": "Anxiety and grief in a kitchen, filmed like a panic attack. It's frenetic and emotionally devastating in the best way. Hard to watch, impossible to stop.",
|
||||
"image": "/images/tv/the-bear.jpg"
|
||||
},
|
||||
{
|
||||
"title": "WandaVision",
|
||||
"favorite": true,
|
||||
"blurb": "The MCU at its strangest and most experimental. Sitcom pastiche hiding real grief, and it actually landed. Brilliant.",
|
||||
"image": "/images/tv/wandavision.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Suits",
|
||||
"favorite": true,
|
||||
"blurb": "Sharp dialogue and the Harvey and Mike dynamic are the whole pitch. And Louis Litt's growth sneaks up on you. Pure comfort TV.",
|
||||
"image": "/images/tv/suits.jpg"
|
||||
},
|
||||
{
|
||||
"title": "30 Rock",
|
||||
"image": "/images/tv/30-rock.jpg"
|
||||
},
|
||||
{
|
||||
"title": "House",
|
||||
"image": "/images/tv/house.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Arcane",
|
||||
"favorite": true,
|
||||
"blurb": "One of the best animated shows made, period. The art is staggering and it earns every emotional beat without leaning on you to know the game.",
|
||||
"image": "/images/tv/arcane.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Wire",
|
||||
"image": "/images/tv/the-wire.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Sopranos",
|
||||
"image": "/images/tv/the-sopranos.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Succession",
|
||||
"image": "/images/tv/succession.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Crown",
|
||||
"image": "/images/tv/the-crown.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Queen's Gambit",
|
||||
"image": "/images/tv/the-queens-gambit.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Ozark",
|
||||
"image": "/images/tv/ozark.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Peaky Blinders",
|
||||
"image": "/images/tv/peaky-blinders.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Breaking Bad",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Wire",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Sopranos",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Succession",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Crown",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Game of Thrones",
|
||||
"favorite": true,
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed",
|
||||
"image": "/images/tv/game-of-thrones.jpg",
|
||||
"blurb": "When it hit, nothing else was close. The political scheming and the sheer scale set the bar for a decade. I watched every week."
|
||||
},
|
||||
{
|
||||
"title": "House of the Dragon",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Watching",
|
||||
"note": "Watching Season 2"
|
||||
},
|
||||
{
|
||||
"title": "The Last of Us",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Westworld",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Big Little Lies",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "True Detective",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "House of Cards",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Ozark",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Queen's Gambit",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Peaky Blinders",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Mindhunter",
|
||||
"category": "Prestige Drama",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Stranger Things",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Dark",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "You",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The OA",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Russian Doll",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Warrior Nun",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Black Mirror",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Altered Carbon",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Alice in Borderland",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Lupin",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Wednesday",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Shadow and Bone",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Fate: The Winx Saga",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Order",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Cobra Kai",
|
||||
"favorite": true,
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed",
|
||||
"image": "/images/tv/cobra-kai.jpg",
|
||||
"blurb": "Turns a nostalgia property into something genuinely good. The Johnny and Daniel rivalry actually has legs, and it's more fun than it has any right to be."
|
||||
},
|
||||
{
|
||||
"title": "Squid Game",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Boys",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Invincible",
|
||||
"favorite": true,
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed",
|
||||
"image": "/images/tv/invincible.jpg",
|
||||
"blurb": "Takes the superhero thing seriously in the ways the movies can't. When it goes, it GOES. Brutal in the best way."
|
||||
},
|
||||
{
|
||||
"title": "Severance",
|
||||
"category": "Streaming Originals",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Office (US)",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Parks and Recreation",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "30 Rock",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Brooklyn Nine-Nine",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Friends",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Community",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Arrested Development",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Veep",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Curb Your Enthusiasm",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Silicon Valley",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Big Bang Theory",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Modern Family",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Abbott Elementary",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Never Have I Ever",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Sex Education",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "BoJack Horseman",
|
||||
"category": "Workplace & Sitcom Comedies",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "House",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The West Wing",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Closer",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Doctor Who",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "NCIS",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Criminal Minds",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Law & Order: SVU",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Rizzoli & Isles",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Bodyguard",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Designated Survivor",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Recruit",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Night Agent",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Lincoln Lawyer",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Watching"
|
||||
},
|
||||
{
|
||||
"title": "A Man on the Inside",
|
||||
"category": "Procedural & Long-Running",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Daredevil",
|
||||
"favorite": true,
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed",
|
||||
"image": "/images/tv/daredevil.jpg",
|
||||
"blurb": "The Netflix Marvel high water mark. Charlie Cox is born for this role and the hallway fights are legendary. Still the benchmark."
|
||||
},
|
||||
{
|
||||
"title": "Jessica Jones",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Luke Cage",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Iron Fist",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Punisher",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Defenders",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "WandaVision",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Loki",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "What If...?",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Hawkeye",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Moon Knight",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Ms. Marvel",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "She-Hulk",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Secret Invasion",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Wonder Man",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Agatha All Along",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Flash",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Supergirl",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Arrow",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Legends of Tomorrow",
|
||||
"category": "Marvel Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Mandalorian",
|
||||
"favorite": true,
|
||||
"category": "Star Wars Universe",
|
||||
"status": "Completed",
|
||||
"image": "/images/tv/the-mandalorian.jpg",
|
||||
"blurb": "Star Wars finally felt small and personal again. Grogu is the obvious hook but the western framework is what kept me. Andor gets the prestige love but this is my comfort watch."
|
||||
},
|
||||
{
|
||||
"title": "The Book of Boba Fett",
|
||||
"category": "Star Wars Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Ahsoka",
|
||||
"category": "Star Wars Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Obi-Wan Kenobi",
|
||||
"category": "Star Wars Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Andor",
|
||||
"category": "Star Wars Universe",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Arcane",
|
||||
"category": "Animation (Non-Anime)",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Castlevania",
|
||||
"favorite": true,
|
||||
"category": "Animation (Non-Anime)",
|
||||
"status": "Completed",
|
||||
"image": "/images/tv/castlevania.jpg",
|
||||
"blurb": "Gothic, violent, and surprisingly literary. Trevor Belmont's crew and the Dracula grief storyline are top tier. Animation that doesn't pull punches."
|
||||
},
|
||||
{
|
||||
"title": "Blood of Zeus",
|
||||
"category": "Animation (Non-Anime)",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Rick and Morty",
|
||||
"category": "Animation (Non-Anime)",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Archer",
|
||||
"category": "Animation (Non-Anime)",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Futurama",
|
||||
"category": "Animation (Non-Anime)",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Cyberpunk: Edgerunners",
|
||||
"category": "Animation (Non-Anime)",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "The Great British Baking Show",
|
||||
"favorite": true,
|
||||
"category": "Reality & Competition",
|
||||
"status": "Completed",
|
||||
"image": "/images/tv/the-great-british-baking-show.jpg",
|
||||
"blurb": "The antidote to everything else on this list. Pure comfort. I put it on when I need the world to be gentle for an hour."
|
||||
},
|
||||
{
|
||||
"title": "Hell's Kitchen",
|
||||
"category": "Reality & Competition",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "MasterChef",
|
||||
"category": "Reality & Competition",
|
||||
"status": "Completed"
|
||||
},
|
||||
{
|
||||
"title": "Kitchen Nightmares",
|
||||
"category": "Reality & Competition",
|
||||
"status": "Completed"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
[
|
||||
{
|
||||
"title": "Brandon Sanderson — Cosmere",
|
||||
"category": "Books",
|
||||
"status": "Complete",
|
||||
"favorite": true,
|
||||
"blurb": "The gold standard for me. Every novel, every era, all consumed. The way the shared universe quietly connects across worlds is something no one else is doing at this scale. Eagerly waiting the next era.",
|
||||
"image": "/images/reading/brandon-sanderson-cosmere.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Frank Herbert — Dune",
|
||||
"category": "Books",
|
||||
"status": "Complete",
|
||||
"favorite": true,
|
||||
"blurb": "Books 1 through 3 complete. Dune reshaped how I think about worldbuilding: the politics, the religion, the ecology all woven together. The original is untouchable.",
|
||||
"image": "/images/reading/frank-herbert-dune.jpg"
|
||||
},
|
||||
{
|
||||
"title": "J.R.R. Tolkien — The Lord of the Rings",
|
||||
"category": "Books",
|
||||
"status": "Listening",
|
||||
"favorite": true,
|
||||
"blurb": "The foundation. Currently rereading via the Andy Serkis audiobooks, which are fantastic. Tolkien invented the modern fantasy map and no one has topped the depth since.",
|
||||
"image": "/images/reading/j-r-r-tolkien-the-lord-of-the-rings.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Douglas Preston & Lincoln Child — Pendergast",
|
||||
"category": "Books",
|
||||
"favorite": true,
|
||||
"status": "Rereading",
|
||||
"blurb": "The series that proved to me a great thriller doesn't need dragons. Agent Pendergast is one of a kind. Currently rereading, up to Cabinet of Curiosities.",
|
||||
"image": "/images/reading/douglas-preston-lincoln-child-pendergast.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Sarah J. Maas — Throne of Glass",
|
||||
"category": "Books",
|
||||
"status": "Reading"
|
||||
},
|
||||
{
|
||||
"title": "Emily Rodda — Deltora Quest",
|
||||
"category": "Books",
|
||||
"favorite": true,
|
||||
"status": "Reread-pending",
|
||||
"blurb": "A childhood favorite I'm planning to revisit via the audiobooks. Don't let the middle-grade label fool you, the worldbuilding stuck with me for decades.",
|
||||
"image": "/images/reading/emily-rodda-deltora-quest.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Fonda Lee — Green Bone Saga",
|
||||
"category": "Books",
|
||||
"status": "Next"
|
||||
},
|
||||
{
|
||||
"title": "The Hobbit",
|
||||
"category": "Books",
|
||||
"status": "Complete"
|
||||
},
|
||||
{
|
||||
"title": "That Time I Got Reincarnated as a Slime",
|
||||
"category": "Light Novels",
|
||||
"status": "Reading",
|
||||
"note": "Read up to Volume 13 before, planning to finish now that it is complete. 21/23 volumes."
|
||||
},
|
||||
{
|
||||
"title": "Overlord",
|
||||
"category": "Light Novels",
|
||||
"status": "Reading",
|
||||
"note": "16/18 volumes."
|
||||
},
|
||||
{
|
||||
"title": "Re:Zero",
|
||||
"category": "Light Novels",
|
||||
"status": "Reading",
|
||||
"note": "26-28/44+ volumes."
|
||||
},
|
||||
{
|
||||
"title": "The King's Avatar",
|
||||
"category": "Light Novels",
|
||||
"favorite": true,
|
||||
"status": "Complete",
|
||||
"blurb": "A pro gamer gets forced out of his team and climbs back to the top from scratch. I've consumed it in every format: novel, animation, live-action. The esports scene is immaculately detailed.",
|
||||
"note": "Read fully. Also seen all animation and live-action shows.",
|
||||
"image": "/images/reading/the-king-s-avatar.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Solo Leveling",
|
||||
"category": "Webnovels",
|
||||
"favorite": true,
|
||||
"status": "Complete",
|
||||
"blurb": "The weakest hunter gets a system and refuses to waste it. The 'level up' loop is pure dopamine and the manhwa art is gorgeous. Gobbled it up in every format.",
|
||||
"note": "Korean webnovel turned manhwa. Consumed in all formats.",
|
||||
"image": "/images/reading/solo-leveling.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The God of High School",
|
||||
"category": "Webtoons",
|
||||
"status": "Complete",
|
||||
"note": "Also watched the anime."
|
||||
},
|
||||
{
|
||||
"title": "Noblesse",
|
||||
"category": "Webtoons",
|
||||
"status": "Complete",
|
||||
"note": "Also watched the anime."
|
||||
},
|
||||
{
|
||||
"title": "Tower of God",
|
||||
"category": "Webtoons",
|
||||
"status": "Complete",
|
||||
"note": "Also watched the anime."
|
||||
},
|
||||
{
|
||||
"title": "Tokyo Revengers",
|
||||
"category": "Manga & Manhwa",
|
||||
"status": "Complete",
|
||||
"note": "Finished the manga. Also watched the anime."
|
||||
},
|
||||
{
|
||||
"title": "Wistoria: Wand and Sword",
|
||||
"category": "Manga & Manhwa",
|
||||
"status": "New",
|
||||
"note": "Recently added to the reading list. After finishing season 2."
|
||||
},
|
||||
{
|
||||
"title": "Omniscient Reader's Viewpoint",
|
||||
"category": "Webnovels",
|
||||
"status": "Complete",
|
||||
"favorite": true,
|
||||
"blurb": "The sole reader of a dying webnovel watches it become reality, and he's the only one who knows how the story ends. It's about stories, friendship, and what you'd sacrifice to keep a narrative alive. One of the best things I've read, full stop.",
|
||||
"image": "/images/reading/omniscient-reader-s-viewpoint.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Overgeared",
|
||||
"category": "Webnovels",
|
||||
"status": "Complete",
|
||||
"favorite": true,
|
||||
"image": "/images/reading/overgeared.jpg",
|
||||
"blurb": "Started rough and became an obsession. Grid is the most frustratingly lovable lead; watching the 'worst legendary class' snowball into something absurd never gets old. The crafting and base-building scratch my optimization itch hard and limitless blacksmith is my kind of power fantasy."
|
||||
},
|
||||
{
|
||||
"title": "Desolate Era (I Eat Tomatoes)",
|
||||
"category": "Webnovels",
|
||||
"status": "Complete",
|
||||
"favorite": true,
|
||||
"image": "/images/reading/desolate-era.jpg",
|
||||
"blurb": "Classic xianxia cultivation done right. Ji Ning's rebirth into a brutal cultivation world hooked me immediately. The power progression actually feels earned, and the world just keeps opening up."
|
||||
},
|
||||
{
|
||||
"title": "Lord Xue Ying (I Eat Tomatoes)",
|
||||
"category": "Webnovels",
|
||||
"status": "Complete",
|
||||
"note": "Same author as Desolate Era. Spear-wielding prodigy in a cultivation world."
|
||||
},
|
||||
{
|
||||
"title": "Stellar Transformation (I Eat Tomatoes)",
|
||||
"category": "Webnovels",
|
||||
"status": "Complete",
|
||||
"note": "Same universe as Coiling Dragon. Cultivation through stellar tiers."
|
||||
},
|
||||
{
|
||||
"title": "Coiling Dragon (I Eat Tomatoes)",
|
||||
"category": "Webnovels",
|
||||
"status": "Complete",
|
||||
"note": "The breakout hit. Linley's rise through a cultivation world. Where I started with I Eat Tomatoes."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"ordersOpen": false,
|
||||
"lastUpdated": "2026-08-06",
|
||||
"turnaround": "Estimated turnaround: ~2 weeks once a list is confirmed.",
|
||||
"maxNote": "I take a limited number of orders at a time so each one gets full attention. Slots reopen as current orders finish.",
|
||||
"slots": {
|
||||
"cards": {
|
||||
"open": 1,
|
||||
"total": 3
|
||||
},
|
||||
"deckBoxes": {
|
||||
"open": 2,
|
||||
"total": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* store-availability.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Typed reader for store-availability.json. Both the storefront pages and the
|
||||
* /api/order endpoint read through here, so the hard order gate (ordersOpen)
|
||||
* is enforced from a single source of truth.
|
||||
*
|
||||
* To open orders: edit `./store-availability.json` and set `"ordersOpen": true`,
|
||||
* then (optionally) bump the `slots.*.open` counts and `lastUpdated`.
|
||||
*/
|
||||
import raw from './store-availability.json';
|
||||
|
||||
export interface SlotStatus {
|
||||
open: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface StoreAvailability {
|
||||
/** Master gate. false => order form hidden + /api/order returns 403. */
|
||||
ordersOpen: boolean;
|
||||
/** Free-form date string shown as "Updated <date>". */
|
||||
lastUpdated: string;
|
||||
/** Turnaround estimate shown on storefront + order page. */
|
||||
turnaround: string;
|
||||
/** Note explaining the limited-capacity model. */
|
||||
maxNote: string;
|
||||
slots: {
|
||||
cards: SlotStatus;
|
||||
deckBoxes: SlotStatus;
|
||||
};
|
||||
}
|
||||
|
||||
/** The parsed availability, asserted to the shape above. */
|
||||
export const storeAvailability = raw as StoreAvailability;
|
||||
|
||||
/** True when at least one slot is open in a category. */
|
||||
export function categoryOpen(slot: SlotStatus): boolean {
|
||||
return slot.open > 0;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* store-products.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Typed product + pricing data for the store. This is version-controlled source
|
||||
* (not the user-editable availability file) so the storefront and pricing stay
|
||||
* in sync with the components that render them.
|
||||
*
|
||||
* - Availability (orders open/closed, slot counts, turnaround) lives in
|
||||
* `./store-availability.json`, which is the simple file Greg edits.
|
||||
* - Product specs and pricing tiers live HERE.
|
||||
*
|
||||
* Prices and concrete specs are deliberately left as [Greg to fill] placeholders
|
||||
* so real numbers can be dropped in without touching component code.
|
||||
*/
|
||||
|
||||
export interface SpecRow {
|
||||
/** Label, e.g. "Size". */
|
||||
label: string;
|
||||
/** Value, e.g. "63 x 88 mm (standard MTG)". */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single example image for a product. Rendered in the product-page carousel;
|
||||
* `label` is shown as a caption on/under the image.
|
||||
*/
|
||||
export interface ProductImage {
|
||||
/** Public path, e.g. "/images/proxies.jpg". */
|
||||
src: string;
|
||||
/** Alt text for accessibility. */
|
||||
alt: string;
|
||||
/** Caption shown on the slide, e.g. "Front finish \u2014 matte". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PriceTier {
|
||||
/** Quantity band label, e.g. "1-9 cards". */
|
||||
band: string;
|
||||
/** Price for that band. Placeholder until Greg sets real prices. */
|
||||
price: string;
|
||||
/** Optional note, e.g. "per card". */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable filament/print color for a printed product. Rendered as a
|
||||
* swatch on the product page and offered in the order form. Flip `inStock` to
|
||||
* false when a color runs out — it shows as "Out of stock" and is removed from
|
||||
* the order-form color list.
|
||||
*/
|
||||
export interface ColorOption {
|
||||
/** Display name, e.g. "Midnight Black". */
|
||||
name: string;
|
||||
/** Hex swatch color shown in the grid + form, e.g. "#1b1730". */
|
||||
hex: string;
|
||||
/** Whether the color is currently available to order. */
|
||||
inStock: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
/** mdi icon used as the decorative mark. */
|
||||
icon: string;
|
||||
/** Product name. */
|
||||
title: string;
|
||||
/** One-line summary shown on cards + the storefront. */
|
||||
summary: string;
|
||||
/**
|
||||
* Which availability slot category this product belongs to. Used so the
|
||||
* product page can show the matching SlotMeter at the top.
|
||||
*/
|
||||
slotKey: 'cards' | 'deckBoxes';
|
||||
/**
|
||||
* Example images for the product-page carousel, in display order. Each slide
|
||||
* shows the image plus its `label` caption. Add/remove entries here to
|
||||
* manage the carousel from the server side.
|
||||
*/
|
||||
images: ProductImage[];
|
||||
/** Detailed spec list rendered as a definition list. */
|
||||
specs: SpecRow[];
|
||||
/** Pricing tiers rendered as a pricing table. */
|
||||
pricing: PriceTier[];
|
||||
/**
|
||||
* Optional selectable print colors (filament). Rendered as a swatch grid on
|
||||
* the product page and offered in the order form. Add/remove entries here to
|
||||
* manage available colors; toggle `inStock` per color. Omit for products that
|
||||
* have no color choice (e.g. proxy cards).
|
||||
*/
|
||||
colors?: ColorOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy MTG card product line. Sizes/finishes are standard for the format; the
|
||||
* exact cardstock weights and finish names are Greg's to fill.
|
||||
*/
|
||||
export const cardProduct: Product = {
|
||||
icon: 'cards-playing',
|
||||
title: 'Proxy MTG Cards',
|
||||
summary:
|
||||
'High-quality printed proxy cards for casual play, sized to match real Magic cards and clearly marked as proxies intended for use sleeved.',
|
||||
slotKey: 'cards',
|
||||
images: [
|
||||
{
|
||||
src: '/images/store/cards/proxies.jpg',
|
||||
alt: 'Example printed proxy MTG cards, front faces',
|
||||
label: 'Card fronts \u2014 full-color print',
|
||||
},{
|
||||
src: '/images/store/cards/proxies2.jpg',
|
||||
alt: 'Example printed foil proxy MTG cards, front faces',
|
||||
label: 'Card fronts \u2014 foil print',
|
||||
},{
|
||||
src: '/images/store/cards/foil_cloud_measured.jpg',
|
||||
alt: 'Example printed foil cloud proxy card with measurements',
|
||||
label: 'Rainbow foil card front with measurements',
|
||||
},{
|
||||
src: '/images/store/cards/disclosure.jpg',
|
||||
alt: 'Example printed deck disclosure card',
|
||||
label: 'Deck disclosure card',
|
||||
},{
|
||||
src: '/images/store/cards/sleeved.jpg',
|
||||
alt: 'Example printed proxy cards in sleeves',
|
||||
label: 'Proxy cards in sleeves',
|
||||
},{
|
||||
src: '/images/store/cards/ravenous_duo.jpg',
|
||||
alt: 'Example printed Ravenous TRex foil and non-foil proxy cards',
|
||||
label: 'Ravenous TRex foil and non-foil proxy cards',
|
||||
},{
|
||||
src: '/images/store/cards/ghalta_duo.jpg',
|
||||
alt: 'Example printed Ghalta foil and non-foil proxy cards',
|
||||
label: 'Ghalta foil and non-foil proxy cards',
|
||||
},
|
||||
],
|
||||
specs: [
|
||||
{ label: 'Size', value: '63 x 88 mm (standard MTG card size)' },
|
||||
{ label: 'Thickness', value: '0.37mm laminated photo cardstock' },
|
||||
{ label: 'Back', value: 'Solid white back (clearly not tournament-legal)' },
|
||||
{
|
||||
label: 'Finishes',
|
||||
value: 'Default matte finish, available in glossy on request (Foil cards are always glossy finish)',
|
||||
},
|
||||
{
|
||||
label: 'Double-sided cards',
|
||||
value: 'Printed as two separate cards and count as 2 card slots',
|
||||
},
|
||||
{ label: 'Art', value: 'Official card art only. No offensive art (custom cards usable if fully provided)' },
|
||||
],
|
||||
pricing: [
|
||||
{ band: 'Individual cards', price: '$1.00', note: 'per card' },
|
||||
{ band: 'Individual foil cards', price: '$2.00', note: 'per card' },
|
||||
{ band: '36 card set', price: '$20.00', note: 'per set of cards' },
|
||||
{ band: '60 card deck', price: '$30.00', note: 'per set of cards' },
|
||||
{ band: '100 card deck', price: '$40.00', note: 'per set of cards' },
|
||||
{ band: 'Deck Disclosure Card', price: '$2.00', note: 'per card' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 3D-printed deck box + counters product line.
|
||||
*/
|
||||
export const deckBoxProduct: Product = {
|
||||
icon: 'printer-3d-nozzle',
|
||||
title: '3D-Printed Deck Boxes & Counters',
|
||||
summary:
|
||||
'Custom 3D-printed deck boxes, life counters, and tokens to store and track your games.',
|
||||
slotKey: 'deckBoxes',
|
||||
images: [
|
||||
{
|
||||
src: '/images/deckboxes.jpg',
|
||||
alt: 'Example 3D-printed deck boxes',
|
||||
label: 'Deck boxes - Marble, Nebula, and Sparkle Black with Metallic Gold Accents',
|
||||
},
|
||||
{
|
||||
src: '/images/store/deckboxes/ff_front.jpg',
|
||||
alt: 'Deck Box front view - Fantastic Four',
|
||||
label: 'Deck Box front view - Fantastic Four - Matte Blue with Metallic Black Accents',
|
||||
},
|
||||
{
|
||||
src: '/images/store/deckboxes/ff_side.jpg',
|
||||
alt: 'Deck Box side view - Fantastic Four',
|
||||
label: 'Deck Box side view - Fantastic Four - Matte Blue with Metallic Black Accents',
|
||||
},
|
||||
{
|
||||
src: '/images/store/deckboxes/ff_back.jpg',
|
||||
alt: 'Deck Box back view - Fantastic Four',
|
||||
label: 'Deck Box back view - Fantastic Four - Matte Blue with Metallic Black Accents',
|
||||
},
|
||||
{
|
||||
src: '/images/store/deckboxes/ff_open.jpg',
|
||||
alt: 'Deck Box open view - Fantastic Four',
|
||||
label: 'Deck Box open view - Fantastic Four - Matte Blue with Metallic Black Accents',
|
||||
}
|
||||
],
|
||||
specs: [
|
||||
{ label: 'Capacity', value: '100 double-sleeved card bay / 82mm depth and tray 67 mm x 76 mm x 24 mm (width x depth x height)' },
|
||||
{ label: 'Material', value: 'PLA with 2x4 magnets and top-loader protection pane' },
|
||||
{ label: 'Finish', value: 'as-printed' },
|
||||
{ label: 'Sizing', value: '86 mm x 109 mm x 143 mm (width x depth x height)' },
|
||||
{ label: 'Customization', value: 'Color selection from available filament. Default pages uses Matte Bone White. Default strap uses Matte Light Leather Brown.' },
|
||||
],
|
||||
pricing: [
|
||||
{ band: 'Single deck box', price: '$30.00', note: 'per print' },
|
||||
{ band: 'Deck box + counter set', price: 'Coming Soon', note: 'per set' },
|
||||
{ band: 'Counter / token only', price: 'Coming Soon', note: 'per print' },
|
||||
],
|
||||
/**
|
||||
* Available filament colors. Toggle `inStock` to reflect what's loaded on
|
||||
* the printer; out-of-stock colors show a label and drop out of the order
|
||||
* form. Add a new { name, hex, inStock } entry to offer a new color.
|
||||
*/
|
||||
colors: [
|
||||
{ name: 'Charcoal Matte Black', hex: '#1d1d1d', inStock: true },
|
||||
{ name: 'Sparkle Black', hex: '#242424', inStock: true },
|
||||
{ name: 'Metallic Black', hex: '#242424', inStock: true },
|
||||
{ name: 'Metallic Gold', hex: '#96793b', inStock: true },
|
||||
{ name: 'Metallic Blue', hex: '#21356b', inStock: true },
|
||||
{ name: 'Metallic Silver', hex: '#b1b1b1', inStock: true },
|
||||
{ name: 'Silver', hex: '#d8d8d8', inStock: true },
|
||||
{ name: 'Copper', hex: '#61462d', inStock: true },
|
||||
{ name: 'Orange', hex: '#f77b28', inStock: true },
|
||||
{ name: 'Shiny Red', hex: '#af0505', inStock: true },
|
||||
{ name: 'Glossy Red', hex: '#cc2a17', inStock: true },
|
||||
{ name: 'Scarlet', hex: '#6e140b', inStock: true },
|
||||
{ name: 'Matte Bone White', hex: '#f4f1e8', inStock: true },
|
||||
{ name: 'Matte Light Leather Brown', hex: '#bea072', inStock: true },
|
||||
{ name: 'Basic White', hex: '#ffffff', inStock: true },
|
||||
{ name: 'Basic Cyan', hex: '#2ba8ce', inStock: true },
|
||||
{ name: 'Matte Blue', hex: '#365ecc', inStock: true },
|
||||
{ name: 'Royal Blue', hex: '#2247ac', inStock: true },
|
||||
{ name: 'Blue Grey', hex: '#46535f', inStock: true },
|
||||
{ name: 'Purple', hex: '#3a0961', inStock: true },
|
||||
{ name: 'Marble', hex: '#b9b9b9', inStock: true },
|
||||
{ name: 'Coffee Brown', hex: '#36241c', inStock: true },
|
||||
{ name: 'Sunflower Yellow', hex: '#e6e332', inStock: true },
|
||||
{ name: 'Basic Green', hex: '#24da00', inStock: true },
|
||||
{ name: 'Matte Leaf Green', hex: '#a6d19d', inStock: true },
|
||||
{ name: 'Army Green', hex: '#3e611d', inStock: true },
|
||||
{ name: 'Sparkle Green', hex: '#0b662e', inStock: true },
|
||||
{ name: 'Sparkle Nebula', hex: '#104568', inStock: true },
|
||||
],
|
||||
};
|
||||
|
||||
/** Both product lines, for storefront iteration. */
|
||||
export const products = [cardProduct, deckBoxProduct];
|
||||
|
||||
/**
|
||||
* The store requirements / rules. Rendered on the storefront and repeated on
|
||||
* the order page; the order form requires an explicit acknowledgment of these.
|
||||
* Wording mirrors what Greg asked to state clearly.
|
||||
*/
|
||||
export interface Requirement {
|
||||
/** mdi icon: 'check' for a rule, 'alert' for a hard limit/warning. */
|
||||
icon: 'check-decagram' | 'alert' | 'close-circle' | 'information';
|
||||
/** Short title. */
|
||||
title: string;
|
||||
/** One-to-two sentence explanation. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const requirements: Requirement[] = [
|
||||
{
|
||||
icon: 'information',
|
||||
title: 'Card list is final',
|
||||
body: 'You must provide a complete card list up front. Once the list is received, the cards in that order cannot be changed.',
|
||||
},
|
||||
{
|
||||
icon: 'alert',
|
||||
title: 'Double-sided cards = 2 slots',
|
||||
body: 'Double-sided cards are printed as two separate cards (front and back) and count as 2 card slots in your order.',
|
||||
},
|
||||
{
|
||||
icon: 'close-circle',
|
||||
title: 'All sales are final',
|
||||
body: 'Payment is final. Because each order is printed to spec, refunds and exchanges are not available.',
|
||||
},
|
||||
{
|
||||
icon: 'alert',
|
||||
title: 'These are proxies, not counterfeits',
|
||||
body: 'All cards are clearly marked as proxies with a solid white back. They are not tournament-legal and are not made to pass as genuine.',
|
||||
},
|
||||
{
|
||||
icon: 'close-circle',
|
||||
title: 'No affiliation with Wizards of the Coast',
|
||||
body: 'I am not associated with Wizards of the Coast or any game company. Magic: The Gathering and all related properties belong to their respective owners.',
|
||||
},
|
||||
{
|
||||
icon: 'close-circle',
|
||||
title: 'No counterfeiting',
|
||||
body: 'I will not counterfeit cards. Every card is produced as a clearly identifiable proxy for casual play.',
|
||||
},
|
||||
{
|
||||
icon: 'alert',
|
||||
title: 'No offensive art',
|
||||
body: 'I will not print custom art or any cards I find to be offensive. Only official card art is used unless card art fully provided by the customer.',
|
||||
},
|
||||
{
|
||||
icon: 'information',
|
||||
title: 'Limited capacity',
|
||||
body: 'I can only take a limited number of orders at a time. Slots are shown live on the order page and reopen as orders finish.',
|
||||
},
|
||||
];
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
/**
|
||||
* env.d.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Ambient declarations for side-effect-only CSS imports (fontsource packages)
|
||||
* and any other modules that ship no types but are imported for their effect.
|
||||
*/
|
||||
|
||||
// Fontsource variable font packages are imported purely for their CSS side
|
||||
// effect (registering the @font-face rules). They have no runtime exports we
|
||||
// use, so we declare them as side-effect modules to satisfy the TS compiler.
|
||||
declare module '@fontsource-variable/inter';
|
||||
declare module '@fontsource-variable/plus-jakarta-sans';
|
||||
declare module '@fontsource-variable/jetbrains-mono';
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
/**
|
||||
* BaseLayout.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The shared shell for every page: <html>, <head> (fonts, meta, theme-init
|
||||
* inline script), the fixed NavBar, the main content slot, and the Footer.
|
||||
*
|
||||
* Theming axis #1 (light/dark) is set on <html data-theme> by the inline
|
||||
* theme-init script before paint (no FOUC). Theming axis #2 (section tone) is
|
||||
* set on the <body data-surface> via the `surface` prop so each page can opt
|
||||
* into neutral / professional / interest styling.
|
||||
*
|
||||
* Page transitions use Astro's <ClientRouter /> (View Transitions API).
|
||||
*/
|
||||
import "@/styles/global.css";
|
||||
|
||||
// Self-hosted fonts (woff2, variable). Avoids external requests for perf + privacy.
|
||||
// Patiun brand type: Cinzel (headings, names) + Montserrat (body, UI).
|
||||
// JetBrains Mono stays for the rare code-like block.
|
||||
import "@fontsource-variable/cinzel";
|
||||
import "@fontsource-variable/montserrat";
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
import NavBar from "@components/nav/NavBar.astro";
|
||||
import StoreNav from "@components/store/StoreNav.astro";
|
||||
import Footer from "@components/layout/Footer.astro";
|
||||
import { themeInitScript } from "@/scripts/theme-init";
|
||||
import { siteIdentity } from "@lib/site";
|
||||
// Dev-only react-refresh preamble (see astro.config.mjs for why). Imported as a
|
||||
// raw string so we can emit it as the FIRST module script in <head>, ahead of
|
||||
// any React island, so $RefreshReg$/$RefreshSig$ exist before islands eval.
|
||||
import { REACT_REFRESH_PREAMBLE } from "../../astro.config.mjs";
|
||||
|
||||
// Only emit the preamble in dev (import.meta.env.DEV). Production builds compile
|
||||
// islands to jsx/jsxs and never touch the refresh runtime.
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
type Surface = "neutral" | "professional" | "interest";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Which section tone to apply. Defaults to neutral (Home/About/Contact). */
|
||||
surface?: Surface;
|
||||
/** Override default OG image path. */
|
||||
ogImage?: string;
|
||||
/** When true, emit <meta name="robots" content="noindex, nofollow">. Use on
|
||||
pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */
|
||||
noindex?: boolean;
|
||||
/** When true, render the store-only nav (StoreNav) instead of the main NavBar.
|
||||
Used by store pages, which intentionally share no links with the main site. */
|
||||
storeNav?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = "Greg Kilmer is a software developer, maker, and tabletop gamemaster based in Philadelphia. This is where his professional and personal worlds meet.",
|
||||
surface = "neutral",
|
||||
ogImage,
|
||||
noindex = false,
|
||||
storeNav = false,
|
||||
} = Astro.props;
|
||||
|
||||
// True on the Home page. Used to switch the NavBar's view-transition-name so
|
||||
// the Home hero can morph into / out of the nav bar (expand/collapse effect).
|
||||
const isHome = Astro.url.pathname === "/";
|
||||
|
||||
const fullTitle = title
|
||||
? `${title} | ${siteIdentity.name}`
|
||||
: `${siteIdentity.name} — Developer. Maker. Storyteller.`;
|
||||
|
||||
const canonical = new URL(
|
||||
Astro.url.pathname,
|
||||
Astro.site ?? "https://gregkilmer.com",
|
||||
);
|
||||
const ogImageUrl = ogImage
|
||||
? new URL(ogImage, Astro.site ?? "https://gregkilmer.com").toString()
|
||||
: new URL(
|
||||
"/images/hero-portrait.jpg",
|
||||
Astro.site ?? "https://gregkilmer.com",
|
||||
).toString();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<!-- no-js guard: removed by reveal-init.ts once JS runs; while present, the
|
||||
global.css `.no-js .reveal { opacity:1 }` rule keeps content visible even
|
||||
if the reveal script fails. Theme model: data-theme on <html>, data-surface
|
||||
on <body>. DARK MODE IS CURRENTLY FORCED — light mode is deferred (the
|
||||
site is designed for dark first). theme-init.ts pins data-theme="dark" and
|
||||
ignores any stored/OS preference until light mode is reintroduced. -->
|
||||
<html lang="en" class="no-js" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
{/* Dev-only react-refresh preamble. MUST be the first module script so the
|
||||
refresh globals exist before any React island module evaluates. Without
|
||||
it, islands throw at eval time and never hydrate (see astro.config.mjs). */}
|
||||
{isDev && (
|
||||
<script type="module" set:html={REACT_REFRESH_PREAMBLE} />
|
||||
)}
|
||||
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<meta name="theme-color" content="#131c2b" />
|
||||
|
||||
<!-- Open Graph / social cards -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:site_name" content={siteIdentity.name} />
|
||||
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<!-- Favicons and app icons: the Greg Kilmer package from the Patiun brand
|
||||
files (GK monogram on Midnight). -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<!-- Theme boot: blocking inline script prevents flash of the wrong theme.
|
||||
Dark mode is currently forced (light mode deferred), so this just pins
|
||||
data-theme="dark" before paint. No data-astro-rerun needed since the
|
||||
theme never changes across navigations. -->
|
||||
<script is:inline set:html={themeInitScript} />
|
||||
|
||||
<!-- Reveal-on-scroll observer. Pairs with Reveal.astro / [data-reveal].
|
||||
Loaded as a module so it self-initializes on load + on View Transitions
|
||||
(it listens for astro:page-load internally). -->
|
||||
<script>
|
||||
import "@/scripts/reveal-init";
|
||||
</script>
|
||||
|
||||
<!-- View Transitions flash-killer. The incoming page is snapshotted with its
|
||||
`.reveal` elements still at opacity:0 (the observer hasn't fired yet),
|
||||
which makes content vanish during the morph. This toggles a class on
|
||||
<html> around the swap so global.css forces reveals visible mid-flight,
|
||||
then lets the observer take over for scroll reveals on the new page. -->
|
||||
<script is:inline>
|
||||
document.addEventListener("astro:before-swap", () => {
|
||||
document.documentElement.classList.add("vt-transitioning");
|
||||
});
|
||||
document.addEventListener("astro:after-swap", () => {
|
||||
document.documentElement.classList.remove("vt-transitioning");
|
||||
});
|
||||
</script>
|
||||
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body data-surface={surface} data-page={isHome ? "home" : "other"}>
|
||||
<a
|
||||
href="#main"
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-(--color-teal) focus:px-4 focus:py-2 focus:text-(--color-midnight)"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
{storeNav ? <StoreNav /> : <NavBar isHome={isHome} />}
|
||||
|
||||
<main id="main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
/**
|
||||
* StoreLayout.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Thin wrapper around BaseLayout for all store pages. Pins:
|
||||
* - surface="interest" (warm candlelit tone — clearly a store, still on-brand)
|
||||
* - storeNav (renders StoreNav, NOT the main NavBar — no cross-links)
|
||||
* - noindex=true by default (store stays out of search until Greg is ready)
|
||||
*
|
||||
* Store pages pass their own title/description; everything else is defaulted
|
||||
* here so each page stays short.
|
||||
*/
|
||||
import BaseLayout from '@layouts/BaseLayout.astro';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
/** Override the default noindex=true if you want the store indexed. */
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Greg Kilmer\u2019s store: clearly-marked proxy MTG cards and 3D-printed deck boxes & counters. Order requests by email.',
|
||||
ogImage,
|
||||
noindex = true,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
surface="interest"
|
||||
storeNav
|
||||
noindex={noindex}
|
||||
ogImage={ogImage}
|
||||
>
|
||||
<slot />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* archidekt.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Server-side helper for pulling Greg's public MTG decks from Archidekt into
|
||||
* the TCGs page. The site is SSR (output: 'server'), so this runs on the
|
||||
* server — no CORS or API key required (Archidekt's decks list endpoint is
|
||||
* public JSON).
|
||||
*
|
||||
* Endpoint: GET https://archidekt.com/api/decks/v3/?ownerUsername=<username>
|
||||
* -> { count, results: [{ id, name, featured, colors, size, deckFormat,
|
||||
* updatedAt, private, unlisted, theorycrafted, ... }] }
|
||||
*
|
||||
* Decks are cached to a filesystem file for `CACHE_TTL_MS` (default 1 hour) so
|
||||
* repeat page loads are instant and we're gentle on Archidekt. The cache is
|
||||
* stale-while-revalidate: if a refresh fails, we serve the stale data rather
|
||||
* than erroring. Every failure path returns an empty list — the page renders a
|
||||
* fallback link and never breaks.
|
||||
*/
|
||||
|
||||
/** Greg's Archidekt username. Change here to point at a different account. */
|
||||
export const ARCHIDEKT_USERNAME = 'Patiun';
|
||||
|
||||
/** How long the cached deck list is considered fresh. Default: 1 hour. */
|
||||
export const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
const API_BASE = 'https://archidekt.com/api/decks/v3';
|
||||
const USER_AGENT = 'GregKilmerProfileSite/1.0 (+https://gregkilmer.com)';
|
||||
|
||||
/** Known Archidekt deckFormat ids → labels. Add as needed. */
|
||||
const FORMAT_LABELS: Record<number, string> = {
|
||||
1: 'Casual',
|
||||
2: 'Modern',
|
||||
3: 'Commander',
|
||||
5: 'Standard',
|
||||
7: 'Legacy',
|
||||
10: 'Pauper',
|
||||
14: 'Historic',
|
||||
16: 'Pioneer',
|
||||
37: 'Oathbreaker',
|
||||
45: 'Brawl',
|
||||
};
|
||||
|
||||
export interface ArchidektDeck {
|
||||
id: number;
|
||||
name: string;
|
||||
/** Featured card art URL (may be empty). */
|
||||
featured: string;
|
||||
/** Mana color counts present in the deck. */
|
||||
colors: { W: number; U: number; B: number; R: number; G: number };
|
||||
/** Total card count. */
|
||||
size: number;
|
||||
/** Archidekt format id. */
|
||||
formatId: number;
|
||||
/** Human-readable format label (e.g. "Commander"). */
|
||||
formatLabel: string;
|
||||
/** ISO timestamp of last update. */
|
||||
updatedAt: string;
|
||||
/** Direct link to the deck on Archidekt. */
|
||||
href: string;
|
||||
theorycrafted: boolean;
|
||||
}
|
||||
|
||||
export const formatLabel = (id: number): string => FORMAT_LABELS[id] ?? 'Custom';
|
||||
|
||||
interface RawDeck {
|
||||
id: number;
|
||||
name: string;
|
||||
featured?: string;
|
||||
customFeatured?: string;
|
||||
colors?: { W?: number; U?: number; B?: number; R?: number; G?: number };
|
||||
size?: number;
|
||||
deckFormat?: number;
|
||||
updatedAt?: string;
|
||||
private?: boolean;
|
||||
unlisted?: boolean;
|
||||
theorycrafted?: boolean;
|
||||
}
|
||||
|
||||
const fileURL = (username: string): string =>
|
||||
// node_modules/.cache is gitignored and survives across dev reloads.
|
||||
new URL(
|
||||
`../node_modules/.cache/archidekt-${username}.json`,
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
/**
|
||||
* Reads the cached decks for `username` if still fresh, else null.
|
||||
* Best-effort: any read/parse error returns null (treated as a miss).
|
||||
*/
|
||||
async function readCache(username: string): Promise<ArchidektDeck[] | null> {
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const raw = await readFile(fileURL(username), 'utf8');
|
||||
const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] };
|
||||
if (!data?.fetchedAt || !Array.isArray(data.decks)) return null;
|
||||
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null;
|
||||
return data.decks;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the cache. Failures are non-fatal (best-effort). */
|
||||
async function writeCache(username: string, decks: ArchidektDeck[]): Promise<void> {
|
||||
try {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises');
|
||||
const { dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const path = fileURLToPath(fileURL(username));
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, JSON.stringify({ fetchedAt: Date.now(), decks }, null, 2));
|
||||
} catch {
|
||||
/* cache write is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads stale cache regardless of TTL (used as a fallback on fetch failure). */
|
||||
async function readStale(username: string): Promise<ArchidektDeck[] | null> {
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const raw = await readFile(fileURL(username), 'utf8');
|
||||
const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] };
|
||||
return Array.isArray(data?.decks) ? data.decks : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Greg's public Archidekt decks, using a ~1h filesystem cache.
|
||||
* Never throws — on any failure returns an empty list (page shows a fallback).
|
||||
*/
|
||||
export async function getDecks(username = ARCHIDEKT_USERNAME): Promise<ArchidektDeck[]> {
|
||||
// 1. Fresh cache hit.
|
||||
const cached = await readCache(username);
|
||||
if (cached) return cached;
|
||||
|
||||
// 2. Live fetch.
|
||||
const url = `${API_BASE}/?ownerUsername=${encodeURIComponent(username)}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||
// Don't let a slow Archidekt hang the page render.
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Archidekt responded ${res.status}`);
|
||||
const data = (await res.json()) as { results?: RawDeck[] };
|
||||
const decks: ArchidektDeck[] = (data.results ?? [])
|
||||
.filter((d) => !d.private && !d.unlisted)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
featured: d.customFeatured || d.featured || '',
|
||||
colors: {
|
||||
W: d.colors?.W ?? 0,
|
||||
U: d.colors?.U ?? 0,
|
||||
B: d.colors?.B ?? 0,
|
||||
R: d.colors?.R ?? 0,
|
||||
G: d.colors?.G ?? 0,
|
||||
},
|
||||
size: d.size ?? 0,
|
||||
formatId: d.deckFormat ?? 0,
|
||||
formatLabel: formatLabel(d.deckFormat ?? 0),
|
||||
updatedAt: d.updatedAt ?? '',
|
||||
href: `https://archidekt.com/decks/${d.id}`,
|
||||
theorycrafted: Boolean(d.theorycrafted),
|
||||
}));
|
||||
|
||||
await writeCache(username, decks);
|
||||
return decks;
|
||||
} catch {
|
||||
// 3. Stale-while-revalidate fallback, else empty.
|
||||
return (await readStale(username)) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The Archidekt search-URL for the full decks list (used in fallback links). */
|
||||
export const archidektSearchUrl = (username = ARCHIDEKT_USERNAME): string =>
|
||||
`https://archidekt.com/search/decks?ownerUsername=${encodeURIComponent(username)}`;
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* brand-icons.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* The Patiun UI icon set (24px grid, 1.6 stroke, round caps, at most one gold
|
||||
* four-point star accent). Generated from the Patiun brand icon source
|
||||
* (branding/source, newicons.py); edit there and regenerate rather than by hand.
|
||||
*
|
||||
* Each entry is the SVG body only. Lines use currentColor; the gold accent uses
|
||||
* var(--icon-gold), which global.css sets per ground (deep gold on Starlight,
|
||||
* flat gold on the night sky).
|
||||
*/
|
||||
|
||||
export const brandIcons: Record<string, string> = {
|
||||
"alert": "<path d=\"M12 4 21 19.5H3z\"/><path d=\"M12 10v4.5\"/><circle cx=\"12\" cy=\"17.2\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/>",
|
||||
"animation": "<rect x=\"7.5\" y=\"7.5\" width=\"13\" height=\"12\" rx=\"1.5\"/><path d=\"M5 16.5V6.5A1.5 1.5 0 0 1 6.5 5H16\"/><path d=\"M3 13.5V4A1 1 0 0 1 4 3h9\"/><path d=\"M12.5 10.8v5.4l4.3-2.7z\"/>",
|
||||
"arrow-left": "<path d=\"M19.5 12h-15M10 6.5 4.5 12l5.5 5.5\"/>",
|
||||
"arrow-right": "<path d=\"M4.5 12h15M14 6.5l5.5 5.5-5.5 5.5\"/>",
|
||||
"bookshelf": "<path d=\"M3.5 20.5h17\"/><rect x=\"5\" y=\"6\" width=\"3\" height=\"14.5\" rx=\"0.5\"/><rect x=\"8.5\" y=\"4\" width=\"3\" height=\"16.5\" rx=\"0.5\"/><path d=\"M13 7.2l2.9-.8 3.9 13.6-2.9.8z\"/>",
|
||||
"brain": "<path d=\"M12 5.5a3 3 0 0 0-5.6-1.2A3 3 0 0 0 4 8.8a3.2 3.2 0 0 0 .3 5.4A3 3 0 0 0 7.5 18.6 3 3 0 0 0 12 19.5z\"/><path d=\"M12 5.5a3 3 0 0 1 5.6-1.2A3 3 0 0 1 20 8.8a3.2 3.2 0 0 1-.3 5.4 3 3 0 0 1-3.2 4.4A3 3 0 0 1 12 19.5z\"/><path d=\"M12 5.5v14\"/>",
|
||||
"briefcase": "<rect x=\"3.5\" y=\"7.5\" width=\"17\" height=\"12\" rx=\"1.8\"/><path d=\"M9 7.5V5.8A1.3 1.3 0 0 1 10.3 4.5h3.4A1.3 1.3 0 0 1 15 5.8v1.7\"/><path d=\"M3.5 12.5h17\"/><polygon class=\"acc\" points=\"12.00,9.84 12.56,11.94 14.66,12.50 12.56,13.06 12.00,15.16 11.44,13.06 9.34,12.50 11.44,11.94\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"calendar": "<rect x=\"3.5\" y=\"5\" width=\"17\" height=\"15.5\" rx=\"1.8\"/><path d=\"M3.5 9.5h17M8 3v4M16 3v4\"/><path d=\"M12 12.3v2.7l1.8 1.1\"/>",
|
||||
"castle": "<path d=\"M4 20.5V8h3v2h2.5V8h5v2H17V8h3v12.5z\"/><path d=\"M10 20.5v-4a2 2 0 0 1 4 0v4\"/><polygon class=\"acc\" points=\"12.00,1.68 12.53,3.67 14.52,4.20 12.53,4.73 12.00,6.72 11.47,4.73 9.48,4.20 11.47,3.67\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"chart-bar": "<path d=\"M4 20h16\"/><path d=\"M7 20v-6M12 20V8M17 20v-9.5\"/><polygon class=\"acc\" points=\"12.00,1.84 12.56,3.94 14.66,4.50 12.56,5.06 12.00,7.16 11.44,5.06 9.34,4.50 11.44,3.94\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"chart-line": "<path d=\"M4 4v16h16\"/><path d=\"M7.5 15.5l3.5-4 3 2.5 5-6.5\"/>",
|
||||
"check": "<path d=\"M5 12.5l4.5 4.5L19 7.5\"/>",
|
||||
"chess": "<path d=\"M8 20.5h8M7 18h10M8.5 18l1-6h5l1 6\"/><path d=\"M8.5 12h7M10 12 9 8.5h6L14 12\"/><path d=\"M12 3v3.5M10.5 4.7h3\"/>",
|
||||
"chevron-down": "<path d=\"M6 9.5l6 6 6-6\"/>",
|
||||
"chevron-left": "<path d=\"M14.5 6 8.5 12l6 6\"/>",
|
||||
"chevron-right": "<path d=\"M9.5 6l6 6-6 6\"/>",
|
||||
"clapper": "<rect x=\"3.5\" y=\"9.5\" width=\"17\" height=\"11\" rx=\"1.5\"/><path d=\"M3.5 9.5 19 5.3l.8 2.9\"/><path d=\"M8.2 8.2l1.6 2.9M13.5 6.8l1.6 2.9\"/>",
|
||||
"clipboard": "<rect x=\"5\" y=\"5\" width=\"14\" height=\"16\" rx=\"1.8\"/><path d=\"M9 5V4.2A1.2 1.2 0 0 1 10.2 3h3.6A1.2 1.2 0 0 1 15 4.2V5\"/><path d=\"M8.5 10h7M8.5 13.5h7M8.5 17h4.5\"/>",
|
||||
"clipboard-check": "<rect x=\"5\" y=\"5\" width=\"14\" height=\"16\" rx=\"1.8\"/><path d=\"M9 5V4.2A1.2 1.2 0 0 1 10.2 3h3.6A1.2 1.2 0 0 1 15 4.2V5\"/><path d=\"M8.5 13l2.5 2.5 4.5-5\"/>",
|
||||
"clock": "<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 7v5l3.2 2\"/>",
|
||||
"close": "<path d=\"M6 6l12 12M18 6 6 18\"/>",
|
||||
"cloud": "<path d=\"M7 18.5a4 4 0 0 1-.6-8A5.5 5.5 0 0 1 17 9.2a4.7 4.7 0 0 1 .2 9.3z\"/>",
|
||||
"cog": "<polygon points=\"12.76,3.43 14.58,3.80 15.05,6.15 16.24,6.94 18.59,6.48 19.62,8.02 18.30,10.02 18.57,11.42 20.57,12.76 20.20,14.58 17.85,15.05 17.06,16.24 17.52,18.59 15.98,19.62 13.98,18.30 12.58,18.57 11.24,20.57 9.42,20.20 8.95,17.85 7.76,17.06 5.41,17.52 4.38,15.98 5.70,13.98 5.43,12.58 3.43,11.24 3.80,9.42 6.15,8.95 6.94,7.76 6.48,5.41 8.02,4.38 10.02,5.70 11.42,5.43\"/><circle cx=\"12\" cy=\"12\" r=\"2.8\"/>",
|
||||
"commission": "<path d=\"M15.5 3.5 20.5 8.5 11 18l-5.5 1.5L7 14z\"/><path d=\"M13.5 5.5l5 5\"/><path d=\"M5.5 19.5 9 16\"/><polygon class=\"acc\" points=\"19.00,14.36 19.77,17.23 22.64,18.00 19.77,18.77 19.00,21.64 18.23,18.77 15.36,18.00 18.23,17.23\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"contact": "<rect x=\"3\" y=\"5.5\" width=\"18\" height=\"13\" rx=\"1.5\"/><path d=\"m3.5 6.5 8.5 6.5 8.5-6.5\"/>",
|
||||
"cube": "<path d=\"M12 3.5 20 8v8l-8 4.5L4 16V8z\"/><path d=\"M4 8l8 4.5L20 8M12 12.5v8\"/>",
|
||||
"devices": "<rect x=\"3\" y=\"6\" width=\"11\" height=\"8.5\" rx=\"1.2\"/><path d=\"M2 17.5h13\"/><rect x=\"16\" y=\"8\" width=\"5.5\" height=\"12\" rx=\"1.2\"/><path d=\"M18 18h1.5\"/>",
|
||||
"dice": "<polygon points=\"12.00,3.00 19.79,7.50 19.79,16.50 12.00,21.00 4.21,16.50 4.21,7.50\"/><path d=\"M12 6.5 7.3 14.6h9.4z\"/><path d=\"M12 3v3.5M4.2 16.5l3.1-1.9M19.8 16.5l-3.1-1.9\"/>",
|
||||
"dog": "<path d=\"M8.2 6.5C9.5 5.6 14.5 5.6 15.8 6.5l1.2 5.5c0 4-2.2 7.5-5 7.5s-5-3.5-5-7.5z\"/><path d=\"M8.2 6.5C6 5.8 3.8 7.8 4.3 11.8c.3 1.6 1.8 1.6 2.7.2M15.8 6.5c2.2-.7 4.4 1.3 3.9 5.3-.3 1.6-1.8 1.6-2.7.2\"/><circle cx=\"10\" cy=\"11\" r=\"0.8\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"14\" cy=\"11\" r=\"0.8\" fill=\"currentColor\" stroke=\"none\"/><path d=\"M11 14.5h2l-1 1.2z\"/>",
|
||||
"external": "<path d=\"M13.5 4.5h6v6M19.5 4.5 11 13\"/><path d=\"M17.5 13.5v5a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 18.5V8A1.5 1.5 0 0 1 6 6.5h5\"/>",
|
||||
"factory": "<path d=\"M3.5 20.5V11l5 3V11l5 3V11l5 3V5h2v15.5z\"/><path d=\"M7 17.5h2M11.5 17.5h2M16 17.5h2\"/>",
|
||||
"favorite": "<path d=\"M12 3.5 13.6 10.4 20.5 12 13.6 13.6 12 20.5 10.4 13.6 3.5 12 10.4 10.4z\"/>",
|
||||
"film": "<rect x=\"4\" y=\"3.5\" width=\"16\" height=\"17\" rx=\"1.5\"/><path d=\"M8 3.5v17M16 3.5v17M4 8h4M4 12h4M4 16h4M16 8h4M16 12h4M16 16h4\"/>",
|
||||
"gallery": "<rect x=\"7\" y=\"3.5\" width=\"13.5\" height=\"12\" rx=\"1.5\"/><path d=\"M4 7v11.5A1.5 1.5 0 0 0 5.5 20H17\"/><path d=\"M8.5 13.5l3.5-3.5 3 3 1.5-1.5 2.5 2.5\"/><polygon class=\"acc\" points=\"16.20,5.02 16.70,6.90 18.58,7.40 16.70,7.90 16.20,9.78 15.70,7.90 13.82,7.40 15.70,6.90\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"gamepad": "<path d=\"M7 7.5h10c2.4 0 3.9 1.6 4.3 4l.6 4.2c.3 2-1.9 3.4-3.5 2l-2.4-2.2H8l-2.4 2.2c-1.6 1.4-3.8 0-3.5-2l.6-4.2c.4-2.4 1.9-4 4.3-4z\"/><path d=\"M7.5 10.5v3M6 12h3\"/><circle cx=\"15.5\" cy=\"11\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"17.5\" cy=\"13\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/>",
|
||||
"gauge": "<path d=\"M4 17a8.5 8.5 0 1 1 16 0\"/><path d=\"M12 15l4-5\"/><circle cx=\"12\" cy=\"15\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\"/><path d=\"M6.5 12.5h1M12 6.8v1M17.5 12.5h-1\"/>",
|
||||
"gem": "<path d=\"M7 4h10l3.5 5L12 20.5 3.5 9z\"/><path d=\"M3.5 9h17M9.5 9 12 20.5 14.5 9M9.5 9 12 4l2.5 5\"/>",
|
||||
"graduation": "<path d=\"M2.5 9.5 12 5l9.5 4.5L12 14z\"/><path d=\"M6.5 11.5v4.5c3.5 2.6 7.5 2.6 11 0v-4.5\"/><path d=\"M21.5 9.5v5\"/>",
|
||||
"grid": "<rect x=\"4\" y=\"4\" width=\"6.5\" height=\"6.5\" rx=\"1.2\"/><rect x=\"13.5\" y=\"4\" width=\"6.5\" height=\"6.5\" rx=\"1.2\"/><rect x=\"4\" y=\"13.5\" width=\"6.5\" height=\"6.5\" rx=\"1.2\"/><rect x=\"13.5\" y=\"13.5\" width=\"6.5\" height=\"6.5\" rx=\"1.2\"/>",
|
||||
"hammer": "<rect x=\"4\" y=\"4.5\" width=\"12.5\" height=\"4.5\" rx=\"1.2\"/><path d=\"M16.5 6.75h3\"/><path d=\"M10.25 9V20.5\"/>",
|
||||
"heart": "<path d=\"M12 19.5S4 14.8 4 9.3A4 4 0 0 1 12 7.6a4 4 0 0 1 8 1.7c0 5.5-8 10.2-8 10.2z\"/>",
|
||||
"hearth": "<path d=\"M4 11 12 4.5 20 11\"/><path d=\"M6 9.5V19.5h12V9.5\"/><path d=\"M12 17.5s-3.2-2-3.2-4.1a1.6 1.6 0 0 1 3.2-.5 1.6 1.6 0 0 1 3.2.5c0 2.1-3.2 4.1-3.2 4.1z\"/>",
|
||||
"home": "<path d=\"M4 11 12 4.5 20 11\"/><path d=\"M6 9.5V19.5h12V9.5\"/><path d=\"M10 19.5v-5h4v5\"/>",
|
||||
"info": "<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 11v5.5\"/><circle cx=\"12\" cy=\"7.9\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/>",
|
||||
"leather": "<path d=\"M7 4c1.5 1 3 1.3 5 1.3S15.5 5 17 4c.6 1.6 1.8 2.6 3 3-.9 2-1 4-.3 6.2.5 1.6.2 3.4-1 4.6-1 .9-2.4 1.3-3.7 2.2H9c-1.3-.9-2.7-1.3-3.7-2.2-1.2-1.2-1.5-3-1-4.6.7-2.2.6-4.2-.3-6.2 1.2-.4 2.4-1.4 3-3z\"/><path d=\"M8 9.5v7M16 9.5v7\" stroke-dasharray=\".1 2.2\"/>",
|
||||
"link": "<path d=\"M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2\"/><path d=\"M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2\"/>",
|
||||
"list": "<circle cx=\"5\" cy=\"6.5\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"5\" cy=\"12\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"5\" cy=\"17.5\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/><path d=\"M9 6.5h11M9 12h11M9 17.5h11\"/>",
|
||||
"list-text": "<path d=\"M4 6h16M4 10h10M4 14h16M4 18h10\"/>",
|
||||
"location": "<path d=\"M12 21s-6.5-6.2-6.5-11.2a6.5 6.5 0 0 1 13 0C18.5 14.8 12 21 12 21z\"/><polygon class=\"acc\" points=\"12.00,5.88 12.83,8.97 15.92,9.80 12.83,10.63 12.00,13.72 11.17,10.63 8.08,9.80 11.17,8.97\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"lock": "<rect x=\"5\" y=\"10.5\" width=\"14\" height=\"10\" rx=\"1.8\"/><path d=\"M8 10.5V8a4 4 0 0 1 8 0v2.5\"/><path d=\"M12 14.5v2\"/>",
|
||||
"menu": "<path d=\"M4 7h16M4 12h11M4 17h16\"/><polygon class=\"acc\" points=\"19.20,8.92 19.85,11.35 22.28,12.00 19.85,12.65 19.20,15.08 18.55,12.65 16.12,12.00 18.55,11.35\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"monitor": "<rect x=\"3\" y=\"4\" width=\"18\" height=\"12.5\" rx=\"1.5\"/><path d=\"M9 20.5h6M12 16.5v4\"/>",
|
||||
"our-story": "<path d=\"M12 6.5C10 5 7 4.6 3.5 5v13c3.5-.4 6.5 0 8.5 1.5 2-1.5 5-1.9 8.5-1.5V5C17 4.6 14 5 12 6.5z\"/><path d=\"M12 6.5v13\"/><polygon class=\"acc\" points=\"7.70,8.42 8.35,10.85 10.78,11.50 8.35,12.15 7.70,14.58 7.05,12.15 4.62,11.50 7.05,10.85\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"palette": "<path d=\"M12 3.5a8.5 8.5 0 0 0 0 17c1.3 0 1.9-.9 1.6-2-.4-1.4.3-2.5 1.8-2.5H17a3.5 3.5 0 0 0 3.5-3.5c0-5-3.8-9-8.5-9z\"/><circle cx=\"7.8\" cy=\"11.5\" r=\"1.1\"/><circle cx=\"10\" cy=\"7.6\" r=\"1.1\"/><circle cx=\"14.5\" cy=\"7.6\" r=\"1.1\"/><polygon class=\"acc\" points=\"17.00,9.26 17.48,11.02 19.24,11.50 17.48,11.98 17.00,13.74 16.52,11.98 14.76,11.50 16.52,11.02\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"people": "<circle cx=\"9\" cy=\"8.5\" r=\"3\"/><path d=\"M3 19c.6-3.2 3-5 6-5s5.4 1.8 6 5\"/><path d=\"M15.2 5.8a3 3 0 0 1 0 5.6M17.4 14.3c1.8.7 3 2.5 3.4 4.7\"/>",
|
||||
"person": "<circle cx=\"12\" cy=\"8\" r=\"3.6\"/><path d=\"M4.5 20c.8-3.8 3.8-6 7.5-6s6.7 2.2 7.5 6\"/>",
|
||||
"person-details": "<circle cx=\"8.5\" cy=\"8.5\" r=\"3\"/><path d=\"M2.8 19c.6-3.2 3-5 5.7-5s5.1 1.8 5.7 5\"/><path d=\"M15 8h6M15 11.5h6M17 15h4\"/>",
|
||||
"play": "<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M10 8.5v7l5.5-3.5z\"/>",
|
||||
"price": "<path d=\"M3.5 12.8V4.5a1 1 0 0 1 1-1h8.3l8.2 8.2-9 9z\"/><circle cx=\"8\" cy=\"8\" r=\"1.5\"/><polygon class=\"acc\" points=\"14.60,11.60 15.19,13.81 17.40,14.40 15.19,14.99 14.60,17.20 14.01,14.99 11.80,14.40 14.01,13.81\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"print": "<path d=\"M12 3.5 20 8l-8 4.5L4 8z\"/><path d=\"M4 11.5l8 4.5 8-4.5\"/><path d=\"M4 15l8 4.5 8-4.5\"/>",
|
||||
"proxy-cards": "<path d=\"M8.5 6.5H6A1.5 1.5 0 0 0 4.5 8v12A1.5 1.5 0 0 0 6 21.5h8a1.5 1.5 0 0 0 1.5-1.5v-.5\"/><rect x=\"8.5\" y=\"3\" width=\"11\" height=\"16.5\" rx=\"1.5\"/><rect x=\"10.6\" y=\"5.1\" width=\"6.8\" height=\"12.3\" rx=\".6\" stroke-width=\".9\"/><polygon class=\"acc\" points=\"14.00,7.75 14.74,10.51 17.50,11.25 14.74,11.99 14.00,14.75 13.26,11.99 10.50,11.25 13.26,10.51\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"robot": "<rect x=\"5\" y=\"8\" width=\"14\" height=\"11\" rx=\"2.5\"/><path d=\"M12 8V5\"/><circle cx=\"12\" cy=\"4\" r=\"1\"/><circle cx=\"9.3\" cy=\"13\" r=\"1.2\"/><circle cx=\"14.7\" cy=\"13\" r=\"1.2\"/><path d=\"M9.5 16.5h5M3 12.5v2.5M21 12.5v2.5\"/>",
|
||||
"round-knife": "<path d=\"M4 12.5h16a8 8 0 0 1-16 0z\"/><path d=\"M12 12.5V7\"/><rect x=\"10.3\" y=\"3\" width=\"3.4\" height=\"4\" rx=\"1\"/>",
|
||||
"ruler": "<path d=\"M3.5 15.5 15.5 3.5l5 5-12 12z\"/><path d=\"M7 12l1.8 1.8M9.5 9.5l1.2 1.2M12 7l1.8 1.8M14.5 4.5l1.2 1.2\"/>",
|
||||
"scissors": "<circle cx=\"6.5\" cy=\"17.5\" r=\"2.5\"/><circle cx=\"6.5\" cy=\"6.5\" r=\"2.5\"/><path d=\"M8.6 7.9 20 17M8.6 16.1 20 7\"/>",
|
||||
"search": "<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m15.5 15.5 5 5\"/><polygon class=\"acc\" points=\"10.50,7.14 11.21,9.79 13.86,10.50 11.21,11.21 10.50,13.86 9.79,11.21 7.14,10.50 9.79,9.79\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"server": "<rect x=\"4\" y=\"3.5\" width=\"16\" height=\"6.5\" rx=\"1.5\"/><rect x=\"4\" y=\"12\" width=\"16\" height=\"6.5\" rx=\"1.5\"/><circle cx=\"7.5\" cy=\"6.8\" r=\"0.8\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"7.5\" cy=\"15.3\" r=\"0.8\" fill=\"currentColor\" stroke=\"none\"/><path d=\"M12 18.5v2.5M7 21h10\"/>",
|
||||
"shapes": "<circle cx=\"7.5\" cy=\"7.5\" r=\"4\"/><rect x=\"13\" y=\"13\" width=\"7.5\" height=\"7.5\" rx=\"1\"/><path d=\"M17 3.5l4 7h-8z\"/><path d=\"M3.5 20.5 7.5 13.5l4 7z\"/>",
|
||||
"shield-moon": "<path d=\"M12 3.5 19.5 6v5.5c0 4.5-3.1 7.9-7.5 9.5-4.4-1.6-7.5-5-7.5-9.5V6z\"/><path d=\"M13.8 8.8a3.6 3.6 0 1 0 1.5 5.6 3 3 0 0 1-1.5-5.6z\"/>",
|
||||
"shop": "<path d=\"M5 8h14l-1.2 12H6.2z\"/><path d=\"M9 8V6.5a3 3 0 0 1 6 0V8\"/><polygon class=\"acc\" points=\"12.00,10.64 12.71,13.29 15.36,14.00 12.71,14.71 12.00,17.36 11.29,14.71 8.64,14.00 11.29,13.29\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"split": "<path d=\"M12 21v-7.5L6 7M12 13.5 18 7\"/><path d=\"M6 11V7h4M18 11V7h-4\"/>",
|
||||
"staff": "<path d=\"M6 21 14.5 9.5\"/><circle cx=\"16.5\" cy=\"7\" r=\"3.2\"/><polygon class=\"acc\" points=\"16.50,4.34 17.06,6.44 19.16,7.00 17.06,7.56 16.50,9.66 15.94,7.56 13.84,7.00 15.94,6.44\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/><path d=\"M4.5 18.5l3 3\"/>",
|
||||
"swords": "<path d=\"M4.5 4.5 14.5 14.5M19.5 4.5 9.5 14.5\"/><path d=\"M12.5 16.5l4-4M7.5 12.5l4 4\"/><path d=\"M15 15l3.5 3.5M9 15l-3.5 3.5\"/><circle cx=\"19.2\" cy=\"19.2\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"4.8\" cy=\"19.2\" r=\"0.9\" fill=\"currentColor\" stroke=\"none\"/>",
|
||||
"target": "<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><circle cx=\"12\" cy=\"12\" r=\"4.8\"/><polygon class=\"acc\" points=\"12.00,9.20 12.59,11.41 14.80,12.00 12.59,12.59 12.00,14.80 11.41,12.59 9.20,12.00 11.41,11.41\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"tea": "<path d=\"M5 10h12v3.5a6 6 0 0 1-12 0z\"/><path d=\"M17 11.5h1.5a2 2 0 0 1 0 4H16.5\"/><path d=\"M3.5 21h15\"/><path d=\"M9 3.5c-.8 1 .8 2-.1 3.2M12.5 3.5c-.8 1 .8 2-.1 3.2\"/>",
|
||||
"timeline": "<path d=\"M6 3.5v17\"/><circle cx=\"6\" cy=\"7\" r=\"1.5\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"6\" cy=\"12\" r=\"1.5\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"6\" cy=\"17\" r=\"1.5\" fill=\"currentColor\" stroke=\"none\"/><path d=\"M10.5 7h9.5M10.5 12h7M10.5 17h8.5\"/>",
|
||||
"toolbox": "<rect x=\"3.5\" y=\"9\" width=\"17\" height=\"10.5\" rx=\"1.5\"/><path d=\"M8.5 9V6.5A1.5 1.5 0 0 1 10 5h4a1.5 1.5 0 0 1 1.5 1.5V9\"/><path d=\"M3.5 13.5h17M10.5 12.3v2.4M13.5 12.3v2.4\"/>",
|
||||
"translate": "<path d=\"M3.5 4.5h10v8h-5.5l-3 2.5v-2.5h-1.5z\"/><path d=\"M15.5 9.5h5v8H19v2.5l-3-2.5h-5.5v-3\"/><path d=\"M6.5 10.5l2-4.5 2 4.5M7.2 9h2.6\"/><path d=\"M13.5 13h4.5M13.5 15h3\"/>",
|
||||
"trophy": "<path d=\"M7.5 4h9v5a4.5 4.5 0 0 1-9 0z\"/><path d=\"M7.5 6H4.5a3 3 0 0 0 3 4M16.5 6h3a3 3 0 0 1-3 4\"/><path d=\"M12 13.5v3.5M8 20.5h8M9.5 20.5V17h5v3.5\"/><polygon class=\"acc\" points=\"12.00,5.96 12.48,7.72 14.24,8.20 12.48,8.68 12.00,10.44 11.52,8.68 9.76,8.20 11.52,7.72\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"tv": "<rect x=\"3\" y=\"7\" width=\"18\" height=\"12.5\" rx=\"2\"/><path d=\"M8.5 3.5 12 7l3.5-3.5\"/><path d=\"M7 21h10\"/>",
|
||||
"unavailable": "<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M9 9l6 6M15 9l-6 6\"/>",
|
||||
"verified": "<path d=\"M12 3.2l2.2 1.6 2.7-.2.8 2.6 2.3 1.4-.9 2.6.9 2.6-2.3 1.4-.8 2.6-2.7-.2L12 20.8l-2.2-1.6-2.7.2-.8-2.6-2.3-1.4.9-2.6-.9-2.6 2.3-1.4.8-2.6 2.7.2z\"/><path d=\"M8.5 12.2l2.4 2.4 4.6-4.8\"/>",
|
||||
"wand": "<path d=\"M4 20 15 9\"/><path d=\"M13 7l4 4\"/><polygon class=\"acc\" points=\"18.50,1.86 19.27,4.73 22.14,5.50 19.27,6.27 18.50,9.14 17.73,6.27 14.86,5.50 17.73,4.73\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/><path d=\"M8 5.5v2M7 6.5h2M19 15v2M18 16h2\"/>",
|
||||
"web": "<path d=\"M8.5 7 3.5 12l5 5M15.5 7l5 5-5 5\"/><polygon class=\"acc\" points=\"12.00,7.52 12.95,11.05 16.48,12.00 12.95,12.95 12.00,16.48 11.05,12.95 7.52,12.00 11.05,11.05\" fill=\"var(--icon-gold, #C9A24A)\" stroke=\"none\"/>",
|
||||
"wind": "<path d=\"M3.5 9h11a3 3 0 1 0-3-3\"/><path d=\"M3.5 13h15a3 3 0 1 1-3 3\"/><path d=\"M3.5 17h7\"/>",
|
||||
"wrench": "<path d=\"M14.8 4.2a4.5 4.5 0 0 0-5.6 5.6L3.8 15.2a2 2 0 0 0 2.8 2.8l5.4-5.4a4.5 4.5 0 0 0 5.6-5.6l-2.7 2.7-2.6-.6-.6-2.6z\"/>",
|
||||
};
|
||||
|
||||
/** Material Design icon names used around the site, mapped to brand icons.
|
||||
* Third-party logos (GitHub, LinkedIn, Steam, PlayStation, Nintendo Switch,
|
||||
* Azure DevOps, Git, Discord, Poke Ball) are deliberately absent: they stay as
|
||||
* their owners drew them. */
|
||||
export const brandIconAliases: Record<string, string> = {
|
||||
"account": "person",
|
||||
"account-details": "person-details",
|
||||
"account-group": "people",
|
||||
"alert": "alert",
|
||||
"animation-play": "animation",
|
||||
"arrow-left": "arrow-left",
|
||||
"arrow-right": "arrow-right",
|
||||
"auto-fix": "wand",
|
||||
"book-open-page-variant": "our-story",
|
||||
"book-open-variant": "our-story",
|
||||
"bookshelf": "bookshelf",
|
||||
"brain": "brain",
|
||||
"briefcase": "briefcase",
|
||||
"calendar-clock": "calendar",
|
||||
"call-split": "split",
|
||||
"cards": "proxy-cards",
|
||||
"cards-outline": "proxy-cards",
|
||||
"cards-playing": "proxy-cards",
|
||||
"castle": "castle",
|
||||
"cellphone-link": "devices",
|
||||
"chart-bar": "chart-bar",
|
||||
"chart-line": "chart-line",
|
||||
"check": "check",
|
||||
"check-decagram": "verified",
|
||||
"chess-king": "chess",
|
||||
"chevron-down": "chevron-down",
|
||||
"chevron-left": "chevron-left",
|
||||
"chevron-right": "chevron-right",
|
||||
"clipboard-check": "clipboard-check",
|
||||
"clipboard-list": "clipboard",
|
||||
"clock-outline": "clock",
|
||||
"close": "close",
|
||||
"close-circle": "unavailable",
|
||||
"cloud": "cloud",
|
||||
"code-braces": "web",
|
||||
"cog-refresh": "cog",
|
||||
"content-cut": "scissors",
|
||||
"cube-outline": "cube",
|
||||
"currency-usd": "price",
|
||||
"diamond-stone": "gem",
|
||||
"dice-multiple": "dice",
|
||||
"dog": "dog",
|
||||
"email": "contact",
|
||||
"email-outline": "contact",
|
||||
"factory": "factory",
|
||||
"filmstrip": "film",
|
||||
"format-list-bulleted": "list",
|
||||
"format-list-text": "list-text",
|
||||
"gamepad-variant": "gamepad",
|
||||
"gauge": "gauge",
|
||||
"hammer": "hammer",
|
||||
"heart": "heart",
|
||||
"home": "home",
|
||||
"home-heart": "hearth",
|
||||
"image-multiple": "gallery",
|
||||
"information": "info",
|
||||
"knife-military": "round-knife",
|
||||
"link-variant": "link",
|
||||
"lock": "lock",
|
||||
"magic-staff": "staff",
|
||||
"map-marker": "location",
|
||||
"menu": "menu",
|
||||
"monitor": "monitor",
|
||||
"movie-open": "clapper",
|
||||
"open-in-new": "external",
|
||||
"palette": "palette",
|
||||
"pencil": "commission",
|
||||
"play-circle": "play",
|
||||
"printer-3d": "print",
|
||||
"printer-3d-nozzle": "print",
|
||||
"robot": "robot",
|
||||
"ruler": "ruler",
|
||||
"school": "graduation",
|
||||
"scissors-cutting": "scissors",
|
||||
"server-network": "server",
|
||||
"shape": "shapes",
|
||||
"shield-moon-outline": "shield-moon",
|
||||
"star": "favorite",
|
||||
"star-four-points": "favorite",
|
||||
"store": "shop",
|
||||
"sword-cross": "swords",
|
||||
"target": "target",
|
||||
"tea": "tea",
|
||||
"television-classic": "tv",
|
||||
"timeline-text": "timeline",
|
||||
"toolbox": "toolbox",
|
||||
"translate": "translate",
|
||||
"trophy": "trophy",
|
||||
"view-grid": "grid",
|
||||
"weather-windy": "wind",
|
||||
"wrench": "wrench",
|
||||
};
|
||||
|
||||
/** Resolve a brand icon body by brand name or mdi alias, or undefined. */
|
||||
export function brandIconBody(name: string): string | undefined {
|
||||
return brandIcons[name] ?? brandIcons[brandIconAliases[name] ?? ""];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* env.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Runtime env-var reader that works in BOTH local dev (`astro dev`, where the
|
||||
* `.env` file is NOT auto-loaded into `process.env`) AND the Docker production
|
||||
* runtime (where docker-compose `env_file` sets real `process.env`).
|
||||
*
|
||||
* Background: Astro/Vite loads `.env` into `import.meta.env`, NOT `process.env`
|
||||
* (https://github.com/withastro/roadmap/issues/853). We deliberately AVOID
|
||||
* `import.meta.env` for these reads because Vite statically replaces it at
|
||||
* build time — and for SECRET values (SMTP password, Turnstile secret key)
|
||||
* that means the secret gets baked into the built JS bundle in plaintext,
|
||||
* which is a security leak and defeats the point of env vars.
|
||||
*
|
||||
* Solution: at module load (once), pull values from the `.env` file via Vite's
|
||||
* `loadEnv` and merge any MISSING keys into `process.env`. Existing real env
|
||||
* vars (e.g. Docker's) always take precedence. All reads then go through
|
||||
* `process.env`, which is runtime-only and never baked into the bundle.
|
||||
*
|
||||
* `loadEnv` is imported from `vite`, which is already a transitive dependency
|
||||
* via Astro — no new dependency required.
|
||||
*/
|
||||
import { loadEnv } from 'vite';
|
||||
|
||||
// One-time: populate process.env from the .env file for keys not already set
|
||||
// by the real environment (Docker env_file / shell exports). This makes local
|
||||
// dev work without any extra setup, and is a no-op in Docker where process.env
|
||||
// is already fully populated.
|
||||
//
|
||||
// NOTE: do NOT reference `import.meta.env` anywhere in this module. Vite
|
||||
// statically replaces any `import.meta.env` usage at build time, and for a
|
||||
// bare reference it inlines the ENTIRE env object (including secrets like
|
||||
// SMTP_PASS / TURNSTILE_SECRET_KEY) as a literal in the bundle. Use
|
||||
// process.env.NODE_ENV to determine mode instead.
|
||||
try {
|
||||
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
||||
const fromFile = loadEnv(
|
||||
mode,
|
||||
process.cwd(),
|
||||
// Empty prefix = load ALL vars (not just PUBLIC_*). They land only in the
|
||||
// returned object, not the client bundle, because we copy them into
|
||||
// process.env here rather than referencing import.meta.env elsewhere.
|
||||
'',
|
||||
);
|
||||
for (const [key, value] of Object.entries(fromFile)) {
|
||||
if (process.env[key] === undefined && value !== '') {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// loadEnv throws if it can't find a project root / .env; safe to ignore in
|
||||
// environments where there's no .env (e.g. some serverless runtimes). Real
|
||||
// env vars set via the platform still work via process.env directly.
|
||||
}
|
||||
|
||||
/** Read a single env var from process.env (runtime only; never baked into bundle). */
|
||||
export function env(key: string): string | undefined {
|
||||
return process.env[key] || undefined;
|
||||
}
|
||||
|
||||
/** Public Cloudflare Turnstile site key (safe to ship to the client). */
|
||||
export const turnstileSiteKey = (): string | undefined => env('TURNSTILE_SITE_KEY');
|
||||
|
||||
/** Cloudflare Turnstile secret key (server-only). */
|
||||
export const turnstileSecretKey = (): string | undefined => env('TURNSTILE_SECRET_KEY');
|
||||
|
||||
/** SMTP relay host. When unset, the contact form returns 503. */
|
||||
export const smtpHost = (): string | undefined => env('SMTP_HOST');
|
||||
|
||||
/** SMTP relay port (defaults to 587 at the call site). */
|
||||
export const smtpPort = (): string | undefined => env('SMTP_PORT');
|
||||
|
||||
/** SMTP auth username, if the relay requires auth. */
|
||||
export const smtpUser = (): string | undefined => env('SMTP_USER');
|
||||
|
||||
/** SMTP auth password, if the relay requires auth. */
|
||||
export const smtpPass = (): string | undefined => env('SMTP_PASS');
|
||||
|
||||
/** Inbox that receives contact form submissions. */
|
||||
export const contactTo = (): string | undefined => env('CONTACT_TO');
|
||||
|
||||
/**
|
||||
* Inbox that receives store order submissions. Falls back to CONTACT_TO so the
|
||||
* store works without a separate inbox configured. Used by /api/order.
|
||||
*/
|
||||
export const orderTo = (): string | undefined => env('ORDER_TO') ?? contactTo();
|
||||
|
||||
/** From address for outgoing mail. */
|
||||
export const smtpFrom = (): string | undefined => env('SMTP_FROM');
|
||||
|
||||
/** Optional webhook URL for delivery fallback (Discord/Slack/n8n). */
|
||||
export const contactWebhookUrl = (): string | undefined => env('CONTACT_WEBHOOK_URL');
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* media.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Shared types and helpers for the media viewing-history feature. Three media
|
||||
* kinds (anime, tv, movies) plus reading live as JSON files in `src/data/`
|
||||
* (and `src/data/media/`), following the `boardgames.json` precedent.
|
||||
*
|
||||
* Each item uses one flexible shape: a `favorite` flag is the single source of
|
||||
* truth for what shows in the top "Favorites" grid (no duplicate list to keep
|
||||
* in sync), and `category` drives the grouping of the full library below.
|
||||
*
|
||||
* The JSON is the source of truth — these helpers just read it, filter, and
|
||||
* sort. Run the matching `scripts/fetch-*-art.mjs` tool to populate the
|
||||
* optional `image` field for favorites; the site serves local files from
|
||||
* `public/images/`, so no external API is hit at runtime.
|
||||
*/
|
||||
import moviesData from '@/data/media/movies.json';
|
||||
import tvData from '@/data/media/tv.json';
|
||||
import animeData from '@/data/media/anime.json';
|
||||
import readingData from '@/data/reading.json';
|
||||
|
||||
/** A single viewing-history or reading entry. Most fields optional. */
|
||||
export interface MediaItem {
|
||||
/** Display title, e.g. "Breaking Bad" or "The Matrix". */
|
||||
title: string;
|
||||
/** True → shown in the top "Favorites" grid as a rich card. */
|
||||
favorite?: boolean;
|
||||
/** Grouping label for the full library, e.g. "Prestige Drama". */
|
||||
category?: string;
|
||||
/** Consumption state pill, e.g. "Completed", "Watching", "Caught up". */
|
||||
status?: 'Completed' | 'Watching' | 'Caught up' | string;
|
||||
/** Release year (movies primarily). */
|
||||
year?: number;
|
||||
/** Human-readable span for a franchise, e.g. "All 4 films". */
|
||||
count?: string;
|
||||
/** Franchise constituent titles (movies franchises). */
|
||||
films?: string[];
|
||||
/** Long-form why-I-love-it copy shown on favorite cards. */
|
||||
blurb?: string;
|
||||
/** Short detail shown next to a full-list pill, e.g. "21/23 volumes". */
|
||||
note?: string;
|
||||
/** Local public path to poster/cover art, filled by the fetcher scripts. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** The four kinds backed by JSON data. */
|
||||
export type MediaKind = 'anime' | 'tv' | 'movies' | 'reading';
|
||||
|
||||
const dataByKind: Record<MediaKind, MediaItem[]> = {
|
||||
anime: animeData as MediaItem[],
|
||||
tv: tvData as MediaItem[],
|
||||
movies: moviesData as MediaItem[],
|
||||
reading: readingData as MediaItem[],
|
||||
};
|
||||
|
||||
/** Per-page metadata so each `.astro` page stays thin. */
|
||||
export interface MediaKindMeta {
|
||||
kind: MediaKind;
|
||||
/** mdi icon for the hero mark + hub card. */
|
||||
icon: string;
|
||||
/** Page heading. */
|
||||
title: string;
|
||||
/** Italic pull-quote tagline. */
|
||||
tagline: string;
|
||||
/** One-paragraph overview rendered under the hero. */
|
||||
overview: string;
|
||||
/** Hero banner image (public path) — vibe-matched stock; swap freely. */
|
||||
heroImage?: string;
|
||||
/** Alt text for the hero image. */
|
||||
heroImageAlt?: string;
|
||||
}
|
||||
|
||||
export const mediaKinds: Record<MediaKind, MediaKindMeta> = {
|
||||
anime: {
|
||||
kind: 'anime',
|
||||
icon: 'animation-play',
|
||||
title: 'Anime',
|
||||
tagline: 'Subbed, dubbed, and somehow still behind.',
|
||||
overview:
|
||||
"I don't dabble in anime. I live here. 150+ completed series and counting, and this isn't a side shelf next to my live-action watching. Shonen, isekai, dark psychological, whatever Frieren is. If it's mattered in the medium over the last couple decades, I've probably seen it. The backlog is its own final boss at this point.",
|
||||
heroImage: '/images/anime-hero.jpg',
|
||||
heroImageAlt: 'A cosplayer in a colorful anime-inspired costume at a convention',
|
||||
},
|
||||
tv: {
|
||||
kind: 'tv',
|
||||
icon: 'television-classic',
|
||||
title: 'TV Shows',
|
||||
tagline: 'My rewatch rotation has a rewatch rotation.',
|
||||
overview:
|
||||
"I watch TV like it's my job. 200+ completed series across decades and every platform that's mattered: the HBO prestige-drama run, the NBC comedy golden age, the streaming-original explosion, and the full Marvel and Star Wars rosters. If it was the show everyone was talking about, I was watching it. My comfort-rewatch rotation never stops growing.",
|
||||
heroImage: '/images/tv-hero.jpg',
|
||||
heroImageAlt: 'A cozy living room with a couch facing a TV',
|
||||
},
|
||||
movies: {
|
||||
kind: 'movies',
|
||||
icon: 'movie-open',
|
||||
title: 'Movies',
|
||||
tagline: 'I stayed for the post-credits scene.',
|
||||
overview:
|
||||
"When I love a franchise, I commit all the way: every film, every spinoff, every post-credits scene. 160+ films across 17+ franchises. But the real favorites are the ones I'll stop on any time they cross my path. The Princess Bride's quotability, The Matrix bending my brain, the MCU at its decade-long peak. Those are the comfort rewatches.",
|
||||
heroImage: '/images/movies-hero.jpg',
|
||||
heroImageAlt: 'Empty red seats in a vintage movie theater',
|
||||
},
|
||||
reading: {
|
||||
kind: 'reading',
|
||||
icon: 'book-open-page-variant',
|
||||
title: 'Reading',
|
||||
tagline: 'Perpetually three books ahead of my to-read list.',
|
||||
overview:
|
||||
"I read like it's a competitive sport. Fantasy is my native language. Brandon Sanderson's Cosmere is fully consumed and I am eagerly awaiting the next era. Frank Herbert's Dune trilogy shaped how I think about worldbuilding. Douglas Preston & Lincoln Child's Pendergast saga showed me that a well-crafted narrative can keep me engaged outside of a Tolkien-esque fantasy world. And I'm currently working through Sarah J. Maas's Throne of Glass series. Beyond novels, I read light novels, comic books, manhwa, and manga. If a story exists in any format, I'm probably consuming it in some capacity.",
|
||||
heroImage: '/images/reading-nook.jpg',
|
||||
heroImageAlt: 'A cozy reading nook with shelves of books',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Favorites for a kind: items flagged `favorite: true`, sorted alphabetically
|
||||
* by title (stable, predictable order across renders).
|
||||
*/
|
||||
export function getFavorites(kind: MediaKind): MediaItem[] {
|
||||
return dataByKind[kind]
|
||||
.filter((item) => item.favorite)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
export interface MediaGroup {
|
||||
category: string;
|
||||
items: MediaItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full library for a kind, grouped by `category` in order of first appearance
|
||||
* (so the JSON's authored ordering is preserved). Items without a category are
|
||||
* omitted — favorites-without-category belong only to the Favorites grid.
|
||||
*/
|
||||
export function getGrouped(kind: MediaKind): MediaGroup[] {
|
||||
const groups: MediaGroup[] = [];
|
||||
const indexByCategory = new Map<string, number>();
|
||||
|
||||
for (const item of dataByKind[kind]) {
|
||||
if (!item.category) continue;
|
||||
let idx = indexByCategory.get(item.category);
|
||||
if (idx === undefined) {
|
||||
idx = groups.length;
|
||||
indexByCategory.set(item.category, idx);
|
||||
groups.push({ category: item.category, items: [] });
|
||||
}
|
||||
groups[idx].items.push(item);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Total count of entries for a kind (favorites + full library, deduped by title). */
|
||||
export function getCount(kind: MediaKind): number {
|
||||
const titles = new Set<string>();
|
||||
for (const item of dataByKind[kind]) titles.add(item.title.toLowerCase());
|
||||
return titles.size;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* motion.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Shared animation helpers. Most scroll reveals are handled in CSS via the
|
||||
* `reveal` utility (IntersectionObserver in Reveal.astro, zero JS framework
|
||||
* cost). These are for the few React islands that genuinely need Framer Motion
|
||||
* (animated hero background, mobile menu, contact form states).
|
||||
*
|
||||
* Every helper here respects `prefers-reduced-motion`: when set, animations are
|
||||
* collapsed to instant final states.
|
||||
*/
|
||||
|
||||
/** True when the user has asked the OS to minimize motion. SSR-safe. */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** Standard ease curves reused across islands. */
|
||||
export const easings = {
|
||||
out: [0.22, 1, 0.36, 1] as const,
|
||||
inOut: [0.65, 0, 0.35, 1] as const,
|
||||
};
|
||||
|
||||
/** Fade + rise entrance. Used for staggered card/hero children. */
|
||||
export const fadeUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
/** A simple, accessible menu/framer transition. */
|
||||
export const mobileMenuTransition = {
|
||||
hidden: { opacity: 0, height: 0 },
|
||||
visible: { opacity: 1, height: 'auto' },
|
||||
} as const;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* platforms.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Helpers for mapping a free-form platform string (as authored in the games /
|
||||
* nowPlaying content) to an mdi icon name. Kept here so it can be shared
|
||||
* between the gaming page and any future components without coupling to the
|
||||
* content collection config.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns an mdi icon name (without the `mdi:` prefix) for a platform string.
|
||||
* Matches case-insensitively on substrings; unknown platforms fall back to a
|
||||
* generic gamepad.
|
||||
*
|
||||
* @example platformToIcon("PlayStation 5") // "sony-playstation"
|
||||
*/
|
||||
export const platformToIcon = (platform: string): string => {
|
||||
const p = platform.toLowerCase();
|
||||
if (p.includes('playstation') || p.includes('ps5') || p.includes('ps4')) return 'sony-playstation';
|
||||
if (p.includes('switch')) return 'nintendo-switch';
|
||||
if (p.includes('steam') || p.includes('pc')) return 'steam';
|
||||
if (p.includes('xbox')) return 'microsoft-xbox';
|
||||
return 'gamepad-variant';
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* site.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Central source of truth for site-wide config: identity, nav structure (the
|
||||
* two-group nav from the spec), social links, and the tagline.
|
||||
*
|
||||
* Components import from here rather than hard-coding values, so a single edit
|
||||
* propagates everywhere. The spec's emoji icons are mapped to mdi icon names;
|
||||
* actual icon rendering happens in components via <Icon />.
|
||||
*/
|
||||
|
||||
export interface NavItem {
|
||||
/** Display label. */
|
||||
label: string;
|
||||
/** Route path. */
|
||||
href: string;
|
||||
/** mdi icon name (without the `mdi:` prefix). */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface NavLinkGroup {
|
||||
/** The two visual groups in the top nav ("who I am" vs "what I do"). */
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/** Top-level identity shown in the nav/logo area. */
|
||||
export const siteIdentity = {
|
||||
name: 'Greg Kilmer',
|
||||
/** Spec tagline; appears beside the logo. */
|
||||
tagline: 'Developer. Maker. Storyteller.',
|
||||
/** Brand lockup line shown under the name in the header (Patiun brand). */
|
||||
lockupLine: 'Web Development · a Patiun LLC company',
|
||||
/** Domain, written capitalized per the Patiun brand. */
|
||||
domain: 'GregKilmer.com',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Primary navigation. Two groups separated by a subtle divider, per the spec.
|
||||
* Left group = "who I am" (Home, About)
|
||||
* Right group = "what I do" (Professional, Interests, Contact)
|
||||
*/
|
||||
export const navGroups: NavLinkGroup[] = [
|
||||
{
|
||||
items: [
|
||||
{ label: 'Home', href: '/', icon: 'home' },
|
||||
{ label: 'About', href: '/about', icon: 'account' },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: 'Professional', href: '/professional', icon: 'briefcase' },
|
||||
{ label: 'Interests', href: '/interests', icon: 'dice-multiple' },
|
||||
{ label: 'Contact', href: '/contact', icon: 'email' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Flat list of all nav items (handy for active-link checks / mobile menus). */
|
||||
export const allNavItems: NavItem[] = navGroups.flatMap((g) => g.items);
|
||||
|
||||
/**
|
||||
* Social links for the footer and contact page.
|
||||
* TODO placeholders flagged with [Greg to fill]; URLs left generic until provided.
|
||||
*/
|
||||
export const socials = {
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
handle: 'Patiun',
|
||||
href: 'https://github.com/patiun',
|
||||
icon: 'github',
|
||||
},
|
||||
linkedin: {
|
||||
label: 'LinkedIn',
|
||||
handle: 'Gregory Kilmer',
|
||||
href: 'https://www.linkedin.com/in/gregory-kilmer-36646bb5/',
|
||||
icon: 'linkedin',
|
||||
},
|
||||
/*discord: {
|
||||
label: 'Discord',
|
||||
handle: '[Greg to fill]',
|
||||
href: '#',
|
||||
icon: 'discord',
|
||||
},*/
|
||||
} as const;
|
||||
|
||||
/** Spec footer microcopy. */
|
||||
export const footerMicrocopy = 'Built with intention. Powered by caffeine.';
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* middleware.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Sets HTTP security headers on every response. Version-controlled so the
|
||||
* policy travels with deploys instead of living only in the reverse proxy.
|
||||
*
|
||||
* Division of responsibility (hybrid model):
|
||||
* - THIS FILE: content headers that must stay in sync with the app
|
||||
* (CSP especially — its allowed origins depend on what the app loads).
|
||||
* - REVERSE PROXY (Nginx Proxy Manager): HSTS + the HTTP->HTTPS redirect +
|
||||
* X-Forwarded-For sanitization. HSTS belongs at the TLS edge because it is
|
||||
* ignored over HTTP, and the proxy is the only hop that can overwrite
|
||||
* client-supplied XFF values (so the contact API's per-IP rate limiter
|
||||
* can't be spoofed).
|
||||
*
|
||||
* CSP is intentionally permissive on this first pass: `script-src` allows
|
||||
* 'unsafe-inline' because two inline <script is:inline> blocks in
|
||||
* BaseLayout.astro (the theme-init boot at line ~104 and the View Transitions
|
||||
* flash-killer at line ~118) can't be nonce-tagged without restructuring how
|
||||
* they're emitted. FUTURE WORK: generate a per-request nonce in this
|
||||
* middleware (set it in locals, inject it onto those two scripts in
|
||||
* BaseLayout via a nonce attribute, and replace 'unsafe-inline' with 'nonce-…'
|
||||
* in script-src). Astro View Transitions can complicate nonce propagation
|
||||
* across the client-side swap, so that migration is its own task — tracked
|
||||
* here so 'unsafe-inline' is known to be temporary.
|
||||
*
|
||||
* Allowed origins:
|
||||
* - challenges.cloudflare.com — Cloudflare Turnstile, used only on /contact.
|
||||
* The Turnstile loader (api.js) is injected dynamically by ContactForm.tsx
|
||||
* and renders its challenge inside an iframe, so script/frame/connect all
|
||||
* need the origin. (SRI is intentionally NOT applied to the Turnstile
|
||||
* loader — see ContactForm.tsx for why.)
|
||||
*
|
||||
* Fonts: Fontsource variable fonts (woff2) are self-hosted and emitted as
|
||||
* real files (Vite's assetsInlineLimit is set to 0 in astro.config.mjs so no
|
||||
* subset is inlined as a data URI), so font-src can stay 'self'.
|
||||
*
|
||||
* Images: board-game covers are self-hosted (fetched at build time by
|
||||
* scripts/fetch-bgg-covers.mjs) and the hero portrait is local, but TCG deck
|
||||
* featured-card art is hotlinked live from Archidekt's card-image bucket at
|
||||
* storage.googleapis.com (see src/lib/archidekt.ts). img-src therefore allows
|
||||
* that origin in addition to self/data. There is no analytics/tracking.
|
||||
*
|
||||
* script-src deliberately does NOT include data: even though Turnstile's
|
||||
* challenge runtime injects an empty data:application/javascript, resource
|
||||
* (which CSP therefore blocks — it's functionally inert). Allowing data:
|
||||
* in script-src would permit arbitrary scripts via data URIs, a serious
|
||||
* regression. The block is harmless; do not "fix" it by widening script-src.
|
||||
*/
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
|
||||
/**
|
||||
* The full Content-Security-Policy. Kept as a single string so it reads as a
|
||||
* spec; tighten individual directives here as the app hardens.
|
||||
*/
|
||||
const CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'self'",
|
||||
// 'self' = Astro-bundled scripts. 'unsafe-inline' = the two inline blocks in
|
||||
// BaseLayout (see file header) — temporary, to be replaced by a nonce.
|
||||
// Turnstile origin = its loader + challenge runtime.
|
||||
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
|
||||
// Astro/Tailwind inject inline styles at runtime, so 'unsafe-inline' stays
|
||||
// for now. Tightening this requires hashing the generated styles.
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
// Self-hosted images + data: for inlined SVG/data-URIs. The
|
||||
// storage.googleapis.com origin serves TCG deck featured-card art hotlinked
|
||||
// from Archidekt's card-image bucket (see src/lib/archidekt.ts).
|
||||
"img-src 'self' data: https://storage.googleapis.com",
|
||||
// Self-hosted variable fonts (woff2) via @fontsource-variable, emitted as
|
||||
// real files (assetsInlineLimit: 0 — see astro.config.mjs).
|
||||
"font-src 'self'",
|
||||
// Same-origin fetch/XHR (the contact form POSTs to /api/contact) plus the
|
||||
// Turnstile siteverify calls the widget makes from the browser.
|
||||
"connect-src 'self' https://challenges.cloudflare.com",
|
||||
// Turnstile renders its challenge inside an iframe from this origin.
|
||||
"frame-src https://challenges.cloudflare.com",
|
||||
// Hard kill-switches — none of these are used by the app.
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
// Forms may only submit to this origin.
|
||||
"form-action 'self'",
|
||||
// Clickjacking defense (X-Frame-Options: DENY covers legacy browsers too).
|
||||
"frame-ancestors 'none'",
|
||||
].join('; ');
|
||||
|
||||
/** Security headers applied to every response. HSTS lives in the proxy. */
|
||||
const SECURITY_HEADERS: Record<string, string> = {
|
||||
'Content-Security-Policy': CONTENT_SECURITY_POLICY,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
// Deny features the site never uses. interest-cohort=() also opts out of
|
||||
// FLoC-style cohort-based ad tech.
|
||||
'Permissions-Policy':
|
||||
'camera=(), microphone=(), geolocation=(), interest-cohort=()',
|
||||
};
|
||||
|
||||
export const onRequest = defineMiddleware(async (_context, next) => {
|
||||
const response = await next();
|
||||
// Set headers on the response. Using .headers.set means we overwrite any
|
||||
// stray value rather than appending duplicates.
|
||||
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user