Compare commits
4 Commits
feat/scaff
...
fix/trivy-
| Author | SHA1 | Date | |
|---|---|---|---|
| e67727e009 | |||
| 261c0019bb | |||
| c85e76e5cd | |||
| 8c5a25e976 |
19
.dockerignore
Normal file
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
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) ----
|
||||||
|
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
10
.gitignore
vendored
@@ -59,3 +59,13 @@ scripts/local/
|
|||||||
|
|
||||||
# design samples zip (HTML + DESIGN.md kept as reference)
|
# design samples zip (HTML + DESIGN.md kept as reference)
|
||||||
design-samples/*.zip
|
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
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
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
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}
|
||||||
|
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
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
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;
|
||||||
6
next-env.d.ts
vendored
Normal file
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
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 });
|
||||||
57
package.json
Normal file
57
package.json
Normal 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
7619
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
postcss.config.js
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
0
public/.gitkeep
Normal file
27
src/app/(frontend)/about/page.tsx
Normal file
27
src/app/(frontend)/about/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/(frontend)/contact/page.tsx
Normal file
26
src/app/(frontend)/contact/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/(frontend)/globals.css
Normal file
52
src/app/(frontend)/globals.css
Normal 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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/app/(frontend)/layout.tsx
Normal file
57
src/app/(frontend)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/app/(frontend)/page.tsx
Normal file
40
src/app/(frontend)/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/(frontend)/projects/[slug]/page.tsx
Normal file
45
src/app/(frontend)/projects/[slug]/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/(frontend)/projects/page.tsx
Normal file
26
src/app/(frontend)/projects/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/(frontend)/resume/page.tsx
Normal file
28
src/app/(frontend)/resume/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/app/(frontend)/writing/[slug]/page.tsx
Normal file
43
src/app/(frontend)/writing/[slug]/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/(frontend)/writing/page.tsx
Normal file
25
src/app/(frontend)/writing/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
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
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;
|
||||||
2
src/app/(payload)/admin/importMap.js
Normal file
2
src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Auto-generated stub. Regenerate with `pnpm generate:importmap` after adding custom components.
|
||||||
|
export const importMap = {};
|
||||||
16
src/app/(payload)/api/[...slug]/route.ts
Normal file
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
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
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
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
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
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;
|
||||||
21
src/collections/Categories.ts
Normal file
21
src/collections/Categories.ts
Normal 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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
33
src/collections/ContactSubmissions.ts
Normal file
33
src/collections/ContactSubmissions.ts
Normal 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
36
src/collections/Gear.ts
Normal 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
26
src/collections/Media.ts
Normal 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
43
src/collections/Posts.ts
Normal 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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
64
src/collections/Projects.ts
Normal file
64
src/collections/Projects.ts
Normal 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
21
src/collections/Users.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
14
src/components/SiteFooter.tsx
Normal file
14
src/components/SiteFooter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/SiteHeader.tsx
Normal file
35
src/components/SiteHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/StatusTerminal.tsx
Normal file
23
src/components/StatusTerminal.tsx
Normal 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
27
src/globals/About.ts
Normal 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
33
src/globals/Contact.ts
Normal 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
35
src/globals/Home.ts
Normal 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
21
src/globals/Navigation.ts
Normal 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
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
25
src/globals/SEO.ts
Normal file
25
src/globals/SEO.ts
Normal 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
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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
102
tailwind.config.ts
Normal file
102
tailwind.config.ts
Normal 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
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user