Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a10d82417a | |||
| e097bb8a8c | |||
| c9ad6ff181 | |||
| ea63347cd6 |
@@ -12,7 +12,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install SSH and rsync
|
- name: Install SSH and rsync
|
||||||
run: apk add --no-cache openssh-client rsync git
|
run: apk add --no-cache openssh-client rsync
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -38,33 +38,6 @@ jobs:
|
|||||||
ssh -i ~/.ssh/deploy_key root@10.99.0.23 \
|
ssh -i ~/.ssh/deploy_key root@10.99.0.23 \
|
||||||
"cd /opt/lerkolabs && \
|
"cd /opt/lerkolabs && \
|
||||||
docker build -t portfolio . && \
|
docker build -t portfolio . && \
|
||||||
docker stop portfolio 2>/dev/null || true && \
|
docker stop portfolio && \
|
||||||
docker rm portfolio 2>/dev/null || true && \
|
docker rm portfolio && \
|
||||||
docker run -d --name portfolio -p 80:80 --restart unless-stopped portfolio"
|
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
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,17 +1,33 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
# astro
|
# next.js
|
||||||
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
/.astro/
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
# docs
|
# claude code
|
||||||
/docs
|
.claude/
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,40 +1,37 @@
|
|||||||
# Tyler Koenig portfolio
|
# lerko96 portfolio
|
||||||
|
|
||||||
Personal portfolio site. Live at [lerkolabs.com](https://lerkolabs.com) — self-hosted.
|
Personal portfolio site. Live at [lerkolabs.com](https://lerkolabs.com) — self-hosted, deployed and maintained through my own operation.
|
||||||
|
|
||||||
Source: [gitea.lerkolabs.com/lerko/portfolio](https://gitea.lerkolabs.com/lerko/portfolio)
|
Source lives on my Gitea at [gitea.lerkolabs.com](https://gitea.lerkolabs.com). GitHub is a backup mirror, not the primary.
|
||||||
|
|
||||||
**Stack:** Astro 5 · TypeScript · Tailwind v4
|
**Stack:** Next.js 16 · React 19 · TypeScript · Tailwind v4
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Branches
|
## Branches
|
||||||
|
|
||||||
- `dev` — source code; pushing here updates lerkolabs.com
|
- `dev` — source code, all work happens here
|
||||||
- `master` — reserved for future GitHub mirror; don't touch manually
|
- `master` — built output only; what GitHub Pages serves for the backup mirror. don't touch this manually.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # dev server at localhost:4321
|
npm run dev # dev server at localhost:3000
|
||||||
npm run build # static export into out/
|
npm run build # static export into out/
|
||||||
npm run preview # preview production build
|
npm run deploy # build + push out/ to master (GitHub mirror)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deploy
|
## How it deploys
|
||||||
|
|
||||||
```bash
|
`npm run deploy` runs `predeploy` (build) then pushes the `out/` directory to `master` via `gh-pages`. That's what feeds the GitHub Pages backup at lerko96.com.
|
||||||
git checkout dev && git merge <branch> && git push gitea dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Push to `dev` triggers Gitea Actions (`.gitea/workflows/deploy.yml`):
|
`postbuild` drops `out/.nojekyll` so GitHub Pages doesn't ignore `_next/` assets.
|
||||||
1. Builds the static site (`npm run build`)
|
|
||||||
2. rsyncs `out/` to the portfolio LXC
|
Custom domain is in `public/CNAME` — gets copied into `out/` on build.
|
||||||
3. Rebuilds and restarts the Docker container serving lerkolabs.com
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,22 +39,20 @@ Push to `dev` triggers Gitea Actions (`.gitea/workflows/deploy.yml`):
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
layouts/
|
app/
|
||||||
Base.astro # root layout, fonts, theme script, nav/footer
|
layout.tsx # root layout, fonts, ThemeProvider
|
||||||
pages/
|
page.tsx # home: hero, skills, project cards
|
||||||
index.astro # home: hero, timeline
|
homelab/page.tsx # homelab page: VLANs, services, ADRs
|
||||||
projects.astro # featured + archive projects
|
archive/page.tsx # older projects grid
|
||||||
homelab.astro # VLANs, services, ADRs
|
globals.css # full design system (Tailwind v4 CSS-first, all tokens here)
|
||||||
archive.astro # redirect to /projects/
|
components/ # Nav, Footer, Hero, ThemeScript, etc.
|
||||||
components/ # Nav, Footer, Hero, Timeline, Widget, ProjectCard, Skills
|
context/
|
||||||
|
ThemeContext.tsx # dark mode provider + useTheme hook
|
||||||
data/
|
data/
|
||||||
projects.ts # all projects, featured + archive split
|
projects.ts # all projects, featured + archive split
|
||||||
services.ts # homelab services with categories
|
services.ts # homelab services with categories
|
||||||
timeline.ts # career/project timeline
|
|
||||||
styles/
|
|
||||||
globals.css # full design system (Tailwind v4 CSS-first, all tokens here)
|
|
||||||
public/
|
public/
|
||||||
fonts/ # self-hosted Source Code Pro woff2
|
CNAME # www.lerko96.com
|
||||||
```
|
```
|
||||||
|
|
||||||
> Tailwind v4 is CSS-first — no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`.
|
> Tailwind v4 is CSS-first — no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`.
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
output: 'static',
|
|
||||||
trailingSlash: 'always',
|
|
||||||
outDir: 'out',
|
|
||||||
build: {
|
|
||||||
format: 'directory',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# 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 2–3Gbps 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
|
||||||
|
|
||||||
|
|
||||||
|
\
|
||||||
|
 
|
||||||
|
|
||||||
|
|
||||||
|
\
|
||||||
|
> *This lab is actively maintained and evolving. Documentation lives alongside the infrastructure.*
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 ~800–900px) 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 48–64px. Entry gaps should be 24–32px. Tag/chip padding: 4px vertical, 10px horizontal. Comfortable line-height (1.5–1.6) throughout.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
trailingSlash: true,
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -4,15 +4,27 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://www.lerko96.com",
|
"homepage": "https://www.lerko96.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "next dev",
|
||||||
"build": "astro build",
|
"build": "next build",
|
||||||
"preview": "astro preview"
|
"postbuild": "node -e \"require('fs').writeFileSync('out/.nojekyll', '')\"",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"predeploy": "npm run build",
|
||||||
|
"deploy": "gh-pages -b master -d out -t"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5"
|
"next": "16.2.2",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"gh-pages": "^6.0.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1001 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { archiveProjects } from "@/data/projects";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Archive | Tyler Koenig",
|
||||||
|
description: "Earlier projects and experiments — browser extensions, canvas apps, and bootcamp work.",
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Colors */
|
||||||
|
--color-green: #2bf3c4;
|
||||||
|
--color-green-dark: #27bb98;
|
||||||
|
--color-green-darker: #238770;
|
||||||
|
--color-green-darkest: #1f4b40;
|
||||||
|
|
||||||
|
--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;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-mono: "Source Code Pro", ui-monospace, monospace;
|
||||||
|
--font-sans: "Montserrat", 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;
|
||||||
|
|
||||||
|
@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);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { services, categoryOrder, categoryLabels } from "@/data/services";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Homelab | Tyler Koenig",
|
||||||
|
description:
|
||||||
|
"Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services.",
|
||||||
|
};
|
||||||
|
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adrs = [
|
||||||
|
{
|
||||||
|
title: "AT&T Gateway: IP Passthrough over EAP bypass",
|
||||||
|
decision:
|
||||||
|
"BGW320 stays in-line with IP Passthrough mode. pfSense gets the public IP directly. Gateway WiFi disabled.",
|
||||||
|
why: "AT&T locks 802.1X auth to their gateway hardware. EAP proxy bypass is brittle — breaks on firmware updates and only saves 1–2ms. True bridge mode isn't supported.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Caddy over NGINX Proxy Manager",
|
||||||
|
decision:
|
||||||
|
"Caddy with DNS-01 challenge via Cloudflare API. All subdomains resolve to Caddy internally via Pi-hole. Caddy terminates SSL and proxies to backends.",
|
||||||
|
why: "Single Caddyfile, auto-cert without exposing port 80/443 to the internet. NPM has more UI overhead for the same outcome. Traefik is more complex for no benefit here.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "WireGuard over OpenVPN",
|
||||||
|
decision:
|
||||||
|
"WireGuard on pfSense, UDP 51820, VPN subnet 10.200.0.0/24. Clients get LAN + MGMT access, blocked from Guest/IoT/WFH.",
|
||||||
|
why: "Faster, simpler config, better battery life on mobile. ~600–900 Mbps on an N100. OpenVPN has no advantage here. Tailscale adds an external relay dependency.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pi-hole in Homelab VLAN, not MGMT",
|
||||||
|
decision:
|
||||||
|
"Pi-hole at 10.2.0.11 (VLAN 1020). Firewall allows port 53 inbound from all VLANs. MGMT uses pfSense Unbound as its primary DNS.",
|
||||||
|
why: "Putting Pi-hole in MGMT would require opening MGMT to all VLANs — a larger attack surface. DNS traffic crossing into Homelab VLAN is the lesser risk.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "N100 for pfSense",
|
||||||
|
decision:
|
||||||
|
"Intel N100 mini PC: 4-core 3.4 GHz, ~6W idle. Handles 2–3 Gbps routing and 600–900 Mbps WireGuard.",
|
||||||
|
why: "Right-sized for 1 Gbps fiber with headroom. Raspberry Pi can't handle 1 Gbps + VPN. A full rack server wastes power for this role.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Shared Postgres + Redis in apps LXC",
|
||||||
|
decision:
|
||||||
|
"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).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomelabPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-16">
|
||||||
|
<p className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2">
|
||||||
|
lerkolabs
|
||||||
|
</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>
|
||||||
|
</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 }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)]"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm 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>
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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">
|
||||||
|
{categoryLabels[cat]}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`${svc.icon} text-[var(--color-green)] text-sm mt-0.5 w-4 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed">
|
||||||
|
<span className="text-[var(--color-grey-2)]">Why: </span>{adr.why}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
View repo <i className="fas fa-arrow-up-right-from-square ml-1 text-xs" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Montserrat, 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",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Tyler Koenig | Portfolio",
|
||||||
|
description:
|
||||||
|
"SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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`}
|
||||||
|
>
|
||||||
|
<ThemeProvider>
|
||||||
|
<Nav />
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-16">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Hero from "@/components/Hero";
|
||||||
|
import Skills from "@/components/Skills";
|
||||||
|
import ProjectCard from "@/components/ProjectCard";
|
||||||
|
import { featuredProjects } from "@/data/projects";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Tyler Koenig | Portfolio",
|
||||||
|
description:
|
||||||
|
"SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
label: "studying",
|
|
||||||
title: "comptia network+",
|
|
||||||
meta: "ch. 7 · ipv6 addressing",
|
|
||||||
progress: 0.62,
|
|
||||||
progressText: "62%",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "shipping",
|
|
||||||
title: "portfolio site v2",
|
|
||||||
meta: "astro 5 · gitea actions ci · dmz lxc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "maintaining",
|
|
||||||
title: "32 services across 4 vlans",
|
|
||||||
meta: "last alert 11d ago · backups green",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
|
||||||
|
|
||||||
<section class="border-r border-[var(--color-border)]">
|
|
||||||
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
|
|
||||||
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">active</span>
|
|
||||||
<span class="text-[var(--color-text-label)] text-[11px]">updated 4m ago</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{items.map((it, i) => (
|
|
||||||
<div class:list={[
|
|
||||||
"px-5 py-4",
|
|
||||||
i < items.length - 1 && "border-b border-[var(--color-border)]",
|
|
||||||
]}>
|
|
||||||
<div class="flex items-baseline justify-between gap-2.5">
|
|
||||||
<span class="text-[var(--color-text-label)] text-[10px] tracking-[0.16em] uppercase">{it.label}</span>
|
|
||||||
{it.progress != null && (
|
|
||||||
<span class="text-[var(--color-text-fg)] text-[11px] tabular-nums">{it.progressText}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="text-[var(--color-text-heading)] text-[15px] mt-1">{it.title}</div>
|
|
||||||
<div class="text-[var(--color-text)] text-xs mt-0.5">{it.meta}</div>
|
|
||||||
{it.progress != null && (
|
|
||||||
<div class="mt-2.5 h-[3px] bg-[var(--color-border-bright)]">
|
|
||||||
<div class="h-full bg-[var(--color-accent)]" style={`width: ${it.progress * 100}%`} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
---
|
|
||||||
|
|
||||||
<footer class="flex flex-col sm:flex-row justify-between items-center gap-2 px-6 py-3.5 border-t border-[var(--color-border)] bg-[var(--color-surface-raised)] text-[var(--color-text-label)] text-xs">
|
|
||||||
<span>© {year} tyler koenig · self-hosted in a dmz lxc</span>
|
|
||||||
<div class="flex items-center gap-5">
|
|
||||||
<a
|
|
||||||
href="https://github.com/lerko96"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="hover:text-[var(--color-text-fg)]"
|
|
||||||
>
|
|
||||||
github
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://gitea.lerkolabs.com/lerko"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="hover:text-[var(--color-text-fg)]"
|
|
||||||
>
|
|
||||||
gitea
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.linkedin.com/in/tyler-koenig"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="hover:text-[var(--color-text-fg)]"
|
|
||||||
>
|
|
||||||
linkedin
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="mailto:tyler@lerkolabs.com"
|
|
||||||
class="hover:text-[var(--color-text-fg)]"
|
|
||||||
>
|
|
||||||
email
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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">
|
||||||
|
© {new Date().getFullYear()} Tyler Koenig
|
||||||
|
</p>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<i className="fab fa-github text-lg" aria-hidden="true" />
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
const identity = [
|
|
||||||
["role", "soc analyst i · fortress srm"],
|
|
||||||
["based", "cleveland, oh · est. 2021"],
|
|
||||||
["stack", "go · react · typescript · linux"],
|
|
||||||
["infra", "proxmox · pfsense · authentik · nginx"],
|
|
||||||
["observ", "victoriametrics · grafana · beszel · ntfy"],
|
|
||||||
["certs", "comptia a+ · network+ (in progress)"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const contact = [
|
|
||||||
{ key: "github", value: "lerko96", href: "https://github.com/lerko96", glyph: "↗" },
|
|
||||||
{ key: "gitea", value: "gitea.lerkolabs.com/lerko", href: "https://gitea.lerkolabs.com/lerko", glyph: "↗" },
|
|
||||||
{ key: "linkedin", value: "tyler-koenig", href: "https://www.linkedin.com/in/tyler-koenig", glyph: "↗" },
|
|
||||||
{ key: "email", value: "tyler@lerkolabs.com", href: "mailto:tyler@lerkolabs.com", glyph: "✉" },
|
|
||||||
];
|
|
||||||
---
|
|
||||||
|
|
||||||
<section class="border-b border-[var(--color-border)]">
|
|
||||||
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
|
|
||||||
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">identity</span>
|
|
||||||
<span class="text-[var(--color-text-label)] text-[11px]">~/identity.toml</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-6 py-6 grid grid-cols-1 md:grid-cols-[1.4fr_1fr] gap-9">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-[var(--color-text-heading)] text-[38px] leading-[1.05] tracking-tight font-semibold">
|
|
||||||
tyler koenig
|
|
||||||
</h1>
|
|
||||||
<p class="text-[var(--color-text)] text-sm mt-2 leading-relaxed max-w-[540px]">
|
|
||||||
security operations, self-hosted infrastructure, and the software that holds it together.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<dl class="mt-6 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-[13px] tabular-nums">
|
|
||||||
{identity.map(([k, v]) => (
|
|
||||||
<Fragment>
|
|
||||||
<dt class="text-[var(--color-text-label)] tracking-wide">{k}</dt>
|
|
||||||
<dd class="text-[var(--color-text-fg)]">{v}</dd>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside>
|
|
||||||
<div class="text-[var(--color-text-label)] text-[11px] tracking-[0.12em] mb-2.5">CONTACT</div>
|
|
||||||
<div class="grid gap-px">
|
|
||||||
{contact.map(({ key, value, href, glyph }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target={href.startsWith("mailto") ? undefined : "_blank"}
|
|
||||||
rel={href.startsWith("mailto") ? undefined : "noopener noreferrer"}
|
|
||||||
class="grid grid-cols-[90px_1fr_16px] gap-x-2.5 items-baseline py-[7px] border-b border-[var(--color-border)] text-[13px] no-underline hover:bg-[var(--color-surface)]"
|
|
||||||
>
|
|
||||||
<span class="text-[var(--color-text-label)] tracking-wide">{key}</span>
|
|
||||||
<span class="text-[var(--color-text-fg)] overflow-hidden text-ellipsis whitespace-nowrap">{value}</span>
|
|
||||||
<span class="text-[var(--color-accent)] text-right">{glyph}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-5 justify-center sm:justify-start">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<i className="fab fa-github text-xl" aria-hidden="true" />
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<i className="fab fa-linkedin text-xl" 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-xl" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
---
|
|
||||||
const vlans = [
|
|
||||||
{
|
|
||||||
id: "vlan10",
|
|
||||||
label: "management",
|
|
||||||
services: [
|
|
||||||
{ name: "pfsense", kind: "firewall" },
|
|
||||||
{ name: "authentik", kind: "sso" },
|
|
||||||
{ name: "proxmox", kind: "hypervisor" },
|
|
||||||
{ name: "pbs", kind: "backup" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "vlan20",
|
|
||||||
label: "public-facing",
|
|
||||||
services: [
|
|
||||||
{ name: "gitea", kind: "scm/ci" },
|
|
||||||
{ name: "caddy", kind: "proxy" },
|
|
||||||
{ name: "lerkolabs.com", kind: "web" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "vlan30",
|
|
||||||
label: "internal services",
|
|
||||||
services: [
|
|
||||||
{ name: "victoriametrics", kind: "tsdb" },
|
|
||||||
{ name: "grafana", kind: "dashboards" },
|
|
||||||
{ name: "beszel", kind: "host-mon" },
|
|
||||||
{ name: "ntfy", kind: "alerts" },
|
|
||||||
{ name: "jellyfin", kind: "media" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalSvc = vlans.reduce((n, v) => n + v.services.length, 0);
|
|
||||||
---
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
|
|
||||||
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">infrastructure</span>
|
|
||||||
<span class="text-[var(--color-text-label)] text-[11px]">{totalSvc} up · 0 failed</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{vlans.map((vlan, gi) => (
|
|
||||||
<div class:list={[gi < vlans.length - 1 && "border-b border-[var(--color-border)]"]}>
|
|
||||||
<div class="flex items-baseline gap-2.5 px-5 pt-2.5 pb-1 text-[var(--color-text-label)] text-[10px] tracking-[0.14em]">
|
|
||||||
<span class="text-[var(--color-text-fg)]">{vlan.id}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span class="uppercase">{vlan.label}</span>
|
|
||||||
<span class="ml-auto text-[var(--color-text-faint)]">{vlan.services.length} svc</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{vlan.services.map((s) => (
|
|
||||||
<div class="grid grid-cols-[12px_1fr_110px_60px] gap-x-2.5 px-5 py-1.5 text-[13px] items-baseline tabular-nums">
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-[var(--color-accent-green)] self-center" />
|
|
||||||
<span class="text-[var(--color-text-fg)]">{s.name}</span>
|
|
||||||
<span class="text-[var(--color-text-label)] text-[11px]">{s.kind}</span>
|
|
||||||
<span class="text-[var(--color-text-label)] text-[11px] text-right">up</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div class="h-2" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
---
|
|
||||||
const pathname = Astro.url.pathname;
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ href: "/", label: "tyler" },
|
|
||||||
{ href: "/homelab/", label: "homelab" },
|
|
||||||
{ href: "/projects/", label: "projects" },
|
|
||||||
];
|
|
||||||
---
|
|
||||||
|
|
||||||
<header class="sticky top-0 z-50 bg-[var(--color-surface-raised)] border-b border-[var(--color-border)]">
|
|
||||||
<nav class="max-w-[960px] mx-auto px-6 h-11 flex items-center justify-between text-xs tabular-nums">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<a href="/" class="text-[var(--color-text-fg)] tracking-wide hover:text-[var(--color-text-heading)]">
|
|
||||||
lerkolabs
|
|
||||||
</a>
|
|
||||||
<span class="text-[var(--color-text-faint)]">/</span>
|
|
||||||
<span class="text-[var(--color-text-label)]">tyler</span>
|
|
||||||
<span class="text-[var(--color-text-faint)]">/</span>
|
|
||||||
<span class="text-[var(--color-text-label)]">~</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-5">
|
|
||||||
<span class="hidden sm:inline-flex items-center gap-1.5">
|
|
||||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-[var(--color-accent-green)] status-pip" />
|
|
||||||
<span class="text-[var(--color-text-fg)]">available</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{links.map(({ href, label }) => {
|
|
||||||
const active = pathname === href || pathname === href.replace(/\/$/, "");
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
class:list={[
|
|
||||||
active
|
|
||||||
? "text-[var(--color-text-fg)]"
|
|
||||||
: "text-[var(--color-text-label)] hover:text-[var(--color-text-fg)]",
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
data-theme-toggle
|
|
||||||
aria-label="Switch to light mode"
|
|
||||||
class="text-[var(--color-text-fg)] hover:text-[var(--color-accent)] cursor-pointer"
|
|
||||||
>
|
|
||||||
light
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.status-pip {
|
|
||||||
box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent-green) 67%, transparent);
|
|
||||||
}
|
|
||||||
:root:not(.dark) .status-pip {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const btn = document.querySelector("[data-theme-toggle]") as HTMLButtonElement;
|
|
||||||
|
|
||||||
function update() {
|
|
||||||
const isDark = document.documentElement.classList.contains("dark");
|
|
||||||
btn.textContent = isDark ? "light" : "dark";
|
|
||||||
btn.setAttribute(
|
|
||||||
"aria-label",
|
|
||||||
isDark ? "Switch to light mode" : "Switch to dark mode",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
const next = !document.documentElement.classList.contains("dark");
|
|
||||||
document.documentElement.classList.toggle("dark", next);
|
|
||||||
localStorage.setItem("lerko96-dark-mode", String(next));
|
|
||||||
update();
|
|
||||||
});
|
|
||||||
|
|
||||||
update();
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/", label: "Home" },
|
||||||
|
{ href: "/homelab/", label: "Homelab" },
|
||||||
|
{ href: "/archive/", label: "Archive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Nav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
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">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="font-mono text-xl font-bold text-[var(--color-green)] tracking-widest hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
import type { Project } from "@/data/projects";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
project: Project;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { project } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<article class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex flex-col gap-3 p-5">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<a
|
|
||||||
href={project.githubUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm font-semibold text-[var(--color-text-fg)] hover:text-[var(--color-accent)]"
|
|
||||||
>
|
|
||||||
{project.title}
|
|
||||||
</a>
|
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
|
||||||
{project.stats && (
|
|
||||||
<span class="text-xs text-[var(--color-text-label)]">
|
|
||||||
{project.stats}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href={project.githubUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`View ${project.title} on GitHub`}
|
|
||||||
class="text-sm text-[var(--color-text-label)] hover:text-[var(--color-accent)]"
|
|
||||||
>
|
|
||||||
↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{project.statusBadge && (
|
|
||||||
<span class="text-xs text-[var(--color-accent)] border border-[var(--color-accent)] px-2 py-0.5 w-fit opacity-80">
|
|
||||||
{project.statusBadge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p class="text-sm text-[var(--color-text)] leading-relaxed flex-1">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
|
|
||||||
{project.tags.map((tag) => (
|
|
||||||
<span class="text-xs text-[var(--color-text-label)]">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Project } from "@/data/projects";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
project: Project;
|
||||||
|
reversed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectCard({ project, reversed = false }: 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`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{project.title}
|
||||||
|
</a>
|
||||||
|
{project.stats && (
|
||||||
|
<span className="font-mono text-xs text-[var(--color-green)] ml-3 opacity-70">
|
||||||
|
{project.stats}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--color-grey-3)] leading-relaxed max-w-md">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
import Widget from "./Widget.astro";
|
|
||||||
|
|
||||||
const skillGroups = [
|
|
||||||
{
|
|
||||||
label: "Infrastructure",
|
|
||||||
skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Desktop & Tools",
|
|
||||||
skills: ["Git", "Docker", "TDD", "Node.js", "REST APIs"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Practices",
|
|
||||||
skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Languages",
|
|
||||||
skills: ["Go", "JavaScript", "TypeScript", "HTML", "CSS"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Frontend",
|
|
||||||
skills: ["React", "React Native", "Expo", "Next.js", "Three.js"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
|
|
||||||
---
|
|
||||||
|
|
||||||
<Widget title="tyler/skills" badge={totalCount} as="section">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
{skillGroups.map(({ label, skills }) => (
|
|
||||||
<div class="flex flex-col xs:flex-row gap-1ch xs:gap-2ch py-half-lh">
|
|
||||||
<span class="font-mono text-sm text-[var(--color-text-dim)] w-28 shrink-0">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span class="font-mono text-sm text-[var(--color-text)]">
|
|
||||||
{skills.join(" · ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const skillGroups = [
|
||||||
|
{
|
||||||
|
label: "Languages",
|
||||||
|
skills: ["JavaScript", "TypeScript", "HTML", "CSS"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Frontend & Mobile",
|
||||||
|
skills: ["React", "React Native", "Expo", "Next.js", "Three.js", "Responsive Design"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Desktop & Tools",
|
||||||
|
skills: ["Electron", "Node.js", "REST APIs", "Git", "Docker", "TDD"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Infrastructure",
|
||||||
|
skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Practices",
|
||||||
|
skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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">
|
||||||
|
{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">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Server component — renders a blocking inline script that sets the dark class
|
||||||
|
// on <html> before React hydrates, preventing flash of wrong theme.
|
||||||
|
export default function ThemeScript() {
|
||||||
|
const script = `
|
||||||
|
(function() {
|
||||||
|
var stored = localStorage.getItem('lerko96-dark-mode');
|
||||||
|
var dark = stored === null ? true : stored === 'true';
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
return <script dangerouslySetInnerHTML={{ __html: script }} />;
|
||||||
|
}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
---
|
|
||||||
import { timeline } from "@/data/timeline";
|
|
||||||
|
|
||||||
const branchColor: Record<string, 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 hashes = [
|
|
||||||
"a14c2e1", "8f9b3d2", "7a02b41", "4c1f0aa", "3e9d8c0",
|
|
||||||
"2b6a51f", "9d4c12a", "6e0b8a3", "1c7a4ff", "d5b3e92",
|
|
||||||
"5a8f1ee", "e2b71ac", "f0a39db", "0000000",
|
|
||||||
];
|
|
||||||
---
|
|
||||||
|
|
||||||
<section class="border-t border-[var(--color-border)]">
|
|
||||||
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
|
|
||||||
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">journey</span>
|
|
||||||
<span class="text-[var(--color-text-label)] text-[11px]">{timeline.length} commits · 5 branches · git log --all</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop: tabular grid -->
|
|
||||||
<div class="hidden md:grid grid-cols-[92px_86px_92px_1fr] text-[13px] tabular-nums">
|
|
||||||
{["date", "commit", "scope", "message"].map((h) => (
|
|
||||||
<div class:list={[
|
|
||||||
"text-[var(--color-text-label)] text-[10px] tracking-[0.16em] uppercase py-3 px-3 border-b border-[var(--color-border)]",
|
|
||||||
h === "date" && "pl-6",
|
|
||||||
]}>
|
|
||||||
{h}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{timeline.map((entry, i) => {
|
|
||||||
const color = branchColor[entry.type] || "var(--color-text-fg)";
|
|
||||||
const hash = hashes[i] || "0000000";
|
|
||||||
const last = i === timeline.length - 1;
|
|
||||||
const borderClass = last ? "" : "border-b border-[var(--color-border)]";
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<div class:list={["py-3.5 px-3 pl-6", borderClass]}>
|
|
||||||
<span class="text-[var(--color-text-fg)]">{entry.date}</span>
|
|
||||||
</div>
|
|
||||||
<div class:list={["py-3.5 px-3", borderClass]}>
|
|
||||||
<span class="text-[var(--color-accent)]">{hash}</span>
|
|
||||||
</div>
|
|
||||||
<div class:list={["py-3.5 px-3", borderClass]}>
|
|
||||||
<span class="text-[11px] border-b border-dotted pb-px" style={`color: ${color}; border-color: ${color}`}>
|
|
||||||
{entry.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class:list={["py-3.5 px-3 pr-6", borderClass]}>
|
|
||||||
<span class="text-[var(--color-text-heading)]">{entry.title}</span>
|
|
||||||
<div class="text-[var(--color-text)] text-xs mt-0.5 leading-relaxed">{entry.description}</div>
|
|
||||||
{entry.tags && entry.tags.length > 0 && (
|
|
||||||
<div class="mt-1.5 flex gap-3.5 flex-wrap text-[var(--color-text-label)] text-[10px]">
|
|
||||||
{entry.tags.map((t) => (
|
|
||||||
<span>· {t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile: stacked cards -->
|
|
||||||
<div class="md:hidden">
|
|
||||||
{timeline.map((entry, i) => {
|
|
||||||
const color = branchColor[entry.type] || "var(--color-text-fg)";
|
|
||||||
const hash = hashes[i] || "0000000";
|
|
||||||
const last = i === timeline.length - 1;
|
|
||||||
return (
|
|
||||||
<div class:list={[
|
|
||||||
"px-5 py-4",
|
|
||||||
!last && "border-b border-[var(--color-border)]",
|
|
||||||
]}>
|
|
||||||
<div class="flex items-center gap-3 mb-1.5 text-xs">
|
|
||||||
<span class="text-[var(--color-text-fg)]">{entry.date}</span>
|
|
||||||
<span class="text-[var(--color-accent)]">{hash}</span>
|
|
||||||
<span class="border-b border-dotted pb-px text-[11px]" style={`color: ${color}; border-color: ${color}`}>
|
|
||||||
{entry.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-[var(--color-text-heading)] text-sm">{entry.title}</div>
|
|
||||||
<div class="text-[var(--color-text)] text-xs mt-0.5 leading-relaxed">{entry.description}</div>
|
|
||||||
{entry.tags && entry.tags.length > 0 && (
|
|
||||||
<div class="mt-1.5 flex gap-3 flex-wrap text-[var(--color-text-label)] text-[10px]">
|
|
||||||
{entry.tags.map((t) => (
|
|
||||||
<span>· {t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
badge?: string | number;
|
|
||||||
meta?: string;
|
|
||||||
as?: "section" | "div" | "article";
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { title, badge, meta, as: Tag = "section", class: className } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<Tag class:list={["mb-0", className]}>
|
|
||||||
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
|
|
||||||
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">{title}</span>
|
|
||||||
<span class="text-[var(--color-text-label)] text-[11px]">
|
|
||||||
{badge !== undefined && <span>[{badge}]</span>}
|
|
||||||
{meta && <span>{meta}</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<slot />
|
|
||||||
</Tag>
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type ThemeContextType = {
|
||||||
|
isDark: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({
|
||||||
|
isDark: true,
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [isDark, setIsDark] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("lerko96-dark-mode");
|
||||||
|
const dark = stored === null ? true : stored === "true";
|
||||||
|
setIsDark(dark);
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const next = !isDark;
|
||||||
|
setIsDark(next);
|
||||||
|
localStorage.setItem("lerko96-dark-mode", String(next));
|
||||||
|
document.documentElement.classList.toggle("dark", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ isDark, toggle }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
@@ -4,76 +4,13 @@ export type Project = {
|
|||||||
description: string;
|
description: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
githubUrl: string;
|
githubUrl: string;
|
||||||
|
gradient: string; // Tailwind gradient classes for placeholder image tile
|
||||||
tier: "featured" | "archive";
|
tier: "featured" | "archive";
|
||||||
stats?: string;
|
stats?: string;
|
||||||
year?: number;
|
|
||||||
statusBadge?: string;
|
|
||||||
externalUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const projects: Project[] = [
|
export const projects: Project[] = [
|
||||||
// --- Featured ---
|
// --- Featured ---
|
||||||
{
|
|
||||||
slug: "homelab",
|
|
||||||
title: "homelab",
|
|
||||||
description:
|
|
||||||
"7-VLAN segmented network, Wireguard VPN, Proxmox VMs/LXCs, SSO via Authentik, full monitoring stack (VictoriaMetrics + Grafana + Beszel + ntfy).",
|
|
||||||
tags: ["Markdown", "Mermaid", "Proxmox", "Monitor", "Backup"],
|
|
||||||
githubUrl: "https://gitea.lerkolabs.com/lerko/homelab",
|
|
||||||
tier: "featured",
|
|
||||||
year: 2026,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "portfolio",
|
|
||||||
title: "portfolio",
|
|
||||||
description:
|
|
||||||
"Astro static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
|
|
||||||
tags: ["Astro", "Dockerfile", "Tailwind", "nginx", "Caddy"],
|
|
||||||
githubUrl: "https://gitea.lerkolabs.com/lerko/portfolio",
|
|
||||||
tier: "featured",
|
|
||||||
year: 2021,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "nib",
|
|
||||||
title: "nib",
|
|
||||||
description:
|
|
||||||
"Capture-first personal journal built with Go + React + SQLite. Currently developing in private when I have spare time.",
|
|
||||||
tags: ["Go", "React", "SQLite", "Journal", "Stream-of-Thought"],
|
|
||||||
githubUrl: "https://github.com/lerko96/nib",
|
|
||||||
tier: "featured",
|
|
||||||
year: 2026,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
year: 2026,
|
|
||||||
},
|
|
||||||
// --- Archive ---
|
|
||||||
{
|
|
||||||
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: "archive",
|
|
||||||
year: 2026,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: "golf-book-mobile",
|
slug: "golf-book-mobile",
|
||||||
title: "golf-book-mobile",
|
title: "golf-book-mobile",
|
||||||
@@ -81,30 +18,9 @@ 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.",
|
"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"],
|
tags: ["React Native", "Expo", "Zustand", "AI", "Mobile"],
|
||||||
githubUrl: "https://github.com/lerko96/golf-book-mobile",
|
githubUrl: "https://github.com/lerko96/golf-book-mobile",
|
||||||
tier: "archive",
|
gradient: "from-[var(--color-green-darkest)] via-[var(--color-bg)] to-[var(--color-bg-deep)]",
|
||||||
stats: "200+ commits",
|
tier: "featured",
|
||||||
statusBadge: "Pending App Store Approval",
|
stats: "211 commits",
|
||||||
year: 2025,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "twitter-thread-ext",
|
|
||||||
title: "twitter-thread-ext",
|
|
||||||
description:
|
|
||||||
"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",
|
|
||||||
tier: "archive",
|
|
||||||
year: 2025,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "notes-app-1.0",
|
|
||||||
title: "notes-app-1.0",
|
|
||||||
description:
|
|
||||||
"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",
|
|
||||||
tier: "archive",
|
|
||||||
year: 2025,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "plaiground",
|
slug: "plaiground",
|
||||||
@@ -113,8 +29,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.",
|
"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"],
|
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
|
||||||
githubUrl: "https://github.com/lerko96/plaiground",
|
githubUrl: "https://github.com/lerko96/plaiground",
|
||||||
tier: "archive",
|
gradient: "from-[var(--color-green-darker)] via-[var(--color-surface)] to-[var(--color-bg-deep)]",
|
||||||
year: 2025,
|
tier: "featured",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "service-monitor",
|
slug: "service-monitor",
|
||||||
@@ -123,8 +39,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.",
|
"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"],
|
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
|
||||||
githubUrl: "https://github.com/lerko96/service-monitor",
|
githubUrl: "https://github.com/lerko96/service-monitor",
|
||||||
tier: "archive",
|
gradient: "from-[var(--color-bg)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
|
||||||
year: 2025,
|
tier: "featured",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "tht-1.2",
|
slug: "tht-1.2",
|
||||||
@@ -133,8 +49,30 @@ 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.",
|
"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"],
|
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
|
||||||
githubUrl: "https://github.com/lerko96/tht-1.2",
|
githubUrl: "https://github.com/lerko96/tht-1.2",
|
||||||
|
gradient: "from-[var(--color-surface)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
|
||||||
|
tier: "featured",
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Archive ---
|
||||||
|
{
|
||||||
|
slug: "twitter-thread-ext",
|
||||||
|
title: "twitter-thread-ext",
|
||||||
|
description:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "notes-app-1.0",
|
||||||
|
title: "notes-app-1.0",
|
||||||
|
description:
|
||||||
|
"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",
|
tier: "archive",
|
||||||
year: 2025,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "were-hooked",
|
slug: "were-hooked",
|
||||||
@@ -143,8 +81,8 @@ export const projects: Project[] = [
|
|||||||
"Fishing location discovery app built as a team of 5 during bootcamp. Java/Spring MVC backend with Thymeleaf templates.",
|
"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"],
|
tags: ["Java", "Spring", "Thymeleaf", "HTML", "CSS"],
|
||||||
githubUrl: "https://github.com/lerko96/were-hooked-repo",
|
githubUrl: "https://github.com/lerko96/were-hooked-repo",
|
||||||
|
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
|
||||||
tier: "archive",
|
tier: "archive",
|
||||||
year: 2021,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "mystery-educator",
|
slug: "mystery-educator",
|
||||||
@@ -153,8 +91,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.",
|
"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"],
|
tags: ["JavaScript", "REST APIs", "HTML", "CSS"],
|
||||||
githubUrl: "https://github.com/lerko96/mystery-educator",
|
githubUrl: "https://github.com/lerko96/mystery-educator",
|
||||||
|
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
|
||||||
tier: "archive",
|
tier: "archive",
|
||||||
year: 2021,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,55 +2,43 @@ export type Service = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: "infrastructure" | "security" | "monitoring" | "productivity" | "media";
|
category: "infrastructure" | "security" | "monitoring" | "productivity" | "media";
|
||||||
|
icon: string; // Font Awesome class
|
||||||
};
|
};
|
||||||
|
|
||||||
export const services: Service[] = [
|
export const services: Service[] = [
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
{ name: "pfSense", description: "Firewall, DHCP, routing gateway on Netgate 1100", category: "infrastructure" },
|
{ name: "pfSense", description: "Firewall, DHCP, routing, WireGuard VPN", category: "infrastructure", icon: "fas fa-shield-halved" },
|
||||||
{ name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure" },
|
{ 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" },
|
{ name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure", icon: "fas fa-filter" },
|
||||||
{ name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure" },
|
{ name: "WireGuard", description: "VPN — 600–900 Mbps on N100, full LAN access for clients", category: "infrastructure", icon: "fas fa-lock" },
|
||||||
{ name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure" },
|
|
||||||
{ name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure" },
|
|
||||||
{ name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure" },
|
|
||||||
|
|
||||||
// Security / Auth
|
// Security / Auth
|
||||||
{ name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security" },
|
{ name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security", icon: "fas fa-id-badge" },
|
||||||
{ name: "Vaultwarden", description: "Self-hosted password manager, isolated in its own LXC", category: "security" },
|
{ name: "Vaultwarden", description: "Self-hosted password manager, isolated in its own LXC", category: "security", icon: "fas fa-vault" },
|
||||||
|
|
||||||
// Monitoring
|
// Monitoring
|
||||||
{ name: "Victoria Metrics", description: "Long-term metrics storage and querying", category: "monitoring" },
|
{ name: "Victoria Metrics", description: "Long-term metrics storage and querying", category: "monitoring", icon: "fas fa-chart-line" },
|
||||||
{ name: "Grafana", description: "Dashboards and alerting across all hosts and services", category: "monitoring" },
|
{ name: "Grafana", description: "Dashboards and alerting across all hosts and services", category: "monitoring", icon: "fas fa-chart-bar" },
|
||||||
{ name: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring" },
|
{ name: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring", icon: "fas fa-server" },
|
||||||
{ name: "ntfy", description: "Self-hosted push notifications", category: "monitoring" },
|
{ name: "ntfy", description: "Self-hosted push notifications", category: "monitoring", icon: "fas fa-bell" },
|
||||||
|
|
||||||
// Productivity
|
// Productivity
|
||||||
{ name: "Gitea", description: "Personal Git server", category: "productivity" },
|
{ name: "Gitea", description: "Personal Git server", category: "productivity", icon: "fas fa-code-branch" },
|
||||||
{ name: "Outline", description: "Team wiki and knowledge base", category: "productivity" },
|
{ name: "Outline", description: "Team wiki and knowledge base", category: "productivity", icon: "fas fa-book" },
|
||||||
{ name: "Vikunja", description: "Task management", category: "productivity" },
|
{ name: "Vikunja", description: "Task management", category: "productivity", icon: "fas fa-list-check" },
|
||||||
{ name: "Actual Budget", description: "Personal budgeting", category: "productivity" },
|
{ name: "Actual Budget", description: "Personal budgeting", category: "productivity", icon: "fas fa-wallet" },
|
||||||
{ name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity" },
|
{ name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity", icon: "fas fa-coins" },
|
||||||
{ name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity" },
|
{ name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity", icon: "fas fa-bookmark" },
|
||||||
{ name: "FreshRSS", description: "RSS reader", category: "productivity" },
|
{ name: "FreshRSS", description: "RSS reader", category: "productivity", icon: "fas fa-rss" },
|
||||||
{ name: "Memos", description: "Quick notes and journal", category: "productivity" },
|
{ name: "Memos", description: "Quick notes and journal", category: "productivity", icon: "fas fa-note-sticky" },
|
||||||
{ name: "Traggo", description: "Time tracking", category: "productivity" },
|
{ name: "Traggo", description: "Time tracking", category: "productivity", icon: "fas fa-clock" },
|
||||||
{ name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity" },
|
{ name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity", icon: "fas fa-calendar" },
|
||||||
{ name: "Grist", description: "Spreadsheets and structured data", category: "productivity" },
|
{ 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" },
|
|
||||||
{ name: "Filebrowser", description: "Web-based file manager", category: "productivity" },
|
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
{ name: "Plex", description: "Media streaming — movies, TV, music", category: "media" },
|
{ name: "Plex + Jellyfin", description: "Media streaming", category: "media", icon: "fas fa-film" },
|
||||||
{ name: "Jellyfin", description: "Open-source media streaming", category: "media" },
|
{ name: "Sonarr / Radarr / Lidarr", description: "Automated media management", category: "media", icon: "fas fa-download" },
|
||||||
{ name: "Sonarr", description: "Automated TV show management", category: "media" },
|
{ name: "Calibre-Web", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" },
|
||||||
{ name: "Radarr", description: "Automated movie management", category: "media" },
|
|
||||||
{ name: "Lidarr", description: "Automated music management", category: "media" },
|
|
||||||
{ name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media" },
|
|
||||||
{ name: "Bazarr", description: "Automatic subtitle download and management", category: "media" },
|
|
||||||
{ name: "nzbget", description: "Usenet downloader", category: "media" },
|
|
||||||
{ name: "qBittorrent", description: "Torrent client with web UI", category: "media" },
|
|
||||||
{ name: "Kavita", description: "Self-hosted manga and book reader", category: "media" },
|
|
||||||
{ name: "Openshelf", description: "Book library with auto-ingest", category: "media" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const categoryOrder: Service["category"][] = [
|
export const categoryOrder: Service["category"][] = [
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
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: "WIP",
|
|
||||||
title: "CompTIA Network+ — in progress",
|
|
||||||
type: "cert",
|
|
||||||
description:
|
|
||||||
"Studying for Network+ to formalize networking knowledge built through the homelab.",
|
|
||||||
tags: ["networking", "certification"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2026-04",
|
|
||||||
title: "Portfolio Site v2",
|
|
||||||
type: "project",
|
|
||||||
description:
|
|
||||||
"Astro portfolio site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
|
|
||||||
tags: ["astro", "tailwind", "self-hosted"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2026-04",
|
|
||||||
title: "lerkolabs.com",
|
|
||||||
type: "homelab",
|
|
||||||
description:
|
|
||||||
"Self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
|
|
||||||
tags: ["LXC", "DMZ", "self-hosted"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2026-03",
|
|
||||||
title: "Helm",
|
|
||||||
type: "project",
|
|
||||||
description:
|
|
||||||
"Full-stack task and project management tool built in Go + React.",
|
|
||||||
tags: ["go", "react", "typescript"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2025",
|
|
||||||
title: "Proxmox Backup Server",
|
|
||||||
type: "homelab",
|
|
||||||
description: "Deployed PBS on used desktop hardware for disaster recovery.",
|
|
||||||
tags: ["backup", "recovery", "retention"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2025",
|
|
||||||
title: "Proxmox Cluster",
|
|
||||||
type: "homelab",
|
|
||||||
description:
|
|
||||||
"Proxmox installed on dedicated server and the fun begins. VMs/LXCs, SSO via Authentik, full monitoring stack (VictoriaMetrics + Grafana + Beszel + ntfy).",
|
|
||||||
tags: ["proxmox", "containers", "VMs", "linux"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2024-06",
|
|
||||||
title: "CompTIA A+",
|
|
||||||
type: "cert",
|
|
||||||
description:
|
|
||||||
"Earned A+ certification, formalizing hardware and OS fundamentals.",
|
|
||||||
tags: ["certification"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2024-03",
|
|
||||||
title: "pfSense",
|
|
||||||
type: "homelab",
|
|
||||||
description:
|
|
||||||
"Netgate 1100 (Marvell ARMADA 3720) picked up on eBay — hands-on networking configuration, VLANs, firewall rules, and troubleshooting.",
|
|
||||||
tags: ["network", "firewall", "vlan", "dhcp"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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-11",
|
|
||||||
title: "PC Build",
|
|
||||||
type: "homelab",
|
|
||||||
description: "Sourced parts online and built a personal computer.",
|
|
||||||
tags: ["amd", "windows 10", "configure", "desktop"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2022-05",
|
|
||||||
title: "Config Tech I — MCPc",
|
|
||||||
type: "career",
|
|
||||||
description:
|
|
||||||
"Hardware configuration, OS imaging, and deployment at scale for enterprise clients.",
|
|
||||||
tags: ["sysadmin", "hardware"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2021-10",
|
|
||||||
title: "Portfolio Site v1",
|
|
||||||
type: "project",
|
|
||||||
description:
|
|
||||||
"React portfolio deployed to www.lerko96.github.io using github pages.",
|
|
||||||
tags: ["React", "CSS", "github pages"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "2021-01",
|
|
||||||
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"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
---
|
|
||||||
interface Props {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
title = "Tyler Koenig",
|
|
||||||
description = "SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
|
|
||||||
} = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<title>{title}</title>
|
|
||||||
<meta name="description" content={description} />
|
|
||||||
<script is:inline>
|
|
||||||
(function () {
|
|
||||||
var stored = localStorage.getItem("lerko96-dark-mode");
|
|
||||||
var dark = stored === null ? true : stored === "true";
|
|
||||||
if (dark) document.documentElement.classList.add("dark");
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body
|
|
||||||
class="bg-[var(--color-bg)] text-[var(--color-text)] font-mono min-h-screen"
|
|
||||||
>
|
|
||||||
<slot name="nav" />
|
|
||||||
<div class="max-w-[960px] mx-auto">
|
|
||||||
<main>
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
<slot name="footer" />
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
<style is:global>
|
|
||||||
@import "../styles/globals.css";
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Source Code Pro";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SourceCodePro-latin-ext.woff2") format("woff2");
|
|
||||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
|
|
||||||
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
|
|
||||||
U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
|
|
||||||
U+A720-A7FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Source Code Pro";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SourceCodePro-latin.woff2") format("woff2");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
|
|
||||||
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
|
|
||||||
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta http-equiv="refresh" content="0; url=/projects/" />
|
|
||||||
<link rel="canonical" href="/projects/" />
|
|
||||||
<title>Redirecting...</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>This page moved. <a href="/projects/">/projects/</a></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from "@/layouts/Base.astro";
|
|
||||||
import Nav from "@/components/Nav.astro";
|
|
||||||
import Footer from "@/components/Footer.astro";
|
|
||||||
import Widget from "@/components/Widget.astro";
|
|
||||||
import { services, categoryOrder, categoryLabels } from "@/data/services";
|
|
||||||
|
|
||||||
const glanceStats = [
|
|
||||||
{ label: "Hypervisor", value: "Proxmox VE" },
|
|
||||||
{ label: "Firewall", value: "pfSense (Netgate 1100)" },
|
|
||||||
{ 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: "MGMT", name: "MGMT", purpose: "Network equipment only" },
|
|
||||||
{ id: "LAN", name: "LAN", purpose: "Trusted personal devices" },
|
|
||||||
{ id: "Lab", name: "Homelab", purpose: "All self-hosted services" },
|
|
||||||
{ id: "Guest", name: "Guests", purpose: "Internet only, RFC1918 blocked" },
|
|
||||||
{ id: "IoT", name: "IoT", purpose: "Smart home, isolated" },
|
|
||||||
{ id: "WFH", name: "WFH", purpose: "Work devices, no personal access" },
|
|
||||||
{ id: "DMZ", name: "DMZ", purpose: "Public-facing, hard-blocked internally" },
|
|
||||||
{ id: "VPN", name: "VPN", purpose: "WireGuard clients, LAN-equivalent access" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const adrs = [
|
|
||||||
{
|
|
||||||
title: "ISP gateway: passthrough mode",
|
|
||||||
decision:
|
|
||||||
"ISP gateway stays in-line in passthrough mode, pfSense gets the public IP directly. Gateway WiFi disabled.",
|
|
||||||
why: "Carrier locks 802.1X auth to their own gateway hardware, and bypassing it is brittle — breaks on firmware updates and only saves a millisecond or two. True bridge mode isn't supported. Passthrough is the cleanest option that keeps pfSense as the actual perimeter.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Caddy over NGINX Proxy Manager",
|
|
||||||
decision:
|
|
||||||
"Caddy with DNS-01 challenge via Cloudflare API. All subdomains resolve to Caddy internally via Pi-hole. Caddy terminates TLS and proxies to backends.",
|
|
||||||
why: "Single Caddyfile, automatic certs without ever needing to expose internal services to the internet for an HTTP-01 challenge. NPM has more UI overhead for the same outcome. Traefik is more complex for no benefit at this scale.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "WireGuard over OpenVPN",
|
|
||||||
decision:
|
|
||||||
"WireGuard on pfSense as the only remote-access path. Clients get the access tier documented in the access model — same as LAN, plus the admin surfaces that aren't reachable any other way.",
|
|
||||||
why: "Faster, simpler config, better battery life on mobile. Throughput on the firewall hardware comfortably exceeds the WAN link. OpenVPN has no advantage here. Tailscale would add an external relay dependency for a problem WireGuard already solves.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Pi-hole in Homelab VLAN, not MGMT",
|
|
||||||
decision:
|
|
||||||
"Pi-hole runs in the Homelab VLAN. Firewall allows port 53 inbound from VLANs that need local resolution. MGMT uses pfSense Unbound as its primary resolver instead.",
|
|
||||||
why: "Putting Pi-hole in MGMT would mean opening MGMT to all the VLANs that need DNS — much bigger attack surface for the most sensitive tier. DNS traffic crossing into the Homelab VLAN is the lesser risk, and Homelab is already where service traffic terminates anyway.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Netgate 1100 for pfSense",
|
|
||||||
decision:
|
|
||||||
"Netgate 1100 (Marvell ARMADA 3720, dual-core ARM) as the firewall appliance. ~6W idle, line-rate NAT at 1 Gbps, WireGuard at ~100–150 Mbps.",
|
|
||||||
why: "Purpose-built for pfSense. Right-sized for 1 Gbps fiber — NAT saturates the link, WireGuard is fast enough for remote access. A full rack server wastes power for this role. Configs and version tracked in private repo.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Shared Postgres + Redis in apps LXC",
|
|
||||||
decision:
|
|
||||||
"One Postgres instance hosting multiple databases. One Redis instance. A single init script provisions schemas on first run.",
|
|
||||||
why: "Avoids ~15 separate DB containers. Big RAM savings. Productivity apps colocate in one LXC anyway, so a shared backing store there is the natural shape.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Gitea CI/CD: self-hosted runner, internal pipeline, static deploy",
|
|
||||||
decision:
|
|
||||||
"Self-hosted Gitea Actions runner builds the portfolio on push, then deploys pre-built static files to the public-facing host. Build runs in an isolated container so the runner host stays clean. Public host serves static files only — no build toolchain on it.",
|
|
||||||
why: "Keeps the whole pipeline internal. No external runners, no GitHub Actions. The build/serve split means the public-facing host has the smallest possible footprint — static file server, nothing more.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
|
||||||
|
|
||||||
<Base
|
|
||||||
title="Homelab | Tyler Koenig"
|
|
||||||
description="Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services."
|
|
||||||
>
|
|
||||||
<Nav slot="nav" />
|
|
||||||
|
|
||||||
<div class="px-6 py-6 border-b border-[var(--color-border)]">
|
|
||||||
<h1 class="text-[var(--color-text-heading)] text-lg font-semibold mb-2">homelab</h1>
|
|
||||||
<p class="text-[var(--color-text)] 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Widget title="homelab/overview" badge={glanceStats.length} as="section">
|
|
||||||
<div class="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
|
|
||||||
{glanceStats.map(({ label, value }) => (
|
|
||||||
<div class="bg-[var(--color-surface)] px-5 py-3">
|
|
||||||
<p class="text-sm text-[var(--color-text-label)] mb-1">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-[var(--color-text-fg)]">
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
|
|
||||||
<Widget
|
|
||||||
title="homelab/network"
|
|
||||||
meta="8 network segments · default deny"
|
|
||||||
as="section"
|
|
||||||
>
|
|
||||||
<div class="overflow-x-auto px-5">
|
|
||||||
<table class="w-full text-sm border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-[var(--color-border)]">
|
|
||||||
<th class="text-[var(--color-text-label)] text-left py-2 pr-8 text-[10px] tracking-[0.16em] uppercase">
|
|
||||||
Segment
|
|
||||||
</th>
|
|
||||||
<th class="text-[var(--color-text-label)] text-left py-2 pr-8 text-[10px] tracking-[0.16em] uppercase">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th class="text-[var(--color-text-label)] text-left py-2 text-[10px] tracking-[0.16em] uppercase">
|
|
||||||
Purpose
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{vlans.map((v) => (
|
|
||||||
<tr class="border-b border-[var(--color-border)] hover:bg-[var(--color-surface)]">
|
|
||||||
<td class="text-[var(--color-accent)] py-2.5 pr-8 text-sm">
|
|
||||||
{v.id}
|
|
||||||
</td>
|
|
||||||
<td class="text-[var(--color-text-fg)] py-2.5 pr-8 text-sm">
|
|
||||||
{v.name}
|
|
||||||
</td>
|
|
||||||
<td class="text-[var(--color-text)] py-2.5 text-sm">
|
|
||||||
{v.purpose}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
|
|
||||||
<Widget title="homelab/services" badge={services.length} as="section">
|
|
||||||
<div class="flex flex-col gap-6 px-5 py-4">
|
|
||||||
{categoryOrder.map((cat) => {
|
|
||||||
const catServices = services.filter((s) => s.category === cat);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p class="text-[var(--color-text-label)] text-[10px] tracking-[0.14em] uppercase mb-2 px-1">
|
|
||||||
{categoryLabels[cat]}
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
|
|
||||||
{catServices.map((svc) => (
|
|
||||||
<div class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-5 py-3">
|
|
||||||
<p class="text-sm text-[var(--color-text-fg)] mb-0.5">
|
|
||||||
{svc.name}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-[var(--color-text)] leading-relaxed">
|
|
||||||
{svc.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
|
|
||||||
<Widget
|
|
||||||
title="homelab/ADRs"
|
|
||||||
meta="why things are configured the way they are"
|
|
||||||
badge={adrs.length}
|
|
||||||
as="section"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-px bg-[var(--color-border)] mx-5 my-4">
|
|
||||||
{adrs.map((adr) => (
|
|
||||||
<div class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-5 py-4">
|
|
||||||
<p class="text-sm text-[var(--color-text-fg)] mb-3">
|
|
||||||
{adr.title}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-[var(--color-text)] leading-relaxed mb-2">
|
|
||||||
<span class="text-[var(--color-text-label)]">
|
|
||||||
decision:{" "}
|
|
||||||
</span>
|
|
||||||
{adr.decision}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-[var(--color-text)] leading-relaxed">
|
|
||||||
<span class="text-[var(--color-text-label)]">
|
|
||||||
why:{" "}
|
|
||||||
</span>
|
|
||||||
{adr.why}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
|
|
||||||
<section class="px-5 py-6">
|
|
||||||
<p class="text-[var(--color-text-label)] text-[10px] tracking-[0.14em] uppercase mb-2">
|
|
||||||
homelab/docs
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-[var(--color-text)] mb-3">
|
|
||||||
VLAN maps, runbooks, service registry, config exports, and setup guides.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://gitea.lerkolabs.com/lerko/homelab"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-sm text-[var(--color-text-label)] hover:text-[var(--color-accent)]"
|
|
||||||
>
|
|
||||||
<span class="text-[var(--color-accent)]">↗</span> gitea.lerkolabs.com/lerko/homelab
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer slot="footer" />
|
|
||||||
</Base>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from "@/layouts/Base.astro";
|
|
||||||
import Nav from "@/components/Nav.astro";
|
|
||||||
import Footer from "@/components/Footer.astro";
|
|
||||||
import Hero from "@/components/Hero.astro";
|
|
||||||
import Active from "@/components/Active.astro";
|
|
||||||
import HomeServices from "@/components/HomeServices.astro";
|
|
||||||
import Timeline from "@/components/Timeline.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Base>
|
|
||||||
<Nav slot="nav" />
|
|
||||||
<Hero />
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-[1fr_1.2fr] border-b border-[var(--color-border)]">
|
|
||||||
<Active />
|
|
||||||
<HomeServices />
|
|
||||||
</div>
|
|
||||||
<Timeline />
|
|
||||||
<Footer slot="footer" />
|
|
||||||
</Base>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from "@/layouts/Base.astro";
|
|
||||||
import Nav from "@/components/Nav.astro";
|
|
||||||
import Footer from "@/components/Footer.astro";
|
|
||||||
import Widget from "@/components/Widget.astro";
|
|
||||||
import ProjectCard from "@/components/ProjectCard.astro";
|
|
||||||
import { featuredProjects, archiveProjects } from "@/data/projects";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Base
|
|
||||||
title="Projects | Tyler Koenig"
|
|
||||||
description="Featured projects and earlier work — homelab, open-pact, helm, and bootcamp/experiment archive."
|
|
||||||
>
|
|
||||||
<Nav slot="nav" />
|
|
||||||
|
|
||||||
<div class="px-6 py-6 border-b border-[var(--color-border)]">
|
|
||||||
<h1 class="text-[var(--color-text-heading)] text-lg font-semibold mb-2">projects</h1>
|
|
||||||
<p class="text-[var(--color-text)] text-sm leading-relaxed max-w-xl">
|
|
||||||
Featured work first. Earlier experiments, browser extensions, and bootcamp projects below — kept for context.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Widget title="projects/featured" badge={featuredProjects.length} as="section">
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-px bg-[var(--color-border)] m-5">
|
|
||||||
{featuredProjects.map((project) => (
|
|
||||||
<ProjectCard project={project} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
|
|
||||||
<Widget title="projects/archive" badge={archiveProjects.length} as="section">
|
|
||||||
<div class="flex flex-col gap-px bg-[var(--color-border)] m-5">
|
|
||||||
{archiveProjects.map((project) => (
|
|
||||||
<a
|
|
||||||
href={project.githubUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start justify-between gap-5 px-5 py-4 group"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2 flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
{project.year && (
|
|
||||||
<span class="text-sm text-[var(--color-text-label)] shrink-0">
|
|
||||||
{project.year}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span class="text-sm text-[var(--color-text-fg)] group-hover:text-[var(--color-accent)] truncate">
|
|
||||||
{project.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-[var(--color-text)] leading-relaxed">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5">
|
|
||||||
{project.tags.map((tag) => (
|
|
||||||
<span class="text-xs text-[var(--color-text-label)]">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-sm text-[var(--color-text-label)] group-hover:text-[var(--color-accent)] shrink-0 mt-0.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
↗
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
|
|
||||||
<Footer slot="footer" />
|
|
||||||
</Base>
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@variant dark (&:where(.dark, .dark *));
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
/* Console Dark (default) */
|
|
||||||
--color-bg: #0a0c0f;
|
|
||||||
--color-surface: #0e1116;
|
|
||||||
--color-surface-raised: #11151b;
|
|
||||||
--color-border: #1c2128;
|
|
||||||
--color-border-bright: #2a3038;
|
|
||||||
--color-text: #c1bdb3;
|
|
||||||
--color-text-fg: #e8e4d8;
|
|
||||||
--color-text-heading: #ffffff;
|
|
||||||
--color-text-label: #6a675f;
|
|
||||||
--color-text-dim: #6a675f;
|
|
||||||
--color-text-faint: #3a3830;
|
|
||||||
--color-accent: #d4a259;
|
|
||||||
--color-accent-green: #7b9e7b;
|
|
||||||
--color-accent-red: #c74028;
|
|
||||||
|
|
||||||
/* Timeline branch colors — dark */
|
|
||||||
--color-timeline-career: #b390a8;
|
|
||||||
--color-timeline-education: #999999;
|
|
||||||
--color-timeline-cert: #cba76c;
|
|
||||||
--color-timeline-project: #8ba8c0;
|
|
||||||
--color-timeline-homelab: #9ab494;
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font-mono: "Source Code Pro", ui-monospace, monospace;
|
|
||||||
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
|
||||||
|
|
||||||
/* Breakpoints */
|
|
||||||
--breakpoint-xs: 576px;
|
|
||||||
|
|
||||||
/* Character-grid spacing — horizontal (ch) */
|
|
||||||
--spacing-1ch: 1ch;
|
|
||||||
--spacing-2ch: 2ch;
|
|
||||||
--spacing-3ch: 3ch;
|
|
||||||
--spacing-4ch: 4ch;
|
|
||||||
|
|
||||||
/* Character-grid spacing — vertical (lh, requires line-height:1.5 on html) */
|
|
||||||
--spacing-qtr-lh: 0.25lh;
|
|
||||||
--spacing-half-lh: 0.5lh;
|
|
||||||
--spacing-1lh: 1lh;
|
|
||||||
--spacing-2lh: 2lh;
|
|
||||||
--spacing-3lh: 3lh;
|
|
||||||
--spacing-4lh: 4lh;
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
--animate-fade-in: fadeIn 120ms linear forwards;
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base */
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.5;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-variant-ligatures: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Console Light overrides */
|
|
||||||
:root:not(.dark) {
|
|
||||||
--color-bg: #f3f0e8;
|
|
||||||
--color-surface: #faf6ec;
|
|
||||||
--color-surface-raised: #f6f2e7;
|
|
||||||
--color-border: #d8d2c2;
|
|
||||||
--color-border-bright: #b8b2a2;
|
|
||||||
--color-text: #3a3c40;
|
|
||||||
--color-text-fg: #1c1e22;
|
|
||||||
--color-text-heading: #000000;
|
|
||||||
--color-text-label: #8a857a;
|
|
||||||
--color-text-dim: #8a857a;
|
|
||||||
--color-text-faint: #c0bba8;
|
|
||||||
--color-accent: #b1631c;
|
|
||||||
--color-accent-green: #3d7048;
|
|
||||||
--color-accent-red: #d21f07;
|
|
||||||
|
|
||||||
--color-timeline-career: #6e3d7a;
|
|
||||||
--color-timeline-education: #595959;
|
|
||||||
--color-timeline-cert: #9c6620;
|
|
||||||
--color-timeline-project: #355d8a;
|
|
||||||
--color-timeline-homelab: #3d7048;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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) {
|
|
||||||
*, *::before, *::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,34 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"target": "ES2017",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||