Compare commits
6 Commits
fix/trivy-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d655fae1 | |||
| 7d125fe7d4 | |||
| ad811ba70e | |||
| 936a98f955 | |||
| b47c5e420a | |||
| 6db28bc81f |
@@ -23,7 +23,7 @@ NEXT_PUBLIC_BUILD_SHA=dev
|
||||
NEXT_PUBLIC_BUILD_REV=local
|
||||
|
||||
# ---- Cloudflare Turnstile (contact form CAPTCHA) ----
|
||||
TURNSTILE_SITE_KEY=
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
# ---- Umami analytics (self-hosted; empty disables tracker) ----
|
||||
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
NEXT_PUBLIC_SITE_URL: https://${SITE_DOMAIN:-jasonwoltje.com}
|
||||
NEXT_PUBLIC_BUILD_SHA: ${WEB_IMAGE_TAG}
|
||||
NEXT_PUBLIC_BUILD_REV: ${WEB_IMAGE_TAG}
|
||||
TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-}
|
||||
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
|
||||
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
|
||||
|
||||
BIN
images/at-the-desk.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
images/editorial-blazer.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 128 KiB |
BIN
images/illustrated-portrait.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 641 KiB After Width: | Height: | Size: 641 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
BIN
images/tech-founder-dark.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
images/tech-founder-warm.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/thought-leader-city.jpg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
images/thought-leader-office.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
@@ -14,9 +14,11 @@
|
||||
"payload": "payload",
|
||||
"generate:types": "payload generate:types",
|
||||
"generate:importmap": "payload generate:importmap",
|
||||
"test": "echo \"no tests yet\" && exit 0"
|
||||
"test": "echo \"no tests yet\" && exit 0",
|
||||
"seed": "tsx scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"@payloadcms/db-postgres": "^3.50.0",
|
||||
"@payloadcms/next": "^3.50.0",
|
||||
"@payloadcms/richtext-lexical": "^3.50.0",
|
||||
@@ -41,6 +43,7 @@
|
||||
"eslint-config-next": "16.1.6",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
19
pnpm-lock.yaml
generated
@@ -13,6 +13,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@marsidev/react-turnstile':
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@payloadcms/db-postgres':
|
||||
specifier: ^3.50.0
|
||||
version: 3.82.1(payload@3.82.1(graphql@16.13.2)(typescript@5.9.3))
|
||||
@@ -80,6 +83,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.19(tsx@4.21.0)
|
||||
tsx:
|
||||
specifier: ^4.19.4
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -1046,6 +1052,12 @@ packages:
|
||||
peerDependencies:
|
||||
yjs: '>=13.5.22'
|
||||
|
||||
'@marsidev/react-turnstile@1.5.0':
|
||||
resolution: {integrity: sha512-Ph6mcj8u9WBDsBO7s9jKPsyRDz1sBPBJwrk+Ngx09vFInvKsQ6U6kW5amEcGq4dHOreB6DgFrOJk7/fy318YlQ==}
|
||||
peerDependencies:
|
||||
react: ^17.0.2 || ^18.0.0 || ^19.0
|
||||
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||
|
||||
@@ -4478,6 +4490,11 @@ snapshots:
|
||||
lexical: 0.41.0
|
||||
yjs: 13.6.30
|
||||
|
||||
'@marsidev/react-turnstile@1.5.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
dependencies:
|
||||
state-local: 1.0.7
|
||||
@@ -7392,7 +7409,7 @@ snapshots:
|
||||
tsx@4.21.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.7
|
||||
get-tsconfig: 4.8.1
|
||||
get-tsconfig: 4.13.7
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
|
||||
75
scripts/rename-media.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Rename media — re-uploads each file under its new filename.
|
||||
* Payload replaces the old file + regenerates thumbnails while keeping the same ID.
|
||||
*/
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const imagesDir = path.resolve(process.cwd(), 'images')
|
||||
|
||||
const renames: Array<{ alt: string; newFile: string }> = [
|
||||
{ alt: 'Jason Woltje portrait', newFile: 'jason-portrait.jpg' },
|
||||
{ alt: 'Stylized portrait — thought leader', newFile: 'thought-leader-city.jpg' },
|
||||
{ alt: 'Stylized portrait — social', newFile: 'social-neon.jpg' },
|
||||
{ alt: 'Jason Woltje — tech founder portrait, dark background', newFile: 'tech-founder-dark.jpg' },
|
||||
{ alt: 'Jason Woltje — social media profile, neon gradient', newFile: 'social-neon.jpg' },
|
||||
{ alt: 'Jason Woltje — at the desk, documentary style', newFile: 'at-the-desk.jpg' },
|
||||
{ alt: 'Jason Woltje — illustrated portrait', newFile: 'illustrated-portrait.jpg' },
|
||||
{ alt: 'Jason Woltje — thought leader portrait, city backdrop', newFile: 'thought-leader-city.jpg' },
|
||||
{ alt: 'Jason Woltje — fashion editorial portrait', newFile: 'editorial-blazer.jpg' },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const seen = new Set<number>()
|
||||
|
||||
for (const r of renames) {
|
||||
const { docs } = await payload.find({
|
||||
collection: 'media',
|
||||
where: { alt: { equals: r.alt } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
})
|
||||
if (docs.length === 0) {
|
||||
console.log(` skip — no media with alt "${r.alt}"`)
|
||||
continue
|
||||
}
|
||||
|
||||
const doc = docs[0]!
|
||||
if (seen.has(doc.id as number)) continue
|
||||
seen.add(doc.id as number)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const currentFilename = (doc as any).filename as string
|
||||
if (currentFilename === r.newFile) {
|
||||
console.log(` ↷ id=${doc.id} already named ${r.newFile}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const filePath = path.join(imagesDir, r.newFile)
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(` ⚠ file missing: ${r.newFile}`)
|
||||
continue
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: 'media',
|
||||
id: doc.id,
|
||||
filePath,
|
||||
data: {},
|
||||
})
|
||||
console.log(` ✓ id=${doc.id}: ${currentFilename} → ${r.newFile}`)
|
||||
}
|
||||
|
||||
console.log('\nDone.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
943
scripts/seed.ts
Normal 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)
|
||||
})
|
||||
179
scripts/update-images.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Update images — jasonwoltje.com
|
||||
* Uploads new photos and assigns them to globals/posts.
|
||||
* Idempotent by alt-text match.
|
||||
*/
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
async function main() {
|
||||
const payload = await getPayload({ config })
|
||||
const imagesDir = path.resolve(process.cwd(), 'images')
|
||||
|
||||
const uploads: Array<{ file: string; alt: string; key: string }> = [
|
||||
{
|
||||
file: 'gpt-image-1.5_creative_tech_founder_portrait_man_with_bald_head_and_brown-ginger_full_beard_bl-0.jpg',
|
||||
alt: 'Jason Woltje — tech founder portrait, dark background',
|
||||
key: 'hero',
|
||||
},
|
||||
{
|
||||
file: 'gpt-image-1.5_bold_social_media_profile_portrait_man_with_bald_head_and_brown-ginger_full_bear-0.jpg',
|
||||
alt: 'Jason Woltje — social media profile, neon gradient',
|
||||
key: 'og',
|
||||
},
|
||||
{
|
||||
file: 'gpt-image-1.5_documentary_style_environmental_portrait_man_with_bald_head_and_brown-ginger_ful-0.jpg',
|
||||
alt: 'Jason Woltje — at the desk, documentary style',
|
||||
key: 'desk',
|
||||
},
|
||||
{
|
||||
file: 'gpt-image-1.5_illustrated_portrait_stylized_modern_digital_art_style_man_with_bald_head_and_br-0.jpg',
|
||||
alt: 'Jason Woltje — illustrated portrait',
|
||||
key: 'illustration',
|
||||
},
|
||||
{
|
||||
file: 'gpt-image-1.5_authoritative_thought_leader_portrait_man_with_bald_head_and_brown-ginger_full_b-0.jpg',
|
||||
alt: 'Jason Woltje — thought leader portrait, city backdrop',
|
||||
key: 'thought-leader',
|
||||
},
|
||||
{
|
||||
file: 'gpt-image-1.5_high-end_fashion_forward_portrait_man_with_bald_head_and_brown-ginger_full_beard-0.jpg',
|
||||
alt: 'Jason Woltje — fashion editorial portrait',
|
||||
key: 'editorial',
|
||||
},
|
||||
]
|
||||
|
||||
const ids: Record<string, number> = {}
|
||||
|
||||
console.log('\n── Uploading new images ──────────────────────────────────')
|
||||
for (const u of uploads) {
|
||||
const filePath = path.join(imagesDir, u.file)
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(` ⚠ Missing: ${u.file}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: 'media',
|
||||
where: { alt: { equals: u.alt } },
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (existing.totalDocs > 0) {
|
||||
ids[u.key] = existing.docs[0]!.id as number
|
||||
console.log(` ↷ Already exists: ${u.alt} (id=${ids[u.key]})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update alt of old upload if it used a generic name (from initial seed)
|
||||
const doc = await payload.create({
|
||||
collection: 'media',
|
||||
filePath,
|
||||
data: { alt: u.alt },
|
||||
})
|
||||
ids[u.key] = doc.id as number
|
||||
console.log(` ✓ Uploaded: ${u.alt} (id=${doc.id})`)
|
||||
}
|
||||
|
||||
// ── Update Home hero ──────────────────────────────────────────────────
|
||||
if (ids['hero']) {
|
||||
console.log('\n── Updating Home hero image ─────────────────────────────')
|
||||
const home = await payload.findGlobal({ slug: 'home', depth: 0 })
|
||||
const heroData = (home as unknown as Record<string, unknown>).hero as Record<string, unknown> | undefined
|
||||
await payload.updateGlobal({
|
||||
slug: 'home',
|
||||
data: {
|
||||
hero: {
|
||||
...(heroData ?? {}),
|
||||
heroImage: ids['hero'],
|
||||
},
|
||||
},
|
||||
})
|
||||
console.log(` ✓ Home heroImage → id=${ids['hero']}`)
|
||||
}
|
||||
|
||||
// ── Update SEO OG image ───────────────────────────────────────────────
|
||||
if (ids['og']) {
|
||||
console.log('\n── Updating SEO defaultOgImage ──────────────────────────')
|
||||
await payload.updateGlobal({
|
||||
slug: 'seo',
|
||||
data: { defaultOgImage: ids['og'] },
|
||||
})
|
||||
console.log(` ✓ SEO defaultOgImage → id=${ids['og']}`)
|
||||
}
|
||||
|
||||
// ── Update post cover images ──────────────────────────────────────────
|
||||
console.log('\n── Updating post cover images ───────────────────────────')
|
||||
const postCovers: Array<{ slugContains: string; imageKey: string }> = [
|
||||
{ slugContains: 'cascading-traefik', imageKey: 'desk' },
|
||||
{ slugContains: 'migrating-from-it', imageKey: 'thought-leader' },
|
||||
{ slugContains: 'payload-3', imageKey: 'illustration' },
|
||||
]
|
||||
|
||||
for (const pc of postCovers) {
|
||||
const imageId = ids[pc.imageKey]
|
||||
if (!imageId) continue
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { contains: pc.slugContains } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
})
|
||||
if (docs.length === 0) {
|
||||
console.log(` ⚠ Post not found: slug contains "${pc.slugContains}"`)
|
||||
continue
|
||||
}
|
||||
const post = docs[0]!
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: { coverImage: imageId },
|
||||
})
|
||||
console.log(` ✓ Post "${post.slug}" coverImage → id=${imageId}`)
|
||||
}
|
||||
|
||||
// ── Assign project hero images ────────────────────────────────────────
|
||||
console.log('\n── Updating project hero images ─────────────────────────')
|
||||
const projectHeroes: Array<{ slugContains: string; imageKey: string }> = [
|
||||
{ slugContains: 'mosaic-stack', imageKey: 'hero' },
|
||||
{ slugContains: 'jasonwoltje', imageKey: 'editorial' },
|
||||
]
|
||||
|
||||
for (const ph of projectHeroes) {
|
||||
const imageId = ids[ph.imageKey]
|
||||
if (!imageId) continue
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: 'projects',
|
||||
where: { slug: { contains: ph.slugContains } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
})
|
||||
if (docs.length === 0) {
|
||||
console.log(` ⚠ Project not found: slug contains "${ph.slugContains}"`)
|
||||
continue
|
||||
}
|
||||
const project = docs[0]!
|
||||
await payload.update({
|
||||
collection: 'projects',
|
||||
id: project.id,
|
||||
data: { heroImage: imageId },
|
||||
})
|
||||
console.log(` ✓ Project "${project.slug}" heroImage → id=${imageId}`)
|
||||
}
|
||||
|
||||
console.log('\n════════════════════════════════════════════════════════')
|
||||
console.log(' IMAGE UPDATE COMPLETE')
|
||||
console.log('════════════════════════════════════════════════════════\n')
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,27 +1,280 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import type { Metadata } from "next";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import Image from "next/image";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { GridOverlay, TechChip } from "@/components/site";
|
||||
|
||||
export const metadata = { title: "About" };
|
||||
export const metadata: Metadata = { title: "About" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type MediaDoc = {
|
||||
url?: string | null;
|
||||
alt: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
};
|
||||
|
||||
type GearDoc = {
|
||||
id: string;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
summary?: string | null;
|
||||
link?: string | null;
|
||||
image?: MediaDoc | string | null;
|
||||
};
|
||||
|
||||
type FeaturedGearItem = {
|
||||
id?: string | null;
|
||||
gear?: GearDoc | string | null;
|
||||
};
|
||||
|
||||
type TimelineItem = {
|
||||
id?: string | null;
|
||||
year: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
tags?: { id?: string | null; label?: string | null }[] | null;
|
||||
};
|
||||
|
||||
type SkillGroup = {
|
||||
id?: string | null;
|
||||
category: string;
|
||||
items?: { id?: string | null; label?: string | null }[] | null;
|
||||
};
|
||||
|
||||
type AboutData = {
|
||||
intro?: {
|
||||
eyebrow?: string | null;
|
||||
headline?: string | null;
|
||||
subheadline?: string | null;
|
||||
portrait?: MediaDoc | string | null;
|
||||
} | null;
|
||||
bio?: Record<string, unknown> | null;
|
||||
timeline?: TimelineItem[] | null;
|
||||
skills?: SkillGroup[] | null;
|
||||
featuredGear?: FeaturedGearItem[] | null;
|
||||
};
|
||||
|
||||
function isMediaDoc(val: unknown): val is MediaDoc {
|
||||
return typeof val === "object" && val !== null && "alt" in val;
|
||||
}
|
||||
|
||||
function isGearDoc(val: unknown): val is GearDoc {
|
||||
return typeof val === "object" && val !== null && "name" in val;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
compute: "Compute",
|
||||
audio: "Audio",
|
||||
peripherals: "Peripherals",
|
||||
network: "Network",
|
||||
"dev-tools": "Dev Tools",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const about = (await payload.findGlobal({ slug: "about", depth: 2 })) as AboutData;
|
||||
|
||||
const { intro, bio, timeline, skills, featuredGear } = about;
|
||||
|
||||
const portrait = isMediaDoc(intro?.portrait) ? intro.portrait : null;
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-7xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-tertiary">
|
||||
02 // PROFILE
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
About
|
||||
</h1>
|
||||
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
|
||||
Engineering growth through technological mastery and strategic
|
||||
leadership. Content sourced from Payload CMS (global:{" "}
|
||||
<code className="font-label text-primary">about</code>) — populated on
|
||||
first publish.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<main className="mx-auto max-w-7xl space-y-32 px-6 py-20">
|
||||
{/* ── 1. INTRO ─────────────────────────────────────────────────── */}
|
||||
<section className="grid grid-cols-1 items-start gap-12 lg:grid-cols-12">
|
||||
{/* Text — 60% */}
|
||||
<div className="space-y-8 lg:col-span-7">
|
||||
<div className="space-y-4">
|
||||
{intro?.eyebrow && (
|
||||
<span className="label-sm block text-primary uppercase tracking-[0.2em]">
|
||||
{intro.eyebrow}
|
||||
</span>
|
||||
)}
|
||||
{intro?.headline && (
|
||||
<h1 className="display-lg leading-tight tracking-tighter text-on-surface">
|
||||
{intro.headline}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
{intro?.subheadline && (
|
||||
<p className="body-lg max-w-2xl leading-relaxed text-on-surface-variant">
|
||||
{intro.subheadline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Portrait — 40% */}
|
||||
<div className="relative lg:col-span-5">
|
||||
<div className="absolute inset-0 -z-10 blur-[80px] opacity-20 bg-primary/20" />
|
||||
<div className="relative aspect-square overflow-hidden rounded-md border border-outline-variant/15 bg-surface-container">
|
||||
<GridOverlay opacity={0.15} />
|
||||
{portrait?.url ? (
|
||||
<Image
|
||||
src={portrait.url}
|
||||
alt={portrait.alt}
|
||||
fill
|
||||
className="object-cover mix-blend-luminosity hover:mix-blend-normal transition-all duration-500"
|
||||
sizes="(max-width: 1024px) 100vw, 40vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-on-surface-variant label-sm uppercase">
|
||||
Portrait
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 2. BIO ────────────────────────────────────────────────────── */}
|
||||
{bio && (
|
||||
<section className="space-y-8">
|
||||
<span className="label-sm block text-secondary uppercase tracking-[0.2em]">
|
||||
02 // BIO
|
||||
</span>
|
||||
<div className="body-lg max-w-3xl space-y-4 leading-relaxed text-on-surface-variant [&_h2]:headline-lg [&_h2]:text-on-surface [&_h3]:title-lg [&_h3]:text-on-surface [&_a]:text-primary [&_a]:underline [&_a:hover]:text-primary/80">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<RichText data={bio as any} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 3. TIMELINE ───────────────────────────────────────────────── */}
|
||||
{timeline && timeline.length > 0 && (
|
||||
<section className="space-y-8">
|
||||
<span className="label-sm block text-tertiary uppercase tracking-[0.2em]">
|
||||
03 // TIMELINE
|
||||
</span>
|
||||
<div className="space-y-0">
|
||||
{timeline.map((item, i) => (
|
||||
<div
|
||||
key={item.id ?? item.year}
|
||||
className={`grid grid-cols-1 gap-6 px-8 py-8 md:grid-cols-12 ${
|
||||
i % 2 === 0
|
||||
? "bg-surface-container-low"
|
||||
: "bg-surface-container"
|
||||
}`}
|
||||
>
|
||||
<div className="md:col-span-2">
|
||||
<span className="label-sm font-mono text-primary">
|
||||
{item.year}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 md:col-span-10">
|
||||
<h3 className="title-lg text-on-surface">{item.title}</h3>
|
||||
{item.body && (
|
||||
<p className="body-md text-on-surface-variant leading-relaxed">
|
||||
{item.body}
|
||||
</p>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{item.tags.map((tag, ti) =>
|
||||
tag.label ? (
|
||||
<TechChip key={tag.id ?? ti} accent="secondary">
|
||||
{tag.label}
|
||||
</TechChip>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 4. SKILLS ─────────────────────────────────────────────────── */}
|
||||
{skills && skills.length > 0 && (
|
||||
<section className="space-y-8">
|
||||
<span className="label-sm block text-primary uppercase tracking-[0.2em]">
|
||||
04 // SKILLS
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{skills.map((group) => (
|
||||
<div key={group.id ?? group.category} className="space-y-3">
|
||||
<h3 className="label-md uppercase text-on-surface-variant">
|
||||
{group.category}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.items?.map((item, ii) =>
|
||||
item.label ? (
|
||||
<TechChip key={item.id ?? ii} accent="tertiary">
|
||||
{item.label}
|
||||
</TechChip>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 5. GEAR ───────────────────────────────────────────────────── */}
|
||||
{featuredGear && featuredGear.length > 0 && (
|
||||
<section className="space-y-8">
|
||||
<span className="label-sm block text-secondary uppercase tracking-[0.2em]">
|
||||
05 // GEAR
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{featuredGear.map((entry) => {
|
||||
if (!isGearDoc(entry.gear)) return null;
|
||||
const gear = entry.gear;
|
||||
const img = isMediaDoc(gear.image) ? gear.image : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={gear.id}
|
||||
className="relative overflow-hidden rounded-md border border-outline-variant/15 bg-surface-container-low flex flex-col"
|
||||
>
|
||||
{img?.url ? (
|
||||
<div className="relative aspect-video w-full overflow-hidden">
|
||||
<Image
|
||||
src={img.url}
|
||||
alt={img.alt}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video w-full bg-surface-container" />
|
||||
)}
|
||||
<div className="flex flex-1 flex-col gap-3 p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="title-lg text-on-surface">{gear.name}</h3>
|
||||
{gear.category && (
|
||||
<TechChip accent="primary">
|
||||
{categoryLabels[gear.category] ?? gear.category}
|
||||
</TechChip>
|
||||
)}
|
||||
</div>
|
||||
{gear.summary && (
|
||||
<p className="body-md flex-1 text-on-surface-variant leading-relaxed">
|
||||
{gear.summary}
|
||||
</p>
|
||||
)}
|
||||
{gear.link && (
|
||||
<a
|
||||
href={gear.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="label-sm mt-auto self-start text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
191
src/app/(frontend)/contact/ContactForm.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { Button } from "@/components/site";
|
||||
|
||||
const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface ContactFormProps {
|
||||
submitLabel: string;
|
||||
successMessage: string;
|
||||
}
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ContactForm({ submitLabel, successMessage }: ContactFormProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const turnstileToken = useRef<string | null>(null);
|
||||
|
||||
function validate(): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
if (!name.trim()) e.name = "Name is required.";
|
||||
if (!email.trim()) {
|
||||
e.email = "Email is required.";
|
||||
} else if (!EMAIL_RE.test(email)) {
|
||||
e.email = "Enter a valid email address.";
|
||||
}
|
||||
if (!message.trim()) e.message = "Message is required.";
|
||||
return e;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setApiError(null);
|
||||
|
||||
const fieldErrors = validate();
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
message: message.trim(),
|
||||
...(turnstileToken.current ? { turnstileToken: turnstileToken.current } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = (await res.json()) as { ok: boolean; error?: string };
|
||||
|
||||
if (!res.ok || !data.ok) {
|
||||
setApiError(data.error ?? "Submission failed. Please try again.");
|
||||
} else {
|
||||
setSubmitted(true);
|
||||
}
|
||||
} catch {
|
||||
setApiError("Network error. Please check your connection and try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="rounded-md border border-tertiary/20 bg-surface-container-low px-6 py-8 text-center">
|
||||
<span className="h-2 w-2 inline-block rounded-full bg-tertiary shadow-[0_0_10px_rgba(142,255,113,0.5)] mb-4" />
|
||||
<p className="body-lg text-on-surface">{successMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-6">
|
||||
{/* Name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="label-sm uppercase tracking-widest text-on-surface-variant">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="WALTER O'BRIEN"
|
||||
className="w-full border-0 border-b border-outline-variant/50 bg-surface-container-low px-0 py-3 font-headline uppercase tracking-tighter text-on-surface placeholder:text-outline transition-colors focus:border-primary focus:ring-0 focus:outline-none"
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={errors.name ? "err-name" : undefined}
|
||||
/>
|
||||
{errors.name && (
|
||||
<span id="err-name" className="label-sm text-error">
|
||||
{errors.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="label-sm uppercase tracking-widest text-on-surface-variant">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="W.OBRIEN@CENTRAL.COM"
|
||||
className="w-full border-0 border-b border-outline-variant/50 bg-surface-container-low px-0 py-3 font-headline uppercase tracking-tighter text-on-surface placeholder:text-outline transition-colors focus:border-primary focus:ring-0 focus:outline-none"
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={errors.email ? "err-email" : undefined}
|
||||
/>
|
||||
{errors.email && (
|
||||
<span id="err-email" className="label-sm text-error">
|
||||
{errors.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="label-sm uppercase tracking-widest text-on-surface-variant">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="DESCRIBE THE SYSTEM ARCHITECTURE OR PROBLEM SET..."
|
||||
rows={4}
|
||||
className="w-full resize-none border-0 border-b border-outline-variant/50 bg-surface-container-low px-0 py-3 font-headline uppercase tracking-tighter text-on-surface placeholder:text-outline transition-colors focus:border-primary focus:ring-0 focus:outline-none"
|
||||
aria-invalid={Boolean(errors.message)}
|
||||
aria-describedby={errors.message ? "err-message" : undefined}
|
||||
/>
|
||||
{errors.message && (
|
||||
<span id="err-message" className="label-sm text-error">
|
||||
{errors.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Turnstile */}
|
||||
{SITE_KEY ? (
|
||||
<Turnstile
|
||||
siteKey={SITE_KEY}
|
||||
onSuccess={(token: string) => {
|
||||
turnstileToken.current = token;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="label-sm text-on-surface-variant">
|
||||
Spam protection disabled (dev)
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* API error */}
|
||||
{apiError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-sm border border-error/20 bg-error-container/20 px-4 py-3"
|
||||
>
|
||||
<span className="label-sm text-error">{apiError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
className="mt-2 w-full justify-center disabled:opacity-50"
|
||||
>
|
||||
{loading ? "SENDING..." : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,137 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import type { Metadata } from "next";
|
||||
import type { Contact } from "@/payload-types";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { Mail, Github, Linkedin, Twitter, MessageSquare, Rss, Phone, ArrowUpRight } from "lucide-react";
|
||||
import { GridOverlay, StatusTerminal } from "@/components/site";
|
||||
import { ContactForm } from "./ContactForm";
|
||||
|
||||
export const metadata = { title: "Contact" };
|
||||
export const metadata: Metadata = { title: "Contact" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ICON_MAP = {
|
||||
email: Mail,
|
||||
github: Github,
|
||||
linkedin: Linkedin,
|
||||
twitter: Twitter,
|
||||
mastodon: MessageSquare,
|
||||
rss: Rss,
|
||||
phone: Phone,
|
||||
} as const;
|
||||
|
||||
type ChannelIcon = keyof typeof ICON_MAP;
|
||||
|
||||
export default async function ContactPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const contact = (await payload.findGlobal({ slug: "contact", depth: 1 })) as Contact;
|
||||
|
||||
const { intro, channels, formCopy, availability } = contact;
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-3xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
05 // CONTACT
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
Open channel
|
||||
</h1>
|
||||
<p className="font-body text-xl text-on-surface-variant">
|
||||
Form wiring, Turnstile, and Payload submission persistence land in a
|
||||
follow-up PR (UI-06). For now, direct email lives in the Payload{" "}
|
||||
<code className="font-label text-primary">contact</code> global.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<main className="relative min-h-screen">
|
||||
<StatusTerminal location="CONTACT_CORE" status="ENCRYPTED_HANDSHAKE" />
|
||||
|
||||
<section className="relative mx-auto max-w-7xl overflow-hidden px-6 pb-24 pt-16">
|
||||
<GridOverlay />
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-12 lg:grid-cols-12">
|
||||
{/* Left column: intro + form + availability (60%) */}
|
||||
<div className="flex flex-col gap-10 lg:col-span-7">
|
||||
{/* Intro */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{intro?.eyebrow && (
|
||||
<span className="label-sm text-secondary uppercase tracking-[0.3em]">
|
||||
{intro.eyebrow}
|
||||
</span>
|
||||
)}
|
||||
{intro?.headline && (
|
||||
<h1 className="display-lg text-on-surface">{intro.headline}</h1>
|
||||
)}
|
||||
{intro?.body && (
|
||||
<p className="body-lg mt-4 max-w-2xl text-on-surface-variant">{intro.body}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="glass rounded-md border border-outline-variant/15 p-8">
|
||||
{formCopy?.headline && (
|
||||
<h2 className="title-lg mb-2 text-on-surface">{formCopy.headline}</h2>
|
||||
)}
|
||||
{formCopy?.description && (
|
||||
<p className="body-md mb-6 text-on-surface-variant">{formCopy.description}</p>
|
||||
)}
|
||||
<ContactForm
|
||||
submitLabel={formCopy?.submitLabel ?? "Send signal"}
|
||||
successMessage={formCopy?.successMessage ?? "Message received. I'll be in touch."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Availability */}
|
||||
{availability && (
|
||||
<div className="border-l-2 border-outline-variant bg-surface-container-low p-6">
|
||||
<span className="label-sm mb-2 block uppercase tracking-widest text-primary">
|
||||
AVAILABILITY
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-2 w-2 rounded-full bg-tertiary shadow-[0_0_10px_rgba(142,255,113,0.5)]" />
|
||||
<span className="font-headline font-bold text-tertiary uppercase">
|
||||
STATUS: {availability.statusLine}
|
||||
</span>
|
||||
</div>
|
||||
{availability.note && (
|
||||
<p className="body-md mt-3 text-on-surface-variant">{availability.note}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: channels list (40%) */}
|
||||
<div className="flex flex-col gap-4 lg:col-span-5">
|
||||
<span className="label-sm uppercase tracking-[0.3em] text-on-surface-variant">
|
||||
DIRECT CHANNELS
|
||||
</span>
|
||||
|
||||
{channels && channels.length > 0 ? (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{channels.map((channel) => {
|
||||
const iconKey = (channel.icon ?? "email") as ChannelIcon;
|
||||
const Icon = ICON_MAP[iconKey] ?? Mail;
|
||||
return (
|
||||
<li key={channel.id ?? channel.label}>
|
||||
<a
|
||||
href={channel.href ?? "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-4 rounded-md border border-outline-variant/15 bg-surface-container-low p-5 transition-colors hover:bg-surface-container-high"
|
||||
>
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-sm bg-surface-container-highest text-primary">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="label-sm uppercase tracking-widest text-on-surface">
|
||||
{channel.label}
|
||||
</span>
|
||||
{channel.value && (
|
||||
<span className="label-sm truncate text-on-surface-variant">
|
||||
{channel.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
size={14}
|
||||
className="flex-shrink-0 text-outline opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="body-md text-on-surface-variant">No channels configured yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,19 +13,57 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* DESIGN.md: "Ghost Border" — containment that is felt rather than seen. */
|
||||
.ghost-border {
|
||||
border: 1px solid rgba(71, 72, 77, 0.15);
|
||||
/* ── Typography scale (DESIGN.md §3) ──────────────────────────────── */
|
||||
.display-lg {
|
||||
@apply font-headline text-7xl font-bold leading-[0.9] tracking-tighter md:text-8xl;
|
||||
}
|
||||
.display-md {
|
||||
@apply font-headline text-5xl font-bold leading-[0.9] tracking-tighter md:text-6xl;
|
||||
}
|
||||
.display-sm {
|
||||
@apply font-headline text-4xl font-bold leading-tight tracking-tighter md:text-5xl;
|
||||
}
|
||||
.headline-lg {
|
||||
@apply font-headline text-3xl font-bold tracking-tight md:text-4xl;
|
||||
}
|
||||
.title-lg {
|
||||
@apply font-headline text-2xl font-bold tracking-tight;
|
||||
}
|
||||
.body-lg {
|
||||
@apply font-body text-xl leading-relaxed text-on-surface-variant md:text-2xl;
|
||||
}
|
||||
.body-md {
|
||||
@apply font-body text-base leading-relaxed text-on-surface-variant;
|
||||
}
|
||||
.label-md {
|
||||
@apply font-label text-[10px] uppercase tracking-[0.3em] text-on-surface;
|
||||
}
|
||||
.label-sm {
|
||||
@apply font-label text-[8px] uppercase tracking-[0.4em] text-on-surface;
|
||||
}
|
||||
|
||||
/* DESIGN.md: "Glass & Gradient" — frosted terminal effect. */
|
||||
.glass-card {
|
||||
/* ── Dot-grid background (DESIGN.md §5) ──────────────────────────── */
|
||||
.dot-grid-bg {
|
||||
background-image: radial-gradient(
|
||||
rgba(129, 236, 255, 0.12) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
/* ── Glass surface (DESIGN.md §2 Glass & Gradient rule) ──────────── */
|
||||
.glass {
|
||||
background-color: rgba(42, 44, 50, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
/* DESIGN.md: neon CTA — "lit from within" glow. */
|
||||
/* ── Ghost border — containment felt rather than seen (DESIGN.md §4) */
|
||||
.ghost-border {
|
||||
border: 1px solid rgba(71, 72, 77, 0.15);
|
||||
}
|
||||
|
||||
/* ── Neon CTA gradient (DESIGN.md §2 primary→primary_container) ──── */
|
||||
.neon-cta {
|
||||
background: linear-gradient(135deg, #81ecff 0%, #00e3fd 100%);
|
||||
color: #005762;
|
||||
@@ -34,7 +72,7 @@
|
||||
0 0 4px 1px rgba(129, 236, 255, 0.5);
|
||||
}
|
||||
|
||||
/* DESIGN.md: technical grid background pattern. */
|
||||
/* ── Technical grid (32px, hero variant) ─────────────────────────── */
|
||||
.technical-grid {
|
||||
background-image: radial-gradient(
|
||||
rgba(129, 236, 255, 0.15) 1px,
|
||||
@@ -43,7 +81,7 @@
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
/* DESIGN.md: hero radial gradient ambient. */
|
||||
/* ── Hero ambient radial gradient ────────────────────────────────── */
|
||||
.hero-gradient {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(129, 236, 255, 0.08), transparent 40%),
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Space_Grotesk, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Nav } from "@/components/site/Nav";
|
||||
import { Footer } from "@/components/site/Footer";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "700"],
|
||||
weight: ["300", "400", "500", "700"],
|
||||
variable: "--font-headline",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
variable: "--font-body",
|
||||
display: "swap",
|
||||
});
|
||||
@@ -21,16 +23,14 @@ export const metadata: Metadata = {
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000",
|
||||
),
|
||||
title: {
|
||||
default: "Jason Woltje",
|
||||
default:
|
||||
"Jason Woltje — Systems thinker. Builder. IT leader turning into a software engineer.",
|
||||
template: "%s — Jason Woltje",
|
||||
},
|
||||
description:
|
||||
"A multidisciplinary architect of digital ecosystems. Engineering growth through technological mastery and strategic leadership.",
|
||||
"A multidisciplinary architect of digital ecosystems and agricultural infrastructures. Engineering growth through technological mastery and strategic leadership.",
|
||||
};
|
||||
|
||||
const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev";
|
||||
const BUILD_REV = process.env.NEXT_PUBLIC_BUILD_REV ?? "local";
|
||||
|
||||
export default function FrontendLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -43,14 +43,10 @@ export default function FrontendLayout({
|
||||
style={{ ["--font-label" as string]: "var(--font-headline)" }}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body>
|
||||
<body className="bg-background text-on-surface min-h-screen">
|
||||
<Nav />
|
||||
{children}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed bottom-3 right-4 z-50 font-label text-[10px] uppercase tracking-[0.2em] text-tertiary/80"
|
||||
>
|
||||
REV: {BUILD_REV} · SHA: {BUILD_SHA}
|
||||
</div>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,248 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { StatusTerminal } from "@/components/StatusTerminal";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import Link from "next/link";
|
||||
import { StatusTerminal } from "@/components/site/StatusTerminal";
|
||||
import { GridOverlay } from "@/components/site/GridOverlay";
|
||||
import { Button } from "@/components/site/Button";
|
||||
import { TechChip } from "@/components/site/TechChip";
|
||||
import type { Home, Project } from "@/payload-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const accentTextClass: Record<string, string> = {
|
||||
primary: "text-primary",
|
||||
secondary: "text-secondary",
|
||||
tertiary: "text-tertiary",
|
||||
};
|
||||
|
||||
export default async function HomePage() {
|
||||
const payload = await getPayload({ config });
|
||||
const home: Home = await payload.findGlobal({ slug: "home", depth: 2 });
|
||||
|
||||
const { hero, principles, featuredProjects, closingCta } = home;
|
||||
|
||||
const resolvedProjects: Project[] = (featuredProjects ?? [])
|
||||
.map((fp) => (typeof fp.project === "object" && fp.project !== null ? fp.project : null))
|
||||
.filter((p): p is Project => p !== null)
|
||||
.slice(0, 3);
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<section className="technical-grid hero-gradient relative flex min-h-[92vh] flex-col justify-center overflow-hidden px-6">
|
||||
<StatusTerminal className="absolute left-6 top-8 md:left-12" />
|
||||
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-12 pt-20 lg:grid-cols-12">
|
||||
<div className="lg:col-span-8">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
01 // THE MANIFESTO
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold leading-[0.9] tracking-tighter text-on-surface md:text-8xl">
|
||||
I WILL FIND
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
A WAY,
|
||||
<main>
|
||||
{/* ── Hero ────────────────────────────────────────────────────────── */}
|
||||
<section className="hero-gradient relative flex min-h-[92vh] flex-col justify-center overflow-hidden px-6 border-b border-outline-variant/10">
|
||||
<GridOverlay size={32} opacity={0.15} />
|
||||
|
||||
<StatusTerminal className="absolute left-6 top-8 md:left-12" />
|
||||
|
||||
<div className="mx-auto w-full max-w-7xl pt-20">
|
||||
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-12">
|
||||
{/* Left — 60% */}
|
||||
<div className="lg:col-span-7">
|
||||
{hero?.eyebrow && (
|
||||
<span className="label-sm mb-6 block text-primary uppercase tracking-[0.4em]">
|
||||
{hero.eyebrow}
|
||||
</span>
|
||||
<br />
|
||||
OR I WILL
|
||||
<br />
|
||||
MAKE ONE.
|
||||
</h1>
|
||||
<p className="max-w-2xl font-body text-xl leading-relaxed text-on-surface-variant md:text-2xl">
|
||||
A multidisciplinary architect of digital ecosystems and
|
||||
agricultural infrastructures. Executing vision through
|
||||
precision.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hero?.headline ? (
|
||||
<h1 className="display-lg mb-8 text-on-surface">
|
||||
{hero.headline}
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="display-lg mb-8 text-on-surface">
|
||||
I WILL FIND
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
A WAY,
|
||||
</span>
|
||||
<br />
|
||||
OR I WILL
|
||||
<br />
|
||||
MAKE ONE.
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{hero?.subheadline && (
|
||||
<p className="body-lg mb-10 max-w-2xl text-on-surface-variant">
|
||||
{hero.subheadline}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(hero?.primaryCta?.label || hero?.secondaryCta?.label) && (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{hero.primaryCta?.label && hero.primaryCta?.href && (
|
||||
<Link href={hero.primaryCta.href}>
|
||||
<Button variant="primary">{hero.primaryCta.label}</Button>
|
||||
</Link>
|
||||
)}
|
||||
{hero.secondaryCta?.label && hero.secondaryCta?.href && (
|
||||
<Link href={hero.secondaryCta.href}>
|
||||
<Button variant="secondary">{hero.secondaryCta.label}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — 40% — portrait with dot-grid overlap */}
|
||||
<div className="relative hidden lg:col-span-5 lg:block">
|
||||
{hero?.heroImage && typeof hero.heroImage === "object" && (hero.heroImage as { url?: string }).url ? (
|
||||
<div className="relative">
|
||||
{/* Dot-grid overlap element */}
|
||||
<div
|
||||
className="pointer-events-none absolute -left-8 -top-8 h-32 w-32 opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(rgba(129,236,255,0.5) 1px, transparent 1px)",
|
||||
backgroundSize: "12px 12px",
|
||||
}}
|
||||
/>
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-md bg-surface-container-highest border border-outline-variant/15">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={(hero.heroImage as { url: string }).url}
|
||||
alt="Jason Woltje"
|
||||
className="h-full w-full object-cover grayscale transition-all duration-700 hover:grayscale-0"
|
||||
/>
|
||||
</div>
|
||||
{/* Dot-grid overlap bottom-right */}
|
||||
<div
|
||||
className="pointer-events-none absolute -bottom-8 -right-8 h-32 w-32 opacity-20"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(rgba(216,115,255,0.5) 1px, transparent 1px)",
|
||||
backgroundSize: "12px 12px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crosshair deco */}
|
||||
<div className="absolute bottom-12 right-12 hidden opacity-20 lg:block">
|
||||
<div className="relative flex h-32 w-32 items-center justify-center rounded-full border border-primary/40">
|
||||
<div className="absolute h-px w-full bg-primary" />
|
||||
<div className="absolute h-full w-px bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Silicon Ethos / Principles ──────────────────────────────────── */}
|
||||
{principles && principles.length > 0 && (
|
||||
<section className="bg-surface-container-low py-24 px-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-16">
|
||||
<span className="label-sm mb-4 block text-tertiary uppercase tracking-[0.4em]">
|
||||
02 // SILICON ETHOS
|
||||
</span>
|
||||
<h2 className="display-sm text-on-surface">OPERATING PRINCIPLES</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{principles.map((p) => (
|
||||
<div
|
||||
key={p.id ?? p.title}
|
||||
className="flex flex-col gap-4 rounded-md bg-surface-container-highest border border-outline-variant/15 p-8"
|
||||
>
|
||||
{p.code && (
|
||||
<span className="label-sm text-on-surface-variant tracking-[0.3em] uppercase">
|
||||
{p.code}
|
||||
</span>
|
||||
)}
|
||||
<h3 className={`title-lg font-bold tracking-tight ${accentTextClass[p.accent ?? "primary"] ?? "text-primary"}`}>
|
||||
{p.title}
|
||||
</h3>
|
||||
{p.body && (
|
||||
<p className="body-md text-on-surface-variant leading-relaxed">{p.body}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Featured Projects ───────────────────────────────────────────── */}
|
||||
{resolvedProjects.length > 0 && (
|
||||
<section className="py-24 px-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-16">
|
||||
<span className="label-sm mb-4 block text-secondary uppercase tracking-[0.4em]">
|
||||
03 // SELECTED WORK
|
||||
</span>
|
||||
<h2 className="display-sm text-on-surface">FEATURED PROJECTS</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{resolvedProjects.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/projects/${project.slug}`}
|
||||
className="group flex flex-col gap-5 rounded-md bg-surface-container-high border border-outline-variant/15 p-8 transition-colors hover:border-primary/30 hover:bg-surface-container-highest"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h3 className="title-lg text-on-surface font-bold tracking-tight group-hover:text-primary transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
{project.year && (
|
||||
<span className="label-sm shrink-0 text-on-surface-variant tracking-widest">
|
||||
{project.year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="body-md text-on-surface-variant leading-relaxed line-clamp-3">
|
||||
{project.summary}
|
||||
</p>
|
||||
|
||||
{project.tech && project.tech.length > 0 && (
|
||||
<div className="mt-auto flex flex-wrap gap-2 pt-2">
|
||||
{project.tech.slice(0, 4).map((t, i) =>
|
||||
t.label ? (
|
||||
<TechChip key={t.id ?? i} accent="secondary">
|
||||
{t.label}
|
||||
</TechChip>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Closing CTA ─────────────────────────────────────────────────── */}
|
||||
{closingCta?.headline && (
|
||||
<section className="bg-surface-container py-24 px-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="flex flex-col items-start gap-8 md:flex-row md:items-center md:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
{closingCta.eyebrow && (
|
||||
<span className="label-sm mb-4 block text-primary uppercase tracking-[0.4em]">
|
||||
{closingCta.eyebrow}
|
||||
</span>
|
||||
)}
|
||||
<h2 className="display-md text-on-surface">{closingCta.headline}</h2>
|
||||
{closingCta.body && (
|
||||
<p className="body-lg mt-4 text-on-surface-variant">{closingCta.body}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{closingCta.cta?.label && closingCta.cta?.href && (
|
||||
<div className="shrink-0">
|
||||
<Link href={closingCta.cta.href}>
|
||||
<Button variant="primary">{closingCta.cta.label}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { GridOverlay, TechChip } from "@/components/site";
|
||||
import type { Project, Media } from "@/payload-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { slug: string };
|
||||
|
||||
function isMedia(val: unknown): val is Media {
|
||||
return typeof val === "object" && val !== null && "url" in val;
|
||||
}
|
||||
|
||||
const STATUS_PILL: Record<string, string> = {
|
||||
active: "bg-primary/10 text-primary border border-primary/20",
|
||||
production: "bg-tertiary/10 text-tertiary border border-tertiary/20",
|
||||
prototype: "bg-secondary/10 text-secondary border border-secondary/20",
|
||||
archived:
|
||||
"bg-surface-container-highest text-on-surface-variant border border-outline-variant/15",
|
||||
};
|
||||
|
||||
const LINK_ICONS: Record<string, string> = {
|
||||
live: "↗",
|
||||
repo: "⌥",
|
||||
docs: "⎕",
|
||||
writeup: "✎",
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
return { title: slug };
|
||||
const payload = await getPayload({ config });
|
||||
const { docs } = await payload.find({
|
||||
collection: "projects",
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
});
|
||||
const project = docs[0] as Project | undefined;
|
||||
if (!project) return { title: "Project Not Found" };
|
||||
return {
|
||||
title: project.title,
|
||||
description: project.summary,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectDetailPage({
|
||||
@@ -19,27 +58,186 @@ export default async function ProjectDetailPage({
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
if (!slug) notFound();
|
||||
|
||||
const payload = await getPayload({ config });
|
||||
const { docs } = await payload.find({
|
||||
collection: "projects",
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 2,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const project = docs[0] as Project | undefined;
|
||||
if (!project) notFound();
|
||||
|
||||
const hero = isMedia(project.heroImage) ? project.heroImage : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-4xl px-6 py-24">
|
||||
<span className="mb-4 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
PROJECT //{" "}
|
||||
<code className="text-on-surface-variant">{slug}</code>
|
||||
</span>
|
||||
<h1 className="mb-6 font-headline text-4xl font-bold tracking-tight md:text-6xl">
|
||||
Project detail
|
||||
</h1>
|
||||
<p className="font-body text-lg text-on-surface-variant">
|
||||
Slug: <code className="font-label text-primary">{slug}</code>. Body
|
||||
will render from the Payload{" "}
|
||||
<code className="font-label text-primary">projects</code> collection
|
||||
once wired.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<header className="relative overflow-hidden pb-16 pt-16">
|
||||
<GridOverlay opacity={0.1} />
|
||||
{hero?.url && (
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<Image
|
||||
src={hero.url}
|
||||
alt={hero.alt}
|
||||
fill
|
||||
className="object-cover opacity-10 grayscale"
|
||||
priority
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background/60 via-background/80 to-background" />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-6">
|
||||
<div className="mb-6 inline-flex items-center gap-2">
|
||||
<span className="h-px w-12 bg-primary" />
|
||||
<span className="label-sm uppercase tracking-[0.2em] text-primary">
|
||||
PROJECTS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||||
<span
|
||||
className={`label-sm rounded-sm px-3 py-1 uppercase tracking-widest ${STATUS_PILL[project.status] ?? STATUS_PILL.archived}`}
|
||||
>
|
||||
{project.status}
|
||||
</span>
|
||||
{project.year && (
|
||||
<span className="label-sm text-on-surface-variant">
|
||||
{project.year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="display-md mb-4 leading-tight tracking-tighter text-on-surface">
|
||||
{project.title}
|
||||
</h1>
|
||||
{project.role && (
|
||||
<p className="label-md uppercase tracking-widest text-on-surface-variant">
|
||||
{project.role}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body + Sidebar */}
|
||||
<section className="mx-auto max-w-7xl px-6 pb-32">
|
||||
<div className="grid grid-cols-1 gap-16 lg:grid-cols-12">
|
||||
{/* Main content — 70% */}
|
||||
<div className="space-y-12 lg:col-span-8">
|
||||
<p className="body-lg leading-relaxed text-on-surface-variant">
|
||||
{project.summary}
|
||||
</p>
|
||||
|
||||
{project.body && (
|
||||
<div className="body-lg space-y-4 leading-relaxed text-on-surface-variant [&_h1]:display-sm [&_h1]:text-on-surface [&_h2]:headline-lg [&_h2]:text-on-surface [&_h3]:title-lg [&_h3]:text-on-surface [&_a]:text-primary [&_a]:underline [&_a:hover]:opacity-80 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6">
|
||||
<RichText data={project.body as Parameters<typeof RichText>[0]["data"]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{project.gallery && project.gallery.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<span className="label-sm block uppercase tracking-[0.2em] text-secondary">
|
||||
GALLERY
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{project.gallery.map((entry, i) => {
|
||||
const img = isMedia(entry.image) ? entry.image : null;
|
||||
if (!img?.url) return null;
|
||||
return (
|
||||
<figure key={entry.id ?? i} className="space-y-2">
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-md border border-outline-variant/15">
|
||||
<Image
|
||||
src={img.url}
|
||||
alt={img.alt}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, 50vw"
|
||||
/>
|
||||
</div>
|
||||
{entry.caption && (
|
||||
<figcaption className="label-sm text-on-surface-variant">
|
||||
{entry.caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar — 30% */}
|
||||
<aside className="space-y-10 lg:col-span-4">
|
||||
{/* Links */}
|
||||
{project.links && project.links.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<span className="label-sm block uppercase tracking-[0.2em] text-primary">
|
||||
LINKS
|
||||
</span>
|
||||
<ul className="space-y-3">
|
||||
{project.links.map((link, li) => (
|
||||
<li key={link.id ?? li}>
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-sm bg-surface-container-low px-4 py-3 transition-colors hover:bg-surface-container-high"
|
||||
>
|
||||
<span className="label-sm text-primary">
|
||||
{link.type ? (LINK_ICONS[link.type] ?? "→") : "→"}
|
||||
</span>
|
||||
<span className="label-sm flex-1 uppercase tracking-wider text-on-surface transition-colors group-hover:text-primary">
|
||||
{link.label}
|
||||
</span>
|
||||
{link.type && (
|
||||
<span className="label-sm uppercase text-on-surface-variant">
|
||||
{link.type}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tech */}
|
||||
{project.tech && project.tech.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<span className="label-sm block uppercase tracking-[0.2em] text-secondary">
|
||||
TECH STACK
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tech.map((t, ti) =>
|
||||
t.label ? (
|
||||
<TechChip key={t.id ?? ti} accent="secondary">
|
||||
{t.label}
|
||||
</TechChip>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer nav */}
|
||||
<div className="border-t border-outline-variant/15 bg-surface-container-low py-10">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<Link
|
||||
href="/projects"
|
||||
className="label-sm inline-flex items-center gap-2 uppercase tracking-widest text-on-surface-variant transition-colors hover:text-primary"
|
||||
>
|
||||
← All projects
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import type { Metadata } from "next";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { GridOverlay } from "@/components/site";
|
||||
import { ProjectsGrid } from "@/components/site/ProjectsGrid";
|
||||
|
||||
export const metadata = { title: "Projects" };
|
||||
export const metadata: Metadata = {
|
||||
title: "Projects",
|
||||
description:
|
||||
"A curated selection of industrial infrastructure, research platforms, and independent consultancy projects engineered for high-performance environments.",
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProjectsIndexPage() {
|
||||
const payload = await getPayload({ config });
|
||||
|
||||
const { docs: projects } = await payload.find({
|
||||
collection: "projects",
|
||||
sort: "sortOrder,-year",
|
||||
limit: 100,
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
export default function ProjectsIndexPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-7xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
03 // PROJECTS
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
Strategic Verticals
|
||||
</h1>
|
||||
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
|
||||
Content sourced from Payload CMS collection{" "}
|
||||
<code className="font-label text-primary">projects</code> — rendered
|
||||
here once the bento-grid section component lands in a follow-up PR.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<header className="relative overflow-hidden pb-20 pt-16">
|
||||
<GridOverlay opacity={0.1} />
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-6">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6 inline-flex items-center gap-2">
|
||||
<span className="h-px w-12 bg-primary" />
|
||||
<span className="label-sm uppercase tracking-[0.2em] text-primary">
|
||||
02 // PROJECTS
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="display-lg mb-8 leading-[0.9] tracking-tighter text-on-surface">
|
||||
TECHNICAL
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
ARCHITECTURES.
|
||||
</span>
|
||||
</h1>
|
||||
<p className="body-lg max-w-lg leading-relaxed text-on-surface-variant">
|
||||
A curated selection of industrial infrastructure, research
|
||||
platforms, and independent consultancy projects engineered for
|
||||
high-performance environments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Grid with client-side filter */}
|
||||
<section className="mx-auto max-w-7xl px-6 pb-32">
|
||||
<ProjectsGrid projects={projects} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Resume" };
|
||||
|
||||
export default function ResumePage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-4xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-tertiary">
|
||||
06 // CURRICULUM
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
Resume
|
||||
</h1>
|
||||
<p className="font-body text-xl text-on-surface-variant">
|
||||
Sourced from Payload{" "}
|
||||
<code className="font-label text-primary">resume</code> global. PDF
|
||||
export wires up at{" "}
|
||||
<code className="font-label text-primary">/resume.pdf</code> in
|
||||
UI-07.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<main className="mx-auto max-w-4xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-tertiary">
|
||||
06 // CURRICULUM
|
||||
</span>
|
||||
<h1 className="display-md mb-8 text-on-surface">Resume</h1>
|
||||
<p className="body-lg">
|
||||
Sourced from Payload{" "}
|
||||
<code className="font-label text-primary">resume</code> global. PDF
|
||||
export wires up at{" "}
|
||||
<code className="font-label text-primary">/resume.pdf</code> in UI-07.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
type Params = { slug: string };
|
||||
|
||||
@@ -22,22 +22,16 @@ export default async function PostDetailPage({
|
||||
if (!slug) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||
<span className="mb-4 block font-label text-xs uppercase tracking-[0.4em] text-secondary">
|
||||
POST //{" "}
|
||||
<code className="text-on-surface-variant">{slug}</code>
|
||||
</span>
|
||||
<h1 className="mb-6 font-headline text-4xl font-bold tracking-tight md:text-6xl">
|
||||
Post detail
|
||||
</h1>
|
||||
<p className="font-body text-lg text-on-surface-variant">
|
||||
Body renders from Payload{" "}
|
||||
<code className="font-label text-primary">posts</code> once wired.
|
||||
</p>
|
||||
</article>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||
<span className="mb-4 block font-label text-xs uppercase tracking-[0.4em] text-secondary">
|
||||
POST //{" "}
|
||||
<code className="text-on-surface-variant">{slug}</code>
|
||||
</span>
|
||||
<h1 className="display-sm mb-6 text-on-surface">Post detail</h1>
|
||||
<p className="body-md">
|
||||
Body renders from Payload{" "}
|
||||
<code className="font-label text-primary">posts</code> once wired.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Writing" };
|
||||
|
||||
export default function WritingIndexPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-7xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-secondary">
|
||||
04 // WRITING
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
Signal
|
||||
</h1>
|
||||
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
|
||||
Long-form from the Payload{" "}
|
||||
<code className="font-label text-primary">posts</code> collection.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<main className="mx-auto max-w-7xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-secondary">
|
||||
04 // WRITING
|
||||
</span>
|
||||
<h1 className="display-md mb-8 text-on-surface">Signal</h1>
|
||||
<p className="body-lg max-w-3xl">
|
||||
Long-form from the Payload{" "}
|
||||
<code className="font-label text-primary">posts</code> collection.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,51 @@
|
||||
// Auto-generated stub. Regenerate with `pnpm generate:importmap` after adding custom components.
|
||||
export const importMap = {};
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
||||
93
src/app/api/contact/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface ContactBody {
|
||||
name?: unknown;
|
||||
email?: unknown;
|
||||
message?: unknown;
|
||||
turnstileToken?: unknown;
|
||||
}
|
||||
|
||||
interface TurnstileResponse {
|
||||
success: boolean;
|
||||
"error-codes"?: string[];
|
||||
}
|
||||
|
||||
async function verifyTurnstile(token: string, ip: string): Promise<boolean> {
|
||||
const secret = process.env.TURNSTILE_SECRET_KEY;
|
||||
if (!secret) return true;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
secret,
|
||||
response: token,
|
||||
remoteip: ip,
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
{ method: "POST", body },
|
||||
);
|
||||
|
||||
const data = (await res.json()) as TurnstileResponse;
|
||||
return data.success === true;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const raw = (await req.json().catch(() => null)) as ContactBody | null;
|
||||
|
||||
if (!raw) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { name, email, message, turnstileToken } = raw;
|
||||
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "Name is required." }, { status: 400 });
|
||||
}
|
||||
if (typeof email !== "string" || !email.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "Email is required." }, { status: 400 });
|
||||
}
|
||||
if (typeof message !== "string" || !message.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "Message is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const forwardedFor = req.headers.get("x-forwarded-for") ?? "";
|
||||
const ip = forwardedFor.split(",")[0]?.trim() ?? "";
|
||||
|
||||
if (process.env.TURNSTILE_SECRET_KEY && typeof turnstileToken === "string") {
|
||||
const valid = await verifyTurnstile(turnstileToken, ip);
|
||||
if (!valid) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Spam protection check failed. Please try again." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
|
||||
await payload.create({
|
||||
collection: "contactSubmissions",
|
||||
data: {
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
message: message.trim(),
|
||||
turnstileVerified: Boolean(
|
||||
process.env.TURNSTILE_SECRET_KEY && typeof turnstileToken === "string",
|
||||
),
|
||||
submittedAt: new Date().toISOString(),
|
||||
ip: ip || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,13 @@ import type { CollectionConfig } from "payload";
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: "categories",
|
||||
access: { read: () => true },
|
||||
admin: { useAsTitle: "name", defaultColumns: ["name", "slug", "accent"] },
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "slug"],
|
||||
group: "Content",
|
||||
},
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{
|
||||
name: "accent",
|
||||
type: "select",
|
||||
defaultValue: "primary",
|
||||
options: [
|
||||
{ label: "Primary (cyan)", value: "primary" },
|
||||
{ label: "Secondary (magenta)", value: "secondary" },
|
||||
{ label: "Tertiary (green)", value: "tertiary" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -10,24 +10,25 @@ export const ContactSubmissions: CollectionConfig = {
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "status", "submittedAt"],
|
||||
defaultColumns: ["name", "email", "submittedAt"],
|
||||
group: "System",
|
||||
},
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{ name: "email", type: "email", required: true },
|
||||
{ name: "brief", type: "textarea", required: true },
|
||||
{ name: "source", type: "text" },
|
||||
{ name: "submittedAt", type: "date", defaultValue: () => new Date() },
|
||||
{ name: "ipHash", type: "text" },
|
||||
{ name: "message", type: "textarea", required: true },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "new",
|
||||
options: [
|
||||
{ label: "New", value: "new" },
|
||||
{ label: "Replied", value: "replied" },
|
||||
{ label: "Spam", value: "spam" },
|
||||
],
|
||||
name: "turnstileVerified",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: "submittedAt",
|
||||
type: "date",
|
||||
defaultValue: () => new Date().toISOString(),
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{ name: "ip", type: "text", admin: { readOnly: true } },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,32 +5,26 @@ export const Gear: CollectionConfig = {
|
||||
access: { read: () => true },
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "type"],
|
||||
description: "Music / maker gear — decorative only for v0.0.x",
|
||||
defaultColumns: ["name", "category", "featured"],
|
||||
group: "Content",
|
||||
},
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{
|
||||
name: "type",
|
||||
name: "category",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Daily driver", value: "daily-driver" },
|
||||
{ label: "Interface", value: "interface" },
|
||||
{ label: "Monitor", value: "monitor" },
|
||||
{ label: "Workbench", value: "workbench" },
|
||||
{ label: "Compute", value: "compute" },
|
||||
{ label: "Audio", value: "audio" },
|
||||
{ label: "Peripherals", value: "peripherals" },
|
||||
{ label: "Network", value: "network" },
|
||||
{ label: "Dev Tools", value: "dev-tools" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
},
|
||||
{ name: "notes", type: "textarea" },
|
||||
{ name: "summary", type: "textarea" },
|
||||
{ name: "link", type: "text" },
|
||||
{ name: "image", type: "upload", relationTo: "media" },
|
||||
{
|
||||
name: "accent",
|
||||
type: "select",
|
||||
defaultValue: "tertiary",
|
||||
options: [
|
||||
{ label: "Primary", value: "primary" },
|
||||
{ label: "Secondary", value: "secondary" },
|
||||
{ label: "Tertiary", value: "tertiary" },
|
||||
],
|
||||
},
|
||||
{ name: "featured", type: "checkbox", defaultValue: false },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2,11 +2,10 @@ import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: "media",
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
access: { read: () => true },
|
||||
admin: {
|
||||
useAsTitle: "alt",
|
||||
group: "System",
|
||||
},
|
||||
upload: {
|
||||
staticDir: "media",
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: "posts",
|
||||
access: {
|
||||
read: ({ req: { user } }) =>
|
||||
user ? true : { status: { equals: "published" } },
|
||||
},
|
||||
access: { read: () => true },
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "publishedAt"],
|
||||
group: "Content",
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ data }) => {
|
||||
if (data && !data.slug && data.title) {
|
||||
data.slug = (data.title as string)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
versions: { drafts: true },
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{ name: "excerpt", type: "textarea" },
|
||||
{ name: "body", type: "richText" },
|
||||
{ name: "category", type: "relationship", relationTo: "categories" },
|
||||
{ name: "heroImage", type: "upload", relationTo: "media" },
|
||||
{ name: "tags", type: "array", fields: [{ name: "tag", type: "text" }] },
|
||||
{ name: "summary", type: "textarea" },
|
||||
{ name: "publishedAt", type: "date" },
|
||||
{ name: "body", type: "richText", editor: lexicalEditor({}) },
|
||||
{ name: "coverImage", type: "upload", relationTo: "media" },
|
||||
{
|
||||
name: "categories",
|
||||
type: "relationship",
|
||||
relationTo: "categories",
|
||||
hasMany: true,
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
type: "array",
|
||||
fields: [{ name: "label", type: "text" }],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
@@ -29,15 +50,5 @@ export const Posts: CollectionConfig = {
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
},
|
||||
{ name: "publishedAt", type: "date" },
|
||||
{
|
||||
name: "seo",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "title", type: "text" },
|
||||
{ name: "description", type: "textarea" },
|
||||
{ name: "image", type: "upload", relationTo: "media" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
|
||||
export const Projects: CollectionConfig = {
|
||||
slug: "projects",
|
||||
access: {
|
||||
read: ({ req: { user } }) =>
|
||||
user ? true : { status: { equals: "published" } },
|
||||
},
|
||||
access: { read: () => true },
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "featured", "order", "publishedAt"],
|
||||
defaultColumns: ["title", "status", "featured", "sortOrder", "year"],
|
||||
group: "Content",
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ data }) => {
|
||||
if (data && !data.slug && data.title) {
|
||||
data.slug = (data.title as string)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
},
|
||||
versions: { drafts: true },
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{ name: "summary", type: "textarea", required: true },
|
||||
{
|
||||
name: "role",
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "active",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Founder / CEO", value: "founder" },
|
||||
{ label: "Consultant", value: "consultant" },
|
||||
{ label: "Engineer", value: "engineer" },
|
||||
{ label: "Infrastructure", value: "infra" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Archived", value: "archived" },
|
||||
{ label: "Prototype", value: "prototype" },
|
||||
{ label: "Production", value: "production" },
|
||||
],
|
||||
},
|
||||
{ name: "category", type: "relationship", relationTo: "categories" },
|
||||
{ name: "summary", type: "textarea" },
|
||||
{ name: "body", type: "richText" },
|
||||
{ name: "stack", type: "array", fields: [{ name: "name", type: "text" }] },
|
||||
{ name: "year", type: "number" },
|
||||
{ name: "heroImage", type: "upload", relationTo: "media" },
|
||||
{
|
||||
name: "gallery",
|
||||
@@ -37,28 +48,32 @@ export const Projects: CollectionConfig = {
|
||||
{ name: "caption", type: "text" },
|
||||
],
|
||||
},
|
||||
{ name: "externalUrl", type: "text" },
|
||||
{ name: "featured", type: "checkbox", defaultValue: false },
|
||||
{ name: "order", type: "number", defaultValue: 0 },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "draft",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
name: "tech",
|
||||
type: "array",
|
||||
fields: [{ name: "label", type: "text" }],
|
||||
},
|
||||
{ name: "publishedAt", type: "date" },
|
||||
{ name: "role", type: "text" },
|
||||
{
|
||||
name: "seo",
|
||||
type: "group",
|
||||
name: "links",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "title", type: "text" },
|
||||
{ name: "description", type: "textarea" },
|
||||
{ name: "image", type: "upload", relationTo: "media" },
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Live", value: "live" },
|
||||
{ label: "Repo", value: "repo" },
|
||||
{ label: "Docs", value: "docs" },
|
||||
{ label: "Write-up", value: "writeup" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "body", type: "richText", editor: lexicalEditor({}) },
|
||||
{ name: "featured", type: "checkbox", defaultValue: false },
|
||||
{ name: "sortOrder", type: "number", defaultValue: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export const Users: CollectionConfig = {
|
||||
admin: {
|
||||
useAsTitle: "email",
|
||||
defaultColumns: ["email", "role"],
|
||||
group: "System",
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-24 border-t border-outline-variant/10 bg-surface-container-lowest">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-12 md:flex-row md:items-center md:justify-between">
|
||||
<div className="font-headline text-sm uppercase tracking-[0.2em] text-on-surface-variant">
|
||||
© {new Date().getFullYear()} Jason Woltje · All rights reserved
|
||||
</div>
|
||||
<div className="font-label text-[10px] uppercase tracking-[0.2em] text-tertiary/80">
|
||||
LATENCY: 42ms · CORE STATUS: NOMINAL
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const links = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: "Writing", href: "/writing" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
];
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full bg-background/80 backdrop-blur-xl">
|
||||
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-headline text-xl font-bold uppercase tracking-tighter text-primary"
|
||||
>
|
||||
JASON WOLTJE
|
||||
</Link>
|
||||
<div className="hidden items-center gap-8 md:flex">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="font-label text-[14px] uppercase tracking-tighter text-on-surface-variant transition-colors hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
28
src/components/site/Button.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
primary:
|
||||
"neon-cta font-label text-[12px] uppercase tracking-[0.2em] rounded-md px-5 py-2.5 transition-opacity hover:opacity-90 active:scale-95",
|
||||
secondary:
|
||||
"bg-surface-container-high text-primary font-label text-[12px] uppercase tracking-[0.2em] rounded-md px-5 py-2.5 transition-colors hover:bg-surface-container-highest active:scale-95",
|
||||
ghost:
|
||||
"bg-transparent text-on-surface font-label text-[12px] uppercase tracking-[0.2em] rounded-md px-5 py-2.5 border border-outline-variant/20 transition-colors hover:border-outline-variant/40 active:scale-95",
|
||||
};
|
||||
|
||||
export function Button({ variant = "primary", className = "", children, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center transition-transform ${variantClasses[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
86
src/components/site/Footer.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import Link from "next/link";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { StatusTerminal } from "./StatusTerminal";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
];
|
||||
|
||||
export async function Footer() {
|
||||
const payload = await getPayload({ config });
|
||||
const nav = await payload.findGlobal({ slug: "navigation", depth: 0 });
|
||||
const socials = nav.socials ?? [];
|
||||
|
||||
return (
|
||||
<footer className="bg-background">
|
||||
<div className="mx-auto max-w-7xl px-6 pt-16 pb-8">
|
||||
{/* Columns */}
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-8">
|
||||
{/* Brand */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-headline text-lg font-bold uppercase tracking-tighter text-primary"
|
||||
>
|
||||
JASON WOLTJE
|
||||
</Link>
|
||||
<p className="font-body text-sm leading-relaxed text-on-surface-variant">
|
||||
Systems thinker. Builder. IT leader turning into a software
|
||||
engineer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="label-md text-on-surface-variant mb-1">Navigate</span>
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="font-label text-[11px] uppercase tracking-widest text-on-surface-variant transition-colors hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Socials */}
|
||||
{socials.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="label-md text-on-surface-variant mb-1">Connect</span>
|
||||
{socials.map((s) => (
|
||||
<a
|
||||
key={s.id ?? s.href}
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-label text-[11px] uppercase tracking-widest text-on-surface-variant transition-colors hover:text-secondary"
|
||||
>
|
||||
{s.label ?? s.platform}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider tonal shift — no 1px border at 100% */}
|
||||
<div className="mt-12 h-px bg-gradient-to-r from-transparent via-outline-variant/20 to-transparent" />
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-6 flex flex-col items-start gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<span className="font-label text-[9px] uppercase tracking-[0.3em] text-on-surface-variant">
|
||||
© {new Date().getFullYear()} JASON WOLTJE // ALL RIGHTS RESERVED
|
||||
</span>
|
||||
<StatusTerminal />
|
||||
<span className="font-label text-[8px] uppercase tracking-[0.5em] text-outline">
|
||||
ENGINEERED FOR EXCELLENCE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
18
src/components/site/GridOverlay.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
interface GridOverlayProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export function GridOverlay({ className = "", size = 20, opacity = 0.12 }: GridOverlayProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-0 ${className}`}
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(rgba(129, 236, 255, ${opacity}) 1px, transparent 1px)`,
|
||||
backgroundSize: `${size}px ${size}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
96
src/components/site/Nav.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
];
|
||||
|
||||
export function Nav() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full bg-background/80 backdrop-blur-xl">
|
||||
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-headline text-xl font-bold uppercase tracking-tighter text-primary"
|
||||
>
|
||||
JASON WOLTJE
|
||||
</Link>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden items-center gap-8 md:flex">
|
||||
{NAV_LINKS.filter((l) => l.label !== "Contact").map((link) => {
|
||||
const active = pathname === link.href;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`font-label text-[13px] uppercase tracking-tighter transition-colors hover:text-primary ${
|
||||
active
|
||||
? "border-b border-primary pb-0.5 text-primary"
|
||||
: "text-on-surface-variant"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href="/contact"
|
||||
className="neon-cta rounded-md px-4 py-2 font-label text-[12px] uppercase tracking-[0.15em] transition-opacity hover:opacity-90 active:scale-95"
|
||||
>
|
||||
Contact
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="flex flex-col items-center justify-center gap-1.5 p-2 md:hidden"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span
|
||||
className={`block h-px w-6 bg-on-surface transition-transform duration-200 ${open ? "translate-y-[4px] rotate-45" : ""}`}
|
||||
/>
|
||||
<span
|
||||
className={`block h-px w-6 bg-on-surface transition-opacity duration-200 ${open ? "opacity-0" : ""}`}
|
||||
/>
|
||||
<span
|
||||
className={`block h-px w-6 bg-on-surface transition-transform duration-200 ${open ? "-translate-y-[10px] -rotate-45" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile sheet */}
|
||||
{open && (
|
||||
<div className="glass border-t border-outline-variant/10 md:hidden">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-0 px-6 py-4">
|
||||
{NAV_LINKS.map((link) => {
|
||||
const active = pathname === link.href;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`border-b border-outline-variant/10 py-3 font-label text-[13px] uppercase tracking-widest transition-colors hover:text-primary ${
|
||||
active ? "text-primary" : "text-on-surface-variant"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
144
src/components/site/ProjectsGrid.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { TechChip } from "@/components/site/TechChip";
|
||||
import { GridOverlay } from "@/components/site/GridOverlay";
|
||||
import type { Project, Media } from "@/payload-types";
|
||||
|
||||
type StatusFilter = "all" | "active" | "production" | "prototype" | "archived";
|
||||
|
||||
const STATUS_FILTERS: { label: string; value: StatusFilter }[] = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Production", value: "production" },
|
||||
{ label: "Prototype", value: "prototype" },
|
||||
{ label: "Archived", value: "archived" },
|
||||
];
|
||||
|
||||
const STATUS_PILL: Record<string, string> = {
|
||||
active: "bg-primary/10 text-primary border border-primary/20",
|
||||
production: "bg-tertiary/10 text-tertiary border border-tertiary/20",
|
||||
prototype: "bg-secondary/10 text-secondary border border-secondary/20",
|
||||
archived: "bg-surface-container-highest text-on-surface-variant border border-outline-variant/15",
|
||||
};
|
||||
|
||||
function isMedia(val: unknown): val is Media {
|
||||
return typeof val === "object" && val !== null && "url" in val;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
export function ProjectsGrid({ projects }: Props) {
|
||||
const [active, setActive] = useState<StatusFilter>("all");
|
||||
|
||||
const filtered =
|
||||
active === "all" ? projects : projects.filter((p) => p.status === active);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filter row */}
|
||||
<div className="mb-12 flex gap-2 overflow-x-auto pb-2 scrollbar-none">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setActive(f.value)}
|
||||
className={`shrink-0 rounded-sm px-5 py-2 label-sm uppercase tracking-widest transition-colors ${
|
||||
active === f.value
|
||||
? "bg-primary text-on-primary"
|
||||
: "bg-surface-container-high text-on-surface-variant hover:bg-surface-container-highest hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="body-md py-24 text-center text-on-surface-variant">
|
||||
No projects found.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-0 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((project, i) => {
|
||||
const hero = isMedia(project.heroImage) ? project.heroImage : null;
|
||||
const techVisible = project.tech?.slice(0, 4) ?? [];
|
||||
const overflow = (project.tech?.length ?? 0) - 4;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/projects/${project.slug}`}
|
||||
className={`group relative flex flex-col overflow-hidden transition-colors ${
|
||||
i % 2 === 0
|
||||
? "bg-surface-container-low"
|
||||
: "bg-surface-container"
|
||||
}`}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative aspect-video w-full overflow-hidden">
|
||||
<GridOverlay opacity={0.12} />
|
||||
{hero?.url ? (
|
||||
<Image
|
||||
src={hero.url}
|
||||
alt={hero.alt}
|
||||
fill
|
||||
className="object-cover grayscale opacity-50 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-700"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-surface-container-highest" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-surface-container-low via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col gap-4 p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`label-sm rounded-sm px-2.5 py-1 uppercase tracking-widest ${STATUS_PILL[project.status] ?? STATUS_PILL.archived}`}
|
||||
>
|
||||
{project.status}
|
||||
</span>
|
||||
{project.year && (
|
||||
<span className="label-sm text-on-surface-variant">
|
||||
{project.year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="title-lg text-on-surface group-hover:text-primary transition-colors leading-tight">
|
||||
{project.title}
|
||||
</h2>
|
||||
|
||||
<p className="body-md flex-1 text-on-surface-variant leading-relaxed line-clamp-3">
|
||||
{project.summary}
|
||||
</p>
|
||||
|
||||
{techVisible.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{techVisible.map((t, ti) =>
|
||||
t.label ? (
|
||||
<TechChip key={t.id ?? ti} accent="secondary">
|
||||
{t.label}
|
||||
</TechChip>
|
||||
) : null,
|
||||
)}
|
||||
{overflow > 0 && (
|
||||
<TechChip accent="primary">+{overflow}</TechChip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/components/site/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Site Components — Contract for Page Subagents
|
||||
|
||||
## Imports
|
||||
|
||||
```ts
|
||||
import { Nav, Footer, StatusTerminal, GridOverlay, Button, TechChip } from "@/components/site";
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Do NOT** redefine `<Nav>` or `<Footer>` in page files. They are wired in `src/app/(frontend)/layout.tsx`.
|
||||
- `<StatusTerminal>` reads `NEXT_PUBLIC_BUILD_SHA` and `NEXT_PUBLIC_BUILD_REV` at build time. Accept `location` and `status` props to override defaults.
|
||||
- `<GridOverlay>` is `position: absolute` — wrap parent in `relative overflow-hidden`.
|
||||
|
||||
## Color Token Classes
|
||||
|
||||
| Token | Tailwind class |
|
||||
|---|---|
|
||||
| Background | `bg-background` |
|
||||
| Primary accent | `text-primary`, `bg-primary` |
|
||||
| Secondary | `text-secondary` |
|
||||
| Tertiary (terminal green) | `text-tertiary` |
|
||||
| Surface base | `bg-surface-container` |
|
||||
| Surface low | `bg-surface-container-low` |
|
||||
| Surface high | `bg-surface-container-high` |
|
||||
| Surface highest | `bg-surface-container-highest` |
|
||||
| Surface lowest | `bg-surface-container-lowest` |
|
||||
| Underscore aliases | `bg-surface_container_high`, `text-on_surface`, `text-outline_variant` |
|
||||
| Text default | `text-on-surface` |
|
||||
| Text muted | `text-on-surface-variant` |
|
||||
| Border ghost | `border-outline-variant/15` |
|
||||
|
||||
## Typography Utility Classes
|
||||
|
||||
| Class | Usage |
|
||||
|---|---|
|
||||
| `.display-lg` | Hero headline (H1) |
|
||||
| `.display-md` | Sub-hero or section hero |
|
||||
| `.display-sm` | Large section titles |
|
||||
| `.headline-lg` | Section headings (H2) |
|
||||
| `.title-lg` | Card / module titles (H3) |
|
||||
| `.body-lg` | Primary body copy |
|
||||
| `.body-md` | Secondary / card body |
|
||||
| `.label-md` | ALL CAPS metadata labels |
|
||||
| `.label-sm` | Tiny ALL CAPS technical labels, chips |
|
||||
|
||||
## Button Variants
|
||||
|
||||
```tsx
|
||||
<Button variant="primary">CTA Label</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
```
|
||||
|
||||
## Design Rules (from DESIGN.md)
|
||||
|
||||
- No `border-1 solid` at 100% opacity. Use `border-outline-variant/15` or tonal shifts.
|
||||
- No `rounded-full` on cards. Use `rounded-md` (0.375rem) or `rounded-sm`.
|
||||
- No drop shadows on cards sitting on `bg-background`. Use `bg-surface-container-high` lift.
|
||||
- Glass surfaces: add class `glass` (backdrop-blur-24px + semi-transparent surface_bright).
|
||||
@@ -1,22 +1,23 @@
|
||||
const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev";
|
||||
const BUILD_REV = process.env.NEXT_PUBLIC_BUILD_REV ?? "local";
|
||||
|
||||
type Props = {
|
||||
interface StatusTerminalProps {
|
||||
location?: string;
|
||||
status?: string;
|
||||
className?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function StatusTerminal({
|
||||
location = "39.0997° N, 94.5786° W",
|
||||
status = "ONLINE",
|
||||
className = "",
|
||||
}: Props) {
|
||||
}: StatusTerminalProps) {
|
||||
const sha = BUILD_SHA.length > 8 ? `sha-${BUILD_SHA.slice(0, 8)}` : `sha-${BUILD_SHA}`;
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<span className="flex h-2 w-2 rounded-full bg-tertiary shadow-[0_0_8px_#8eff71]" />
|
||||
<span className="font-label text-[10px] uppercase tracking-[0.2em] text-tertiary">
|
||||
LOC: {location} · STATUS: {status} · REV: {BUILD_REV} · SHA: {BUILD_SHA}
|
||||
LOC: {location} | STATUS: {status} | REV: {BUILD_REV} | {sha}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
25
src/components/site/TechChip.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Accent = "secondary" | "tertiary" | "primary";
|
||||
|
||||
interface TechChipProps {
|
||||
children: ReactNode;
|
||||
accent?: Accent;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const accentClasses: Record<Accent, string> = {
|
||||
primary: "text-primary",
|
||||
secondary: "text-secondary",
|
||||
tertiary: "text-tertiary",
|
||||
};
|
||||
|
||||
export function TechChip({ children, accent = "secondary", className = "" }: TechChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-sm bg-surface-container-highest px-2.5 py-1 label-sm ${accentClasses[accent]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
6
src/components/site/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Nav } from "./Nav";
|
||||
export { StatusTerminal } from "./StatusTerminal";
|
||||
export { GridOverlay } from "./GridOverlay";
|
||||
export { Button } from "./Button";
|
||||
export { TechChip } from "./TechChip";
|
||||
export { ProjectsGrid } from "./ProjectsGrid";
|
||||
@@ -1,27 +1,52 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
|
||||
export const About: GlobalConfig = {
|
||||
slug: "about",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{ name: "intro", type: "richText" },
|
||||
{ name: "makerMindset", type: "richText" },
|
||||
{ name: "soundtrack", type: "richText" },
|
||||
{
|
||||
name: "gearRefs",
|
||||
type: "relationship",
|
||||
relationTo: "gear",
|
||||
hasMany: true,
|
||||
name: "intro",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text", defaultValue: "01 // THE ARCHITECT" },
|
||||
{ name: "headline", type: "text" },
|
||||
{ name: "subheadline", type: "textarea" },
|
||||
{ name: "portrait", type: "upload", relationTo: "media" },
|
||||
],
|
||||
},
|
||||
{ name: "bio", type: "richText", editor: lexicalEditor({}) },
|
||||
{
|
||||
name: "timeline",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "year", type: "text", required: true },
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "note", type: "textarea" },
|
||||
{ name: "body", type: "textarea" },
|
||||
{
|
||||
name: "tags",
|
||||
type: "array",
|
||||
fields: [{ name: "label", type: "text" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "portrait", type: "upload", relationTo: "media" },
|
||||
{
|
||||
name: "skills",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "category", type: "text", required: true },
|
||||
{
|
||||
name: "items",
|
||||
type: "array",
|
||||
fields: [{ name: "label", type: "text" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "featuredGear",
|
||||
type: "array",
|
||||
fields: [{ name: "gear", type: "relationship", relationTo: "gear" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,31 +3,56 @@ import type { GlobalConfig } from "payload";
|
||||
export const Contact: GlobalConfig = {
|
||||
slug: "contact",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{
|
||||
name: "availabilityBadge",
|
||||
type: "text",
|
||||
defaultValue: "Accepting new inquiries",
|
||||
},
|
||||
{ name: "timezoneLabel", type: "text", defaultValue: "America/Chicago" },
|
||||
{ name: "directEmail", type: "email" },
|
||||
{
|
||||
name: "socialLinks",
|
||||
type: "array",
|
||||
name: "intro",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
{ name: "icon", type: "text" },
|
||||
{ name: "eyebrow", type: "text", defaultValue: "01 // DIRECT CHANNELS" },
|
||||
{ name: "headline", type: "text" },
|
||||
{ name: "body", type: "textarea" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "newsletterEnabled",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
"Enable the newsletter subscribe UI. Keep false until Mautic is deployed.",
|
||||
},
|
||||
name: "channels",
|
||||
type: "array",
|
||||
fields: [
|
||||
{
|
||||
name: "icon",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "GitHub", value: "github" },
|
||||
{ label: "LinkedIn", value: "linkedin" },
|
||||
{ label: "Twitter", value: "twitter" },
|
||||
{ label: "Mastodon", value: "mastodon" },
|
||||
{ label: "RSS", value: "rss" },
|
||||
{ label: "Phone", value: "phone" },
|
||||
],
|
||||
},
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "value", type: "text" },
|
||||
{ name: "href", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "formCopy",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "headline", type: "text" },
|
||||
{ name: "description", type: "textarea" },
|
||||
{ name: "submitLabel", type: "text", defaultValue: "Send signal" },
|
||||
{ name: "successMessage", type: "textarea" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "availability",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "statusLine", type: "text", defaultValue: "Open to collaboration" },
|
||||
{ name: "note", type: "textarea" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,33 +3,76 @@ import type { GlobalConfig } from "payload";
|
||||
export const Home: GlobalConfig = {
|
||||
slug: "home",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{ name: "heroPrefix", type: "text", defaultValue: "01 // THE MANIFESTO" },
|
||||
{ name: "heroHeadline", type: "richText" },
|
||||
{ name: "heroSub", type: "textarea" },
|
||||
{
|
||||
name: "ctas",
|
||||
name: "hero",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text", defaultValue: "01 // THE MANIFESTO" },
|
||||
{ name: "headline", type: "text" },
|
||||
{ name: "subheadline", type: "textarea" },
|
||||
{
|
||||
name: "primaryCta",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "label", type: "text" },
|
||||
{ name: "href", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "secondaryCta",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "label", type: "text" },
|
||||
{ name: "href", type: "text" },
|
||||
],
|
||||
},
|
||||
{ name: "heroImage", type: "upload", relationTo: "media" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "principles",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
{ name: "code", type: "text" },
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "body", type: "textarea" },
|
||||
{
|
||||
name: "style",
|
||||
name: "accent",
|
||||
type: "select",
|
||||
defaultValue: "primary",
|
||||
options: [
|
||||
{ label: "Primary (neon)", value: "primary" },
|
||||
{ label: "Primary", value: "primary" },
|
||||
{ label: "Secondary", value: "secondary" },
|
||||
{ label: "Ghost", value: "ghost" },
|
||||
{ label: "Tertiary", value: "tertiary" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "featuredProjects",
|
||||
type: "relationship",
|
||||
relationTo: "projects",
|
||||
hasMany: true,
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "project", type: "relationship", relationTo: "projects" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "closingCta",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text" },
|
||||
{ name: "headline", type: "text" },
|
||||
{ name: "body", type: "textarea" },
|
||||
{
|
||||
name: "cta",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "label", type: "text" },
|
||||
{ name: "href", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,13 +3,35 @@ import type { GlobalConfig } from "payload";
|
||||
export const Navigation: GlobalConfig = {
|
||||
slug: "navigation",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{
|
||||
name: "primaryLinks",
|
||||
name: "primary",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
{ name: "external", type: "checkbox", defaultValue: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "socials",
|
||||
type: "array",
|
||||
fields: [
|
||||
{
|
||||
name: "platform",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "GitHub", value: "github" },
|
||||
{ label: "LinkedIn", value: "linkedin" },
|
||||
{ label: "Twitter", value: "twitter" },
|
||||
{ label: "Mastodon", value: "mastodon" },
|
||||
{ label: "RSS", value: "rss" },
|
||||
],
|
||||
},
|
||||
{ name: "label", type: "text" },
|
||||
{ name: "href", type: "text", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,8 +3,11 @@ import type { GlobalConfig } from "payload";
|
||||
export const SEO: GlobalConfig = {
|
||||
slug: "seo",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{ name: "siteTitle", type: "text", defaultValue: "Jason Woltje" },
|
||||
{ name: "siteName", type: "text", defaultValue: "Jason Woltje" },
|
||||
{ name: "defaultTitle", type: "text", defaultValue: "Jason Woltje" },
|
||||
{ name: "titleTemplate", type: "text", defaultValue: "%s — Jason Woltje" },
|
||||
{
|
||||
name: "defaultDescription",
|
||||
type: "textarea",
|
||||
|
||||
1171
src/payload-types.ts
Normal file
@@ -5,6 +5,7 @@ import containerQueries from "@tailwindcss/container-queries";
|
||||
// Tokens ported verbatim from design-samples/stitch_jasonwoltje.com/*/code.html
|
||||
// Full token rationale in design-samples/stitch_jasonwoltje.com/silicon_ethos/DESIGN.md
|
||||
const stitchColors = {
|
||||
// Core accents
|
||||
primary: "#81ecff",
|
||||
"primary-dim": "#00d4ec",
|
||||
"primary-fixed": "#00e3fd",
|
||||
@@ -41,9 +42,11 @@ const stitchColors = {
|
||||
"on-error": "#490006",
|
||||
"on-error-container": "#ffa8a3",
|
||||
|
||||
// Base
|
||||
background: "#0d0e12",
|
||||
"on-background": "#f7f5fc",
|
||||
|
||||
// Surface stack (hyphenated — existing usage)
|
||||
surface: "#0d0e12",
|
||||
"surface-dim": "#0d0e12",
|
||||
"surface-bright": "#2a2c32",
|
||||
@@ -63,6 +66,20 @@ const stitchColors = {
|
||||
|
||||
outline: "#75757a",
|
||||
"outline-variant": "#47484d",
|
||||
|
||||
// Underscore aliases — for page subagents (matches DESIGN.md token names)
|
||||
on_surface: "#f7f5fc",
|
||||
on_primary: "#005762",
|
||||
outline_variant: "#47484d",
|
||||
primary_container: "#00e3fd",
|
||||
inverse_surface: "#faf8ff",
|
||||
surface_variant: "#24252c",
|
||||
surface_bright: "#2a2c32",
|
||||
surface_container_lowest: "#000000",
|
||||
surface_container_low: "#121318",
|
||||
surface_container: "#18191e",
|
||||
surface_container_high: "#1e1f25",
|
||||
surface_container_highest: "#24252c",
|
||||
};
|
||||
|
||||
const config: Config = {
|
||||
@@ -81,6 +98,9 @@ const config: Config = {
|
||||
full: "9999px",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["Space Grotesk", "sans-serif"],
|
||||
sans: ["Inter", "sans-serif"],
|
||||
mono: ["Space Grotesk", "ui-monospace", "monospace"],
|
||||
headline: ["var(--font-headline)", "Space Grotesk", "sans-serif"],
|
||||
body: ["var(--font-body)", "Inter", "sans-serif"],
|
||||
label: ["var(--font-label)", "Space Grotesk", "monospace"],
|
||||
@@ -90,6 +110,7 @@ const config: Config = {
|
||||
"0 0 32px 4px rgba(129, 236, 255, 0.25), 0 0 4px 1px rgba(129, 236, 255, 0.5)",
|
||||
"neon-secondary":
|
||||
"0 0 32px 4px rgba(216, 115, 255, 0.25), 0 0 4px 1px rgba(216, 115, 255, 0.5)",
|
||||
"ambient-primary": "0 0 40px 6px rgba(129, 236, 255, 0.06)",
|
||||
},
|
||||
backdropBlur: {
|
||||
"2xl": "24px",
|
||||
|
||||