Compare commits
9 Commits
feat/scaff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d655fae1 | |||
| 7d125fe7d4 | |||
| ad811ba70e | |||
| 936a98f955 | |||
| b47c5e420a | |||
| 6db28bc81f | |||
| 261c0019bb | |||
| c85e76e5cd | |||
| 8c5a25e976 |
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/dist
|
||||
**/coverage
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
.woodpecker
|
||||
.mosaic
|
||||
docs
|
||||
README.md
|
||||
LICENSE
|
||||
design-samples
|
||||
images
|
||||
48
.env.example
Normal file
@@ -0,0 +1,48 @@
|
||||
# =============================================================================
|
||||
# jasonwoltje.com — environment variables
|
||||
# =============================================================================
|
||||
# Actual values live in Portainer stack env vars (prod) or .env (local dev).
|
||||
# Never commit .env — see .gitignore.
|
||||
|
||||
# ---- Payload / Database ----
|
||||
# Local dev convenience: pnpm dev reads these directly.
|
||||
# In prod, DATABASE_URI is composed in docker-compose.swarm.yml from PAYLOAD_POSTGRES_*.
|
||||
DATABASE_URI=postgres://payload:payload@localhost:5432/payload
|
||||
PAYLOAD_POSTGRES_USER=payload
|
||||
PAYLOAD_POSTGRES_PASSWORD=replace-me-local-only
|
||||
PAYLOAD_POSTGRES_DB=payload
|
||||
PAYLOAD_SECRET=replace-with-32-plus-char-random-string
|
||||
|
||||
# ---- Public URLs ----
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
|
||||
# ---- Build-time metadata (Status Terminal) ----
|
||||
# CI overrides these during docker-build; local dev falls back to "dev" / "local".
|
||||
NEXT_PUBLIC_BUILD_SHA=dev
|
||||
NEXT_PUBLIC_BUILD_REV=local
|
||||
|
||||
# ---- Cloudflare Turnstile (contact form CAPTCHA) ----
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
# ---- Umami analytics (self-hosted; empty disables tracker) ----
|
||||
NEXT_PUBLIC_UMAMI_SRC=
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
|
||||
# ---- Contact form email (choose one path) ----
|
||||
# Option A: Resend
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM=no-reply@jasonwoltje.com
|
||||
RESEND_TO=jason@diversecanvas.com
|
||||
|
||||
# Option B: SMTP relay
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=
|
||||
# SMTP_PASSWORD=
|
||||
# SMTP_FROM=no-reply@jasonwoltje.com
|
||||
# SMTP_TO=jason@diversecanvas.com
|
||||
|
||||
# ---- Mautic newsletter (not deployed yet; leave empty) ----
|
||||
MAUTIC_FORM_URL=
|
||||
10
.gitignore
vendored
@@ -59,3 +59,13 @@ scripts/local/
|
||||
|
||||
# design samples zip (HTML + DESIGN.md kept as reference)
|
||||
design-samples/*.zip
|
||||
|
||||
# QA/automation auto-generated reports (runtime artifacts)
|
||||
docs/reports/qa-automation/
|
||||
docs/reports/typecheck/
|
||||
|
||||
# Claude Code session state
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/todos/
|
||||
.claude/shell-snapshots/
|
||||
.claude/ide/
|
||||
|
||||
155
.woodpecker/web.yml
Normal file
@@ -0,0 +1,155 @@
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
- event: tag
|
||||
|
||||
variables:
|
||||
- &node_image "node:24-alpine"
|
||||
- &install_deps |
|
||||
corepack enable
|
||||
pnpm config set store-dir /tmp/pnpm-store
|
||||
pnpm install --frozen-lockfile
|
||||
- &enable_pnpm |
|
||||
corepack enable
|
||||
pnpm config set store-dir /tmp/pnpm-store
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
|
||||
steps:
|
||||
install:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm lint
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm typecheck
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
NODE_ENV: "production"
|
||||
NEXT_PUBLIC_BUILD_SHA: ${CI_COMMIT_SHA:0:8}
|
||||
NEXT_PUBLIC_BUILD_REV: ${CI_COMMIT_BRANCH:-${CI_COMMIT_TAG}}
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
depends_on:
|
||||
- lint
|
||||
- typecheck
|
||||
|
||||
security-audit:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm audit --prod --audit-level=high || true
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
docker-build:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
environment:
|
||||
GITEA_USER:
|
||||
from_secret: gitea_username
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- *kaniko_setup
|
||||
- |
|
||||
set -e
|
||||
IMAGE="git.mosaicstack.dev/jason.woltje/professional-website"
|
||||
SHORT_SHA="$${CI_COMMIT_SHA:0:8}"
|
||||
DESTINATIONS="--destination $$IMAGE:sha-$$SHORT_SHA"
|
||||
if [ -n "$$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$$DESTINATIONS --destination $$IMAGE:$$CI_COMMIT_TAG"
|
||||
fi
|
||||
if [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$$DESTINATIONS --destination $$IMAGE:latest"
|
||||
elif [ "$$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||
DESTINATIONS="$$DESTINATIONS --destination $$IMAGE:dev"
|
||||
fi
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Dockerfile \
|
||||
--build-arg NEXT_PUBLIC_BUILD_SHA=sha-$$SHORT_SHA \
|
||||
--build-arg NEXT_PUBLIC_BUILD_REV=$${CI_COMMIT_TAG:-$$CI_COMMIT_BRANCH} \
|
||||
$$DESTINATIONS
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
event: [push, manual]
|
||||
- event: tag
|
||||
depends_on:
|
||||
- build
|
||||
- security-audit
|
||||
|
||||
security-trivy:
|
||||
image: aquasec/trivy:latest
|
||||
environment:
|
||||
GITEA_USER:
|
||||
from_secret: gitea_username
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- |
|
||||
set -e
|
||||
IMAGE="git.mosaicstack.dev/jason.woltje/professional-website"
|
||||
if [ -n "$$CI_COMMIT_TAG" ]; then
|
||||
SCAN_TAG="$$CI_COMMIT_TAG"
|
||||
else
|
||||
SCAN_TAG="sha-$${CI_COMMIT_SHA:0:8}"
|
||||
fi
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
|
||||
$$IMAGE:$$SCAN_TAG
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
event: [push, manual]
|
||||
- event: tag
|
||||
depends_on:
|
||||
- docker-build
|
||||
|
||||
link-package:
|
||||
image: alpine:3
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
set -e
|
||||
STATUS=$$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $$GITEA_TOKEN" \
|
||||
"https://git.mosaicstack.dev/api/v1/packages/jason.woltje/container/professional-website/-/link/professional-website")
|
||||
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
|
||||
echo "Package linked"
|
||||
elif [ "$$STATUS" = "400" ]; then
|
||||
echo "Package already linked (OK)"
|
||||
else
|
||||
echo "Unexpected response: $$STATUS"
|
||||
exit 1
|
||||
fi
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
event: [push, manual]
|
||||
- event: tag
|
||||
depends_on:
|
||||
- security-trivy
|
||||
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# =============================================================================
|
||||
# jasonwoltje.com — Next.js 16 + Payload 3 production image
|
||||
# Multi-stage, non-root, standalone Next output. Built by Kaniko in Woodpecker.
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_VERSION=24-alpine
|
||||
|
||||
# ---- deps ----
|
||||
FROM node:${NODE_VERSION} AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN --mount=type=cache,target=/pnpm-store \
|
||||
pnpm config set store-dir /pnpm-store && \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# ---- build ----
|
||||
FROM node:${NODE_VERSION} AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ARG NEXT_PUBLIC_BUILD_SHA=unknown
|
||||
ARG NEXT_PUBLIC_BUILD_REV=unknown
|
||||
ENV NEXT_PUBLIC_BUILD_SHA=${NEXT_PUBLIC_BUILD_SHA} \
|
||||
NEXT_PUBLIC_BUILD_REV=${NEXT_PUBLIC_BUILD_REV} \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
RUN pnpm build
|
||||
|
||||
# ---- runner ----
|
||||
FROM node:${NODE_VERSION} AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S -u 1001 -G nodejs nextjs
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache wget && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
|
||||
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack && \
|
||||
mkdir -p /app/media && \
|
||||
chown -R nextjs:nodejs /app
|
||||
COPY --from=build --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=45s \
|
||||
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||
CMD ["node", "server.js"]
|
||||
110
docker-compose.swarm.yml
Normal file
@@ -0,0 +1,110 @@
|
||||
# =============================================================================
|
||||
# jasonwoltje.com — Docker Swarm / Portainer stack
|
||||
# =============================================================================
|
||||
#
|
||||
# Deploy target: w-docker0 (10.1.1.45), Portainer endpoint 7.
|
||||
# Routing:
|
||||
# jasonwoltje.com / www.jasonwoltje.com -> web (Next.js + Payload CMS)
|
||||
#
|
||||
# Ingress pattern (mirrors MosaicStack):
|
||||
# Edge Traefik (10.1.1.43) terminates TLS
|
||||
# -> per-swarm Traefik on w-docker0 on entrypoint "web" (HTTP)
|
||||
# -> web:3000
|
||||
#
|
||||
# Usage (Portainer):
|
||||
# Stacks -> Add Stack -> Git repository
|
||||
# URL: https://git.mosaicstack.dev/jason.woltje/professional-website
|
||||
# Compose path: docker-compose.swarm.yml
|
||||
# Env vars: see .env.example (all required unless marked optional)
|
||||
# Deploy
|
||||
#
|
||||
# Image tag rule: WEB_IMAGE_TAG MUST be an immutable tag (sha-<8> or vX.Y.Z).
|
||||
# Never point this stack at `latest`.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
web:
|
||||
image: git.mosaicstack.dev/jason.woltje/professional-website:${WEB_IMAGE_TAG}
|
||||
environment:
|
||||
DATABASE_URI: postgresql://${PAYLOAD_POSTGRES_USER}:${PAYLOAD_POSTGRES_PASSWORD}@jasonwoltje_postgres:5432/${PAYLOAD_POSTGRES_DB}
|
||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
|
||||
PAYLOAD_PUBLIC_SERVER_URL: https://${SITE_DOMAIN:-jasonwoltje.com}
|
||||
NEXT_PUBLIC_SITE_URL: https://${SITE_DOMAIN:-jasonwoltje.com}
|
||||
NEXT_PUBLIC_BUILD_SHA: ${WEB_IMAGE_TAG}
|
||||
NEXT_PUBLIC_BUILD_REV: ${WEB_IMAGE_TAG}
|
||||
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:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM: ${RESEND_FROM:-no-reply@jasonwoltje.com}
|
||||
RESEND_TO: ${RESEND_TO:-jason@diversecanvas.com}
|
||||
MAUTIC_FORM_URL: ${MAUTIC_FORM_URL:-}
|
||||
volumes:
|
||||
- media:/app/media
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- internal
|
||||
- traefik-public
|
||||
deploy:
|
||||
replicas: 1
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 30s
|
||||
order: start-first
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 5
|
||||
window: 120s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.jasonwoltje.rule=Host(`${SITE_DOMAIN:-jasonwoltje.com}`) || Host(`www.${SITE_DOMAIN:-jasonwoltje.com}`)"
|
||||
- "traefik.http.routers.jasonwoltje.entrypoints=web"
|
||||
- "traefik.http.services.jasonwoltje.loadbalancer.server.port=3000"
|
||||
# www -> apex 301
|
||||
- "traefik.http.middlewares.jasonwoltje-www-redirect.redirectregex.regex=^https?://www\\.${SITE_DOMAIN:-jasonwoltje.com}/(.*)"
|
||||
- "traefik.http.middlewares.jasonwoltje-www-redirect.redirectregex.replacement=https://${SITE_DOMAIN:-jasonwoltje.com}/$${1}"
|
||||
- "traefik.http.middlewares.jasonwoltje-www-redirect.redirectregex.permanent=true"
|
||||
- "traefik.http.routers.jasonwoltje.middlewares=jasonwoltje-www-redirect"
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${PAYLOAD_POSTGRES_DB:-payload}
|
||||
POSTGRES_USER: ${PAYLOAD_POSTGRES_USER:-payload}
|
||||
POSTGRES_PASSWORD: ${PAYLOAD_POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- internal
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 5
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == manager
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
media:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: overlay
|
||||
traefik-public:
|
||||
external: true
|
||||
29
docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
# =============================================================================
|
||||
# jasonwoltje.com — local dev Postgres
|
||||
# =============================================================================
|
||||
# Brings up just Postgres for local `pnpm dev`. The Next app runs on the host
|
||||
# via `pnpm dev` (not in-container) for fast iteration.
|
||||
#
|
||||
# docker compose up -d # start Postgres
|
||||
# pnpm dev # run Next + Payload on host
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${PAYLOAD_POSTGRES_DB:-payload}
|
||||
POSTGRES_USER: ${PAYLOAD_POSTGRES_USER:-payload}
|
||||
POSTGRES_PASSWORD: ${PAYLOAD_POSTGRES_PASSWORD:-payload}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypescript from "eslint-config-next/typescript";
|
||||
|
||||
const config = [
|
||||
...nextCoreWebVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
ignores: [
|
||||
".next/**",
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
"src/payload-types.ts",
|
||||
"src/app/(payload)/admin/importMap.js",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default config;
|
||||
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 |
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
21
next.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { withPayload } from "@payloadcms/next/withPayload";
|
||||
import type { NextConfig } from "next";
|
||||
import path from "node:path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
turbopack: {
|
||||
root: path.resolve("."),
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "jasonwoltje.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default withPayload(nextConfig, { devBundleServerPackages: false });
|
||||
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "professional-website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Jason Woltje professional website — Payload CMS 3 + Next.js 16",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"payload": "payload",
|
||||
"generate:types": "payload generate:types",
|
||||
"generate:importmap": "payload generate:importmap",
|
||||
"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",
|
||||
"@payloadcms/ui": "^3.50.0",
|
||||
"graphql": "^16.11.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "16.2.3",
|
||||
"payload": "^3.50.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"sharp": "^0.34.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"minimatch@<10.2.3": ">=10.2.3",
|
||||
"picomatch@<4.0.4": ">=4.0.4",
|
||||
"tar@<7.5.11": ">=7.5.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
7636
pnpm-lock.yaml
generated
Normal file
8
postcss.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
0
public/.gitkeep
Normal file
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)
|
||||
})
|
||||
280
src/app/(frontend)/about/page.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
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: 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;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
137
src/app/(frontend)/contact/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
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: 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;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
90
src/app/(frontend)/globals.css
Normal file
@@ -0,0 +1,90 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply bg-background text-on-background font-body antialiased;
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
@apply min-h-screen selection:bg-primary/30 selection:text-on-primary-container;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
box-shadow:
|
||||
0 0 32px 4px rgba(129, 236, 255, 0.25),
|
||||
0 0 4px 1px rgba(129, 236, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ── Technical grid (32px, hero variant) ─────────────────────────── */
|
||||
.technical-grid {
|
||||
background-image: radial-gradient(
|
||||
rgba(129, 236, 255, 0.15) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
/* ── Hero ambient radial gradient ────────────────────────────────── */
|
||||
.hero-gradient {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(129, 236, 255, 0.08), transparent 40%),
|
||||
radial-gradient(circle at bottom left, rgba(216, 115, 255, 0.05), transparent 40%);
|
||||
}
|
||||
}
|
||||
53
src/app/(frontend)/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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: ["300", "400", "500", "700"],
|
||||
variable: "--font-headline",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
variable: "--font-body",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000",
|
||||
),
|
||||
title: {
|
||||
default:
|
||||
"Jason Woltje — Systems thinker. Builder. IT leader turning into a software engineer.",
|
||||
template: "%s — Jason Woltje",
|
||||
},
|
||||
description:
|
||||
"A multidisciplinary architect of digital ecosystems and agricultural infrastructures. Engineering growth through technological mastery and strategic leadership.",
|
||||
};
|
||||
|
||||
export default function FrontendLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`dark ${spaceGrotesk.variable} ${inter.variable}`}
|
||||
style={{ ["--font-label" as string]: "var(--font-headline)" }}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="bg-background text-on-surface min-h-screen">
|
||||
<Nav />
|
||||
{children}
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
248
src/app/(frontend)/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
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);
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* ── 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>
|
||||
);
|
||||
}
|
||||
243
src/app/(frontend)/projects/[slug]/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
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;
|
||||
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({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
59
src/app/(frontend)/projects/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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: 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,
|
||||
});
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
19
src/app/(frontend)/resume/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Resume" };
|
||||
|
||||
export default function ResumePage() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
37
src/app/(frontend)/writing/[slug]/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
type Params = { slug: string };
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return { title: slug };
|
||||
}
|
||||
|
||||
export default async function PostDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
if (!slug) notFound();
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
17
src/app/(frontend)/writing/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Writing" };
|
||||
|
||||
export default function WritingIndexPage() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
18
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Metadata } from "next";
|
||||
import config from "@payload-config";
|
||||
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params: params as any, searchParams: searchParams as any });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params: params as any, searchParams: searchParams as any, importMap });
|
||||
|
||||
export default NotFound;
|
||||
18
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Metadata } from "next";
|
||||
import config from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params: params as any, searchParams: searchParams as any });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params: params as any, searchParams: searchParams as any, importMap });
|
||||
|
||||
export default Page;
|
||||
51
src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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
|
||||
}
|
||||
16
src/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import config from "@payload-config";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
4
src/app/(payload)/api/graphql-playground/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config);
|
||||
5
src/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
16
src/app/(payload)/api/health/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "ok",
|
||||
buildSha: process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev",
|
||||
buildRev: process.env.NEXT_PUBLIC_BUILD_REV ?? "local",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
1
src/app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
||||
/* Payload admin UI customizations. Keep minimal for v0.0.x. */
|
||||
22
src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/* Payload admin layout — do not modify unless you understand the Payload 3 requirements. */
|
||||
import type { ServerFunctionClient } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { RootLayout, handleServerFunctions } from "@payloadcms/next/layouts";
|
||||
import "@payloadcms/next/css";
|
||||
import { importMap } from "./admin/importMap";
|
||||
import "./custom.scss";
|
||||
|
||||
type Args = { children: React.ReactNode };
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
"use server";
|
||||
return handleServerFunctions({ ...args, config, importMap });
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
15
src/collections/Categories.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: "categories",
|
||||
access: { read: () => true },
|
||||
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 },
|
||||
],
|
||||
};
|
||||
34
src/collections/ContactSubmissions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const ContactSubmissions: CollectionConfig = {
|
||||
slug: "contactSubmissions",
|
||||
access: {
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
create: () => true,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "submittedAt"],
|
||||
group: "System",
|
||||
},
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{ name: "email", type: "email", required: true },
|
||||
{ name: "message", type: "textarea", required: true },
|
||||
{
|
||||
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 } },
|
||||
],
|
||||
};
|
||||
30
src/collections/Gear.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Gear: CollectionConfig = {
|
||||
slug: "gear",
|
||||
access: { read: () => true },
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "category", "featured"],
|
||||
group: "Content",
|
||||
},
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{
|
||||
name: "category",
|
||||
type: "select",
|
||||
options: [
|
||||
{ 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: "summary", type: "textarea" },
|
||||
{ name: "link", type: "text" },
|
||||
{ name: "image", type: "upload", relationTo: "media" },
|
||||
{ name: "featured", type: "checkbox", defaultValue: false },
|
||||
],
|
||||
};
|
||||
25
src/collections/Media.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: "media",
|
||||
access: { read: () => true },
|
||||
admin: {
|
||||
useAsTitle: "alt",
|
||||
group: "System",
|
||||
},
|
||||
upload: {
|
||||
staticDir: "media",
|
||||
imageSizes: [
|
||||
{ name: "thumb", width: 400, height: 400, position: "centre" },
|
||||
{ name: "card", width: 800, height: undefined, position: "centre" },
|
||||
{ name: "hero", width: 1600, height: undefined, position: "centre" },
|
||||
{ name: "og", width: 1200, height: 630, position: "centre" },
|
||||
],
|
||||
adminThumbnail: "thumb",
|
||||
mimeTypes: ["image/*"],
|
||||
},
|
||||
fields: [
|
||||
{ name: "alt", type: "text", required: true },
|
||||
{ name: "credit", type: "text" },
|
||||
],
|
||||
};
|
||||
54
src/collections/Posts.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: "posts",
|
||||
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;
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{ 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",
|
||||
defaultValue: "draft",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
79
src/collections/Projects.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
|
||||
export const Projects: CollectionConfig = {
|
||||
slug: "projects",
|
||||
access: { read: () => true },
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
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;
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{ name: "summary", type: "textarea", required: true },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "active",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Archived", value: "archived" },
|
||||
{ label: "Prototype", value: "prototype" },
|
||||
{ label: "Production", value: "production" },
|
||||
],
|
||||
},
|
||||
{ name: "year", type: "number" },
|
||||
{ name: "heroImage", type: "upload", relationTo: "media" },
|
||||
{
|
||||
name: "gallery",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "image", type: "upload", relationTo: "media", required: true },
|
||||
{ name: "caption", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "tech",
|
||||
type: "array",
|
||||
fields: [{ name: "label", type: "text" }],
|
||||
},
|
||||
{ name: "role", type: "text" },
|
||||
{
|
||||
name: "links",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
22
src/collections/Users.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: "users",
|
||||
admin: {
|
||||
useAsTitle: "email",
|
||||
defaultColumns: ["email", "role"],
|
||||
group: "System",
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
{
|
||||
name: "role",
|
||||
type: "select",
|
||||
defaultValue: "admin",
|
||||
options: [{ label: "Admin", value: "admin" }],
|
||||
access: {
|
||||
update: ({ req: { user } }) => user?.role === "admin",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
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).
|
||||
24
src/components/site/StatusTerminal.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev";
|
||||
const BUILD_REV = process.env.NEXT_PUBLIC_BUILD_REV ?? "local";
|
||||
|
||||
interface StatusTerminalProps {
|
||||
location?: string;
|
||||
status?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusTerminal({
|
||||
location = "39.0997° N, 94.5786° W",
|
||||
status = "ONLINE",
|
||||
className = "",
|
||||
}: 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}
|
||||
</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";
|
||||
52
src/globals/About.ts
Normal file
@@ -0,0 +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: "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: "body", type: "textarea" },
|
||||
{
|
||||
name: "tags",
|
||||
type: "array",
|
||||
fields: [{ name: "label", type: "text" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
58
src/globals/Contact.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Contact: GlobalConfig = {
|
||||
slug: "contact",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{
|
||||
name: "intro",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "eyebrow", type: "text", defaultValue: "01 // DIRECT CHANNELS" },
|
||||
{ name: "headline", type: "text" },
|
||||
{ name: "body", type: "textarea" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
78
src/globals/Home.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Home: GlobalConfig = {
|
||||
slug: "home",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{
|
||||
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: "code", type: "text" },
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "body", type: "textarea" },
|
||||
{
|
||||
name: "accent",
|
||||
type: "select",
|
||||
defaultValue: "primary",
|
||||
options: [
|
||||
{ label: "Primary", value: "primary" },
|
||||
{ label: "Secondary", value: "secondary" },
|
||||
{ label: "Tertiary", value: "tertiary" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "featuredProjects",
|
||||
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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
43
src/globals/Navigation.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Navigation: GlobalConfig = {
|
||||
slug: "navigation",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{
|
||||
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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "footerStatusText",
|
||||
type: "text",
|
||||
defaultValue: "LATENCY: 42ms | CORE STATUS: NOMINAL",
|
||||
},
|
||||
],
|
||||
};
|
||||
46
src/globals/Resume.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Resume: GlobalConfig = {
|
||||
slug: "resume",
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: "summary", type: "textarea" },
|
||||
{
|
||||
name: "experience",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "company", type: "text", required: true },
|
||||
{ name: "role", type: "text", required: true },
|
||||
{ name: "startDate", type: "date" },
|
||||
{ name: "endDate", type: "date" },
|
||||
{ name: "current", type: "checkbox", defaultValue: false },
|
||||
{
|
||||
name: "bullets",
|
||||
type: "array",
|
||||
fields: [{ name: "text", type: "textarea" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "skills",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "category", type: "text", required: true },
|
||||
{
|
||||
name: "items",
|
||||
type: "array",
|
||||
fields: [{ name: "name", type: "text" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "education",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "institution", type: "text", required: true },
|
||||
{ name: "credential", type: "text" },
|
||||
{ name: "year", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
28
src/globals/SEO.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const SEO: GlobalConfig = {
|
||||
slug: "seo",
|
||||
access: { read: () => true },
|
||||
admin: { group: "Site" },
|
||||
fields: [
|
||||
{ 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",
|
||||
defaultValue:
|
||||
"A multidisciplinary architect of digital ecosystems. Engineering growth through technological mastery and strategic leadership.",
|
||||
},
|
||||
{ name: "defaultOgImage", type: "upload", relationTo: "media" },
|
||||
{ name: "twitterHandle", type: "text" },
|
||||
{
|
||||
name: "jsonLdPerson",
|
||||
type: "json",
|
||||
admin: {
|
||||
description:
|
||||
"Schema.org Person JSON-LD. Injected verbatim into the home page <head>.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
1171
src/payload-types.ts
Normal file
66
src/payload.config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildConfig } from "payload";
|
||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { Users } from "@/collections/Users";
|
||||
import { Media } from "@/collections/Media";
|
||||
import { Categories } from "@/collections/Categories";
|
||||
import { Projects } from "@/collections/Projects";
|
||||
import { Posts } from "@/collections/Posts";
|
||||
import { Gear } from "@/collections/Gear";
|
||||
import { ContactSubmissions } from "@/collections/ContactSubmissions";
|
||||
|
||||
import { Home } from "@/globals/Home";
|
||||
import { About } from "@/globals/About";
|
||||
import { Contact } from "@/globals/Contact";
|
||||
import { Resume } from "@/globals/Resume";
|
||||
import { Navigation } from "@/globals/Navigation";
|
||||
import { SEO } from "@/globals/SEO";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
|
||||
secret: process.env.PAYLOAD_SECRET || "",
|
||||
|
||||
admin: {
|
||||
user: "users",
|
||||
meta: {
|
||||
titleSuffix: "— Jason Woltje",
|
||||
},
|
||||
},
|
||||
|
||||
editor: lexicalEditor({}),
|
||||
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URI,
|
||||
},
|
||||
}),
|
||||
|
||||
collections: [
|
||||
Users,
|
||||
Media,
|
||||
Categories,
|
||||
Projects,
|
||||
Posts,
|
||||
Gear,
|
||||
ContactSubmissions,
|
||||
],
|
||||
|
||||
globals: [Home, About, Contact, Resume, Navigation, SEO],
|
||||
|
||||
sharp,
|
||||
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "payload-types.ts"),
|
||||
},
|
||||
|
||||
graphQL: {
|
||||
schemaOutputFile: path.resolve(dirname, "generated-schema.graphql"),
|
||||
},
|
||||
});
|
||||
123
tailwind.config.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import forms from "@tailwindcss/forms";
|
||||
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",
|
||||
"primary-fixed-dim": "#00d4ec",
|
||||
"primary-container": "#00e3fd",
|
||||
"on-primary": "#005762",
|
||||
"on-primary-container": "#004d57",
|
||||
"on-primary-fixed": "#003840",
|
||||
"on-primary-fixed-variant": "#005762",
|
||||
|
||||
secondary: "#d873ff",
|
||||
"secondary-dim": "#bc00fb",
|
||||
"secondary-fixed": "#f1c1ff",
|
||||
"secondary-fixed-dim": "#ebadff",
|
||||
"secondary-container": "#9900ce",
|
||||
"on-secondary": "#39004f",
|
||||
"on-secondary-container": "#fff5fc",
|
||||
"on-secondary-fixed": "#580078",
|
||||
"on-secondary-fixed-variant": "#8400b2",
|
||||
|
||||
tertiary: "#8eff71",
|
||||
"tertiary-dim": "#2be800",
|
||||
"tertiary-fixed": "#2ff801",
|
||||
"tertiary-fixed-dim": "#2be800",
|
||||
"tertiary-container": "#2ff801",
|
||||
"on-tertiary": "#0d6100",
|
||||
"on-tertiary-container": "#0b5800",
|
||||
"on-tertiary-fixed": "#064200",
|
||||
"on-tertiary-fixed-variant": "#0d6200",
|
||||
|
||||
error: "#ff716c",
|
||||
"error-dim": "#d7383b",
|
||||
"error-container": "#9f0519",
|
||||
"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",
|
||||
"surface-variant": "#24252c",
|
||||
"surface-tint": "#81ecff",
|
||||
"surface-container-lowest": "#000000",
|
||||
"surface-container-low": "#121318",
|
||||
"surface-container": "#18191e",
|
||||
"surface-container-high": "#1e1f25",
|
||||
"surface-container-highest": "#24252c",
|
||||
"on-surface": "#f7f5fc",
|
||||
"on-surface-variant": "#abaab0",
|
||||
|
||||
"inverse-surface": "#faf8ff",
|
||||
"inverse-on-surface": "#54555a",
|
||||
"inverse-primary": "#006976",
|
||||
|
||||
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 = {
|
||||
darkMode: "class",
|
||||
content: ["./src/**/*.{ts,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: stitchColors,
|
||||
borderRadius: {
|
||||
DEFAULT: "0.125rem",
|
||||
sm: "0.125rem",
|
||||
md: "0.375rem",
|
||||
lg: "0.25rem",
|
||||
xl: "0.5rem",
|
||||
"2xl": "0.75rem",
|
||||
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"],
|
||||
},
|
||||
boxShadow: {
|
||||
"neon-primary":
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [forms, containerQueries],
|
||||
};
|
||||
|
||||
export default config;
|
||||
47
tsconfig.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./src/payload.config.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".next",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||