feat(site): port stitch design system + seed-ready content model
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/pr/web Pipeline failed

Ports the "Technical Editorial" design sample into real TSX wired to
Payload globals/collections. Home/About/Projects (list+detail)/Contact
pages render against Payload data. Expands schemas (Home principles,
About timeline/skills/gear, Contact channels) to cover the full design
surface. Adds seed script that populates realistic AI-drafted content
for first boot. Defers writing/resume routes per scope cut.

- Design tokens: Material-3 palette + Space Grotesk/Inter typography
  scale + dot-grid + glassmorphism utilities
- Shared layout: Nav, Footer, StatusTerminal, GridOverlay, Button,
  TechChip in src/components/site
- Schemas: expand 5 globals + 6 collections; add auto-slug hook
- Seed: scripts/seed.ts — idempotent upsert for media, categories,
  6 projects, 8 gear, 3 posts, 5 globals; generates placeholder admin
- Contact: form + /api/contact route with optional Turnstile verify
- Rename TURNSTILE_SITE_KEY -> NEXT_PUBLIC_TURNSTILE_SITE_KEY (client)
- Remove dead src/components/SiteHeader|SiteFooter|StatusTerminal
This commit is contained in:
2026-04-14 18:57:53 -05:00
parent 6db28bc81f
commit 486bbc8cf8
42 changed files with 4184 additions and 397 deletions

943
scripts/seed.ts Normal file
View File

@@ -0,0 +1,943 @@
/**
* Seed script — jasonwoltje.com
* Populates all Payload globals and collections with drafted content.
* Idempotent: safe to re-run.
*
* Usage:
* pnpm seed
* # or
* pnpm tsx scripts/seed.ts
*
* Environment:
* DATABASE_URI — Postgres connection string (required)
* PAYLOAD_SECRET — Payload secret (required)
* SEED_ADMIN_PASSWORD — Admin user password (optional; random generated if absent)
*/
import { getPayload } from 'payload'
import config from '@payload-config'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeRichText(paragraphs: string[]) {
return {
root: {
type: 'root' as const,
format: '' as const,
indent: 0,
version: 1,
children: paragraphs.map((text) => ({
type: 'paragraph' as const,
format: '' as const,
indent: 0,
version: 1,
children: [
{
type: 'text' as const,
text,
format: 0 as const,
detail: 0 as const,
mode: 'normal' as const,
style: '',
version: 1,
},
],
direction: 'ltr' as const,
textFormat: 0,
textStyle: '',
})),
direction: 'ltr' as const,
},
}
}
function randomPassword(length = 24): string {
return crypto.randomBytes(length).toString('base64').slice(0, length)
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const payload = await getPayload({ config })
const counts = {
media: 0,
categories: 0,
projects: 0,
gear: 0,
posts: 0,
globals: 0,
}
// -------------------------------------------------------------------------
// 1. Admin user
// -------------------------------------------------------------------------
console.log('\n── 1/7 Admin user ──────────────────────────────────────')
const adminEmail = 'admin@jasonwoltje.com'
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? randomPassword()
let generatedPassword = false
try {
const existing = await payload.find({
collection: 'users',
where: { email: { equals: adminEmail } },
limit: 1,
})
if (existing.totalDocs === 0) {
await payload.create({
collection: 'users',
data: {
email: adminEmail,
password: adminPassword,
role: 'admin',
},
})
console.log(` ✓ Created admin user: ${adminEmail}`)
if (!process.env.SEED_ADMIN_PASSWORD) {
generatedPassword = true
}
} else {
console.log(` ↷ Admin user already exists — skipping`)
}
} catch (err) {
console.error(' ✗ Admin user error:', err)
}
// -------------------------------------------------------------------------
// 2. Media uploads
// -------------------------------------------------------------------------
console.log('\n── 2/7 Media ───────────────────────────────────────────')
const imagesDir = path.resolve(process.cwd(), 'images')
const mediaAssets: Array<{ file: string; alt: string; varName: string }> = [
{
file: 'Jason_fullsize-scaled.jpg',
alt: 'Jason Woltje portrait',
varName: 'portrait',
},
{
file: 'gpt-image-1.5_authoritative_thought_leader_portrait_man_with_bald_head_and_brown-ginger_full_b-0.jpg',
alt: 'Stylized portrait — thought leader',
varName: 'stylized1',
},
{
file: 'gpt-image-1.5_bold_social_media_profile_portrait_man_with_bald_head_and_brown-ginger_full_bear-0.jpg',
alt: 'Stylized portrait — social',
varName: 'stylized2',
},
]
const mediaIds: Record<string, number> = {}
for (const asset of mediaAssets) {
try {
const filePath = path.join(imagesDir, asset.file)
if (!fs.existsSync(filePath)) {
console.log(` ⚠ File not found, skipping: ${asset.file}`)
continue
}
// Check if already uploaded by alt text
const existing = await payload.find({
collection: 'media',
where: { alt: { equals: asset.alt } },
limit: 1,
})
if (existing.totalDocs > 0) {
mediaIds[asset.varName] = existing.docs[0]!.id as number
console.log(` ↷ Media already exists: ${asset.alt}`)
continue
}
const doc = await payload.create({
collection: 'media',
filePath,
data: { alt: asset.alt },
})
mediaIds[asset.varName] = doc.id as number
counts.media++
console.log(` ✓ Uploaded: ${asset.alt} (id=${doc.id})`)
} catch (err) {
console.error(` ✗ Media upload error for ${asset.file}:`, err)
}
}
const portraitId = mediaIds['portrait']
// -------------------------------------------------------------------------
// 3. Categories
// -------------------------------------------------------------------------
console.log('\n── 3/7 Categories ──────────────────────────────────────')
const categoryDefs: Array<{ name: string; slug: string }> = [
{ name: 'Engineering', slug: 'engineering' },
{ name: 'Systems', slug: 'systems' },
{ name: 'Homelab', slug: 'homelab' },
{ name: 'Leadership', slug: 'leadership' },
{ name: 'Notes', slug: 'notes' },
]
const categoryIds: Record<string, number> = {}
for (const cat of categoryDefs) {
try {
const existing = await payload.find({
collection: 'categories',
where: { slug: { equals: cat.slug } },
limit: 1,
})
if (existing.totalDocs > 0) {
categoryIds[cat.slug] = existing.docs[0]!.id as number
console.log(` ↷ Category exists: ${cat.name}`)
continue
}
const doc = await payload.create({
collection: 'categories',
data: { name: cat.name, slug: cat.slug },
})
categoryIds[cat.slug] = doc.id as number
counts.categories++
console.log(` ✓ Created category: ${cat.name}`)
} catch (err) {
console.error(` ✗ Category error for ${cat.name}:`, err)
}
}
// -------------------------------------------------------------------------
// 4. Projects
// -------------------------------------------------------------------------
console.log('\n── 4/7 Projects ────────────────────────────────────────')
type ProjectDef = {
title: string
slug: string
summary: string
status: 'active' | 'archived' | 'prototype' | 'production'
year: number
tech: string[]
role: string
featured: boolean
sortOrder: number
links: Array<{ label: string; href: string; type: 'live' | 'repo' | 'docs' | 'writeup' }>
body: string[]
}
const projectDefs: ProjectDef[] = [
{
title: 'Mosaic Stack',
slug: 'mosaic-stack',
summary:
'Self-hosted application platform built on NestJS and Next.js, deployed across a multi-node Docker Swarm cluster with cascading Traefik ingress.',
status: 'production',
year: 2025,
tech: ['Next.js', 'NestJS', 'PostgreSQL', 'Docker Swarm', 'Traefik', 'TypeScript'],
role: 'Founder / Principal Engineer',
featured: true,
sortOrder: 10,
links: [{ label: 'mosaicstack.dev', href: 'https://mosaicstack.dev', type: 'live' }],
body: [
'Mosaic Stack is the platform I built to run my entire digital operation. It combines a NestJS API with a Next.js web layer, deployed across three Docker Swarm nodes (w-docker0, dc-mosaic-stack, dy-docker0) with Traefik handling both edge and per-swarm ingress. Every service is containerized, every deploy goes through Woodpecker CI with Kaniko image builds.',
'The stack integrates Authentik for SSO across all services, Postgres for primary data storage, Gitea for source control, and a Nextcloud/Vikunja/Vaultwarden layer for personal productivity. Nothing runs on a third-party cloud if I can avoid it.',
'This is the direct product of 21 years of operational discipline applied to software: build for reliability first, then features. The goal is a platform that compounds — each service making the others more useful.',
],
},
{
title: 'Mosaic Framework',
slug: 'mosaic-framework',
summary:
'Agent orchestration and end-to-end delivery protocol for AI-assisted software development, built on top of Claude Code and MCP.',
status: 'active',
year: 2025,
tech: ['TypeScript', 'Claude Code', 'MCP', 'NestJS', 'Node.js'],
role: 'Author',
featured: true,
sortOrder: 20,
links: [],
body: [
'Mosaic Framework is an opinionated operating system for AI-assisted software delivery. It defines hard contracts for agent behavior: load order, mode declaration, memory routing, subagent model selection, and PR/CI lifecycle gates. No hand-wavy "just ask Claude" — every step is deterministic.',
'The framework uses a layered AGENTS.md / RUNTIME.md / SOUL.md architecture that any agent runtime (Claude, Codex, OpenCode) can load. It enforces trunk-based development, immutable image tags, and sequential-thinking MCP as a planning primitive.',
'Built because AI pair programming without process discipline is just chaos at machine speed. Mosaic Framework applies the same rigor I used managing IT infrastructure to the problem of agent-assisted software delivery.',
],
},
{
title: 'jarvis-brain',
slug: 'jarvis-brain',
summary:
'Personal knowledge and memory layer — a Python-backed JSON store for structured notes, decisions, and context that feeds AI tooling.',
status: 'active',
year: 2024,
tech: ['Python', 'JSON', 'FastAPI', 'SQLite'],
role: 'Author',
featured: true,
sortOrder: 30,
links: [],
body: [
'jarvis-brain is a structured knowledge store that I use as persistent context for AI agents and personal reference. Everything from architectural decisions to homelab runbooks to project context lives here in a queryable JSON/SQLite layer exposed via a minimal FastAPI service.',
'The premise is simple: AI agents hallucinate less and perform better when they have accurate, structured context. jarvis-brain is that context layer — searchable by topic, project, or timestamp, with a capture API so agents can write back what they learn.',
'Named after the obvious reference. Unlike that version, this one actually tells me when it does not know something.',
],
},
{
title: 'jasonwoltje.com',
slug: 'jasonwoltje-com',
summary:
'This site — a Payload CMS 3 + Next.js 16 professional portfolio, self-hosted on Docker Swarm with Woodpecker CI and Kaniko image builds.',
status: 'production',
year: 2025,
tech: ['Next.js', 'Payload CMS', 'PostgreSQL', 'Docker Swarm', 'Traefik', 'Woodpecker CI', 'Kaniko', 'TypeScript'],
role: 'Designer / Engineer',
featured: false,
sortOrder: 40,
links: [{ label: 'jasonwoltje.com', href: 'https://jasonwoltje.com', type: 'live' }],
body: [
'This site is both portfolio and proof of work. It runs Payload 3 as the CMS with a Next.js 16 frontend, all deployed via the same Docker Swarm + Woodpecker CI + Kaniko pipeline that runs everything else in the Mosaic Stack.',
'The design follows a Technical Editorial system — deep midnight background, electric blue accents, aggressive typography scale. Content is managed through Payload\'s admin panel, seeded via a TypeScript script (the one that just ran).',
'No Vercel, no Netlify. Edge ingress via Traefik, TLS via Let\'s Encrypt, image builds via Kaniko inside Woodpecker pipelines. Immutable tags, digest-first promotion.',
],
},
{
title: 'uConnect',
slug: 'uconnect',
summary:
'Internal platform for USC LLC — Next.js frontend and NestJS backend for operational tooling and team coordination.',
status: 'active',
year: 2023,
tech: ['Next.js', 'NestJS', 'PostgreSQL', 'TypeScript', 'Docker'],
role: 'Lead / IT Director',
featured: false,
sortOrder: 50,
links: [],
body: [
'uConnect is a work product built for USC LLC to consolidate internal tooling and team operations. It provides a unified interface for workflows that were previously fragmented across multiple third-party tools.',
'Built as an IT Director who got tired of paying SaaS tax for things that could be owned and operated internally. The stack mirrors Mosaic: Next.js frontend, NestJS API, Postgres, Docker.',
'This project sits at the intersection of my IT management background and my SWE transition — operational requirements I understand intimately, implemented in code I\'m learning to write better every sprint.',
],
},
{
title: 'dyorpro.com',
slug: 'dyorpro-com',
summary:
'Crypto DYOR (Do Your Own Research) tooling — on-chain data aggregation and analysis utilities built on Next.js with direct blockchain RPC connections.',
status: 'prototype',
year: 2024,
tech: ['Next.js', 'TypeScript', 'Ethereum RPC', 'JSON-RPC', 'Blockchain'],
role: 'Founder / Engineer',
featured: false,
sortOrder: 60,
links: [{ label: 'dyorpro.com', href: 'https://dyorpro.com', type: 'live' }],
body: [
'dyorpro.com is a prototype toolset for on-chain crypto research. The premise: most DYOR tools are walled gardens with questionable data quality. This pulls directly from blockchain RPCs and presents raw data with minimal interpretation.',
'Currently prototype-tier — the core data pipeline works, the UX is utilitarian, and the scope is intentionally narrow. Built to scratch a personal itch and as a vehicle for learning on-chain data patterns.',
],
},
]
const projectIds: Record<string, number> = {}
for (const proj of projectDefs) {
try {
const existing = await payload.find({
collection: 'projects',
where: { slug: { equals: proj.slug } },
limit: 1,
})
const data = {
title: proj.title,
slug: proj.slug,
summary: proj.summary,
status: proj.status,
year: proj.year,
tech: proj.tech.map((label) => ({ label })),
role: proj.role,
featured: proj.featured,
sortOrder: proj.sortOrder,
links: proj.links,
body: makeRichText(proj.body),
}
if (existing.totalDocs > 0) {
const doc = await payload.update({
collection: 'projects',
id: existing.docs[0]!.id as number,
data,
})
projectIds[proj.slug] = doc.id as number
console.log(` ↷ Updated project: ${proj.title}`)
} else {
const doc = await payload.create({
collection: 'projects',
data,
})
projectIds[proj.slug] = doc.id as number
counts.projects++
console.log(` ✓ Created project: ${proj.title}`)
}
} catch (err) {
console.error(` ✗ Project error for ${proj.title}:`, err)
}
}
// -------------------------------------------------------------------------
// 5. Gear
// -------------------------------------------------------------------------
console.log('\n── 5/7 Gear ────────────────────────────────────────────')
type GearDef = {
name: string
category: 'compute' | 'audio' | 'peripherals' | 'network' | 'dev-tools' | 'other'
summary: string
link?: string
featured: boolean
}
const gearDefs: GearDef[] = [
{
name: 'Homelab Node — AMD Ryzen 5 (w-docker0)',
category: 'compute',
summary:
'Primary Docker Swarm manager node. Ryzen 5 8-core, 64GB DDR4, NVMe primary. Runs the Mosaic Stack manager services, Portainer, and Traefik edge ingress.',
featured: true,
},
{
name: 'Homelab Node — AMD Ryzen 3 (dy-docker0)',
category: 'compute',
summary:
'Secondary Swarm worker node, dedicated to the dyorpro stack and experimental workloads. Ryzen 3 4-core, 32GB DDR4.',
featured: false,
},
{
name: 'Homelab Node — Mini PC (dc-mosaic-stack)',
category: 'compute',
summary:
'Low-power Swarm worker node. Handles lightweight services: Nextcloud, Vaultwarden, Vikunja. Always-on, fanless form factor.',
featured: false,
},
{
name: 'Workstation — AMD Ryzen 9 Desktop',
category: 'compute',
summary:
'Primary development machine. Ryzen 9 16-core, 128GB DDR5, dual NVMe RAID. Runs Linux full-time; WSL is for other people.',
featured: true,
},
{
name: 'HHKB Professional Hybrid — Topre switches',
category: 'peripherals',
summary:
'Topre electro-capacitive switches. No legends, compact layout, USB-C + Bluetooth. Sounds like typing should sound. The keyboard that ended the keyboard search.',
featured: false,
},
{
name: 'Mechanical keyboard — tactile 65% (backup)',
category: 'peripherals',
summary:
'Tactile clicky switches, PBT keycaps, aluminum case. Lives on the secondary machine. Loud enough to annoy anyone in range.',
featured: false,
},
{
name: 'Audio interface — USB, 2-in/2-out',
category: 'audio',
summary:
'24-bit/96kHz USB interface for monitoring and music production. Handles studio monitor output and microphone input with low-latency ASIO drivers.',
featured: false,
},
{
name: 'Studio monitors — nearfield, 5-inch woofer',
category: 'audio',
summary:
'Bi-amplified nearfield monitors for mixing and reference listening. Flat frequency response, no consumer coloring. What the music actually sounds like.',
featured: false,
},
]
const gearIds: string[] = []
for (const gear of gearDefs) {
try {
const existing = await payload.find({
collection: 'gear',
where: { name: { equals: gear.name } },
limit: 1,
})
const data = {
name: gear.name,
category: gear.category,
summary: gear.summary,
featured: gear.featured,
...(gear.link ? { link: gear.link } : {}),
}
if (existing.totalDocs > 0) {
const doc = await payload.update({
collection: 'gear',
id: existing.docs[0]!.id as number,
data,
})
gearIds.push(String(doc.id))
console.log(` ↷ Updated gear: ${gear.name}`)
} else {
const doc = await payload.create({
collection: 'gear',
data,
})
gearIds.push(String(doc.id))
counts.gear++
console.log(` ✓ Created gear: ${gear.name}`)
}
} catch (err) {
console.error(` ✗ Gear error for ${gear.name}:`, err)
}
}
// Get first 3 featured gear IDs for About global
const featuredGearDocs = await payload.find({
collection: 'gear',
where: { featured: { equals: true } },
limit: 3,
})
const featuredGearIds = featuredGearDocs.docs.map((g) => g.id as number)
// -------------------------------------------------------------------------
// 6. Posts
// -------------------------------------------------------------------------
console.log('\n── 6/7 Posts ───────────────────────────────────────────')
type PostDef = {
title: string
slug: string
summary: string
status: 'draft' | 'published'
publishedAt?: string
categorySlug?: string
tags: string[]
body: string[]
}
const postDefs: PostDef[] = [
{
title: 'Cascading Traefik across a multi-swarm homelab',
slug: 'cascading-traefik-multi-swarm',
summary:
'How I wired three Docker Swarm clusters together through a tiered Traefik ingress — one edge proxy, per-swarm internal proxies, and a sane certificate strategy.',
status: 'published',
publishedAt: '2025-04-01T00:00:00.000Z',
categorySlug: 'homelab',
tags: ['traefik', 'docker-swarm', 'homelab', 'networking'],
body: [
'Running multiple Docker Swarm clusters with a single publicly-routable IP requires a clear ingress hierarchy. My setup: one "edge" Traefik instance on w-docker0 that terminates TLS and routes by hostname, forwarding to per-swarm internal Traefik instances that handle service discovery within each cluster. Certificates are provisioned by the edge proxy only; internal proxies do not touch ACME.',
'The key insight is that each Swarm needs its own Traefik in socket-proxy mode — direct Docker socket exposure on a network-accessible proxy is a bad time. A socket-proxy sidecar constrains what Traefik can request from the daemon and limits blast radius if the proxy is misconfigured or compromised.',
'Label discipline matters more than anything else. Every service gets traefik.enable=true, a router rule scoped to its hostname, and a service definition pointing at the correct internal port. Forget one label and the service is invisible to the mesh. I maintain a label template in jarvis-brain so I stop having to rediscover the right incantation every deploy.',
],
},
{
title: 'Why I\'m migrating from IT director to software engineer',
slug: 'it-director-to-software-engineer',
summary:
'Twenty-one years of IT leadership taught me systems thinking, operational discipline, and how to keep complex things running. Here\'s why I\'m applying all of that to writing code.',
status: 'draft',
categorySlug: 'leadership',
tags: ['career', 'transition', 'neurodivergent', 'software-engineering'],
body: [
'The honest answer is: I was always going to end up here. IT management at its core is systems design — you model dependencies, anticipate failure modes, and build redundancy into processes and people. Software engineering is the same problem with a compiler.',
'The less obvious reason is AI tooling. Claude Code, Codex, and similar tools lowered the barrier to entry for self-teaching in a way that did not exist five years ago. I can now work at the edge of my knowledge and get immediate, contextual feedback. That feedback loop is what formal CS education would have given me — I\'m just getting it at 40 instead of 22.',
'Neurodivergence is actually an advantage here. Hyperfocus is a superpower when the problem is interesting. Process-first thinking means I don\'t skip planning steps other engineers treat as optional. Direct communication means I write code the way I would explain a system to an engineer: no ambiguity, no magic, clear state transitions.',
],
},
{
title: 'Payload 3 + Next.js 16 — notes from building this site',
slug: 'payload-3-nextjs-16-notes',
summary:
'Field notes from wiring Payload 3 CMS into a Next.js 16 App Router project — schema design, richText handling, and the parts the docs glossed over.',
status: 'draft',
categorySlug: 'engineering',
tags: ['payload-cms', 'next-js', 'typescript', 'cms'],
body: [
'Payload 3 and Next.js 16 are designed to run in the same process. That\'s the pitch. The reality is you spend two hours wiring up @payload-config alias resolution before you get a clean build. The tsconfig path alias works great for the Next.js compiler; tsx for scripts needs a separate resolution strategy.',
'RichText with Lexical is where Payload 3 earns its reputation. The schema is a tree — root → paragraph → text — and generating it programmatically (as in a seed script) requires knowing the exact node shape Lexical expects. The editor serializes and deserializes via its own format; if your seed data does not match, the field silently stores garbage.',
'Global upserts via updateGlobal are clean. Collection idempotency requires find-then-create-or-update since Payload does not expose a native upsert. The pattern is predictable once you accept it. Type safety via generated payload-types.ts is the best part of the whole setup.',
],
},
]
for (const post of postDefs) {
try {
const existing = await payload.find({
collection: 'posts',
where: { slug: { equals: post.slug } },
limit: 1,
})
const catId = post.categorySlug ? categoryIds[post.categorySlug] : undefined
const data = {
title: post.title,
slug: post.slug,
summary: post.summary,
status: post.status,
body: makeRichText(post.body),
...(post.publishedAt ? { publishedAt: post.publishedAt } : {}),
...(catId ? { categories: [catId] } : {}),
tags: post.tags.map((label) => ({ label })),
}
if (existing.totalDocs > 0) {
await payload.update({
collection: 'posts',
id: existing.docs[0]!.id as number,
data,
})
console.log(` ↷ Updated post: ${post.title}`)
} else {
await payload.create({
collection: 'posts',
data,
})
counts.posts++
console.log(` ✓ Created post: ${post.title}`)
}
} catch (err) {
console.error(` ✗ Post error for ${post.title}:`, err)
}
}
// -------------------------------------------------------------------------
// 7. Globals
// -------------------------------------------------------------------------
console.log('\n── 7/7 Globals ─────────────────────────────────────────')
// --- Navigation ---
try {
await payload.updateGlobal({
slug: 'navigation',
data: {
primary: [
{ label: 'Home', href: '/', external: false },
{ label: 'About', href: '/about', external: false },
{ label: 'Projects', href: '/projects', external: false },
{ label: 'Contact', href: '/contact', external: false },
],
socials: [
{
platform: 'github',
label: 'git.mosaicstack.dev/jason.woltje',
href: 'https://git.mosaicstack.dev/jason.woltje',
},
{
platform: 'linkedin',
label: 'LinkedIn',
href: 'https://linkedin.com/in/jasonwoltje',
},
{
platform: 'rss',
label: 'RSS',
href: '/feed.xml',
},
],
footerStatusText: 'LATENCY: — | CORE STATUS: NOMINAL | REV: 1.0.0',
},
})
counts.globals++
console.log(' ✓ Navigation')
} catch (err) {
console.error(' ✗ Navigation error:', err)
}
// --- SEO ---
try {
await payload.updateGlobal({
slug: 'seo',
data: {
siteName: 'Jason Woltje',
defaultTitle: 'Jason Woltje — Systems thinker. Operator. Engineer.',
titleTemplate: '%s — Jason Woltje',
defaultDescription:
'IT veteran turned software engineer. 21 years of operational discipline, applied to building systems that compound. Self-hosted, self-operated, no shortcuts.',
twitterHandle: '',
...(portraitId ? { defaultOgImage: portraitId } : {}),
jsonLdPerson: {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Jason Woltje',
url: 'https://jasonwoltje.com',
email: 'jason@diversecanvas.com',
jobTitle: 'Software Engineer / IT Director',
knowsAbout: [
'Software Engineering',
'IT Management',
'Docker Swarm',
'NestJS',
'Next.js',
'TypeScript',
'Systems Architecture',
],
sameAs: [
'https://linkedin.com/in/jasonwoltje',
'https://git.mosaicstack.dev/jason.woltje',
'https://mosaicstack.dev',
],
},
},
})
counts.globals++
console.log(' ✓ SEO')
} catch (err) {
console.error(' ✗ SEO error:', err)
}
// --- Home ---
try {
const featuredProjectSlugs = ['mosaic-stack', 'jarvis-brain', 'jasonwoltje-com']
const featuredProjectIds = featuredProjectSlugs
.map((slug) => projectIds[slug])
.filter((id): id is number => id != null)
await payload.updateGlobal({
slug: 'home',
data: {
hero: {
eyebrow: '01 // INTRODUCTION',
headline: 'Systems thinker. Operator. Building what compounds.',
subheadline:
'21 years running IT infrastructure. Now writing the software that runs it. Chicago area, Central Time, no tolerance for hand-wavy plans.',
primaryCta: { label: 'See the work', href: '/projects' },
secondaryCta: { label: 'Get in touch', href: '/contact' },
...(portraitId ? { heroImage: portraitId } : {}),
},
principles: [
{
code: '01',
title: 'Systems over heroics',
body: 'Good processes don\'t require heroes. If the system needs a hero every other week, the system is broken. Build the process, document the runbook, automate the toil.',
accent: 'primary',
},
{
code: '02',
title: 'Ship, then refine',
body: 'Perfect is the enemy of deployed. Get running code in front of real conditions first — production has a way of revealing constraints that staging cannot. Iterate from there.',
accent: 'secondary',
},
{
code: '03',
title: 'Own the stack',
body: 'SaaS is rented leverage. When you own the stack you understand every layer, control every failure mode, and stop paying the tax for someone else\'s abstraction.',
accent: 'tertiary',
},
],
featuredProjects: featuredProjectIds.map((id) => ({ project: id })),
closingCta: {
eyebrow: 'LET\'S BUILD',
headline: 'Building something that needs an operator\'s brain?',
body: 'I bring 21 years of infrastructure discipline to software problems. Whether it\'s architecture, automation, or getting a complex system from prototype to production — reach out.',
cta: { label: 'Start a conversation', href: '/contact' },
},
},
})
counts.globals++
console.log(' ✓ Home')
} catch (err) {
console.error(' ✗ Home error:', err)
}
// --- About ---
try {
await payload.updateGlobal({
slug: 'about',
data: {
intro: {
eyebrow: '02 // ABOUT',
headline: 'Operator first. Engineer by conviction.',
subheadline:
'Two decades of IT leadership distilled into a direct, systems-first engineering practice. Neurodivergent, opinionated, and allergic to magical thinking.',
...(portraitId ? { portrait: portraitId } : {}),
},
bio: makeRichText([
'I spent 21 years in IT — support, administration, management, and finally director-level leadership across infrastructure, security, and operations. I\'ve run data centers, managed migrations, stood up MSP practices, and kept the lights on through incidents that would have taken down less disciplined teams.',
'In 2024 I made a deliberate pivot toward full-time software engineering. Not a departure from IT — an application of it. Everything I built in infrastructure: the dependency modeling, the failure mode analysis, the runbook discipline, the change management rigor — all of it transfers directly to writing good software. The compiler just gives faster feedback than a helpdesk ticket.',
'I\'m autistic with ADHD and PDA profile. That means: direct communication (not rude, just precise), process-first orientation (not rigid, just disciplined), hyperfocus that goes deep when something is interesting, and zero patience for plans that exist to protect someone\'s feelings rather than ship something. These traits were liabilities in corporate IT politics. They are assets in engineering.',
'The homelab is where theory becomes practice. Three Docker Swarm nodes, cascading Traefik ingress, Woodpecker CI with Kaniko builds, Authentik SSO, and a growing list of services I own end-to-end. Every piece of infrastructure I manage is also a learning environment. There is no "I\'ll figure that out later" when you are the on-call.',
'Current focus: shipping the Mosaic Stack to a production-ready state, developing the Mosaic Framework for AI-assisted delivery, and building this site into a genuine record of the work. I am open to engineering roles and collaborative projects that reward operator-level systems thinking.',
]),
timeline: [
{
year: '2003',
title: 'Started in IT support',
body: 'First professional IT role — desktop support, helpdesk, learning that every user problem is a systems problem in disguise.',
tags: [{ label: 'IT Support' }, { label: 'Career start' }],
},
{
year: '2010',
title: 'IT Manager',
body: 'First management role. Responsible for infrastructure, team of 4, first exposure to change management and vendor negotiations.',
tags: [{ label: 'Management' }, { label: 'Infrastructure' }],
},
{
year: '2018',
title: 'Director of IT / IT Leadership',
body: 'Director-level scope — multi-site infrastructure, MSP practice management, security posture, budget ownership, and executive reporting.',
tags: [{ label: 'Director' }, { label: 'Strategy' }, { label: 'Security' }],
},
{
year: '2024',
title: 'SWE transition begins',
body: 'Deliberate pivot to full-time software engineering. Started building Mosaic Stack, jarvis-brain, and uConnect. AI tooling as accelerant.',
tags: [{ label: 'Software Engineering' }, { label: 'Career pivot' }],
},
{
year: '2025',
title: 'Mosaic Stack + Framework',
body: 'Production deployment of Mosaic Stack across three Swarm nodes. Mosaic Framework published as AI delivery operating system. This site launched.',
tags: [{ label: 'Production' }, { label: 'Mosaic' }, { label: 'Open' }],
},
],
skills: [
{
category: 'Languages / Runtimes',
items: [
{ label: 'TypeScript' },
{ label: 'Python' },
{ label: 'Node.js' },
{ label: 'Bun' },
{ label: 'Bash' },
],
},
{
category: 'Frameworks',
items: [
{ label: 'Next.js' },
{ label: 'NestJS' },
{ label: 'Payload CMS' },
{ label: 'React' },
{ label: 'FastAPI' },
],
},
{
category: 'Infrastructure',
items: [
{ label: 'Docker Swarm' },
{ label: 'Traefik' },
{ label: 'PostgreSQL' },
{ label: 'Gitea' },
{ label: 'Woodpecker CI' },
{ label: 'Kaniko' },
{ label: 'Authentik' },
{ label: 'Linux' },
],
},
{
category: 'Ops / Leadership',
items: [
{ label: 'Incident response' },
{ label: 'SRE practices' },
{ label: 'Observability' },
{ label: 'Change management' },
{ label: 'Budget ownership' },
{ label: 'Team leadership' },
],
},
],
featuredGear: featuredGearIds.map((id) => ({ gear: id })),
},
})
counts.globals++
console.log(' ✓ About')
} catch (err) {
console.error(' ✗ About error:', err)
}
// --- Contact ---
try {
await payload.updateGlobal({
slug: 'contact',
data: {
intro: {
eyebrow: '03 // CONTACT',
headline: 'Send a signal.',
body: 'Direct channels work better than contact forms. Use whatever medium fits — email for serious conversations, GitHub for code, LinkedIn if that\'s your thing. I read everything; I respond to things that are interesting or relevant.',
},
channels: [
{
icon: 'email',
label: 'Email',
value: 'jason@diversecanvas.com',
href: 'mailto:jason@diversecanvas.com',
},
{
icon: 'github',
label: 'git.mosaicstack.dev/jason.woltje',
value: 'git.mosaicstack.dev/jason.woltje',
href: 'https://git.mosaicstack.dev/jason.woltje',
},
{
icon: 'linkedin',
label: 'LinkedIn',
value: 'linkedin.com/in/jasonwoltje',
href: 'https://linkedin.com/in/jasonwoltje',
},
{
icon: 'rss',
label: 'RSS Feed',
value: '/feed.xml',
href: '/feed.xml',
},
],
formCopy: {
headline: 'Or use the form.',
description:
'If you prefer structured input: name, contact info, what you\'re working on. I\'ll read it. If it\'s relevant I\'ll reply.',
submitLabel: 'Send signal',
successMessage:
'Signal received. If it warrants a response, you\'ll hear back within a few days.',
},
availability: {
statusLine: 'Open to collaboration',
note: 'Currently available for contract engineering work, technical advisory, and infrastructure consulting. Chicago area / remote. Central Time.',
},
},
})
counts.globals++
console.log(' ✓ Contact')
} catch (err) {
console.error(' ✗ Contact error:', err)
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
console.log('\n════════════════════════════════════════════════════════')
console.log(' SEED COMPLETE')
console.log(`${counts.media} media uploaded`)
console.log(`${counts.categories} categories created`)
console.log(`${counts.projects} projects created/updated`)
console.log(`${counts.gear} gear items created`)
console.log(`${counts.posts} posts created`)
console.log(`${counts.globals} globals updated`)
console.log('════════════════════════════════════════════════════════')
if (generatedPassword) {
console.log('\n🔑 placeholder admin password (save this NOW):')
console.log(` ${adminPassword}`)
console.log(' Set SEED_ADMIN_PASSWORD env to supply your own on re-run.\n')
}
// Clean shutdown
const db = payload.db as unknown as Record<string, unknown>
if (typeof db['destroy'] === 'function') {
await (db['destroy'] as () => Promise<void>)()
}
process.exit(0)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})