3 Commits

Author SHA1 Message Date
0637992723 fix(security): remediate Trivy HIGH findings on main image
All checks were successful
ci/woodpecker/pr/web Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
- Bump next 16.2.2 -> 16.2.3 (GHSA-q4gf-8mx6-v5v3 RSC DoS)
- pnpm overrides: minimatch>=10.2.3, picomatch>=4.0.4, tar>=7.5.11
  (CVE-2026-27903/27904, 33671, 29786, 31802)
- Dockerfile runner: apk upgrade --no-cache pulls patched openssl 3.5.6-r0
  and zlib 1.3.2-r0 before installing wget (CVE-2026-28390, 22184)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:38:03 -05:00
c85e76e5cd fix(docker): add empty public/ so Kaniko COPY succeeds (#2)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-14 03:30:13 +00:00
8c5a25e976 feat: Next 16 + Payload 3 scaffold with Kaniko CI and Swarm deploy (#1)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-14 03:21:17 +00:00
52 changed files with 9341 additions and 0 deletions

19
.dockerignore Normal file
View 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
View 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) ----
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
View File

@@ -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
View 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

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# 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 && \
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
View 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}
TURNSTILE_SITE_KEY: ${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
View 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
View 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;

6
next-env.d.ts vendored Normal file
View 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
View 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 });

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"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"
},
"dependencies": {
"@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",
"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"
}
}
}

7619
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.js Normal file
View File

@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

0
public/.gitkeep Normal file
View File

View File

@@ -0,0 +1,27 @@
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
export const metadata = { title: "About" };
export default function AboutPage() {
return (
<>
<SiteHeader />
<main className="mx-auto max-w-7xl px-6 py-24">
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-tertiary">
02 // PROFILE
</span>
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
About
</h1>
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
Engineering growth through technological mastery and strategic
leadership. Content sourced from Payload CMS (global:{" "}
<code className="font-label text-primary">about</code>) populated on
first publish.
</p>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,26 @@
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
export const metadata = { title: "Contact" };
export default function ContactPage() {
return (
<>
<SiteHeader />
<main className="mx-auto max-w-3xl px-6 py-24">
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
05 // CONTACT
</span>
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
Open channel
</h1>
<p className="font-body text-xl text-on-surface-variant">
Form wiring, Turnstile, and Payload submission persistence land in a
follow-up PR (UI-06). For now, direct email lives in the Payload{" "}
<code className="font-label text-primary">contact</code> global.
</p>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,52 @@
@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 {
/* DESIGN.md: "Ghost Border" — containment that is felt rather than seen. */
.ghost-border {
border: 1px solid rgba(71, 72, 77, 0.15);
}
/* DESIGN.md: "Glass & Gradient" — frosted terminal effect. */
.glass-card {
background-color: rgba(42, 44, 50, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
/* DESIGN.md: neon CTA — "lit from within" glow. */
.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);
}
/* DESIGN.md: technical grid background pattern. */
.technical-grid {
background-image: radial-gradient(
rgba(129, 236, 255, 0.15) 1px,
transparent 1px
);
background-size: 32px 32px;
}
/* DESIGN.md: hero radial gradient ambient. */
.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%);
}
}

View File

@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { Space_Grotesk, Inter } from "next/font/google";
import "./globals.css";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-headline",
display: "swap",
});
const inter = Inter({
subsets: ["latin"],
weight: ["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",
template: "%s — Jason Woltje",
},
description:
"A multidisciplinary architect of digital ecosystems. Engineering growth through technological mastery and strategic leadership.",
};
const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev";
const BUILD_REV = process.env.NEXT_PUBLIC_BUILD_REV ?? "local";
export default function FrontendLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`dark ${spaceGrotesk.variable} ${inter.variable}`}
style={{ ["--font-label" as string]: "var(--font-headline)" }}
suppressHydrationWarning
>
<body>
{children}
<div
aria-hidden
className="pointer-events-none fixed bottom-3 right-4 z-50 font-label text-[10px] uppercase tracking-[0.2em] text-tertiary/80"
>
REV: {BUILD_REV} · SHA: {BUILD_SHA}
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,40 @@
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
import { StatusTerminal } from "@/components/StatusTerminal";
export default function HomePage() {
return (
<>
<SiteHeader />
<main>
<section className="technical-grid hero-gradient relative flex min-h-[92vh] flex-col justify-center overflow-hidden px-6">
<StatusTerminal className="absolute left-6 top-8 md:left-12" />
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-12 pt-20 lg:grid-cols-12">
<div className="lg:col-span-8">
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
01 // THE MANIFESTO
</span>
<h1 className="mb-8 font-headline text-5xl font-bold leading-[0.9] tracking-tighter text-on-surface md:text-8xl">
I WILL FIND
<br />
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
A WAY,
</span>
<br />
OR I WILL
<br />
MAKE ONE.
</h1>
<p className="max-w-2xl font-body text-xl leading-relaxed text-on-surface-variant md:text-2xl">
A multidisciplinary architect of digital ecosystems and
agricultural infrastructures. Executing vision through
precision.
</p>
</div>
</div>
</section>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
type Params = { slug: string };
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}) {
const { slug } = await params;
return { title: slug };
}
export default async function ProjectDetailPage({
params,
}: {
params: Promise<Params>;
}) {
const { slug } = await params;
if (!slug) notFound();
return (
<>
<SiteHeader />
<main className="mx-auto max-w-4xl px-6 py-24">
<span className="mb-4 block font-label text-xs uppercase tracking-[0.4em] text-primary">
PROJECT //{" "}
<code className="text-on-surface-variant">{slug}</code>
</span>
<h1 className="mb-6 font-headline text-4xl font-bold tracking-tight md:text-6xl">
Project detail
</h1>
<p className="font-body text-lg text-on-surface-variant">
Slug: <code className="font-label text-primary">{slug}</code>. Body
will render from the Payload{" "}
<code className="font-label text-primary">projects</code> collection
once wired.
</p>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,26 @@
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
export const metadata = { title: "Projects" };
export default function ProjectsIndexPage() {
return (
<>
<SiteHeader />
<main className="mx-auto max-w-7xl px-6 py-24">
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
03 // PROJECTS
</span>
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
Strategic Verticals
</h1>
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
Content sourced from Payload CMS collection{" "}
<code className="font-label text-primary">projects</code> rendered
here once the bento-grid section component lands in a follow-up PR.
</p>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,28 @@
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
export const metadata = { title: "Resume" };
export default function ResumePage() {
return (
<>
<SiteHeader />
<main className="mx-auto max-w-4xl px-6 py-24">
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-tertiary">
06 // CURRICULUM
</span>
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
Resume
</h1>
<p className="font-body text-xl text-on-surface-variant">
Sourced from Payload{" "}
<code className="font-label text-primary">resume</code> global. PDF
export wires up at{" "}
<code className="font-label text-primary">/resume.pdf</code> in
UI-07.
</p>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,43 @@
import { notFound } from "next/navigation";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
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 (
<>
<SiteHeader />
<article className="mx-auto max-w-3xl px-6 py-24">
<span className="mb-4 block font-label text-xs uppercase tracking-[0.4em] text-secondary">
POST //{" "}
<code className="text-on-surface-variant">{slug}</code>
</span>
<h1 className="mb-6 font-headline text-4xl font-bold tracking-tight md:text-6xl">
Post detail
</h1>
<p className="font-body text-lg text-on-surface-variant">
Body renders from Payload{" "}
<code className="font-label text-primary">posts</code> once wired.
</p>
</article>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,25 @@
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
export const metadata = { title: "Writing" };
export default function WritingIndexPage() {
return (
<>
<SiteHeader />
<main className="mx-auto max-w-7xl px-6 py-24">
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-secondary">
04 // WRITING
</span>
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
Signal
</h1>
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
Long-form from the Payload{" "}
<code className="font-label text-primary">posts</code> collection.
</p>
</main>
<SiteFooter />
</>
);
}

View 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;

View 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;

View File

@@ -0,0 +1,2 @@
// Auto-generated stub. Regenerate with `pnpm generate:importmap` after adding custom components.
export const importMap = {};

View 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);

View 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);

View 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);

View 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 },
);
}

View File

@@ -0,0 +1 @@
/* Payload admin UI customizations. Keep minimal for v0.0.x. */

View 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;

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from "payload";
export const Categories: CollectionConfig = {
slug: "categories",
access: { read: () => true },
admin: { useAsTitle: "name", defaultColumns: ["name", "slug", "accent"] },
fields: [
{ name: "name", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true, index: true },
{
name: "accent",
type: "select",
defaultValue: "primary",
options: [
{ label: "Primary (cyan)", value: "primary" },
{ label: "Secondary (magenta)", value: "secondary" },
{ label: "Tertiary (green)", value: "tertiary" },
],
},
],
};

View File

@@ -0,0 +1,33 @@
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", "status", "submittedAt"],
},
fields: [
{ name: "name", type: "text", required: true },
{ name: "email", type: "email", required: true },
{ name: "brief", type: "textarea", required: true },
{ name: "source", type: "text" },
{ name: "submittedAt", type: "date", defaultValue: () => new Date() },
{ name: "ipHash", type: "text" },
{
name: "status",
type: "select",
defaultValue: "new",
options: [
{ label: "New", value: "new" },
{ label: "Replied", value: "replied" },
{ label: "Spam", value: "spam" },
],
},
],
};

36
src/collections/Gear.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { CollectionConfig } from "payload";
export const Gear: CollectionConfig = {
slug: "gear",
access: { read: () => true },
admin: {
useAsTitle: "name",
defaultColumns: ["name", "type"],
description: "Music / maker gear — decorative only for v0.0.x",
},
fields: [
{ name: "name", type: "text", required: true },
{
name: "type",
type: "select",
options: [
{ label: "Daily driver", value: "daily-driver" },
{ label: "Interface", value: "interface" },
{ label: "Monitor", value: "monitor" },
{ label: "Workbench", value: "workbench" },
],
},
{ name: "notes", type: "textarea" },
{ name: "image", type: "upload", relationTo: "media" },
{
name: "accent",
type: "select",
defaultValue: "tertiary",
options: [
{ label: "Primary", value: "primary" },
{ label: "Secondary", value: "secondary" },
{ label: "Tertiary", value: "tertiary" },
],
},
],
};

26
src/collections/Media.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { CollectionConfig } from "payload";
export const Media: CollectionConfig = {
slug: "media",
access: {
read: () => true,
},
admin: {
useAsTitle: "alt",
},
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" },
],
};

43
src/collections/Posts.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { CollectionConfig } from "payload";
export const Posts: CollectionConfig = {
slug: "posts",
access: {
read: ({ req: { user } }) =>
user ? true : { status: { equals: "published" } },
},
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "publishedAt"],
},
versions: { drafts: true },
fields: [
{ name: "title", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true, index: true },
{ name: "excerpt", type: "textarea" },
{ name: "body", type: "richText" },
{ name: "category", type: "relationship", relationTo: "categories" },
{ name: "heroImage", type: "upload", relationTo: "media" },
{ name: "tags", type: "array", fields: [{ name: "tag", type: "text" }] },
{
name: "status",
type: "select",
defaultValue: "draft",
required: true,
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
},
{ name: "publishedAt", type: "date" },
{
name: "seo",
type: "group",
fields: [
{ name: "title", type: "text" },
{ name: "description", type: "textarea" },
{ name: "image", type: "upload", relationTo: "media" },
],
},
],
};

View File

@@ -0,0 +1,64 @@
import type { CollectionConfig } from "payload";
export const Projects: CollectionConfig = {
slug: "projects",
access: {
read: ({ req: { user } }) =>
user ? true : { status: { equals: "published" } },
},
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "featured", "order", "publishedAt"],
},
versions: { drafts: true },
fields: [
{ name: "title", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true, index: true },
{
name: "role",
type: "select",
options: [
{ label: "Founder / CEO", value: "founder" },
{ label: "Consultant", value: "consultant" },
{ label: "Engineer", value: "engineer" },
{ label: "Infrastructure", value: "infra" },
],
},
{ name: "category", type: "relationship", relationTo: "categories" },
{ name: "summary", type: "textarea" },
{ name: "body", type: "richText" },
{ name: "stack", type: "array", fields: [{ name: "name", type: "text" }] },
{ name: "heroImage", type: "upload", relationTo: "media" },
{
name: "gallery",
type: "array",
fields: [
{ name: "image", type: "upload", relationTo: "media", required: true },
{ name: "caption", type: "text" },
],
},
{ name: "externalUrl", type: "text" },
{ name: "featured", type: "checkbox", defaultValue: false },
{ name: "order", type: "number", defaultValue: 0 },
{
name: "status",
type: "select",
defaultValue: "draft",
required: true,
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
},
{ name: "publishedAt", type: "date" },
{
name: "seo",
type: "group",
fields: [
{ name: "title", type: "text" },
{ name: "description", type: "textarea" },
{ name: "image", type: "upload", relationTo: "media" },
],
},
],
};

21
src/collections/Users.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from "payload";
export const Users: CollectionConfig = {
slug: "users",
admin: {
useAsTitle: "email",
defaultColumns: ["email", "role"],
},
auth: true,
fields: [
{
name: "role",
type: "select",
defaultValue: "admin",
options: [{ label: "Admin", value: "admin" }],
access: {
update: ({ req: { user } }) => user?.role === "admin",
},
},
],
};

View File

@@ -0,0 +1,14 @@
export function SiteFooter() {
return (
<footer className="mt-24 border-t border-outline-variant/10 bg-surface-container-lowest">
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-12 md:flex-row md:items-center md:justify-between">
<div className="font-headline text-sm uppercase tracking-[0.2em] text-on-surface-variant">
© {new Date().getFullYear()} Jason Woltje · All rights reserved
</div>
<div className="font-label text-[10px] uppercase tracking-[0.2em] text-tertiary/80">
LATENCY: 42ms · CORE STATUS: NOMINAL
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,35 @@
import Link from "next/link";
const links = [
{ label: "Home", href: "/" },
{ label: "Projects", href: "/projects" },
{ label: "Writing", href: "/writing" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
];
export function SiteHeader() {
return (
<header className="sticky top-0 z-50 w-full bg-background/80 backdrop-blur-xl">
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<Link
href="/"
className="font-headline text-xl font-bold uppercase tracking-tighter text-primary"
>
JASON WOLTJE
</Link>
<div className="hidden items-center gap-8 md:flex">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className="font-label text-[14px] uppercase tracking-tighter text-on-surface-variant transition-colors hover:text-primary"
>
{link.label}
</Link>
))}
</div>
</nav>
</header>
);
}

View File

@@ -0,0 +1,23 @@
const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev";
const BUILD_REV = process.env.NEXT_PUBLIC_BUILD_REV ?? "local";
type Props = {
location?: string;
status?: string;
className?: string;
};
export function StatusTerminal({
location = "39.0997° N, 94.5786° W",
status = "ONLINE",
className = "",
}: Props) {
return (
<div className={`flex items-center gap-3 ${className}`}>
<span className="flex h-2 w-2 rounded-full bg-tertiary shadow-[0_0_8px_#8eff71]" />
<span className="font-label text-[10px] uppercase tracking-[0.2em] text-tertiary">
LOC: {location} · STATUS: {status} · REV: {BUILD_REV} · SHA: {BUILD_SHA}
</span>
</div>
);
}

27
src/globals/About.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { GlobalConfig } from "payload";
export const About: GlobalConfig = {
slug: "about",
access: { read: () => true },
fields: [
{ name: "intro", type: "richText" },
{ name: "makerMindset", type: "richText" },
{ name: "soundtrack", type: "richText" },
{
name: "gearRefs",
type: "relationship",
relationTo: "gear",
hasMany: true,
},
{
name: "timeline",
type: "array",
fields: [
{ name: "year", type: "text", required: true },
{ name: "title", type: "text", required: true },
{ name: "note", type: "textarea" },
],
},
{ name: "portrait", type: "upload", relationTo: "media" },
],
};

33
src/globals/Contact.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { GlobalConfig } from "payload";
export const Contact: GlobalConfig = {
slug: "contact",
access: { read: () => true },
fields: [
{
name: "availabilityBadge",
type: "text",
defaultValue: "Accepting new inquiries",
},
{ name: "timezoneLabel", type: "text", defaultValue: "America/Chicago" },
{ name: "directEmail", type: "email" },
{
name: "socialLinks",
type: "array",
fields: [
{ name: "label", type: "text", required: true },
{ name: "href", type: "text", required: true },
{ name: "icon", type: "text" },
],
},
{
name: "newsletterEnabled",
type: "checkbox",
defaultValue: false,
admin: {
description:
"Enable the newsletter subscribe UI. Keep false until Mautic is deployed.",
},
},
],
};

35
src/globals/Home.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { GlobalConfig } from "payload";
export const Home: GlobalConfig = {
slug: "home",
access: { read: () => true },
fields: [
{ name: "heroPrefix", type: "text", defaultValue: "01 // THE MANIFESTO" },
{ name: "heroHeadline", type: "richText" },
{ name: "heroSub", type: "textarea" },
{
name: "ctas",
type: "array",
fields: [
{ name: "label", type: "text", required: true },
{ name: "href", type: "text", required: true },
{
name: "style",
type: "select",
defaultValue: "primary",
options: [
{ label: "Primary (neon)", value: "primary" },
{ label: "Secondary", value: "secondary" },
{ label: "Ghost", value: "ghost" },
],
},
],
},
{
name: "featuredProjects",
type: "relationship",
relationTo: "projects",
hasMany: true,
},
],
};

21
src/globals/Navigation.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { GlobalConfig } from "payload";
export const Navigation: GlobalConfig = {
slug: "navigation",
access: { read: () => true },
fields: [
{
name: "primaryLinks",
type: "array",
fields: [
{ name: "label", type: "text", required: true },
{ name: "href", type: "text", required: true },
],
},
{
name: "footerStatusText",
type: "text",
defaultValue: "LATENCY: 42ms | CORE STATUS: NOMINAL",
},
],
};

46
src/globals/Resume.ts Normal file
View 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" },
],
},
],
};

25
src/globals/SEO.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { GlobalConfig } from "payload";
export const SEO: GlobalConfig = {
slug: "seo",
access: { read: () => true },
fields: [
{ name: "siteTitle", type: "text", defaultValue: "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>.",
},
},
],
};

66
src/payload.config.ts Normal file
View 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"),
},
});

102
tailwind.config.ts Normal file
View File

@@ -0,0 +1,102 @@
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 = {
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",
background: "#0d0e12",
"on-background": "#f7f5fc",
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",
};
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: {
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)",
},
backdropBlur: {
"2xl": "24px",
},
},
},
plugins: [forms, containerQueries],
};
export default config;

47
tsconfig.json Normal file
View 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"
]
}