@@ -202,7 +202,7 @@ export default function HomelabPage() {
{/* ADRs */}
- lerkolabs on GitHub
-
- Full documentation: VLAN maps, runbooks, service registry, config exports, and setup guides.
+
+ homelab/docs → github.com/lerko96/homelab-wip
+
+ VLAN maps, runbooks, service registry, config exports, and setup guides.
↗ github.com/lerko96/homelab-wip
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 0f0a8aa2..a2c840a3 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -27,12 +27,6 @@ export default function RootLayout({
-
-
-
+
{featuredProjects.map((project) => (
))}
+
+
>
);
}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 2085cd15..a367933c 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -2,7 +2,7 @@ export default function Footer() {
return (
-
+
© {new Date().getFullYear()} Tyler Koenig
@@ -11,20 +11,18 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
- className="font-mono text-xs text-[var(--color-text-label)] hover:text-[var(--color-text)]"
+ className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
-
- github
+ [github]
-
- linkedin
+ [linkedin]
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx
index f039e56a..caac4fcc 100644
--- a/src/components/Hero.tsx
+++ b/src/components/Hero.tsx
@@ -1,75 +1,54 @@
-import Image from "next/image";
-
export default function Hero() {
return (
- {/* Section header */}
-
-
-
-
-
-
-
-
- Tyler Koenig
-
-
- SOC Helpdesk I · Homelab Operator
-
-
-
-
- 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.
+
+
+
+ ❯
+ tyler koenig
+
+ Security Operations · Self-Hosted Infrastructure
+
+
-
+
+ Security operations and self-hosted infrastructure. Homelab runs 37
+ services across segmented VLANs — pfSense, Authentik SSO, full
+ observability stack. Write software too: mobile apps, Go backends,
+ open protocols. Daily drivers, all of it.{' '}
+ █
+
+
+
diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx
index c5ce90e7..9b020f29 100644
--- a/src/components/Nav.tsx
+++ b/src/components/Nav.tsx
@@ -2,26 +2,28 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
+import { useTheme } from "@/context/ThemeContext";
const links = [
- { href: "/", label: "home" },
+ { href: "/", label: "tyler" },
{ href: "/homelab/", label: "homelab" },
{ href: "/archive/", label: "archive" },
];
export default function Nav() {
const pathname = usePathname();
+ const { isDark, toggle } = useTheme();
return (
- tk
+ ~/
-
+
{links.map(({ href, label }) => {
const active =
pathname === href || pathname === href.replace(/\/$/, "");
@@ -30,7 +32,7 @@ export default function Nav() {
);
})}
+
+
+ {isDark ? "[light]" : "[dark]"}
+
+
diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx
index 76d75a00..53d21669 100644
--- a/src/components/ProjectCard.tsx
+++ b/src/components/ProjectCard.tsx
@@ -6,7 +6,7 @@ type Props = {
export default function ProjectCard({ project }: Props) {
return (
-
+
-
+ {project.statusBadge && (
+
+ {project.statusBadge}
+
+ )}
+
+
{project.description}
-
+
{project.tags.map((tag) => (
{tag}
diff --git a/src/components/Skills.tsx b/src/components/Skills.tsx
index 6e535d4a..530464cd 100644
--- a/src/components/Skills.tsx
+++ b/src/components/Skills.tsx
@@ -3,7 +3,7 @@ import Widget from "@/components/Widget";
const skillGroups = [
{
label: "Languages",
- skills: ["JavaScript", "TypeScript", "HTML", "CSS"],
+ skills: ["Go", "JavaScript", "TypeScript", "HTML", "CSS"],
},
{
label: "Frontend",
@@ -27,16 +27,14 @@ const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
export default function Skills() {
return (
-
+
- {skillGroups.map(({ label, skills }, i) => (
+ {skillGroups.map(({ label, skills }) => (
-
+
{label}
diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx
new file mode 100644
index 00000000..4af12b46
--- /dev/null
+++ b/src/components/Timeline.tsx
@@ -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 = {
+ 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 = {
+ career: 'career',
+ education: 'education',
+ cert: 'cert',
+ project: 'project',
+ homelab: 'homelab',
+}
+
+export default function Timeline() {
+ const listRef = useRef(null)
+
+ useEffect(() => {
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
+
+ const entries = listRef.current?.querySelectorAll('[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 (
+
+
+ {timeline.map((entry, i) => (
+
+ {/* Spine dot */}
+
+
+ {/* Date + type badge */}
+
+ {entry.date}
+
+ {typeLabel[entry.type]}
+
+
+
+ {/* Title */}
+
+ {entry.title}
+
+
+ {/* Description */}
+
+ {entry.description}
+
+
+ {/* Tags */}
+ {entry.tags && entry.tags.length > 0 && (
+
+ {entry.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/Widget.tsx b/src/components/Widget.tsx
index 6ec63db8..2a2faf6f 100644
--- a/src/components/Widget.tsx
+++ b/src/components/Widget.tsx
@@ -15,22 +15,22 @@ export default function Widget({
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 (
-
-
- {title}
-
- {meta && (
-
- {meta}
-
+
+ {prefix && (
+
{prefix}
)}
-
+
{name}
{badge !== undefined && (
-
- {badge}
-
+
[{badge}]
+ )}
+ {meta && (
+
— {meta}
)}
{children}
diff --git a/src/data/projects.ts b/src/data/projects.ts
index 8d6e3192..83fdc4cf 100644
--- a/src/data/projects.ts
+++ b/src/data/projects.ts
@@ -7,6 +7,8 @@ export type Project = {
tier: "featured" | "archive";
stats?: string;
year?: number;
+ statusBadge?: string;
+ externalUrl?: string;
};
export const projects: Project[] = [
@@ -20,6 +22,8 @@ export const projects: Project[] = [
githubUrl: "https://github.com/lerko96/golf-book-mobile",
tier: "featured",
stats: "211 commits",
+ statusBadge: "Pending App Store Approval",
+ externalUrl: "#",
},
{
slug: "plaiground",
@@ -28,7 +32,8 @@ export const projects: Project[] = [
"Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.",
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
githubUrl: "https://github.com/lerko96/plaiground",
- tier: "featured",
+ tier: "archive",
+ year: 2025,
},
{
slug: "service-monitor",
@@ -37,7 +42,8 @@ export const projects: Project[] = [
"Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.",
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
githubUrl: "https://github.com/lerko96/service-monitor",
- tier: "featured",
+ tier: "archive",
+ year: 2025,
},
{
slug: "tht-1.2",
@@ -46,10 +52,40 @@ export const projects: Project[] = [
"3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.",
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
githubUrl: "https://github.com/lerko96/tht-1.2",
+ tier: "archive",
+ year: 2025,
+ },
+
+ {
+ slug: "open-pact",
+ title: "open-pact",
+ description:
+ "Open protocol for AI agent identity, delegation, and portable memory. Ed25519 keypair identity, signed delegation warrants, portable signed memory facts. No central registry.",
+ tags: ["TypeScript", "Ed25519", "DID", "npm", "CC0"],
+ githubUrl: "https://github.com/lerko96/open-pact",
+ tier: "featured",
+ },
+ {
+ slug: "helm",
+ title: "helm",
+ description:
+ "Full-stack personal productivity dashboard. Go backend with chi router and SQLite, React + TypeScript frontend. Notes, todos, calendar (CalDAV), clipboard, bookmarks, memos. Self-hosted, single-user, daily use.",
+ tags: ["Go", "React", "TypeScript", "SQLite", "CalDAV"],
+ githubUrl: "https://github.com/lerko96/helm",
tier: "featured",
},
// --- Archive ---
+ {
+ slug: "risk-ops",
+ title: "risk-ops",
+ description:
+ "Browser-based strategy dashboard for Risk: Global Domination (SMG Studio). Open one HTML file — no install needed.",
+ tags: ["HTML", "JavaScript"],
+ githubUrl: "#",
+ tier: "archive",
+ year: 2026,
+ },
{
slug: "twitter-thread-ext",
title: "twitter-thread-ext",
diff --git a/src/data/services.ts b/src/data/services.ts
index 76d8d2e4..d3c206f6 100644
--- a/src/data/services.ts
+++ b/src/data/services.ts
@@ -7,10 +7,13 @@ export type Service = {
export const services: Service[] = [
// Infrastructure
- { name: "pfSense", description: "Firewall, DHCP, routing, WireGuard VPN", category: "infrastructure", icon: "fas fa-shield-halved" },
+ { name: "pfSense", description: "Firewall, DHCP, routing gateway on N100", category: "infrastructure", icon: "fas fa-shield-halved" },
{ name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure", icon: "fas fa-globe" },
{ name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure", icon: "fas fa-filter" },
- { name: "WireGuard", description: "VPN — 600–900 Mbps on N100, full LAN access for clients", category: "infrastructure", icon: "fas fa-lock" },
+ { name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure", icon: "fas fa-lock" },
+ { name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure", icon: "fas fa-envelope" },
+ { name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure", icon: "fas fa-shield" },
+ { name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure", icon: "fas fa-house" },
// Security / Auth
{ name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security", icon: "fas fa-id-badge" },
@@ -34,11 +37,21 @@ export const services: Service[] = [
{ name: "Traggo", description: "Time tracking", category: "productivity", icon: "fas fa-clock" },
{ name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity", icon: "fas fa-calendar" },
{ name: "Grist", description: "Spreadsheets and structured data", category: "productivity", icon: "fas fa-table" },
+ { name: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity", icon: "fas fa-gauge" },
+ { name: "Filebrowser", description: "Web-based file manager", category: "productivity", icon: "fas fa-folder-open" },
// Media
- { name: "Plex + Jellyfin", description: "Media streaming", category: "media", icon: "fas fa-film" },
- { name: "Sonarr / Radarr / Lidarr", description: "Automated media management", category: "media", icon: "fas fa-download" },
- { name: "Calibre-Web", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" },
+ { name: "Plex", description: "Media streaming — movies, TV, music", category: "media", icon: "fas fa-film" },
+ { name: "Jellyfin", description: "Open-source media streaming", category: "media", icon: "fas fa-play" },
+ { name: "Sonarr", description: "Automated TV show management", category: "media", icon: "fas fa-tv" },
+ { name: "Radarr", description: "Automated movie management", category: "media", icon: "fas fa-video" },
+ { name: "Lidarr", description: "Automated music management", category: "media", icon: "fas fa-music" },
+ { name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media", icon: "fas fa-magnifying-glass" },
+ { name: "Bazarr", description: "Automatic subtitle download and management", category: "media", icon: "fas fa-closed-captioning" },
+ { name: "nzbget", description: "Usenet downloader", category: "media", icon: "fas fa-download" },
+ { name: "qBittorrent", description: "Torrent client with web UI", category: "media", icon: "fas fa-magnet" },
+ { name: "Kavita", description: "Self-hosted manga and book reader", category: "media", icon: "fas fa-book-open" },
+ { name: "Openshelf", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" },
];
export const categoryOrder: Service["category"][] = [
diff --git a/src/data/timeline.ts b/src/data/timeline.ts
new file mode 100644
index 00000000..98466699
--- /dev/null
+++ b/src/data/timeline.ts
@@ -0,0 +1,75 @@
+export type TimelineType = 'career' | 'cert' | 'project' | 'homelab' | 'education'
+
+export interface TimelineEntry {
+ date: string
+ title: string
+ type: TimelineType
+ description: string
+ tags?: string[]
+}
+
+export const timeline: TimelineEntry[] = [
+ {
+ date: '2026',
+ title: 'CompTIA Network+ — in progress',
+ type: 'cert',
+ description: 'Studying for Network+ to formalize networking knowledge built through the homelab.',
+ tags: ['networking', 'certification'],
+ },
+ {
+ date: '2025',
+ title: 'Portfolio Site v2',
+ type: 'project',
+ description: 'Next.js 16 static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.',
+ tags: ['next.js', 'tailwind', 'self-hosted'],
+ },
+ {
+ date: '2024',
+ title: 'CompTIA A+',
+ type: 'cert',
+ description: 'Earned A+ certification, formalizing hardware and OS fundamentals.',
+ tags: ['certification'],
+ },
+ {
+ date: '2024',
+ title: 'Project Helm',
+ type: 'project',
+ description: 'Full-stack task and project management tool built in Go + React.',
+ tags: ['go', 'react', 'typescript'],
+ },
+ {
+ date: 'ongoing',
+ title: 'Homelab — Proxmox Cluster',
+ type: 'homelab',
+ description: '8-VLAN segmented network, Proxmox VMs/LXCs, SSO via Authentik, full monitoring stack (VictoriaMetrics + Grafana + Beszel + ntfy).',
+ tags: ['proxmox', 'networking', 'monitoring', 'sso'],
+ },
+ {
+ date: '2023-10',
+ title: 'SOC Analyst I — Fortress SRM',
+ type: 'career',
+ description: 'Threat monitoring, incident triage, and client-facing security operations in a managed SOC.',
+ tags: ['soc', 'security'],
+ },
+ {
+ date: '2023-03',
+ title: 'Config Tech II — MCPc',
+ type: 'career',
+ description: 'Promoted to Config Tech II. Led imaging workflows and expanded into scripting for endpoint provisioning.',
+ tags: ['sysadmin', 'scripting'],
+ },
+ {
+ date: '2022-07',
+ title: 'Config Tech I — MCPc',
+ type: 'career',
+ description: 'Hardware configuration, OS imaging, and deployment at scale for enterprise clients.',
+ tags: ['sysadmin', 'hardware'],
+ },
+ {
+ date: '2021',
+ title: 'We Can Code IT — Java Bootcamp',
+ type: 'education',
+ description: '9-month intensive bootcamp covering Java, OOP, SQL, REST APIs, and Agile development practices.',
+ tags: ['java', 'sql', 'agile'],
+ },
+]