24 Commits

Author SHA1 Message Date
tyler d34f9f136c 'feat(site): /projects consolidation, homelab copy pass, theme fix' (#6)
Build and Deploy / deploy (push) Successful in 1m0s
Reviewed-on: #6
2026-04-27 06:18:24 +00:00
lerko96 22660bed7a fix(theme): unblock light mode in dev
Hardcoded class="dark" on <html> meant React owned it via JSX. HMR
re-renders and reconciliation kept restoring the class after
classList.toggle removed it, so light toggle never stuck.

ThemeScript already handles initial paint; suppressHydrationWarning
covers the post-script class mismatch.
2026-04-27 00:49:57 -04:00
lerko96 7f614d28b5 feat(projects): consolidate /projects, hide skills, redirect /archive
- /projects: merged page with featured (top) + archive (bottom)
- titles mirror homelab pattern: projects/featured, projects/archive
- nav: archive → projects
- home: drop Skills section and featured grid
- /archive → /projects via meta-refresh + JS redirect stub
2026-04-27 00:49:52 -04:00
lerko96 e9d7a994c7 chore(content): refresh projects + timeline
- featured: swap helm → nib, drop claude-vault, reorder
- helm → archive
- homelab description: 7 VLANs + Wireguard VPN (matches access-tier framing)
- timeline: add Proxmox Backup Server 2025; pfSense entry corrected to Netgate 1100
2026-04-27 00:49:47 -04:00
lerko96 f6118aa7a4 refactor(homelab): rename VLAN col to Segment for VPN row
VPN is an L3 tunnel, not an 802.1Q VLAN. Reframes the table as
network segments / access tiers so the VPN row is consistent with
the others.
2026-04-27 00:49:42 -04:00
lerko96 5dea6121a3 docs(homelab): trim operational detail from network table and ADRs
Pure copy edit. Page now publishes the reasoning behind decisions, not
the operational specifics (IPs, subnets, ports, hardware fingerprints,
build pipeline mechanics). Reasoning preserved in every ADR.

- VLAN table: drop Subnet column; replace numeric VLAN IDs with tier names
- ISP gateway ADR: drop carrier and gateway model
- Caddy ADR: tighten DNS-01 framing to internal-services exposure; SSL → TLS
- WireGuard ADR: drop port, VPN subnet, throughput numbers, tier enumeration
- Pi-hole ADR: drop host IP and VLAN ID; sharpen trade-off
- N100 ADR: drop core/clock and precise throughput; rename to "Mini-PC"
- Postgres+Redis ADR: drop apps LXC IP
- Gitea CI/CD ADR: drop runner version, build image, host IPs, deploy mechanics
- Authentik ADR: unchanged
2026-04-26 23:19:16 -04:00
tyler 4e51dd4a83 feat/polish (#5)
Build and Deploy / deploy (push) Successful in 51s
Reorder homepage sections (journey above projects), refine component styles, update copy and data across projects, timeline, and homelab.

Reviewed-on: #5
2026-04-20 00:34:50 +00:00
tyler 8e9fcfaeeb Merge pull request 'feat(content): reposition for security engineer, expand services' (#4) from feat/timeline into dev
Build and Deploy / deploy (push) Successful in 1m11s
Reviewed-on: #4
2026-04-17 01:24:22 +00:00
lerko96 2946801517 ci: auto-tag CalVer after deploy if commit untagged 2026-04-16 21:16:01 -04:00
lerko96 49028e7783 feat(content): reposition for security engineer, expand services to 37
Hero subtitle and blurb rewritten to lead with security operations
and homelab credentials over generic "builder" framing.

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

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

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

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

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

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

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

Data: remove gradient field from Project type; add optional year field
2026-04-12 19:23:50 -04:00
20 changed files with 1017 additions and 473 deletions
+27
View File
@@ -41,3 +41,30 @@ jobs:
docker stop portfolio 2>/dev/null || true && \ docker stop portfolio 2>/dev/null || true && \
docker rm portfolio 2>/dev/null || true && \ docker rm portfolio 2>/dev/null || true && \
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
+44
View File
@@ -0,0 +1,44 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- dev
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Build
run: npm ci && npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: out/
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
-4
View File
@@ -29,9 +29,5 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# claude code
CLAUDE.md
.claude/
# docs # docs
/docs /docs
+14 -13
View File
@@ -1,8 +1,8 @@
# lerko96 portfolio # Tyler Koenig portfolio
Personal portfolio site. Live at [lerkolabs.com](https://lerkolabs.com) — self-hosted, deployed and maintained through my own operation. Personal portfolio site. Live at [lerkolabs.com](https://lerkolabs.com) — self-hosted.
Source lives on my Gitea at [gitea.lerkolabs.com](https://gitea.lerkolabs.com). GitHub is a backup mirror, not the primary. Source: [gitea.lerkolabs.com/lerko/portfolio](https://gitea.lerkolabs.com/lerko/portfolio)
**Stack:** Next.js 16 · React 19 · TypeScript · Tailwind v4 **Stack:** Next.js 16 · React 19 · TypeScript · Tailwind v4
@@ -10,8 +10,8 @@ Source lives on my Gitea at [gitea.lerkolabs.com](https://gitea.lerkolabs.com).
## Branches ## Branches
- `dev` — source code, all work happens here - `dev` — source code; pushing here updates lerkolabs.com
- `master`built output only; what GitHub Pages serves for the backup mirror. don't touch this manually. - `master`reserved for future GitHub mirror; don't touch manually
--- ---
@@ -20,18 +20,20 @@ Source lives on my Gitea at [gitea.lerkolabs.com](https://gitea.lerkolabs.com).
```bash ```bash
npm run dev # dev server at localhost:3000 npm run dev # dev server at localhost:3000
npm run build # static export into out/ npm run build # static export into out/
npm run deploy # build + push out/ to master (GitHub mirror)
``` ```
--- ---
## How it deploys ## Deploy
`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. ```bash
git checkout dev && git merge <branch> && git push gitea dev
```
`postbuild` drops `out/.nojekyll` so GitHub Pages doesn't ignore `_next/` assets. Push to `dev` triggers Gitea Actions (`.gitea/workflows/deploy.yml`):
1. Builds the static site (`npm run build`)
Custom domain is in `public/CNAME` — gets copied into `out/` on build. 2. rsyncs `out/` to the portfolio LXC
3. Rebuilds and restarts the Docker container serving lerkolabs.com
--- ---
@@ -51,8 +53,7 @@ src/
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
public/ public/ # static assets copied into out/ on build
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 {}`.
+14 -49
View File
@@ -1,59 +1,24 @@
import type { Metadata } from "next"; "use client";
import { archiveProjects } from "@/data/projects";
export const metadata: Metadata = { import { useEffect } from "react";
title: "Archive | Tyler Koenig",
description: "Earlier projects and experiments — browser extensions, canvas apps, and bootcamp work.", export default function ArchiveRedirect() {
}; useEffect(() => {
window.location.replace("/projects/");
}, []);
export default function ArchivePage() {
return ( return (
<> <>
<div className="mb-14"> <meta httpEquiv="refresh" content="0; url=/projects/" />
<p className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2"> <p className="font-mono text-sm text-[var(--color-text)]">
Archive This page moved.{" "}
</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 <a
key={project.slug} href="/projects/"
href={project.githubUrl} className="text-[var(--color-accent-green)] underline"
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"> /projects/
<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> </a>
))} </p>
</div>
</> </>
); );
} }
+69 -33
View File
@@ -3,66 +3,102 @@
@variant dark (&:where(.dark, .dark *)); @variant dark (&:where(.dark, .dark *));
@theme { @theme {
/* Colors */ /* macOS Classic Dark (default) */
--color-green: #2bf3c4; --color-bg: #131313;
--color-green-dark: #27bb98; --color-surface: #1e1d1e;
--color-green-darker: #238770; --color-surface-raised: #272727;
--color-green-darkest: #1f4b40; --color-border: #3a3a3a;
--color-border-bright: #404040;
--color-text: #caccca;
--color-text-label: #9e9e9e;
--color-text-dim: #8f8f8f;
--color-accent-green: #62ba46;
--color-accent-red: #c74028;
--color-bg: #272727; /* Timeline type colors — dark */
--color-bg-deep: #1b1b1b; --color-timeline-career: #62ba46;
--color-surface: #333333; --color-timeline-education: #c28b12;
--color-timeline-cert: #c75828;
--color-grey-1: #4b4b4b; --color-timeline-project: #c72855;
--color-grey-2: #707171; --color-timeline-homelab: #e1d797;
--color-grey-3: #999a9a;
--color-grey-4: #c5c6c6;
--color-text: #c5c6c6;
--color-text-muted: #999a9a;
--color-text-light: #f7f9fb;
/* Typography */ /* Typography */
--font-mono: "Source Code Pro", ui-monospace, monospace; --font-mono: "Source Code Pro", ui-monospace, monospace;
--font-sans: "Montserrat", ui-sans-serif, system-ui, sans-serif; --font-sans: ui-sans-serif, system-ui, sans-serif;
/* Breakpoints */ /* Breakpoints */
--breakpoint-xs: 576px; --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 */ /* Animations */
--animate-fade-in: fadeIn 0.6s ease forwards; --animate-fade-in: fadeIn 120ms linear forwards;
--animate-slide-up: slideUp 0.5s ease forwards;
--animate-app-scale: appScale 0.4s ease forwards;
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } 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 */ /* Base */
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
background-color: var(--color-bg-deep); font-size: 15px;
line-height: 1.5;
background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-family: var(--font-sans); font-family: var(--font-mono);
} }
@keyframes blink { 50% { opacity: 0; } }
.animate-cursor { animation: blink 1s step-start infinite; }
@layer base {
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
}
/* macOS Classic Light overrides */
:root:not(.dark) {
--color-bg: #ffffff;
--color-surface: #f9f9f9;
--color-surface-raised: #f7f7f7;
--color-border: #e0e0e0;
--color-border-bright: #d2d2d2;
--color-text: #000000;
--color-text-label: #505050;
--color-text-dim: #929292;
--color-accent-green: #036a07;
--color-accent-red: #d21f07;
--color-timeline-career: #036a07;
--color-timeline-education: #0433ff;
--color-timeline-cert: #957931;
--color-timeline-project: #6f42c1;
--color-timeline-homelab: #0000a2;
}
/* Default transitions — linear, fast */
a,
button {
transition: color 120ms linear, border-color 120ms linear,
background-color 120ms linear, opacity 120ms linear;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *, *::before, *::after {
+205 -154
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Widget from "@/components/Widget";
import { services, categoryOrder, categoryLabels } from "@/data/services"; import { services, categoryOrder, categoryLabels } from "@/data/services";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -7,90 +8,7 @@ export const metadata: Metadata = {
"Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services.", "Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services.",
}; };
const vlans = [ const glanceStats = [
{ 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 12ms. 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. ~600900 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 23 Gbps routing and 600900 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).",
},
{
title: "Gitea CI/CD: Self-hosted runner with container build + SSH rsync deploy",
decision:
"act_runner v0.3.1 on Gitea LXC (10.99.0.22). Push to dev → node:22-alpine container builds Next.js → rsync out/ to Portfolio LXC → SSH docker rebuild. Runner WorkingDirectory=/opt/docker/gitea. Feature branches for daily work; merge to dev to deploy.",
why: "Keeps the full pipeline internal — no GitHub Actions, no external runners. Build runs in an isolated Alpine container so the Gitea LXC isn't polluted. Portfolio LXC (10.99.0.23) just serves pre-built static files via nginx. Runner must be registered with the LXC IP (10.99.0.22:3000), not localhost — containers can't resolve localhost to the host. The .runner file must live in WorkingDirectory or the daemon crashes on start.",
},
];
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: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Intel N100)" }, { label: "Firewall", value: "pfSense (Intel N100)" },
{ label: "Switching", value: "TP-Link Omada (managed)" }, { label: "Switching", value: "TP-Link Omada (managed)" },
@@ -100,82 +18,210 @@ export default function HomelabPage() {
{ label: "Auth", value: "Authentik SSO" }, { label: "Auth", value: "Authentik SSO" },
{ label: "DNS", value: "Pi-hole → Unbound → Cloudflare" }, { label: "DNS", value: "Pi-hole → Unbound → Cloudflare" },
{ label: "Containers", value: "9 LXC + 2 VMs" }, { label: "Containers", value: "9 LXC + 2 VMs" },
].map(({ label, value }) => ( ];
<div
key={label} const vlans = [
className="border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)]" {
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: "Mini-PC for pfSense",
decision:
"Intel N100 mini-PC as the firewall host. ~6W idle, handles multi-Gbps routing, saturates the WAN link with WireGuard headroom to spare.",
why: "Right-sized for 1 Gbps fiber. A Raspberry Pi can't handle 1 Gbps plus VPN. A full rack server wastes power for this role and adds noise to a room I sit in.",
},
{
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.",
},
];
export default function HomelabPage() {
return (
<>
{/* Header */}
<div className="mb-4lh">
<p className="font-mono text-sm font-bold text-[var(--color-text)] mb-1lh">
<span
className="text-[var(--color-accent-green)] select-none mr-1ch"
aria-hidden="true"
> >
<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> </span>
homelab
</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed max-w-2xl opacity-80">
Personal infrastructure environment for learning, self-hosting, and
operational practice. Running 24/7 on production-grade hardware with
real network segmentation, SSO, monitoring, and IaC-style
documentation.
</p>
</div>
{/* At a Glance */}
<Widget title="homelab/overview" badge={glanceStats.length} as="section">
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
{glanceStats.map(({ label, value }) => (
<div key={label} className="bg-[var(--color-surface)] px-2ch py-half-lh">
<p className="font-mono text-sm text-[var(--color-text-dim)] mb-half-lh">
{label}
</p>
<p className="font-mono text-sm text-[var(--color-text)]">
{value}
</p>
</div> </div>
))} ))}
</div> </div>
</section> </Widget>
{/* VLAN table */} {/* VLAN table */}
<section className="mb-16" aria-labelledby="network-heading"> <Widget
<h2 title="homelab/network"
id="network-heading" meta="8 network segments · default deny"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6" as="section"
> >
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"> <div className="overflow-x-auto">
<table className="w-full text-sm border-collapse"> <table className="w-full text-sm border-collapse">
<thead> <thead>
<tr className="border-b border-[var(--color-grey-1)]"> <tr className="border-b border-[var(--color-border)]">
<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-[var(--color-text-dim)] text-left py-qtr-lh pr-[3ch] uppercase">
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">Name</th> Segment
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">Subnet</th> </th>
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 uppercase tracking-wider">Purpose</th> <th className="font-mono text-[var(--color-text-dim)] text-left py-qtr-lh pr-[3ch] uppercase">
Name
</th>
<th className="font-mono text-[var(--color-text-dim)] text-left py-qtr-lh uppercase">
Purpose
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{vlans.map((v) => ( {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"> <tr
<td className="font-mono text-xs text-[var(--color-green)] py-2.5 pr-4">{v.id}</td> key={v.id}
<td className="font-mono text-sm text-[var(--color-text-light)] py-2.5 pr-4">{v.name}</td> className="border-b border-[var(--color-border)] hover:bg-[var(--color-surface)]"
<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> <td className="font-mono text-[var(--color-accent-green)] py-half-lh pr-[3ch]">
{v.id}
</td>
<td className="font-mono text-[var(--color-text)] py-half-lh pr-[3ch]">
{v.name}
</td>
<td className="font-mono text-sm text-[var(--color-text)] py-2.5 opacity-80">
{v.purpose}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </Widget>
{/* Services */} {/* Services */}
<section className="mb-16" aria-labelledby="services-heading"> <Widget title="homelab/services" badge={services.length} as="section">
<h2 <div className="flex flex-col gap-3ch">
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) => { {categoryOrder.map((cat) => {
const catServices = services.filter((s) => s.category === cat); const catServices = services.filter((s) => s.category === cat);
return ( return (
<div key={cat}> <div key={cat}>
<h3 className="font-mono text-xs text-[var(--color-grey-2)] uppercase tracking-wider mb-3"> <p className="font-mono text-sm text-[var(--color-text-dim)] mb-1lh">
{categoryLabels[cat]} {categoryLabels[cat]}
</h3> </p>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-3"> <div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
{catServices.map((svc) => ( {catServices.map((svc) => (
<div <div
key={svc.name} key={svc.name}
className="flex items-start gap-3 border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors" className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start gap-1ch px-2ch py-half-lh"
> >
<i <i
className={`${svc.icon} text-[var(--color-green)] text-sm mt-0.5 w-4 shrink-0`} className={`${svc.icon} text-[var(--color-text-label)] text-xs mt-0.5 w-3.5 shrink-0`}
aria-hidden="true" aria-hidden="true"
/> />
<div> <div>
<p className="font-mono text-xs text-[var(--color-text-light)] mb-0.5">{svc.name}</p> <p className="font-mono text-sm text-[var(--color-text)] mb-0.5">
<p className="text-xs text-[var(--color-grey-2)] leading-relaxed">{svc.description}</p> {svc.name}
</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
{svc.description}
</p>
</div> </div>
</div> </div>
))} ))}
@@ -184,52 +230,57 @@ export default function HomelabPage() {
); );
})} })}
</div> </div>
</section> </Widget>
{/* ADRs */} {/* ADRs */}
<section className="mb-16" aria-labelledby="adr-heading"> <Widget
<h2 title="homelab/ADRs"
id="adr-heading" meta="why things are configured the way they are"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2" badge={adrs.length}
as="section"
> >
Architecture Decisions <div className="flex flex-col gap-px bg-[var(--color-border)]">
</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) => ( {adrs.map((adr) => (
<div <div
key={adr.title} key={adr.title}
className="border border-[var(--color-grey-1)] rounded-lg p-5 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors" className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-2ch py-1lh"
> >
<h3 className="font-mono text-sm text-[var(--color-text-light)] mb-2">{adr.title}</h3> <p className="font-mono text-sm text-[var(--color-text)] mb-1lh">
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed mb-2"> {adr.title}
<span className="text-[var(--color-grey-2)]">Decision: </span>{adr.decision}
</p> </p>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed"> <p className="font-mono text-sm text-[var(--color-text)] leading-relaxed mb-half-lh opacity-75">
<span className="text-[var(--color-grey-2)]">Why: </span>{adr.why} <span className="text-[var(--color-text-label)] opacity-100">
decision:{" "}
</span>
{adr.decision}
</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
<span className="text-[var(--color-text-label)] opacity-100">
why:{" "}
</span>
{adr.why}
</p> </p>
</div> </div>
))} ))}
</div> </div>
</section> </Widget>
{/* GitHub CTA */} {/* Gitea 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"> <section className="pt-qtr-lh">
<div> <p className="font-mono text-sm text-[var(--color-text-dim)] mb-half-lh">
<p className="font-mono text-sm text-[var(--color-text-light)] mb-1">lerkolabs on GitHub</p> homelab/docs
<p className="text-xs text-[var(--color-grey-3)]"> </p>
Full documentation: VLAN maps, runbooks, service registry, config exports, and setup guides. <p className="font-mono text-sm text-[var(--color-text)] mb-1lh opacity-75">
VLAN maps, runbooks, service registry, config exports, and setup
guides.
</p> </p>
</div>
<a <a
href="https://github.com/lerko96/homelab-wip" href="https://gitea.lerkolabs.com/lerko/homelab"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="shrink-0 font-mono text-xs px-4 py-2 border border-[var(--color-green-darker)] text-[var(--color-green)] rounded hover:bg-[var(--color-green-darkest)] transition-colors" className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
> >
View repo <i className="fas fa-arrow-up-right-from-square ml-1 text-xs" aria-hidden="true" /> gitea.lerkolabs.com/lerko/homelab
</a> </a>
</section> </section>
</> </>
+10 -17
View File
@@ -1,17 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Montserrat, Source_Code_Pro } from "next/font/google"; import { Source_Code_Pro } from "next/font/google";
import "./globals.css"; import "./globals.css";
import ThemeScript from "@/components/ThemeScript"; import ThemeScript from "@/components/ThemeScript";
import Nav from "@/components/Nav"; import Nav from "@/components/Nav";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import { ThemeProvider } from "@/context/ThemeContext"; import { ThemeProvider } from "@/context/ThemeContext";
const montserrat = Montserrat({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const sourceCodePro = Source_Code_Pro({ const sourceCodePro = Source_Code_Pro({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-mono", variable: "--font-mono",
@@ -19,7 +13,7 @@ const sourceCodePro = Source_Code_Pro({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tyler Koenig | Portfolio", title: "Tyler Koenig",
description: description:
"SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.", "SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
}; };
@@ -30,25 +24,24 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en" className="dark"> <html lang="en" suppressHydrationWarning>
<head> <head>
<ThemeScript /> <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> </head>
<body <body
className={`${montserrat.variable} ${sourceCodePro.variable} bg-[var(--color-bg-deep)] text-[var(--color-text)] font-sans min-h-screen`} className={`${sourceCodePro.variable} bg-[var(--color-bg)] text-[var(--color-text)] font-mono min-h-screen`}
> >
<ThemeProvider> <ThemeProvider>
{/* Full-width sticky nav */}
<Nav /> <Nav />
<main className="max-w-5xl mx-auto px-6 py-16">
{/* Centered content column — border-l/r makes centering always visible */}
<div className="max-w-[740px] mx-auto border-l border-r border-[var(--color-border)]">
<main className="px-4ch py-3lh">
{children} {children}
</main> </main>
<Footer /> <Footer />
</div>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+3 -17
View File
@@ -1,11 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import Skills from "@/components/Skills"; import Timeline from "@/components/Timeline";
import ProjectCard from "@/components/ProjectCard";
import { featuredProjects } from "@/data/projects";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tyler Koenig | Portfolio", title: "Tyler Koenig",
description: description:
"SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.", "SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
}; };
@@ -14,19 +12,7 @@ export default function Home() {
return ( return (
<> <>
<Hero /> <Hero />
<Skills /> <Timeline />
<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>
</> </>
); );
} }
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Widget from "@/components/Widget";
import ProjectCard from "@/components/ProjectCard";
import { featuredProjects, archiveProjects } from "@/data/projects";
export const metadata: Metadata = {
title: "Projects | Tyler Koenig",
description:
"Featured projects and earlier work — homelab, open-pact, helm, and bootcamp/experiment archive.",
};
export default function ProjectsPage() {
return (
<>
<div className="mb-4lh">
<p className="font-mono text-sm font-bold text-[var(--color-text)] mb-1lh">
<span className="text-[var(--color-accent-green)] select-none mr-1ch" aria-hidden="true"></span>
projects
</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed max-w-xl opacity-80">
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 className="grid grid-cols-1 sm:grid-cols-2 gap-1ch">
{featuredProjects.map((project) => (
<ProjectCard key={project.slug} project={project} />
))}
</div>
</Widget>
<Widget title="projects/archive" badge={archiveProjects.length} as="section">
<div className="flex flex-col gap-px bg-[var(--color-border)]">
{archiveProjects.map((project) => (
<a
key={project.slug}
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start justify-between gap-2ch px-2ch py-1lh group"
>
<div className="flex flex-col gap-1ch flex-1 min-w-0">
<div className="flex items-center gap-1ch">
{project.year && (
<span className="font-mono text-sm text-[var(--color-text-dim)] shrink-0">
{project.year}
</span>
)}
<span className="font-mono text-sm text-[var(--color-text)] group-hover:text-[var(--color-accent-green)] truncate">
{project.title}
</span>
</div>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
{project.description}
</p>
<div className="flex flex-wrap gap-x-1ch gap-y-0.5">
{project.tags.map((tag) => (
<span key={tag} className="font-mono text-sm text-[var(--color-text-dim)]">
{tag}
</span>
))}
</div>
</div>
<span
className="font-mono text-sm text-[var(--color-text-label)] group-hover:text-[var(--color-text)] shrink-0 mt-0.5"
aria-hidden="true"
>
</span>
</a>
))}
</div>
</Widget>
</>
);
}
+21 -12
View File
@@ -1,35 +1,44 @@
export default function Footer() { export default function Footer() {
return ( return (
<footer className="border-t border-[var(--color-grey-1)] py-8 mt-16"> <footer className="border-t border-[var(--color-border)] py-1lh mt-2lh">
<div className="max-w-5xl mx-auto px-6 flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="px-4ch flex items-center justify-between">
<p className="font-mono text-xs text-[var(--color-grey-2)] tracking-widest"> <span className="font-mono text-sm text-[var(--color-text-dim)]">
&copy; {new Date().getFullYear()} Tyler Koenig &copy; {new Date().getFullYear()} Tyler Koenig
</p> </span>
<div className="flex items-center gap-5"> <div className="flex items-center gap-2ch">
<a <a
href="https://github.com/lerko96" href="https://github.com/lerko96"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="GitHub" aria-label="GitHub"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
> >
<i className="fab fa-github text-lg" aria-hidden="true" /> [github]
</a>
<a
href="https://gitea.lerkolabs.com/lerko"
target="_blank"
rel="noopener noreferrer"
aria-label="Gitea"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
[gitea]
</a> </a>
<a <a
href="https://www.linkedin.com/in/tyler-koenig" href="https://www.linkedin.com/in/tyler-koenig"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="LinkedIn" aria-label="LinkedIn"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
> >
<i className="fab fa-linkedin text-lg" aria-hidden="true" /> [linkedin]
</a> </a>
<a <a
href="mailto:tylerkoenig96@gmail.com" href="mailto:tyler@lerkolabs.com"
aria-label="Email" aria-label="Email"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
> >
<i className="fas fa-envelope text-lg" aria-hidden="true" /> [email]
</a> </a>
</div> </div>
</div> </div>
+44 -33
View File
@@ -1,61 +1,72 @@
import Image from "next/image";
export default function Hero() { export default function Hero() {
return ( return (
<section className="flex flex-col sm:flex-row items-center sm:items-start gap-8 mb-20"> <section className="mb-16">
<div className="shrink-0"> <div className="flex flex-col gap-1ch">
<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> <div>
<h1 className="font-mono text-2xl font-bold text-[var(--color-text-light)] tracking-wide"> <p className="font-mono text-sm font-bold text-[var(--color-text)]">
Tyler Koenig <span
</h1> className="text-[var(--color-accent-green)] select-none mr-1ch"
<p className="font-mono text-sm text-[var(--color-green)] tracking-widest uppercase mt-1"> aria-hidden="true"
SOC Helpdesk I by day, building beyond the title >
</span>
tyler koenig
</p>
<p className="font-mono text-sm text-[var(--color-text-label)] mt-0.5">
Security Operations · Self-Hosted Infrastructure
</p> </p>
</div> </div>
<p className="text-[var(--color-grey-3)] text-sm leading-relaxed max-w-lg"> <p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-70">
I write software and run infrastructure that goes well past what my job Homelab runs 30+
title implies. Games, AI tooling, mobile apps, and a homelab running services across segmented VLANs pfSense, Authentik SSO, full
20+ self-hosted services on segmented VLANs. Continuously learning observability stack. Write software too: mobile apps, Go backends,
by building things that actually work. open protocols. Daily drivers, all of it.{" "}
<span
className="animate-cursor text-[var(--color-accent-green)]"
aria-hidden="true"
>
</span>
</p> </p>
<div className="flex items-center gap-5 justify-center sm:justify-start"> <div className="flex flex-wrap items-center gap-x-1ch gap-y-half-lh">
<span className="font-mono text-sm text-[var(--color-accent-green)]">
available
</span>
<a <a
href="https://github.com/lerko96" href="https://github.com/lerko96"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="GitHub" aria-label="GitHub"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
> >
<i className="fab fa-github text-xl" aria-hidden="true" /> [github]
</a>
<a
href="https://gitea.lerkolabs.com/lerko"
target="_blank"
rel="noopener noreferrer"
aria-label="Gitea"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
[gitea]
</a> </a>
<a <a
href="https://www.linkedin.com/in/tyler-koenig" href="https://www.linkedin.com/in/tyler-koenig"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="LinkedIn" aria-label="LinkedIn"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
> >
<i className="fab fa-linkedin text-xl" aria-hidden="true" /> [linkedin]
</a> </a>
<a <a
href="mailto:tylerkoenig96@gmail.com" href="mailto:tyler@lerkolabs.com"
aria-label="Email" aria-label="Email"
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
> >
<i className="fas fa-envelope text-xl" aria-hidden="true" /> [email]
</a> </a>
</div> </div>
</div> </div>
+24 -16
View File
@@ -2,39 +2,40 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTheme } from "@/context/ThemeContext";
const links = [ const links = [
{ href: "/", label: "Home" }, { href: "/", label: "tyler" },
{ href: "/homelab/", label: "Homelab" }, { href: "/homelab/", label: "homelab" },
{ href: "/archive/", label: "Archive" }, { href: "/projects/", label: "projects" },
]; ];
export default function Nav() { export default function Nav() {
const pathname = usePathname(); const pathname = usePathname();
const { isDark, toggle } = useTheme();
return ( return (
<header className="sticky top-0 z-50 bg-[var(--color-bg-deep)] border-b border-[var(--color-grey-1)]"> <header className="sticky top-0 z-50 bg-[var(--color-surface)] border-b border-[var(--color-border)]">
<nav className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between"> <nav className="max-w-[740px] mx-auto px-4ch h-11 flex items-center justify-between">
<Link <Link
href="/" href="/"
className="font-mono text-xl font-bold text-[var(--color-green)] tracking-widest hover:opacity-80 transition-opacity" className="font-mono text-sm font-bold text-[var(--color-text)] hover:text-[var(--color-text-label)]"
> >
tk ~/
</Link> </Link>
<div className="flex items-center gap-6"> <ul className="flex items-center gap-2ch">
<ul className="flex gap-6">
{links.map(({ href, label }) => { {links.map(({ href, label }) => {
const active = pathname === href || pathname === href.replace(/\/$/, ""); const active =
pathname === href || pathname === href.replace(/\/$/, "");
return ( return (
<li key={href}> <li key={href}>
<Link <Link
href={href} href={href}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={`text-xs font-mono tracking-widest uppercase transition-colors ${ className={`font-mono text-sm ${
active active
? "text-[var(--color-green)]" ? "text-[var(--color-text)]"
: "text-[var(--color-grey-3)] hover:text-[var(--color-grey-4)]" : "text-[var(--color-text-label)] hover:text-[var(--color-text)]"
}`} }`}
> >
{label} {label}
@@ -42,9 +43,16 @@ export default function Nav() {
</li> </li>
); );
})} })}
<li>
<button
onClick={toggle}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)] cursor-pointer"
>
{isDark ? "[light]" : "[dark]"}
</button>
</li>
</ul> </ul>
</div>
</nav> </nav>
</header> </header>
); );
+36 -38
View File
@@ -2,68 +2,66 @@ import type { Project } from "@/data/projects";
type Props = { type Props = {
project: Project; project: Project;
reversed?: boolean;
}; };
export default function ProjectCard({ project, reversed = false }: Props) { export default function ProjectCard({ project }: Props) {
return ( return (
<article className={`group flex flex-col ${reversed ? "sm:flex-row-reverse" : "sm:flex-row"} gap-6 mb-16`}> <article className="border border-[var(--color-border)] bg-[var(--color-surface)] flex flex-col gap-1lh p-2ch hover:bg-[var(--color-surface-raised)]">
{/* Gradient image tile */} <div className="flex items-start justify-between gap-1ch">
<a <a
href={project.githubUrl} href={project.githubUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="shrink-0 sm:w-56 h-36 rounded-lg overflow-hidden" className="font-mono text-sm font-semibold text-[var(--color-text)] hover:text-[var(--color-accent-green)]"
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} {project.title}
</a> </a>
<div className="flex items-center gap-1ch shrink-0">
{project.stats && ( {project.stats && (
<span className="font-mono text-xs text-[var(--color-green)] ml-3 opacity-70"> <span className="font-mono text-sm text-[var(--color-text-dim)]">
{project.stats} {project.stats}
</span> </span>
)} )}
{project.externalUrl && (
<a
href={project.externalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`View ${project.title} externally`}
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
</a>
)}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`View ${project.title} on GitHub`}
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
</a>
</div>
</div> </div>
<p className="text-sm text-[var(--color-grey-3)] leading-relaxed max-w-md"> {project.statusBadge && (
<span className="font-mono text-sm text-[var(--color-accent-amber,#d4a027)] border border-[var(--color-accent-amber,#d4a027)] px-1ch py-0.5 w-fit opacity-80">
{project.statusBadge}
</span>
)}
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed flex-1 opacity-70">
{project.description} {project.description}
</p> </p>
<div className={`flex flex-wrap gap-2 ${reversed ? "justify-end" : ""}`}> <div className="flex flex-wrap gap-x-1ch gap-y-half-lh mt-half-lh">
{project.tags.map((tag) => ( {project.tags.map((tag) => (
<span <span key={tag} className="font-mono text-sm text-[var(--color-text-dim)]">
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} {tag}
</span> </span>
))} ))}
</div> </div>
</div>
</article> </article>
); );
} }
+26 -32
View File
@@ -1,54 +1,48 @@
import Widget from "@/components/Widget";
const skillGroups = [ 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", label: "Infrastructure",
skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"], skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"],
}, },
{
label: "Desktop & Tools",
skills: ["Git", "Docker", "TDD", "Node.js", "REST APIs", ],
},
{ {
label: "Practices", label: "Practices",
skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"], 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);
export default function Skills() { export default function Skills() {
return ( return (
<section className="mb-20" aria-labelledby="skills-heading"> <Widget title="tyler/skills" badge={totalCount} as="section">
<h2 <div className="flex flex-col">
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 }) => ( {skillGroups.map(({ label, skills }) => (
<div key={label} className="flex flex-col xs:flex-row gap-2 xs:items-start"> <div
<span className="font-mono text-xs text-[var(--color-grey-2)] w-36 shrink-0 pt-0.5"> key={label}
className="flex flex-col xs:flex-row gap-1ch xs:gap-2ch py-half-lh"
>
<span className="font-mono text-sm text-[var(--color-text-dim)] w-28 shrink-0">
{label} {label}
</span> </span>
<div className="flex flex-wrap gap-2"> <span className="font-mono text-sm text-[var(--color-text)]">
{skills.map((skill) => ( {skills.join(" · ")}
<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> </span>
))}
</div>
</div> </div>
))} ))}
</div> </div>
</section> </Widget>
); );
} }
+107
View File
@@ -0,0 +1,107 @@
'use client'
import { useEffect, useRef } from 'react'
import Widget from '@/components/Widget'
import { timeline, type TimelineType } from '@/data/timeline'
const typeColor: Record<TimelineType, string> = {
career: 'var(--color-timeline-career)',
education: 'var(--color-timeline-education)',
cert: 'var(--color-timeline-cert)',
project: 'var(--color-timeline-project)',
homelab: 'var(--color-timeline-homelab)',
}
const typeLabel: Record<TimelineType, string> = {
career: 'career',
education: 'education',
cert: 'cert',
project: 'project',
homelab: 'homelab',
}
export default function Timeline() {
const listRef = useRef<HTMLOListElement>(null)
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const entries = listRef.current?.querySelectorAll<HTMLLIElement>('[data-tl-entry]')
if (!entries) return
const observer = new IntersectionObserver(
(observed) => {
observed.forEach((entry) => {
if (entry.isIntersecting) {
;(entry.target as HTMLElement).style.opacity = '1'
;(entry.target as HTMLElement).style.transform = 'translateY(0)'
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.15 },
)
entries.forEach((el) => {
el.style.opacity = '0'
el.style.transform = 'translateY(8px)'
el.style.transition = 'opacity 240ms linear, transform 240ms linear'
observer.observe(el)
})
return () => observer.disconnect()
}, [])
return (
<Widget title="tyler/journey">
<ol ref={listRef} className="relative border-l border-[var(--color-border)] ml-[2px] flex flex-col gap-0">
{timeline.map((entry, i) => (
<li key={i} data-tl-entry className="pl-[3ch] pb-2lh last:pb-0 relative">
{/* Spine dot */}
<span
className="absolute -left-[7px] top-[3px] w-3 h-3 rounded-full border border-[var(--color-bg)] shrink-0"
style={{ backgroundColor: typeColor[entry.type] }}
aria-hidden="true"
/>
{/* Date + type badge */}
<div className="flex items-center gap-1ch mb-half-lh">
<span className="font-mono text-sm text-[var(--color-text-dim)]">{entry.date}</span>
<span
className="font-mono text-sm px-1 border"
style={{
color: typeColor[entry.type],
borderColor: typeColor[entry.type],
opacity: 0.7,
}}
>
{typeLabel[entry.type]}
</span>
</div>
{/* Title */}
<p className="font-mono text-sm font-semibold text-[var(--color-text)] mb-half-lh">
{entry.title}
</p>
{/* Description */}
<p className="font-mono text-sm text-[var(--color-text)] opacity-70 leading-relaxed mb-half-lh">
{entry.description}
</p>
{/* Tags */}
{entry.tags && entry.tags.length > 0 && (
<div className="flex flex-wrap gap-x-1ch gap-y-half-lh">
{entry.tags.map((tag) => (
<span key={tag} className="font-mono text-sm text-[var(--color-text-dim)]">
{tag}
</span>
))}
</div>
)}
</li>
))}
</ol>
</Widget>
)
}
+39
View File
@@ -0,0 +1,39 @@
type WidgetProps = {
title: string;
badge?: string | number;
meta?: string;
as?: "section" | "div" | "article";
className?: string;
children: React.ReactNode;
};
export default function Widget({
title,
badge,
meta,
as: Tag = "section",
className,
children,
}: WidgetProps) {
const slashIdx = title.lastIndexOf("/");
const prefix = slashIdx >= 0 ? title.slice(0, slashIdx + 1) : null;
const name = slashIdx >= 0 ? title.slice(slashIdx + 1) : title;
return (
<Tag className={`mb-4lh ${className ?? ""}`}>
<div className="flex items-center gap-1ch mb-2lh">
{prefix && (
<span className="font-mono text-sm text-[var(--color-text-dim)] select-none">{prefix}</span>
)}
<span className="font-mono text-sm font-semibold text-[var(--color-text)]">{name}</span>
{badge !== undefined && (
<span className="font-mono text-sm text-[var(--color-text-dim)]">[{badge}]</span>
)}
{meta && (
<span className="font-mono text-sm text-[var(--color-text-dim)]"> {meta}</span>
)}
</div>
{children}
</Tag>
);
}
+102 -40
View File
@@ -4,13 +4,76 @@ 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:
"Next.js 16 static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
tags: ["Next.js", "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",
@@ -18,42 +81,11 @@ 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",
gradient: "from-[var(--color-green-darkest)] via-[var(--color-bg)] to-[var(--color-bg-deep)]", tier: "archive",
tier: "featured", stats: "200+ commits",
stats: "211 commits", statusBadge: "Pending App Store Approval",
year: 2025,
}, },
{
slug: "plaiground",
title: "plAIground",
description:
"Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.",
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
githubUrl: "https://github.com/lerko96/plaiground",
gradient: "from-[var(--color-green-darker)] via-[var(--color-surface)] to-[var(--color-bg-deep)]",
tier: "featured",
},
{
slug: "service-monitor",
title: "service-monitor",
description:
"Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.",
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
githubUrl: "https://github.com/lerko96/service-monitor",
gradient: "from-[var(--color-bg)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
tier: "featured",
},
{
slug: "tht-1.2",
title: "ThoughtSpace",
description:
"3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.",
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
githubUrl: "https://github.com/lerko96/tht-1.2",
gradient: "from-[var(--color-surface)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
tier: "featured",
},
// --- Archive ---
{ {
slug: "twitter-thread-ext", slug: "twitter-thread-ext",
title: "twitter-thread-ext", title: "twitter-thread-ext",
@@ -61,8 +93,8 @@ export const projects: Project[] = [
"Chrome extension (Manifest V3) that captures entire Twitter/X threads and exports them as HTML, Markdown, PDF, or image — with metadata preservation and preview before export.", "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"], tags: ["Chrome Extension", "Manifest V3", "JavaScript", "jsPDF"],
githubUrl: "https://github.com/lerko96/twitter-thread-ext", githubUrl: "https://github.com/lerko96/twitter-thread-ext",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive", tier: "archive",
year: 2025,
}, },
{ {
slug: "notes-app-1.0", slug: "notes-app-1.0",
@@ -71,8 +103,38 @@ export const projects: Project[] = [
"Lightweight canvas drawing app with color picker, adjustable brush size, and PNG export. Runs in the browser, no dependencies.", "Lightweight canvas drawing app with color picker, adjustable brush size, and PNG export. Runs in the browser, no dependencies.",
tags: ["HTML5 Canvas", "JavaScript", "CSS"], tags: ["HTML5 Canvas", "JavaScript", "CSS"],
githubUrl: "https://github.com/lerko96/notes-app-1.0", 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: "plaiground",
title: "plAIground",
description:
"Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.",
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
githubUrl: "https://github.com/lerko96/plaiground",
tier: "archive",
year: 2025,
},
{
slug: "service-monitor",
title: "service-monitor",
description:
"Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.",
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
githubUrl: "https://github.com/lerko96/service-monitor",
tier: "archive",
year: 2025,
},
{
slug: "tht-1.2",
title: "ThoughtSpace",
description:
"3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.",
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
githubUrl: "https://github.com/lerko96/tht-1.2",
tier: "archive",
year: 2025,
}, },
{ {
slug: "were-hooked", slug: "were-hooked",
@@ -81,8 +143,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",
@@ -91,8 +153,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,
}, },
]; ];
+18 -5
View File
@@ -7,10 +7,13 @@ export type Service = {
export const services: Service[] = [ export const services: Service[] = [
// Infrastructure // Infrastructure
{ name: "pfSense", description: "Firewall, DHCP, routing, WireGuard VPN", category: "infrastructure", icon: "fas fa-shield-halved" }, { name: "pfSense", description: "Firewall, DHCP, routing gateway on N100", category: "infrastructure", icon: "fas fa-shield-halved" },
{ name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure", icon: "fas fa-globe" }, { name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure", icon: "fas fa-globe" },
{ name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure", icon: "fas fa-filter" }, { name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure", icon: "fas fa-filter" },
{ name: "WireGuard", description: "VPN — 600900 Mbps on N100, full LAN access for clients", category: "infrastructure", icon: "fas fa-lock" }, { name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure", icon: "fas fa-lock" },
{ name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure", icon: "fas fa-envelope" },
{ name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure", icon: "fas fa-shield" },
{ name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure", icon: "fas fa-house" },
// Security / Auth // Security / Auth
{ name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security", icon: "fas fa-id-badge" }, { name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security", icon: "fas fa-id-badge" },
@@ -34,11 +37,21 @@ export const services: Service[] = [
{ name: "Traggo", description: "Time tracking", category: "productivity", icon: "fas fa-clock" }, { name: "Traggo", description: "Time tracking", category: "productivity", icon: "fas fa-clock" },
{ name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity", icon: "fas fa-calendar" }, { name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity", icon: "fas fa-calendar" },
{ name: "Grist", description: "Spreadsheets and structured data", category: "productivity", icon: "fas fa-table" }, { name: "Grist", description: "Spreadsheets and structured data", category: "productivity", icon: "fas fa-table" },
{ name: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity", icon: "fas fa-gauge" },
{ name: "Filebrowser", description: "Web-based file manager", category: "productivity", icon: "fas fa-folder-open" },
// Media // Media
{ name: "Plex + Jellyfin", description: "Media streaming", category: "media", icon: "fas fa-film" }, { name: "Plex", description: "Media streaming — movies, TV, music", category: "media", icon: "fas fa-film" },
{ name: "Sonarr / Radarr / Lidarr", description: "Automated media management", category: "media", icon: "fas fa-download" }, { name: "Jellyfin", description: "Open-source media streaming", category: "media", icon: "fas fa-play" },
{ name: "Calibre-Web", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" }, { name: "Sonarr", description: "Automated TV show management", category: "media", icon: "fas fa-tv" },
{ name: "Radarr", description: "Automated movie management", category: "media", icon: "fas fa-video" },
{ name: "Lidarr", description: "Automated music management", category: "media", icon: "fas fa-music" },
{ name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media", icon: "fas fa-magnifying-glass" },
{ name: "Bazarr", description: "Automatic subtitle download and management", category: "media", icon: "fas fa-closed-captioning" },
{ name: "nzbget", description: "Usenet downloader", category: "media", icon: "fas fa-download" },
{ name: "qBittorrent", description: "Torrent client with web UI", category: "media", icon: "fas fa-magnet" },
{ name: "Kavita", description: "Self-hosted manga and book reader", category: "media", icon: "fas fa-book-open" },
{ name: "Openshelf", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" },
]; ];
export const categoryOrder: Service["category"][] = [ export const categoryOrder: Service["category"][] = [
+127
View File
@@ -0,0 +1,127 @@
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:
"Next.js 16 portfolio site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
tags: ["next.js", "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 picked up on ebay to experience hands on networking configuration 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"],
},
];