Compare commits

..

17 Commits

Author SHA1 Message Date
lerko96
2946801517 ci: auto-tag CalVer after deploy if commit untagged 2026-04-16 21:16:01 -04:00
lerko96
49028e7783 feat(content): reposition for security engineer, expand services to 37
Hero subtitle and blurb rewritten to lead with security operations
and homelab credentials over generic "builder" framing.

Projects: archive plAIground, service-monitor, ThoughtSpace (2025);
add open-pact and helm as featured; add risk-ops to archive (2026).
Add statusBadge + externalUrl to Project type; wire golf-book-mobile.

Services: 24 → 37 — split grouped arr/media entries, add mail relay,
gluetun, Home Assistant, Glance, Filebrowser, Prowlarr, Bazarr,
nzbget, qBittorrent, Kavita, Openshelf. Drop Calibre-Web.

Skills: add Go to Languages. Timeline: update monitoring stack.
Homelab ADRs: add Authentik over Authelia.
2026-04-16 21:06:18 -04:00
lerko96
c36cc94437 feat(ui): add timeline component; complete terminal-noir design system
- introduce Timeline component with scroll-in animation and type-colored
  spine dots (career/edu/cert/project/homelab)
- swap terminal-noir palette for macOS Classic dark/light with matching
  timeline color tokens in globals.css
- add light mode overrides, cursor blink keyframe, font-size 14px base
- update Widget header: prefix/name split, bracket badge, no divider rule
- align archive and homelab page headers to ❯ prompt style
- convert all font-sans prose in homelab/archive to font-mono
- rename widget titles to namespaced paths (homelab/network, etc.)
- skills label: uppercase tracking → plain text-sm; remove row borders
2026-04-16 18:03:33 -04:00
lerko96
6d0b4e29d8 refactor(ui): enforce terminal metaphor, unify secondary opacity
- drop headshot photo (coherence break vs. full terminal aesthetic)
- replace FA icons with plain-text brackets ([github], [linkedin], [email])
- remove Font Awesome CDN dependency
- nav logo tk → ~/; theme toggle fa-sun/fa-moon → [light]/[dark]
- reorder home sections: projects before skills/journey
- add font-mono + opacity-70 to timeline descriptions (#2 bug + #8 polish)
- uniform opacity-70 across hero bio, project desc, timeline desc
- add hover:bg-surface-raised to ProjectCard article
- drop journey badge count (noise)
- change status ● online → ● available
2026-04-16 18:01:19 -04:00
lerko96
7d9b300d84 ci: add GitHub Actions deploy workflow for GitHub Pages
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
2026-04-12 22:11:57 -04:00
4718d5df31 Merge pull request 'fix(docs): update Gitea repo URL to lerko/portfolio' (#3) from docs/update-readme-gitea-primary into dev
All checks were successful
Build and Deploy / deploy (push) Successful in 1m4s
Reviewed-on: #3
2026-04-13 01:49:21 +00:00
lerko96
153bdf502c fix(docs): update Gitea repo URL to lerko/portfolio 2026-04-12 21:47:52 -04:00
a7edf0b22a Merge pull request 'docs/update-readme-gitea-primary' (#2) from docs/update-readme-gitea-primary into dev
All checks were successful
Build and Deploy / deploy (push) Successful in 50s
Reviewed-on: lerko/Portfolio#2
2026-04-13 01:43:51 +00:00
lerko96
a9b73fd08e fix(docs): correct Gitea repo link in README 2026-04-12 21:38:17 -04:00
lerko96
01f012fc26 docs: update README for Gitea/self-hosted setup
drop GitHub Pages references, document actual deploy flow via
Gitea Actions + rsync to portfolio LXC
2026-04-12 21:23:57 -04:00
lerko96
94cb2da996 feat: terminal-noir redesign with centering fix and readability improvements
All checks were successful
Build and Deploy / deploy (push) Successful in 55s
2026-04-12 20:01:26 -04:00
lerko96
79d3fb142e style: improve readability across all pages
Bump body/description text from text-xs to text-sm. Lighten body copy
from color-text-dim/label (#444/#666) to near-white with opacity.
Increase row padding, card padding, and inter-section spacing to match
GitHub Changelog-style breathing room.
2026-04-12 20:00:05 -04:00
lerko96
b3fc7b2114 fix(layout): restore mx-auto centering by scoping CSS reset to @layer base
Unlayered CSS always wins over Tailwind's @layer utilities, so the
bare * { margin: 0 } reset was overriding mx-auto everywhere. Moving
it into @layer base restores correct cascade order.
2026-04-12 19:59:59 -04:00
lerko96
a58fafc563 fix(layout): narrow content column to 740px for visible centering 2026-04-12 19:36:41 -04:00
lerko96
bf0910a8fe style: narrow content column to max-w-3xl for centered layout 2026-04-12 19:33:40 -04:00
lerko96
088a06a51c feat: terminal-noir redesign — widget system + design token overhaul
Replace cyan-green modern theme with terminal-noir aesthetic aligned to
style-guide.md. Hard edges, monospace-first, linear transitions, no gradients.

Introduce Widget component as the single repeatable section primitive:
title bar with horizontal rule, optional badge/meta — all pages and
sections now use this pattern (Glance-inspired data-driven layout).

Design system changes (globals.css):
- Palette: #0a0a0a bg, #111111 surface, #00cc44 status green, #cc2200 alert red
- Drop Montserrat; Source Code Pro primary, system sans for prose only
- Transitions: linear 120ms; no eased animations, no border-radius

Component changes:
- Nav: flat, border-bottom only, lowercase links
- Hero: 56px square photo, status dot, @ email glyph
- ProjectCard: flat bordered card, 2-col grid, no gradient tile
- Skills: key-value rows with dot-separated values
- Footer: minimal text links

Pages: all sections wrapped in Widget; homelab uses gap-px grid for
at-a-glance, services, and ADRs sections. Archive uses flat list layout.

Data: remove gradient field from Project type; add optional year field
2026-04-12 19:23:50 -04:00
lerko96
05a32492ac rebuild portfolio: Next.js 16, React 19, Tailwind v4, homelab page, CI/CD
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
2026-04-12 18:52:54 -04:00
25 changed files with 832 additions and 794 deletions

View File

@@ -0,0 +1,70 @@
name: Build and Deploy
on:
push:
branches:
- dev
jobs:
deploy:
runs-on: ubuntu-latest
container: node:22-alpine
steps:
- name: Install SSH and rsync
run: apk add --no-cache openssh-client rsync git
- name: Checkout
uses: actions/checkout@v4
- name: Build
run: npm ci && npm run build
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H 10.99.0.23 >> ~/.ssh/known_hosts
- name: Sync out/ to Portfolio LXC
run: |
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key" \
out/ root@10.99.0.23:/opt/lerkolabs/out/
- name: Rebuild and restart container
run: |
ssh -i ~/.ssh/deploy_key root@10.99.0.23 \
"cd /opt/lerkolabs && \
docker build -t portfolio . && \
docker stop portfolio 2>/dev/null || true && \
docker rm portfolio 2>/dev/null || true && \
docker run -d --name portfolio -p 80:80 --restart unless-stopped portfolio"
- name: Tag release (CalVer)
run: |
git fetch --tags
if git describe --exact-match --tags HEAD 2>/dev/null; then
echo "Commit already tagged, skipping."
else
YEAR=$(date +%Y)
MONTH=$(date +%m)
LATEST=$(git tag --sort=-v:refname | grep -E '^[0-9]{4}\.[0-9]{2}\.[0-9]+$' | head -1)
if [ -n "$LATEST" ]; then
LATEST_YEAR=$(echo "$LATEST" | cut -d. -f1)
LATEST_MONTH=$(echo "$LATEST" | cut -d. -f2)
LATEST_MICRO=$(echo "$LATEST" | cut -d. -f3)
if [ "$YEAR" = "$LATEST_YEAR" ] && [ "$MONTH" = "$LATEST_MONTH" ]; then
MICRO=$((LATEST_MICRO + 1))
else
MICRO=1
fi
else
MICRO=1
fi
NEW_TAG="${YEAR}.$(printf '%02d' $MONTH).${MICRO}"
git tag "$NEW_TAG"
git push origin "$NEW_TAG"
echo "Tagged $NEW_TAG"
fi

44
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- dev
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Build
run: npm ci && npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: out/
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4

4
.gitignore vendored
View File

@@ -30,4 +30,8 @@ yarn-error.log*
next-env.d.ts
# claude code
CLAUDE.md
.claude/
# docs
/docs

View File

@@ -1,55 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Project:** [PERSONAL PORTFOLIO]
**Stack:** [Next.js 16 + React 19 + TypeScript + Tailwind v4]
**Deployed to:** [github pages]
## Branch Strategy
This repo uses two branches:
- **`master`** — production build output (HTML, JS, CSS bundles); what GitHub Pages serves
- **`dev`** — Next.js source code; all development happens here
**Always work on the `dev` branch.** Never manually edit files on `master`.
## Commands
```bash
npm run dev # Dev server at localhost:3000
npm run build # Production build into out/ (static export)
npm run deploy # Build + push out/ to master branch (deploys to GitHub Pages)
```
## Architecture
Next.js 16 static-export portfolio site with React 19 and TypeScript.
- **`src/app/layout.tsx`** — Root layout; Montserrat + Source Code Pro via next/font, Font Awesome CDN, ThemeProvider
- **`src/app/page.tsx`** — Home page (Hero, Skills, ProjectCards)
- **`src/app/homelab/page.tsx`** — Homelab page: at-a-glance, VLAN table, services grid, ADRs, GitHub CTA
- **`src/app/archive/page.tsx`** — Archive grid with older projects
- **`src/components/`** — Nav, Footer, Hero, and other UI components
- **`src/context/ThemeContext.tsx`** — Dark mode provider + useTheme hook, localStorage key `lerko96-dark-mode`
- **`src/components/ThemeScript.tsx`** — Blocking script injected in `<head>` to prevent FOUC
- **`src/data/projects.ts`** — All projects typed, featured + archive split
- **`src/data/services.ts`** — Homelab services with categories
- **`src/app/globals.css`** — Full design system: colors, fonts, breakpoints, keyframes (Tailwind v4 CSS-first config)
- **`public/CNAME`** — Custom domain (`www.lerko96.com`); copied into out/ on deploy
### Tailwind v4 note
Tailwind v4 is CSS-first — there is no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`. Dark mode variant is defined as `@variant dark (&:where(.dark, .dark *))`.
## Coding Standards
### Always
- remove any advertisement of "ai", "claude", "anthropic" from commit messages
## Deployment
`npm run deploy` runs `predeploy` (build) then `gh-pages -b master -d out`, which force-pushes the `out/` directory contents to the `master` branch. GitHub Pages serves from `master`.
`postbuild` writes `out/.nojekyll` to prevent GitHub Pages from ignoring `_next/` asset directories.

3
Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY out/ /usr/share/nginx/html
EXPOSE 80

View File

@@ -1,70 +1,59 @@
# Getting Started with Create React App
# Tyler Koenig portfolio
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
Personal portfolio site. Live at [lerkolabs.com](https://lerkolabs.com) — self-hosted.
## Available Scripts
Source: [gitea.lerkolabs.com/lerko/portfolio](https://gitea.lerkolabs.com/lerko/portfolio)
In the project directory, you can run:
**Stack:** Next.js 16 · React 19 · TypeScript · Tailwind v4
### `npm start`
---
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
## Branches
The page will reload if you make edits.\
You will also see any lint errors in the console.
- `dev` — source code; pushing here updates lerkolabs.com
- `master` — reserved for future GitHub mirror; don't touch manually
### `npm test`
---
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
## Commands
### `npm run build`
```bash
npm run dev # dev server at localhost:3000
npm run build # static export into out/
```
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
---
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
## Deploy
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
```bash
git checkout dev && git merge <branch> && git push gitea dev
```
### `npm run eject`
Push to `dev` triggers Gitea Actions (`.gitea/workflows/deploy.yml`):
1. Builds the static site (`npm run build`)
2. rsyncs `out/` to the portfolio LXC
3. Rebuilds and restarts the Docker container serving lerkolabs.com
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
---
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
## Project layout
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
```
src/
app/
layout.tsx # root layout, fonts, ThemeProvider
page.tsx # home: hero, skills, project cards
homelab/page.tsx # homelab page: VLANs, services, ADRs
archive/page.tsx # older projects grid
globals.css # full design system (Tailwind v4 CSS-first, all tokens here)
components/ # Nav, Footer, Hero, ThemeScript, etc.
context/
ThemeContext.tsx # dark mode provider + useTheme hook
data/
projects.ts # all projects, featured + archive split
services.ts # homelab services with categories
public/ # static assets copied into out/ on build
```
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
> Tailwind v4 is CSS-first — no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`.

View File

@@ -1,30 +0,0 @@
What's done
git tag cra-legacy — old CRA code preserved
Next.js 16 + React 19 + TypeScript + Tailwind v4 scaffolded
next.config.ts — output: 'export', trailingSlash: true, images: { unoptimized: true }
package.json — deploy scripts, postbuild writes out/.nojekyll, gh-pages@^6
globals.css — full design system: colors, fonts, breakpoints, keyframes (Tailwind v4 CSS-first config)
ThemeScript.tsx — FOUC-prevention blocking script
ThemeContext.tsx — dark mode provider + useTheme hook, localStorage key lerko96-dark-mode
Nav.tsx — sticky header, active link detection, dark mode toggle
Footer.tsx — dynamic copyright year, social links
layout.tsx — Montserrat + Source Code Pro via next/font, Font Awesome CDN, ThemeProvider wrapping
src/data/projects.ts — all 8 projects typed, featured + archive split
src/data/services.ts — 20+ homelab services with categories
src/app/page.tsx — home page with Hero, Skills, ProjectCards
src/app/homelab/page.tsx — full homelab page: at-a-glance, VLAN table, services grid, 6 ADRs, GitHub CTA
src/app/archive/page.tsx — archive grid with all older projects
public/CNAME — www.lerko96.com
public/images/headshot-tyler_koenig.png — restored from git tag
What's left
Test build — npm run build and fix any TypeScript/lint errors (hasn't been run yet)
Local preview — npx serve out and verify all 3 routes load correctly, dark mode toggle works, no broken assets
Email address — Footer has tylerkoenig96@gmail.com hardcoded — confirm that's correct or update
Deploy — npm run deploy pushes out/ to master
Post-deploy checks — confirm www.lerko96.com loads, /homelab and /archive routes work via direct URL, _next/ assets load (not 404'd)
Screenshots — swap gradient placeholders for real project screenshots when ready (just drop into public/images/ and update projects.ts)
CLAUDE.md update — update stack, commands, and architecture notes to reflect Next.js (currently still says CRA)
Key gotcha to remember
Tailwind v4 is CSS-first — no tailwind.config.ts. All custom tokens live in globals.css under @theme {}. Dark mode variant is defined as @variant dark (&:where(.dark, .dark *)).
Memory has been saved to /home/lerko/.claude/projects/-home-lerko-Code-lerko96-github-io/memory/ — the next session will have full context automatically.

View File

@@ -1,85 +0,0 @@
# DECISIONS
# decisions
> Short notes on *why* things are configured the way they are.\nYou'll forget this in 6 months. Future-you will thank present-you.\nFormat: title, date, context, decision, alternatives considered.
## AT&T Gateway kept in-line (not bypassed)
**Date:** \[date\]
**Context:** AT&T Fiber requires 802.1X certificate authentication locked to their gateway hardware. No way to replace it with a standard modem.
**Decision:** Keep BGW320 in-line with IP Passthrough mode (DHCPS-fixed to pfSense WAN MAC). pfSense gets the public IP directly. Gateway WiFi disabled.
**Alternatives considered:**
* EAP proxy bypass — more complex, breaks on AT&T firmware updates, only saves 1-2ms latency. Not worth it.
* True bridge mode — AT&T gateways don't support it.
## Caddy as reverse proxy with Cloudflare DNS challenge
**Date:** \[date\]
**Context:** Needed valid SSL certs for all homelab services without exposing port 80/443 to the internet for HTTP challenge validation.
**Decision:** Caddy with `caddy-dns/cloudflare` plugin. DNS-01 challenge via Cloudflare API. All subdomains on [lerkolabs.com](http://lerkolabs.com) point to Caddy (10.2.0.20) in Pi-hole. Caddy terminates SSL and proxies to backends.
**Alternatives considered:**
* Self-signed certs — browser warnings, annoying on mobile/VPN clients.
* Nginx Proxy Manager — more UI overhead, same result.
* Traefik — more complex config for the same outcome here.
## WireGuard over OpenVPN
**Date:** \[date\]
**Context:** Needed remote access to homelab services.
**Decision:** WireGuard on pfSense, UDP 51820, VPN subnet 10.200.0.0/24. Clients get same access as LAN for Homelab and MGMT, blocked from Guest/IoT/WFH.
**Alternatives considered:**
* OpenVPN — slower, more complex config, worse battery on mobile. No advantage here.
* Tailscale — adds external dependency and relay for what can be direct.
## Pi-hole in Homelab VLAN, not MGMT
**Date:** \[date\]
**Context:** Pi-hole serves DNS to all VLANs. Needed to decide where to host it.
**Decision:** VLAN 1020 (Homelab) at 10.2.0.11. Firewall rules allow port 53 inbound from all VLANs. MGMT VLAN uses pfSense as primary DNS (more reliable for network equipment).
**Alternatives considered:**
* MGMT VLAN — cleaner separation, but creates firewall complexity allowing all VLANs to reach MGMT.
* DMZ — no reason to put a DNS server public-facing.
## WFH VLAN uses pfSense DNS only (not Pi-hole)
**Date:** \[date\]
**Context:** Work laptop on WFH VLAN should be maximally isolated from personal infrastructure.
**Decision:** WFH DHCP hands out pfSense (10.5.0.1) as the only DNS server. Work device can't reach Pi-hole at 10.2.0.11 and doesn't need to.
## N100 for pfSense
**Date:** \[date\]
**Context:** Needed hardware that handles 1Gbps routing + WireGuard VPN without bottlenecking.
**Decision:** Intel N100 mini PC. 4-core 3.4GHz, \~6W idle. Handles 2-3Gbps routing, 600-900Mbps WireGuard. Adequate for 1Gbps AT&T fiber with room to grow.
**Alternatives considered:**
* Raspberry Pi — insufficient for 1Gbps + VPN.
* Full rack server — overkill power draw for this role.

View File

@@ -1,171 +0,0 @@
# README
# 🏠 lerkolabs — Home Infrastructure Lab
> Personal infrastructure environment for learning, self-hosting, and operational practice. Running 24/7 on production-grade hardware with real network segmentation, SSO, monitoring, and IaC-style documentation.
---
## ⚡ At a Glance
| | |
|-----|-----|
| **Hypervisor** | Proxmox VE |
| **Firewall** | pfSense (Intel N100) |
| **Switching** | TP-Link Omada (managed, VLANs) |
| **ISP** | AT&T Fiber 1Gbps — IP Passthrough → pfSense WAN |
| **VPN** | WireGuard (pfSense) |
| **Reverse Proxy** | Caddy + Cloudflare DNS-01 — wildcard SSL on `*.lerkolabs.com` |
| **Auth** | Authentik SSO — OIDC + forward auth across all services |
| **DNS** | Pi-hole → pfSense Unbound → Cloudflare |
| **Containers** | 9 LXC containers + 2 VMs |
| **Self-hosted services** | 20+ |
---
## 🌐 Network Architecture
**8 isolated VLANs** — strict inter-VLAN firewall policy (default deny):
| VLAN | Name | Subnet | Purpose |
|------|------|--------|---------|
| 1000 | MGMT | `10.0.0.0/24` | Network equipment only |
| 1010 | LAN | `10.1.0.0/24` | Trusted personal devices |
| 1020 | Homelab | `10.2.0.0/24` | All self-hosted services |
| 1030 | Guests | `10.3.0.0/24` | Internet only, RFC1918 blocked |
| 1040 | IoT | `10.4.0.0/24` | Smart home, isolated |
| 1050 | WFH | `10.5.0.0/24` | Work devices, no personal access |
| 1 | DMZ | `10.99.0.0/24` | Public-facing, hard-blocked internally |
| — | VPN | `10.200.0.0/24` | WireGuard clients = LAN access |
Work devices (VLAN 1050) are isolated from *all* personal infrastructure — they use pfSense DNS only, never Pi-hole. WireGuard VPN clients get full LAN-equivalent access without being on the physical network.
---
## 🖥️ Compute — LXC / VM Layout
| Container | IP | Cores | RAM | What Runs |
|-----------|-----|-------|-----|-----------|
| `pihole` | 10.2.0.5 | 1 | 512MB | Pi-hole DNS + ad blocking |
| `auth` | 10.2.0.25 | 1 | 512MB | Authentik SSO + Redis |
| `infra` | 10.2.0.20 | 2 | 1GB | Caddy reverse proxy, ntfy |
| `monitor` | 10.2.0.51 | 4 | 4GB | Victoria Metrics, Grafana, Beszel |
| `apps` | 10.2.0.60 | 4 | 6GB | 15+ productivity apps (Docker) |
| `vault` | 10.2.0.X | 1 | 256MB | Vaultwarden (isolated) |
| `servarr` (VM) | — | 4 | 8GB | Plex, Jellyfin, \*arr stack, qBittorrent |
| `haos` (VM) | — | 2 | 4GB | Home Assistant OS |
---
## 🚀 Self-Hosted Services
### Core Infrastructure
| Service | Role |
|---------|------|
| pfSense | Firewall, DHCP, routing, WireGuard VPN |
| Pi-hole | Network-wide DNS + ad blocking |
| Caddy | Reverse proxy, automatic wildcard TLS |
| Authentik | SSO provider — OIDC + forward auth |
| Vaultwarden | Self-hosted password manager |
| Victoria Metrics + Grafana | Metrics, dashboards, alerting |
| Beszel | Container + host monitoring |
| ntfy | Push notifications |
### Productivity (`apps` LXC — all behind Authentik SSO)
| Service | URL | Purpose |
|---------|-----|---------|
| Outline | `outline.lerkolabs.com` | Team wiki |
| Gitea | `gitea.lerkolabs.com` | Personal Git |
| Vikunja | `tasks.lerkolabs.com` | Task management |
| Ghostfolio | `finance.lerkolabs.com` | Portfolio tracking |
| Hoarder | `hoarder.lerkolabs.com` | Bookmark manager |
| Grist | `grist.lerkolabs.com` | Spreadsheets / data |
| Actual Budget | `budget.lerkolabs.com` | Personal budgeting |
| FreshRSS | `rss.lerkolabs.com` | RSS reader |
| Memos | `memos.lerkolabs.com` | Quick notes |
| Traggo | `time.lerkolabs.com` | Time tracking |
| Baikal | `dav.lerkolabs.com` | CalDAV / CardDAV |
| Glance | `glance.lerkolabs.com` | Homepage dashboard |
| Filebrowser | `files.lerkolabs.com` | File management |
### Media
| Service | Purpose |
|---------|---------|
| Plex + Jellyfin | Media streaming |
| Sonarr / Radarr / Lidarr | Automated media management |
| Prowlarr + Bazarr | Indexer aggregation + subtitles |
| qBittorrent (VPN-gated) | Downloads via Gluetun |
| Calibre-Web Automated | Book library with auto-ingest |
| Kavita | E-reader |
---
## 🔒 Security Design
* All services require Authentik authentication — no anonymous access
* Caddy handles TLS termination; internal services run HTTP only
* VPN-only admin access to pfSense, Proxmox, Pi-hole
* IoT devices can only reach the internet and Home Assistant
* Guests are RFC1918 hard-blocked — internet only
* WFH laptop on isolated VLAN, DNS from pfSense only
* Vaultwarden isolated in its own LXC — no shared container
* Zero open ports for management traffic (all VPN-gated)
---
## 🗂️ Architecture Decisions
Short-form ADRs documenting *why* things are built this way:
* **AT&T IP Passthrough over EAP bypass** — AT&T locks 802.1X to their gateway; passthrough mode gives pfSense the real public IP without brittle workarounds.
* **Caddy over NGINX Proxy Manager** — single Caddyfile, auto-cert via Cloudflare DNS-01, no UI overhead.
* **WireGuard over OpenVPN** — lower latency, better mobile battery life, \~600Mbps on the N100.
* **Authentik over Authelia** — full OIDC provider + forward auth in one. Lets services like Outline, Gitea, and Vikunja use real SSO rather than just a login gate.
* **Shared Postgres + Redis in** `**apps**` **LXC** — one instance, multiple databases. Avoids 15 separate DB containers; a single init script provisions all schemas on first run.
* **N100 for pfSense** — 6W idle, handles 23Gbps routing + 600Mbps WireGuard. Right-sized for 1Gbps fiber.
* **Pi-hole in Homelab VLAN, not MGMT** — MGMT access from all VLANs would be a larger attack surface than filtering DNS traffic.
---
## 📁 Repo Structure
```bash
homelab/
├── docs/
│ ├── README.md # quick reference — IPs, URLs, hardware
│ ├── SERVICES.md # full service registry (IP, port, VLAN, status)
│ ├── NETWORK.md # VLAN map, firewall policy, DNS architecture
│ ├── DECISIONS.md # ADRs — why things are configured the way they are
│ ├── RUNBOOKS.md # break-fix procedures
│ ├── SECURITY.md # open ports, update cadence, known debt
│ └── setup/ # one-time deploy guides (archived, read-only)
├── configs/
│ ├── caddy/ # Caddyfile + compose
│ ├── pfsense/ # config.xml exports
│ ├── pihole/ # teleporter backups
│ └── lxc/ # per-container configs
└── scripts/ # automation, health checks
```
---
## 🔗 Contact
\
![LinkedIn](uploads/164c25fb-9f88-4726-87fb-a640c43b142f/75308bef-4010-4a87-8afb-c9896f0eb6ba/LinkedIn-Connect-0A66C2 " =112x20") ![GitHub](uploads/164c25fb-9f88-4726-87fb-a640c43b142f/10d87e72-d33c-4cd3-99b7-2595a9501f5f/GitHub-lerkolabs-181717 " =123x20")
\
> *This lab is actively maintained and evolving. Documentation lives alongside the infrastructure.*

View File

@@ -1,19 +0,0 @@
Design this UI in the style of a modern developer-focused changelog/content feed. Use the following style principles:
Layout & Structure
Use a single-column, left-aligned content feed with generous vertical whitespace between entries. Group items under clear date/month headers that act as visual anchors. Keep the overall page width constrained (max ~800900px) and centered, so the content feels focused and readable rather than sprawling.
Typography
Use a clean sans-serif system font stack. Headings should be bold and concise — favor short, scannable titles over long descriptive ones. Date labels and category tags should be small, muted, and secondary. Use size contrast (not just weight) to establish hierarchy: large section headers → medium entry titles → small metadata.
Color & Theme
Support both light and dark modes natively. Use a near-black/off-white background with high-contrast body text. Accent colors should be minimal and purposeful — one primary action color (e.g., a muted blue or purple) for links and interactive elements. Avoid decorative gradients; let whitespace and typography do the visual work.
Entry/Card Pattern
Each entry is a flat, borderless row — no card shadows or heavy containers. Include: a date label (left-aligned, muted), an entry type badge (e.g., "Release", "Improvement", "Retired") as a small pill/tag with a corresponding icon, a bold clickable title, and one or more category filter tags rendered as small inline chips with subtle background tints.
Tags & Filtering
Render category tags as small rounded pill buttons with very low-contrast background fills (e.g., light gray or transparent with a border). On a filter/sidebar, use checkboxes or toggle-style selectors. Show an active filter count as a badge. Include a visible "Clear all" affordance.
Navigation & Header
Minimal top navigation bar: site logo/name on the left, a small set of text links in the center or right, and one prominent CTA button (e.g., "Try X"). The header should be sticky or at minimum feel anchored. Below the page title, include secondary utility links (RSS, social follow) at small size.
Interaction & Hover States
Links and entry titles should have subtle underline-on-hover behavior. Tags/chips should lighten or highlight on hover. No heavy animations — transitions should be under 150ms and feel instantaneous.
Footer
Multi-column link footer with section headings (Product, Platform, Support, Company). Use small, muted text. Social icons should be monochrome and sized consistently. Include legal links (Terms, Privacy) as inline text at the very bottom.
Spacing System
Use an 8px base grid. Section gaps should be 4864px. Entry gaps should be 2432px. Tag/chip padding: 4px vertical, 10px horizontal. Comfortable line-height (1.51.6) throughout.

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Widget from "@/components/Widget";
import { archiveProjects } from "@/data/projects";
export const metadata: Metadata = {
@@ -9,51 +10,59 @@ export const metadata: Metadata = {
export default function ArchivePage() {
return (
<>
<div className="mb-14">
<p className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2">
Archive
<div className="mb-16">
<p className="font-mono text-base font-bold text-[var(--color-text)] mb-3">
<span className="text-[var(--color-accent-green)] select-none mr-2" aria-hidden="true"></span>
tyler/projects/archive
</p>
<h1 className="font-mono text-2xl font-bold text-[var(--color-text-light)] mb-4">
Earlier Work
</h1>
<p className="text-[var(--color-grey-3)] text-sm leading-relaxed max-w-xl">
Experiments, browser extensions, and bootcamp projects. Kept here for context not representative of current work.
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed max-w-xl opacity-80">
Experiments, browser extensions, and bootcamp projects. Kept here for context not
representative of current work.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{archiveProjects.map((project) => (
<a
key={project.slug}
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="group border border-[var(--color-grey-1)] rounded-lg p-6 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors flex flex-col gap-4"
>
<div className="flex items-start justify-between gap-2">
<h2 className="font-mono text-sm font-semibold text-[var(--color-text-light)] group-hover:text-[var(--color-green)] transition-colors">
{project.title}
</h2>
<i className="fas fa-arrow-up-right-from-square text-xs text-[var(--color-grey-2)] shrink-0 mt-0.5 group-hover:text-[var(--color-green)] transition-colors" aria-hidden="true" />
</div>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed flex-1">
{project.description}
</p>
<div className="flex flex-wrap gap-1.5">
{project.tags.map((tag) => (
<span
key={tag}
className="font-mono text-xs px-2 py-0.5 border border-[var(--color-grey-1)] text-[var(--color-grey-2)] rounded"
>
{tag}
</span>
))}
</div>
</a>
))}
</div>
<Widget title="tyler/projects/archive" badge={archiveProjects.length} as="section">
<div className="flex flex-col gap-px bg-[var(--color-border)]">
{archiveProjects.map((project) => (
<a
key={project.slug}
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start justify-between gap-6 px-4 py-4 group"
>
<div className="flex flex-col gap-2 flex-1 min-w-0">
<div className="flex items-center gap-3">
{project.year && (
<span className="font-mono text-xs text-[var(--color-text-dim)] shrink-0">
{project.year}
</span>
)}
<span className="font-mono text-sm text-[var(--color-text)] group-hover:text-[var(--color-accent-green)] truncate">
{project.title}
</span>
</div>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
{project.description}
</p>
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{project.tags.map((tag) => (
<span key={tag} className="font-mono text-xs text-[var(--color-text-dim)]">
{tag}
</span>
))}
</div>
</div>
<span
className="font-mono text-xs text-[var(--color-text-label)] group-hover:text-[var(--color-text)] shrink-0 mt-0.5"
aria-hidden="true"
>
</span>
</a>
))}
</div>
</Widget>
</>
);
}

View File

@@ -3,65 +3,86 @@
@variant dark (&:where(.dark, .dark *));
@theme {
/* Colors */
--color-green: #2bf3c4;
--color-green-dark: #27bb98;
--color-green-darker: #238770;
--color-green-darkest: #1f4b40;
/* macOS Classic Dark (default) */
--color-bg: #131313;
--color-surface: #1e1d1e;
--color-surface-raised: #272727;
--color-border: #3a3a3a;
--color-border-bright: #404040;
--color-text: #caccca;
--color-text-label: #9e9e9e;
--color-text-dim: #8f8f8f;
--color-accent-green: #62ba46;
--color-accent-red: #c74028;
--color-bg: #272727;
--color-bg-deep: #1b1b1b;
--color-surface: #333333;
--color-grey-1: #4b4b4b;
--color-grey-2: #707171;
--color-grey-3: #999a9a;
--color-grey-4: #c5c6c6;
--color-text: #c5c6c6;
--color-text-muted: #999a9a;
--color-text-light: #f7f9fb;
/* Timeline type colors — dark */
--color-timeline-career: #62ba46;
--color-timeline-education: #c28b12;
--color-timeline-cert: #c75828;
--color-timeline-project: #c72855;
--color-timeline-homelab: #e1d797;
/* Typography */
--font-mono: "Source Code Pro", ui-monospace, monospace;
--font-sans: "Montserrat", ui-sans-serif, system-ui, sans-serif;
--font-sans: ui-sans-serif, system-ui, sans-serif;
/* Breakpoints */
--breakpoint-xs: 576px;
/* Animations */
--animate-fade-in: fadeIn 0.6s ease forwards;
--animate-slide-up: slideUp 0.5s ease forwards;
--animate-app-scale: appScale 0.4s ease forwards;
--animate-fade-in: fadeIn 120ms linear forwards;
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes appScale {
from { transform: scale(0.97); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
}
/* Base */
html {
scroll-behavior: smooth;
background-color: var(--color-bg-deep);
font-size: 14px;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-family: var(--font-mono);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
@keyframes blink { 50% { opacity: 0; } }
.animate-cursor { animation: blink 1s step-start infinite; }
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
/* macOS Classic Light overrides */
:root:not(.dark) {
--color-bg: #ffffff;
--color-surface: #f9f9f9;
--color-surface-raised: #f7f7f7;
--color-border: #e0e0e0;
--color-border-bright: #d2d2d2;
--color-text: #000000;
--color-text-label: #505050;
--color-text-dim: #929292;
--color-accent-green: #036a07;
--color-accent-red: #d21f07;
--color-timeline-career: #036a07;
--color-timeline-education: #0433ff;
--color-timeline-cert: #957931;
--color-timeline-project: #6f42c1;
--color-timeline-homelab: #0000a2;
}
/* Default transitions — linear, fast */
a,
button {
transition: color 120ms linear, border-color 120ms linear,
background-color 120ms linear, opacity 120ms linear;
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Widget from "@/components/Widget";
import { services, categoryOrder, categoryLabels } from "@/data/services";
export const metadata: Metadata = {
@@ -7,15 +8,27 @@ export const metadata: Metadata = {
"Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services.",
};
const glanceStats = [
{ label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Intel N100)" },
{ label: "Switching", value: "TP-Link Omada (managed)" },
{ label: "ISP", value: "AT&T Fiber 1 Gbps" },
{ label: "VPN", value: "WireGuard (pfSense)" },
{ label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" },
{ label: "Auth", value: "Authentik SSO" },
{ label: "DNS", value: "Pi-hole → Unbound → Cloudflare" },
{ label: "Containers", value: "9 LXC + 2 VMs" },
];
const vlans = [
{ id: "1000", name: "MGMT", subnet: "10.0.0.0/24", purpose: "Network equipment only" },
{ id: "1010", name: "LAN", subnet: "10.1.0.0/24", purpose: "Trusted personal devices" },
{ id: "1020", name: "Homelab", subnet: "10.2.0.0/24", purpose: "All self-hosted services" },
{ id: "1030", name: "Guests", subnet: "10.3.0.0/24", purpose: "Internet only, RFC1918 blocked" },
{ id: "1040", name: "IoT", subnet: "10.4.0.0/24", purpose: "Smart home, isolated" },
{ id: "1050", name: "WFH", subnet: "10.5.0.0/24", purpose: "Work devices, no personal access" },
{ id: "DMZ", name: "DMZ", subnet: "10.99.0.0/24", purpose: "Public-facing, hard-blocked internally" },
{ id: "VPN", name: "VPN", subnet: "10.200.0.0/24", purpose: "WireGuard clients = LAN access" },
{ id: "1000", name: "MGMT", subnet: "10.0.0.0/24", purpose: "Network equipment only" },
{ id: "1010", name: "LAN", subnet: "10.1.0.0/24", purpose: "Trusted personal devices" },
{ id: "1020", name: "Homelab", subnet: "10.2.0.0/24", purpose: "All self-hosted services" },
{ id: "1030", name: "Guests", subnet: "10.3.0.0/24", purpose: "Internet only, RFC1918 blocked" },
{ id: "1040", name: "IoT", subnet: "10.4.0.0/24", purpose: "Smart home, isolated" },
{ id: "1050", name: "WFH", subnet: "10.5.0.0/24", purpose: "Work devices, no personal access" },
{ id: "DMZ", name: "DMZ", subnet: "10.99.0.0/24", purpose: "Public-facing, hard-blocked internally" },
{ id: "VPN", name: "VPN", subnet: "10.200.0.0/24", purpose: "WireGuard clients = LAN access" },
];
const adrs = [
@@ -55,6 +68,17 @@ const adrs = [
"One Postgres instance, multiple databases. One Redis instance. A single init script provisions all schemas on first run.",
why: "Avoids 15 separate DB containers. Reduces RAM overhead significantly. All productivity apps share the same LXC (10.2.0.60).",
},
{
title: "Gitea CI/CD: Self-hosted runner with container build + SSH rsync deploy",
decision:
"act_runner v0.3.1 on Gitea LXC (10.99.0.22). Push to dev → node:22-alpine container builds Next.js → rsync out/ to Portfolio LXC → SSH docker rebuild.",
why: "Keeps the full pipeline internal — no GitHub Actions, no external runners. Build runs in an isolated Alpine container so the Gitea LXC isn't polluted. Portfolio LXC (10.99.0.23) just serves pre-built static files via nginx.",
},
{
title: "Authentik over Authelia",
decision: "Authentik as the SSO provider across all self-hosted services.",
why: "Full OIDC provider + forward auth in one. Lets services like Outline, Gitea, and Vikunja use real SSO rather than just a login gate. Authelia is forward-auth only — no OIDC provider capability.",
},
];
export default function HomelabPage() {
@@ -62,114 +86,110 @@ export default function HomelabPage() {
<>
{/* Header */}
<div className="mb-16">
<p className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2">
lerkolabs
<p className="font-mono text-base font-bold text-[var(--color-text)] mb-3">
<span className="text-[var(--color-accent-green)] select-none mr-2" aria-hidden="true"></span>
homelab
</p>
<h1 className="font-mono text-2xl font-bold text-[var(--color-text-light)] mb-4">
Home Infrastructure Lab
</h1>
<p className="text-[var(--color-grey-3)] text-sm leading-relaxed max-w-2xl">
Personal infrastructure environment for learning, self-hosting, and operational practice.
Running 24/7 on production-grade hardware with real network segmentation, SSO,
monitoring, and IaC-style documentation.
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed max-w-2xl opacity-80">
Personal infrastructure environment for learning, self-hosting, and operational
practice. Running 24/7 on production-grade hardware with real network segmentation,
SSO, monitoring, and IaC-style documentation.
</p>
</div>
{/* At a glance */}
<section className="mb-16" aria-labelledby="glance-heading">
<h2
id="glance-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6"
>
At a Glance
</h2>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-4">
{[
{ label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Intel N100)" },
{ label: "Switching", value: "TP-Link Omada (managed)" },
{ label: "ISP", value: "AT&T Fiber 1 Gbps" },
{ label: "VPN", value: "WireGuard (pfSense)" },
{ label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" },
{ label: "Auth", value: "Authentik SSO" },
{ label: "DNS", value: "Pi-hole → Unbound → Cloudflare" },
{ label: "Containers", value: "9 LXC + 2 VMs" },
].map(({ label, value }) => (
{/* At a Glance */}
<Widget title="homelab/overview" badge={glanceStats.length} as="section">
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
{glanceStats.map(({ label, value }) => (
<div
key={label}
className="border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)]"
className="bg-[var(--color-surface)] px-4 py-3"
>
<p className="font-mono text-xs text-[var(--color-grey-2)] mb-1">{label}</p>
<p className="font-mono text-sm text-[var(--color-text-light)]">{value}</p>
<p className="font-mono text-xs text-[var(--color-text-dim)] uppercase tracking-wider mb-1">
{label}
</p>
<p className="font-mono text-sm text-[var(--color-text)]">{value}</p>
</div>
))}
</div>
</section>
</Widget>
{/* VLAN table */}
<section className="mb-16" aria-labelledby="network-heading">
<h2
id="network-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6"
>
Network 8 Isolated VLANs
</h2>
<p className="text-xs text-[var(--color-grey-3)] mb-4">
Default deny inter-VLAN policy. Each VLAN has explicit firewall rules for what it can reach.
</p>
<Widget
title="homelab/network"
meta="8 isolated vlans · default deny inter-vlan"
as="section"
>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-[var(--color-grey-1)]">
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">VLAN</th>
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">Name</th>
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">Subnet</th>
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 uppercase tracking-wider">Purpose</th>
<tr className="border-b border-[var(--color-border)]">
<th className="font-mono text-[var(--color-text-dim)] text-left py-2 pr-6 uppercase tracking-wider">
VLAN
</th>
<th className="font-mono text-[var(--color-text-dim)] text-left py-2 pr-6 uppercase tracking-wider">
Name
</th>
<th className="font-mono text-[var(--color-text-dim)] text-left py-2 pr-6 uppercase tracking-wider">
Subnet
</th>
<th className="font-mono text-[var(--color-text-dim)] text-left py-2 uppercase tracking-wider">
Purpose
</th>
</tr>
</thead>
<tbody>
{vlans.map((v) => (
<tr key={v.id} className="border-b border-[var(--color-grey-1)] border-opacity-30 hover:bg-[var(--color-bg)] transition-colors">
<td className="font-mono text-xs text-[var(--color-green)] py-2.5 pr-4">{v.id}</td>
<td className="font-mono text-sm text-[var(--color-text-light)] py-2.5 pr-4">{v.name}</td>
<td className="font-mono text-xs text-[var(--color-grey-3)] py-2.5 pr-4">{v.subnet}</td>
<td className="text-xs text-[var(--color-grey-3)] py-2.5">{v.purpose}</td>
<tr
key={v.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-surface)]"
>
<td className="font-mono text-[var(--color-accent-green)] py-2.5 pr-6">
{v.id}
</td>
<td className="font-mono text-[var(--color-text)] py-2.5 pr-6">{v.name}</td>
<td className="font-mono text-[var(--color-text-label)] py-2.5 pr-6">
{v.subnet}
</td>
<td className="font-mono text-sm text-[var(--color-text)] py-2.5 opacity-80">{v.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</Widget>
{/* Services */}
<section className="mb-16" aria-labelledby="services-heading">
<h2
id="services-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6"
>
Self-Hosted Services
</h2>
<Widget
title="homelab/services"
badge={services.length}
as="section"
>
<div className="flex flex-col gap-8">
{categoryOrder.map((cat) => {
const catServices = services.filter((s) => s.category === cat);
return (
<div key={cat}>
<h3 className="font-mono text-xs text-[var(--color-grey-2)] uppercase tracking-wider mb-3">
<p className="font-mono text-xs text-[var(--color-text-dim)] uppercase tracking-wider mb-3">
{categoryLabels[cat]}
</h3>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-3">
</p>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
{catServices.map((svc) => (
<div
key={svc.name}
className="flex items-start gap-3 border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors"
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start gap-3 px-4 py-3"
>
<i
className={`${svc.icon} text-[var(--color-green)] text-sm mt-0.5 w-4 shrink-0`}
className={`${svc.icon} text-[var(--color-text-label)] text-xs mt-0.5 w-3.5 shrink-0`}
aria-hidden="true"
/>
<div>
<p className="font-mono text-xs text-[var(--color-text-light)] mb-0.5">{svc.name}</p>
<p className="text-xs text-[var(--color-grey-2)] leading-relaxed">{svc.description}</p>
<p className="font-mono text-xs text-[var(--color-text)] mb-0.5">
{svc.name}
</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
{svc.description}
</p>
</div>
</div>
))}
@@ -178,52 +198,48 @@ export default function HomelabPage() {
);
})}
</div>
</section>
</Widget>
{/* ADRs */}
<section className="mb-16" aria-labelledby="adr-heading">
<h2
id="adr-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2"
>
Architecture Decisions
</h2>
<p className="text-xs text-[var(--color-grey-3)] mb-6">
Short-form ADRs why things are configured the way they are.
</p>
<div className="flex flex-col gap-5">
<Widget
title="homelab/ADRs"
meta="why things are configured the way they are"
badge={adrs.length}
as="section"
>
<div className="flex flex-col gap-px bg-[var(--color-border)]">
{adrs.map((adr) => (
<div
key={adr.title}
className="border border-[var(--color-grey-1)] rounded-lg p-5 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors"
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-4 py-4"
>
<h3 className="font-mono text-sm text-[var(--color-text-light)] mb-2">{adr.title}</h3>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed mb-2">
<span className="text-[var(--color-grey-2)]">Decision: </span>{adr.decision}
<p className="font-mono text-sm text-[var(--color-text)] mb-2">{adr.title}</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed mb-1.5 opacity-75">
<span className="text-[var(--color-text-label)] opacity-100">decision: </span>
{adr.decision}
</p>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed">
<span className="text-[var(--color-grey-2)]">Why: </span>{adr.why}
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
<span className="text-[var(--color-text-label)] opacity-100">why: </span>
{adr.why}
</p>
</div>
))}
</div>
</section>
</Widget>
{/* GitHub CTA */}
<section className="border border-[var(--color-grey-1)] rounded-lg p-6 bg-[var(--color-bg)] flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<p className="font-mono text-sm text-[var(--color-text-light)] mb-1">lerkolabs on GitHub</p>
<p className="text-xs text-[var(--color-grey-3)]">
Full documentation: VLAN maps, runbooks, service registry, config exports, and setup guides.
</p>
</div>
<section className="pt-2">
<p className="font-mono text-sm text-[var(--color-text-dim)] mb-1">homelab/docs github.com/lerko96/homelab-wip</p>
<p className="font-mono text-sm text-[var(--color-text)] mb-3 opacity-75">
VLAN maps, runbooks, service registry, config exports, and setup guides.
</p>
<a
href="https://github.com/lerko96/homelab-wip"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 font-mono text-xs px-4 py-2 border border-[var(--color-green-darker)] text-[var(--color-green)] rounded hover:bg-[var(--color-green-darkest)] transition-colors"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
View repo <i className="fas fa-arrow-up-right-from-square ml-1 text-xs" aria-hidden="true" />
github.com/lerko96/homelab-wip
</a>
</section>
</>

View File

@@ -1,17 +1,11 @@
import type { Metadata } from "next";
import { Montserrat, Source_Code_Pro } from "next/font/google";
import { Source_Code_Pro } from "next/font/google";
import "./globals.css";
import ThemeScript from "@/components/ThemeScript";
import Nav from "@/components/Nav";
import Footer from "@/components/Footer";
import { ThemeProvider } from "@/context/ThemeContext";
const montserrat = Montserrat({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const sourceCodePro = Source_Code_Pro({
subsets: ["latin"],
variable: "--font-mono",
@@ -19,7 +13,7 @@ const sourceCodePro = Source_Code_Pro({
});
export const metadata: Metadata = {
title: "Tyler Koenig | Portfolio",
title: "Tyler Koenig",
description:
"SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
};
@@ -33,22 +27,21 @@ export default function RootLayout({
<html lang="en" className="dark">
<head>
<ThemeScript />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
</head>
<body
className={`${montserrat.variable} ${sourceCodePro.variable} bg-[var(--color-bg-deep)] text-[var(--color-text)] font-sans min-h-screen`}
className={`${sourceCodePro.variable} bg-[var(--color-bg)] text-[var(--color-text)] font-mono min-h-screen`}
>
<ThemeProvider>
{/* Full-width sticky nav */}
<Nav />
<main className="max-w-5xl mx-auto px-6 py-16">
{children}
</main>
<Footer />
{/* Centered content column — border-l/r makes centering always visible */}
<div className="max-w-[740px] mx-auto border-l border-r border-[var(--color-border)]">
<main className="px-8 py-14">
{children}
</main>
<Footer />
</div>
</ThemeProvider>
</body>
</html>

View File

@@ -1,11 +1,13 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import Skills from "@/components/Skills";
import Timeline from "@/components/Timeline";
import ProjectCard from "@/components/ProjectCard";
import Widget from "@/components/Widget";
import { featuredProjects } from "@/data/projects";
export const metadata: Metadata = {
title: "Tyler Koenig | Portfolio",
title: "Tyler Koenig",
description:
"SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
};
@@ -14,19 +16,15 @@ export default function Home() {
return (
<>
<Hero />
<Widget title="tyler/projects" badge={featuredProjects.length}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{featuredProjects.map((project) => (
<ProjectCard key={project.slug} project={project} />
))}
</div>
</Widget>
<Skills />
<section aria-labelledby="projects-heading">
<h2
id="projects-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-10"
>
Projects
</h2>
{featuredProjects.map((project, i) => (
<ProjectCard key={project.slug} project={project} reversed={i % 2 !== 0} />
))}
</section>
<Timeline />
</>
);
}

View File

@@ -1,35 +1,28 @@
export default function Footer() {
return (
<footer className="border-t border-[var(--color-grey-1)] py-8 mt-16">
<div className="max-w-5xl mx-auto px-6 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="font-mono text-xs text-[var(--color-grey-2)] tracking-widest">
<footer className="border-t border-[var(--color-border)] py-5 mt-8">
<div className="px-8 flex items-center justify-between">
<span className="font-mono text-sm text-[var(--color-text-dim)]">
&copy; {new Date().getFullYear()} Tyler Koenig
</p>
</span>
<div className="flex items-center gap-5">
<a
href="https://github.com/lerko96"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
<i className="fab fa-github text-lg" aria-hidden="true" />
[github]
</a>
<a
href="https://www.linkedin.com/in/tyler-koenig"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
<i className="fab fa-linkedin text-lg" aria-hidden="true" />
</a>
<a
href="mailto:tylerkoenig96@gmail.com"
aria-label="Email"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors"
>
<i className="fas fa-envelope text-lg" aria-hidden="true" />
[linkedin]
</a>
</div>
</div>

View File

@@ -1,61 +1,53 @@
import Image from "next/image";
export default function Hero() {
return (
<section className="flex flex-col sm:flex-row items-center sm:items-start gap-8 mb-20">
<div className="shrink-0">
<Image
src="/images/headshot-tyler_koenig.png"
alt="Tyler Koenig"
width={120}
height={120}
className="rounded-full border-2 border-[var(--color-green-darker)]"
priority
/>
</div>
<div className="flex flex-col gap-4 text-center sm:text-left">
<section className="mb-16">
<div className="flex flex-col gap-3">
<div>
<h1 className="font-mono text-2xl font-bold text-[var(--color-text-light)] tracking-wide">
Tyler Koenig
</h1>
<p className="font-mono text-sm text-[var(--color-green)] tracking-widest uppercase mt-1">
SOC Helpdesk I by day, building beyond the title
<p className="font-mono text-base font-bold text-[var(--color-text)]">
<span className="text-[var(--color-accent-green)] select-none mr-2" aria-hidden="true"></span>
tyler koenig
</p>
<p className="font-mono text-sm text-[var(--color-text-label)] mt-0.5">
Security Operations · Self-Hosted Infrastructure
</p>
</div>
<p className="text-[var(--color-grey-3)] text-sm leading-relaxed max-w-lg">
I write software and run infrastructure that goes well past what my job
title implies. Games, AI tooling, mobile apps, and a homelab running
20+ self-hosted services on segmented VLANs. Continuously learning
by building things that actually work.
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed max-w-lg opacity-70">
Security operations and self-hosted infrastructure. Homelab runs 37
services across segmented VLANs pfSense, Authentik SSO, full
observability stack. Write software too: mobile apps, Go backends,
open protocols. Daily drivers, all of it.{' '}
<span className="animate-cursor text-[var(--color-accent-green)]" aria-hidden="true"></span>
</p>
<div className="flex items-center gap-5 justify-center sm:justify-start">
<div className="flex flex-wrap items-center gap-x-5 gap-y-1">
<span className="font-mono text-sm text-[var(--color-accent-green)]">
available
</span>
<a
href="https://github.com/lerko96"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
<i className="fab fa-github text-xl" aria-hidden="true" />
[github]
</a>
<a
href="https://www.linkedin.com/in/tyler-koenig"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
<i className="fab fa-linkedin text-xl" aria-hidden="true" />
[linkedin]
</a>
<a
href="mailto:tylerkoenig96@gmail.com"
aria-label="Email"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
<i className="fas fa-envelope text-xl" aria-hidden="true" />
[email]
</a>
</div>
</div>

View File

@@ -2,49 +2,57 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "@/context/ThemeContext";
const links = [
{ href: "/", label: "Home" },
{ href: "/homelab/", label: "Homelab" },
{ href: "/archive/", label: "Archive" },
{ href: "/", label: "tyler" },
{ href: "/homelab/", label: "homelab" },
{ href: "/archive/", label: "archive" },
];
export default function Nav() {
const pathname = usePathname();
const { isDark, toggle } = useTheme();
return (
<header className="sticky top-0 z-50 bg-[var(--color-bg-deep)] border-b border-[var(--color-grey-1)]">
<nav className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<header className="sticky top-0 z-50 bg-[var(--color-surface)] border-b border-[var(--color-border)]">
<nav className="max-w-[740px] mx-auto px-8 h-11 flex items-center justify-between">
<Link
href="/"
className="font-mono text-xl font-bold text-[var(--color-green)] tracking-widest hover:opacity-80 transition-opacity"
className="font-mono text-sm font-bold text-[var(--color-text)] hover:text-[var(--color-text-label)]"
>
tk
~/
</Link>
<div className="flex items-center gap-6">
<ul className="flex gap-6">
{links.map(({ href, label }) => {
const active = pathname === href || pathname === href.replace(/\/$/, "");
return (
<li key={href}>
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`text-xs font-mono tracking-widest uppercase transition-colors ${
active
? "text-[var(--color-green)]"
: "text-[var(--color-grey-3)] hover:text-[var(--color-grey-4)]"
}`}
>
{label}
</Link>
</li>
);
})}
</ul>
</div>
<ul className="flex items-center gap-6">
{links.map(({ href, label }) => {
const active =
pathname === href || pathname === href.replace(/\/$/, "");
return (
<li key={href}>
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`font-mono text-sm ${
active
? "text-[var(--color-text)]"
: "text-[var(--color-text-label)] hover:text-[var(--color-text)]"
}`}
>
{label}
</Link>
</li>
);
})}
<li>
<button
onClick={toggle}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)] cursor-pointer"
>
{isDark ? "[light]" : "[dark]"}
</button>
</li>
</ul>
</nav>
</header>
);

View File

@@ -2,67 +2,65 @@ import type { Project } from "@/data/projects";
type Props = {
project: Project;
reversed?: boolean;
};
export default function ProjectCard({ project, reversed = false }: Props) {
export default function ProjectCard({ project }: Props) {
return (
<article className={`group flex flex-col ${reversed ? "sm:flex-row-reverse" : "sm:flex-row"} gap-6 mb-16`}>
{/* Gradient image tile */}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 sm:w-56 h-36 rounded-lg overflow-hidden"
aria-label={`View ${project.title} on GitHub`}
tabIndex={-1}
>
<div
className={`w-full h-full bg-gradient-to-br ${project.gradient} flex items-center justify-center transition-transform duration-300 group-hover:scale-105`}
<article className="border border-[var(--color-border)] bg-[var(--color-surface)] flex flex-col gap-4 p-5 hover:bg-[var(--color-surface-raised)]">
<div className="flex items-start justify-between gap-3">
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm font-semibold text-[var(--color-text)] hover:text-[var(--color-accent-green)]"
>
<span className="font-mono text-xs text-[var(--color-green)] opacity-60 tracking-widest">
{project.slug}
</span>
</div>
</a>
{/* Content */}
<div className={`flex flex-col justify-center gap-3 ${reversed ? "sm:text-right sm:items-end" : ""}`}>
{/* Animated accent bar */}
<div
className={`h-0.5 w-8 bg-[var(--color-grey-1)] rounded-full transition-all duration-300 group-hover:w-16 group-hover:bg-[var(--color-green-darker)] ${reversed ? "self-end" : ""}`}
/>
<div>
{project.title}
</a>
<div className="flex items-center gap-3 shrink-0">
{project.stats && (
<span className="font-mono text-sm text-[var(--color-text-dim)]">
{project.stats}
</span>
)}
{project.externalUrl && (
<a
href={project.externalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`View ${project.title} externally`}
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
</a>
)}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-base font-semibold text-[var(--color-text-light)] hover:text-[var(--color-green)] transition-colors"
aria-label={`View ${project.title} on GitHub`}
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
{project.title}
</a>
{project.stats && (
<span className="font-mono text-xs text-[var(--color-green)] ml-3 opacity-70">
{project.stats}
</span>
)}
</div>
</div>
<p className="text-sm text-[var(--color-grey-3)] leading-relaxed max-w-md">
{project.description}
</p>
{project.statusBadge && (
<span className="font-mono text-xs text-[var(--color-accent-amber,#d4a027)] border border-[var(--color-accent-amber,#d4a027)] px-1.5 py-0.5 w-fit opacity-80">
{project.statusBadge}
</span>
)}
<div className={`flex flex-wrap gap-2 ${reversed ? "justify-end" : ""}`}>
{project.tags.map((tag) => (
<span
key={tag}
className="font-mono text-xs px-2 py-0.5 border border-[var(--color-grey-1)] text-[var(--color-grey-2)] rounded"
>
{tag}
</span>
))}
</div>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed flex-1 opacity-70">
{project.description}
</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1">
{project.tags.map((tag) => (
<span key={tag} className="font-mono text-xs text-[var(--color-text-dim)]">
{tag}
</span>
))}
</div>
</article>
);

View File

@@ -1,11 +1,13 @@
import Widget from "@/components/Widget";
const skillGroups = [
{
label: "Languages",
skills: ["JavaScript", "TypeScript", "HTML", "CSS"],
skills: ["Go", "JavaScript", "TypeScript", "HTML", "CSS"],
},
{
label: "Frontend & Mobile",
skills: ["React", "React Native", "Expo", "Next.js", "Three.js", "Responsive Design"],
label: "Frontend",
skills: ["React", "React Native", "Expo", "Next.js", "Three.js"],
},
{
label: "Desktop & Tools",
@@ -21,34 +23,26 @@ const skillGroups = [
},
];
const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
export default function Skills() {
return (
<section className="mb-20" aria-labelledby="skills-heading">
<h2
id="skills-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-8"
>
Skills
</h2>
<div className="flex flex-col gap-5">
<Widget title="tyler/skills" badge={totalCount} as="section">
<div className="flex flex-col">
{skillGroups.map(({ label, skills }) => (
<div key={label} className="flex flex-col xs:flex-row gap-2 xs:items-start">
<span className="font-mono text-xs text-[var(--color-grey-2)] w-36 shrink-0 pt-0.5">
<div
key={label}
className="flex flex-col xs:flex-row gap-1 xs:gap-6 py-3"
>
<span className="font-mono text-sm text-[var(--color-text-dim)] w-28 shrink-0">
{label}
</span>
<div className="flex flex-wrap gap-2">
{skills.map((skill) => (
<span
key={skill}
className="text-xs font-mono px-3 py-1 border border-[var(--color-grey-1)] text-[var(--color-grey-3)] rounded hover:border-[var(--color-green-darker)] hover:text-[var(--color-grey-4)] transition-colors"
>
{skill}
</span>
))}
</div>
<span className="font-mono text-sm text-[var(--color-text)]">
{skills.join(" · ")}
</span>
</div>
))}
</div>
</section>
</Widget>
);
}

107
src/components/Timeline.tsx Normal file
View File

@@ -0,0 +1,107 @@
'use client'
import { useEffect, useRef } from 'react'
import Widget from '@/components/Widget'
import { timeline, type TimelineType } from '@/data/timeline'
const typeColor: Record<TimelineType, string> = {
career: 'var(--color-timeline-career)',
education: 'var(--color-timeline-education)',
cert: 'var(--color-timeline-cert)',
project: 'var(--color-timeline-project)',
homelab: 'var(--color-timeline-homelab)',
}
const typeLabel: Record<TimelineType, string> = {
career: 'career',
education: 'education',
cert: 'cert',
project: 'project',
homelab: 'homelab',
}
export default function Timeline() {
const listRef = useRef<HTMLOListElement>(null)
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const entries = listRef.current?.querySelectorAll<HTMLLIElement>('[data-tl-entry]')
if (!entries) return
const observer = new IntersectionObserver(
(observed) => {
observed.forEach((entry) => {
if (entry.isIntersecting) {
;(entry.target as HTMLElement).style.opacity = '1'
;(entry.target as HTMLElement).style.transform = 'translateY(0)'
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.15 },
)
entries.forEach((el) => {
el.style.opacity = '0'
el.style.transform = 'translateY(8px)'
el.style.transition = 'opacity 240ms linear, transform 240ms linear'
observer.observe(el)
})
return () => observer.disconnect()
}, [])
return (
<Widget title="tyler/journey">
<ol ref={listRef} className="relative border-l border-[var(--color-border)] ml-1.5 flex flex-col gap-0">
{timeline.map((entry, i) => (
<li key={i} data-tl-entry className="pl-6 pb-8 last:pb-0 relative">
{/* Spine dot */}
<span
className="absolute -left-[7px] top-[3px] w-3 h-3 rounded-full border border-[var(--color-bg)] shrink-0"
style={{ backgroundColor: typeColor[entry.type] }}
aria-hidden="true"
/>
{/* Date + type badge */}
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-[var(--color-text-dim)]">{entry.date}</span>
<span
className="font-mono text-[10px] uppercase tracking-wider px-1 rounded-sm border"
style={{
color: typeColor[entry.type],
borderColor: typeColor[entry.type],
opacity: 0.7,
}}
>
{typeLabel[entry.type]}
</span>
</div>
{/* Title */}
<p className="font-mono text-sm font-semibold text-[var(--color-text)] mb-1">
{entry.title}
</p>
{/* Description */}
<p className="font-mono text-sm text-[var(--color-text)] opacity-70 leading-relaxed mb-2">
{entry.description}
</p>
{/* Tags */}
{entry.tags && entry.tags.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{entry.tags.map((tag) => (
<span key={tag} className="font-mono text-xs text-[var(--color-text-dim)]">
{tag}
</span>
))}
</div>
)}
</li>
))}
</ol>
</Widget>
)
}

39
src/components/Widget.tsx Normal file
View File

@@ -0,0 +1,39 @@
type WidgetProps = {
title: string;
badge?: string | number;
meta?: string;
as?: "section" | "div" | "article";
className?: string;
children: React.ReactNode;
};
export default function Widget({
title,
badge,
meta,
as: Tag = "section",
className,
children,
}: WidgetProps) {
const slashIdx = title.lastIndexOf("/");
const prefix = slashIdx >= 0 ? title.slice(0, slashIdx + 1) : null;
const name = slashIdx >= 0 ? title.slice(slashIdx + 1) : title;
return (
<Tag className={`mb-16 ${className ?? ""}`}>
<div className="flex items-center gap-2 mb-8">
{prefix && (
<span className="font-mono text-sm text-[var(--color-text-dim)] select-none">{prefix}</span>
)}
<span className="font-mono text-sm font-semibold text-[var(--color-text)]">{name}</span>
{badge !== undefined && (
<span className="font-mono text-xs text-[var(--color-text-dim)]">[{badge}]</span>
)}
{meta && (
<span className="font-mono text-xs text-[var(--color-text-dim)] ml-1"> {meta}</span>
)}
</div>
{children}
</Tag>
);
}

View File

@@ -4,9 +4,11 @@ export type Project = {
description: string;
tags: string[];
githubUrl: string;
gradient: string; // Tailwind gradient classes for placeholder image tile
tier: "featured" | "archive";
stats?: string;
year?: number;
statusBadge?: string;
externalUrl?: string;
};
export const projects: Project[] = [
@@ -18,9 +20,10 @@ export const projects: Project[] = [
"Offline-first mobile app for tracking golf rounds, managing your 14-club bag, and getting AI-powered club recommendations from a Smart Caddie. Covers 7 shot types per hole with full scorecard history.",
tags: ["React Native", "Expo", "Zustand", "AI", "Mobile"],
githubUrl: "https://github.com/lerko96/golf-book-mobile",
gradient: "from-[var(--color-green-darkest)] via-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "featured",
stats: "211 commits",
statusBadge: "Pending App Store Approval",
externalUrl: "#",
},
{
slug: "plaiground",
@@ -29,8 +32,8 @@ export const projects: Project[] = [
"Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.",
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
githubUrl: "https://github.com/lerko96/plaiground",
gradient: "from-[var(--color-green-darker)] via-[var(--color-surface)] to-[var(--color-bg-deep)]",
tier: "featured",
tier: "archive",
year: 2025,
},
{
slug: "service-monitor",
@@ -39,8 +42,8 @@ export const projects: Project[] = [
"Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.",
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
githubUrl: "https://github.com/lerko96/service-monitor",
gradient: "from-[var(--color-bg)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
tier: "featured",
tier: "archive",
year: 2025,
},
{
slug: "tht-1.2",
@@ -49,11 +52,40 @@ export const projects: Project[] = [
"3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.",
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
githubUrl: "https://github.com/lerko96/tht-1.2",
gradient: "from-[var(--color-surface)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
tier: "archive",
year: 2025,
},
{
slug: "open-pact",
title: "open-pact",
description:
"Open protocol for AI agent identity, delegation, and portable memory. Ed25519 keypair identity, signed delegation warrants, portable signed memory facts. No central registry.",
tags: ["TypeScript", "Ed25519", "DID", "npm", "CC0"],
githubUrl: "https://github.com/lerko96/open-pact",
tier: "featured",
},
{
slug: "helm",
title: "helm",
description:
"Full-stack personal productivity dashboard. Go backend with chi router and SQLite, React + TypeScript frontend. Notes, todos, calendar (CalDAV), clipboard, bookmarks, memos. Self-hosted, single-user, daily use.",
tags: ["Go", "React", "TypeScript", "SQLite", "CalDAV"],
githubUrl: "https://github.com/lerko96/helm",
tier: "featured",
},
// --- Archive ---
{
slug: "risk-ops",
title: "risk-ops",
description:
"Browser-based strategy dashboard for Risk: Global Domination (SMG Studio). Open one HTML file — no install needed.",
tags: ["HTML", "JavaScript"],
githubUrl: "#",
tier: "archive",
year: 2026,
},
{
slug: "twitter-thread-ext",
title: "twitter-thread-ext",
@@ -61,8 +93,8 @@ export const projects: Project[] = [
"Chrome extension (Manifest V3) that captures entire Twitter/X threads and exports them as HTML, Markdown, PDF, or image — with metadata preservation and preview before export.",
tags: ["Chrome Extension", "Manifest V3", "JavaScript", "jsPDF"],
githubUrl: "https://github.com/lerko96/twitter-thread-ext",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive",
year: 2023,
},
{
slug: "notes-app-1.0",
@@ -71,8 +103,8 @@ export const projects: Project[] = [
"Lightweight canvas drawing app with color picker, adjustable brush size, and PNG export. Runs in the browser, no dependencies.",
tags: ["HTML5 Canvas", "JavaScript", "CSS"],
githubUrl: "https://github.com/lerko96/notes-app-1.0",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive",
year: 2022,
},
{
slug: "were-hooked",
@@ -81,8 +113,8 @@ export const projects: Project[] = [
"Fishing location discovery app built as a team of 5 during bootcamp. Java/Spring MVC backend with Thymeleaf templates.",
tags: ["Java", "Spring", "Thymeleaf", "HTML", "CSS"],
githubUrl: "https://github.com/lerko96/were-hooked-repo",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive",
year: 2022,
},
{
slug: "mystery-educator",
@@ -91,8 +123,8 @@ export const projects: Project[] = [
"Single-page app mashup of the MET Museum and NASA public APIs. Built as a team of 4 during bootcamp.",
tags: ["JavaScript", "REST APIs", "HTML", "CSS"],
githubUrl: "https://github.com/lerko96/mystery-educator",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive",
year: 2022,
},
];

View File

@@ -7,10 +7,13 @@ export type Service = {
export const services: Service[] = [
// Infrastructure
{ name: "pfSense", description: "Firewall, DHCP, routing, WireGuard VPN", category: "infrastructure", icon: "fas fa-shield-halved" },
{ name: "pfSense", description: "Firewall, DHCP, routing gateway on N100", category: "infrastructure", icon: "fas fa-shield-halved" },
{ name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure", icon: "fas fa-globe" },
{ name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure", icon: "fas fa-filter" },
{ name: "WireGuard", description: "VPN — 600900 Mbps on N100, full LAN access for clients", category: "infrastructure", icon: "fas fa-lock" },
{ name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure", icon: "fas fa-lock" },
{ name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure", icon: "fas fa-envelope" },
{ name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure", icon: "fas fa-shield" },
{ name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure", icon: "fas fa-house" },
// Security / Auth
{ name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security", icon: "fas fa-id-badge" },
@@ -34,11 +37,21 @@ export const services: Service[] = [
{ name: "Traggo", description: "Time tracking", category: "productivity", icon: "fas fa-clock" },
{ name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity", icon: "fas fa-calendar" },
{ name: "Grist", description: "Spreadsheets and structured data", category: "productivity", icon: "fas fa-table" },
{ name: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity", icon: "fas fa-gauge" },
{ name: "Filebrowser", description: "Web-based file manager", category: "productivity", icon: "fas fa-folder-open" },
// Media
{ name: "Plex + Jellyfin", description: "Media streaming", category: "media", icon: "fas fa-film" },
{ name: "Sonarr / Radarr / Lidarr", description: "Automated media management", category: "media", icon: "fas fa-download" },
{ name: "Calibre-Web", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" },
{ name: "Plex", description: "Media streaming — movies, TV, music", category: "media", icon: "fas fa-film" },
{ name: "Jellyfin", description: "Open-source media streaming", category: "media", icon: "fas fa-play" },
{ name: "Sonarr", description: "Automated TV show management", category: "media", icon: "fas fa-tv" },
{ name: "Radarr", description: "Automated movie management", category: "media", icon: "fas fa-video" },
{ name: "Lidarr", description: "Automated music management", category: "media", icon: "fas fa-music" },
{ name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media", icon: "fas fa-magnifying-glass" },
{ name: "Bazarr", description: "Automatic subtitle download and management", category: "media", icon: "fas fa-closed-captioning" },
{ name: "nzbget", description: "Usenet downloader", category: "media", icon: "fas fa-download" },
{ name: "qBittorrent", description: "Torrent client with web UI", category: "media", icon: "fas fa-magnet" },
{ name: "Kavita", description: "Self-hosted manga and book reader", category: "media", icon: "fas fa-book-open" },
{ name: "Openshelf", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" },
];
export const categoryOrder: Service["category"][] = [

75
src/data/timeline.ts Normal file
View File

@@ -0,0 +1,75 @@
export type TimelineType = 'career' | 'cert' | 'project' | 'homelab' | 'education'
export interface TimelineEntry {
date: string
title: string
type: TimelineType
description: string
tags?: string[]
}
export const timeline: TimelineEntry[] = [
{
date: '2026',
title: 'CompTIA Network+ — in progress',
type: 'cert',
description: 'Studying for Network+ to formalize networking knowledge built through the homelab.',
tags: ['networking', 'certification'],
},
{
date: '2025',
title: 'Portfolio Site v2',
type: 'project',
description: 'Next.js 16 static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.',
tags: ['next.js', 'tailwind', 'self-hosted'],
},
{
date: '2024',
title: 'CompTIA A+',
type: 'cert',
description: 'Earned A+ certification, formalizing hardware and OS fundamentals.',
tags: ['certification'],
},
{
date: '2024',
title: 'Project Helm',
type: 'project',
description: 'Full-stack task and project management tool built in Go + React.',
tags: ['go', 'react', 'typescript'],
},
{
date: 'ongoing',
title: 'Homelab — Proxmox Cluster',
type: 'homelab',
description: '8-VLAN segmented network, Proxmox VMs/LXCs, SSO via Authentik, full monitoring stack (VictoriaMetrics + Grafana + Beszel + ntfy).',
tags: ['proxmox', 'networking', 'monitoring', 'sso'],
},
{
date: '2023-10',
title: 'SOC Analyst I — Fortress SRM',
type: 'career',
description: 'Threat monitoring, incident triage, and client-facing security operations in a managed SOC.',
tags: ['soc', 'security'],
},
{
date: '2023-03',
title: 'Config Tech II — MCPc',
type: 'career',
description: 'Promoted to Config Tech II. Led imaging workflows and expanded into scripting for endpoint provisioning.',
tags: ['sysadmin', 'scripting'],
},
{
date: '2022-07',
title: 'Config Tech I — MCPc',
type: 'career',
description: 'Hardware configuration, OS imaging, and deployment at scale for enterprise clients.',
tags: ['sysadmin', 'hardware'],
},
{
date: '2021',
title: 'We Can Code IT — Java Bootcamp',
type: 'education',
description: '9-month intensive bootcamp covering Java, OOP, SQL, REST APIs, and Agile development practices.',
tags: ['java', 'sql', 'agile'],
},
]