18 Commits

Author SHA1 Message Date
lerko da61cbba5d feat(content): add uptop, reorder projects, archive open-pact, fix VLAN count
Build and Deploy / deploy (push) Successful in 1m18s
Add uptop (uptime monitoring TUI) as featured project. Reorder nib
above homelab, update nib stack and URL. Move open-pact to archive.
Update portfolio description (Nginx → Caddy). Fix VLAN count 8 → 7.
Trim Hero bio.
2026-05-31 18:17:44 -04:00
lerko 9037461d27 fix(content): consolidate homelab spacing, dynamic service count in hero
Collapse homelab into single Widget to match Projects/Journey spacing.
Hero service count now reads from services data instead of hardcoded 30+.
2026-05-27 17:33:16 -04:00
lerko 4a2ea3c32d Merge pull request 'feat(design): external link colors, section dividers, nav scroll-spy' (#10) from feat/polish-reader-view into staging
Reviewed-on: #10
2026-05-27 18:31:06 +00:00
lerko 6db5b36989 feat(design): external link colors, section dividers, nav scroll-spy
Dusty blue/violet link colors for external links (target="_blank") with
visited state. Both dark and light theme tokens. Mailto links included.

Section dividers via border-top on anchored sections. Scroll-margin-top
clears sticky nav on anchor jumps.

Nav scroll-spy highlights active section using IntersectionObserver +
scroll listener fallback for bottom-of-page edge case.
2026-05-26 20:43:56 -04:00
lerko 38326cc7ec refactor(content): slim homelab to highlight, trim redundant timeline entries
Homelab: drop VLAN table, services list, and ADRs — full docs live in
the homelab repo. Keep condensed at-a-glance stats, category counts,
and Gitea link.

Timeline: drop project entries (covered by Projects section),
lerkolabs.com (duplicate of portfolio), and PC Build. 14 → 9 entries.

Also: hide nav anchor links on mobile, drop Hero section anchors,
update meta description, add print stylesheet.
2026-05-26 17:39:19 -04:00
lerko aa5fdb579c Merge pull request 'feat(design): single-page reader view' (#9) from feat/single-page into staging
Reviewed-on: #9
2026-05-25 01:38:59 +00:00
lerko ce27a23c4e feat(design): nav anchor links, hidden services flag, drop badges
Add projects/journey/homelab anchor links to sticky nav for in-page
navigation. Add hidden flag to services data — hidden entries count
toward total but don't render. Remove badge counts from all widgets.
2026-05-24 21:38:03 -04:00
lerko 141d66d7bb feat(design): single-page consolidation, drop typeface picker
Merge projects + homelab + timeline into one scrollable page.
Remove typeface picker (sans/serif/mono), keep theme toggle.
Nav simplified to name + toggle. Hero gains anchor links for
in-page navigation (#projects, #journey, #homelab). Old pages
become meta-refresh redirects. Timeline redesigned as two-column
grid layout — date left, content right — cutting vertical space
~50%. Focus states added for keyboard nav. Tags dropped from
timeline entries.
2026-05-24 19:36:19 -04:00
lerko de74019e48 Merge pull request 'feat(design): reader view' (#8) from feat/reader-view into staging 2026-05-24 22:35:35 +00:00
lerko 32455bf7a7 feat(design): reader view — strip terminal chrome, add typography controls
Replace monospace-terminal aesthetic with clean reader layout. System
sans-serif default, typeface picker (sans/serif/mono) with per-face
optical tuning. Warm color palettes (slate dark, bone light), crafted
link underlines, WCAG AA contrast on all text tiers. Semantic HTML
throughout: proper heading hierarchy, <time> elements, role=group,
<dl>/<table>/<article> where appropriate. Net -140 lines.
2026-05-24 18:28:02 -04:00
lerko 0c5d9e03b1 feat(site): migrate from Next.js to Astro
Build and Deploy / deploy (push) Successful in 1m42s
Replace Next.js 16 + React 19 with Astro 5. Same visual design,
same deploy pipeline, zero client-side framework.

- All components rewritten as .astro files
- Dark mode via inline scripts (no React context)
- Timeline animation via IntersectionObserver script
- Nav active state computed at build time
- Self-hosted Source Code Pro woff2 fonts
- Drop Font Awesome (icons were never loaded)
- Drop unused headshot PNG (1MB, unreferenced)
- Fix pfSense hardware refs (Netgate 1100, not N100)
- Output: 212KB static HTML vs 2.6MB before
- JS shipped: ~700 bytes inline vs ~130KB React runtime
2026-05-18 20:07:24 -04:00
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
47 changed files with 4880 additions and 6449 deletions
+3 -23
View File
@@ -1,37 +1,17 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# next.js
/.next/
# astro
/out/
# production
/build
# testing
/coverage
/.astro/
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env.*
# typescript
*.tsbuildinfo
next-env.d.ts
# claude code
CLAUDE.md
.claude/
# docs
/docs
+18 -14
View File
@@ -4,7 +4,7 @@ Personal portfolio site. Live at [lerkolabs.com](https://lerkolabs.com) — self
Source: [gitea.lerkolabs.com/lerko/portfolio](https://gitea.lerkolabs.com/lerko/portfolio)
**Stack:** Next.js 16 · React 19 · TypeScript · Tailwind v4
**Stack:** Astro 5 · TypeScript · Tailwind v4
---
@@ -18,8 +18,9 @@ Source: [gitea.lerkolabs.com/lerko/portfolio](https://gitea.lerkolabs.com/lerko/
## Commands
```bash
npm run dev # dev server at localhost:3000
npm run dev # dev server at localhost:4321
npm run build # static export into out/
npm run preview # preview production build
```
---
@@ -41,19 +42,22 @@ Push to `dev` triggers Gitea Actions (`.gitea/workflows/deploy.yml`):
```
src/
app/
layout.tsx # root layout, fonts, ThemeProvider
page.tsx # home: hero, skills, project cards
homelab/page.tsx # homelab page: VLANs, services, ADRs
archive/page.tsx # older projects grid
globals.css # full design system (Tailwind v4 CSS-first, all tokens here)
components/ # Nav, Footer, Hero, ThemeScript, etc.
context/
ThemeContext.tsx # dark mode provider + useTheme hook
layouts/
Base.astro # root layout, fonts, theme script, nav/footer
pages/
index.astro # home: hero, timeline
projects.astro # featured + archive projects
homelab.astro # VLANs, services, ADRs
archive.astro # redirect to /projects/
components/ # Nav, Footer, Hero, Timeline, Widget, ProjectCard, Skills
data/
projects.ts # all projects, featured + archive split
services.ts # homelab services with categories
public/ # static assets copied into out/ on build
projects.ts # all projects, featured + archive split
services.ts # homelab services with categories
timeline.ts # career/project timeline
styles/
globals.css # full design system (Tailwind v4 CSS-first, all tokens here)
public/
fonts/ # self-hosted Source Code Pro woff2
```
> Tailwind v4 is CSS-first — no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`.
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static',
trailingSlash: 'always',
outDir: 'out',
build: {
format: 'directory',
},
});
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-11
View File
@@ -1,11 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
};
export default nextConfig;
+3990 -5240
View File
File diff suppressed because it is too large Load Diff
+4 -16
View File
@@ -4,27 +4,15 @@
"private": true,
"homepage": "https://www.lerko96.com",
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "node -e \"require('fs').writeFileSync('out/.nojekyll', '')\"",
"start": "next start",
"lint": "eslint",
"predeploy": "npm run build",
"deploy": "gh-pages -b master -d out -t"
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4"
"astro": "^5"
},
"devDependencies": {
"gh-pages": "^6.0.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1001 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

-68
View File
@@ -1,68 +0,0 @@
import type { Metadata } from "next";
import Widget from "@/components/Widget";
import { archiveProjects } from "@/data/projects";
export const metadata: Metadata = {
title: "Archive | Tyler Koenig",
description: "Earlier projects and experiments — browser extensions, canvas apps, and bootcamp work.",
};
export default function ArchivePage() {
return (
<>
<div className="mb-16">
<p className="font-mono text-base font-bold text-[var(--color-text)] mb-3">
<span className="text-[var(--color-accent-green)] select-none mr-2" aria-hidden="true"></span>
tyler/projects/archive
</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed max-w-xl opacity-80">
Experiments, browser extensions, and bootcamp projects. Kept here for context not
representative of current work.
</p>
</div>
<Widget title="tyler/projects/archive" badge={archiveProjects.length} as="section">
<div className="flex flex-col gap-px bg-[var(--color-border)]">
{archiveProjects.map((project) => (
<a
key={project.slug}
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start justify-between gap-6 px-4 py-4 group"
>
<div className="flex flex-col gap-2 flex-1 min-w-0">
<div className="flex items-center gap-3">
{project.year && (
<span className="font-mono text-xs text-[var(--color-text-dim)] shrink-0">
{project.year}
</span>
)}
<span className="font-mono text-sm text-[var(--color-text)] group-hover:text-[var(--color-accent-green)] truncate">
{project.title}
</span>
</div>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
{project.description}
</p>
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{project.tags.map((tag) => (
<span key={tag} className="font-mono text-xs text-[var(--color-text-dim)]">
{tag}
</span>
))}
</div>
</div>
<span
className="font-mono text-xs text-[var(--color-text-label)] group-hover:text-[var(--color-text)] shrink-0 mt-0.5"
aria-hidden="true"
>
</span>
</a>
))}
</div>
</Widget>
</>
);
}
-93
View File
@@ -1,93 +0,0 @@
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
/* macOS Classic Dark (default) */
--color-bg: #131313;
--color-surface: #1e1d1e;
--color-surface-raised: #272727;
--color-border: #3a3a3a;
--color-border-bright: #404040;
--color-text: #caccca;
--color-text-label: #9e9e9e;
--color-text-dim: #8f8f8f;
--color-accent-green: #62ba46;
--color-accent-red: #c74028;
/* Timeline type colors — dark */
--color-timeline-career: #62ba46;
--color-timeline-education: #c28b12;
--color-timeline-cert: #c75828;
--color-timeline-project: #c72855;
--color-timeline-homelab: #e1d797;
/* Typography */
--font-mono: "Source Code Pro", ui-monospace, monospace;
--font-sans: ui-sans-serif, system-ui, sans-serif;
/* Breakpoints */
--breakpoint-xs: 576px;
/* Animations */
--animate-fade-in: fadeIn 120ms linear forwards;
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
}
/* Base */
html {
scroll-behavior: smooth;
font-size: 14px;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-mono);
}
@keyframes blink { 50% { opacity: 0; } }
.animate-cursor { animation: blink 1s step-start infinite; }
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
/* macOS Classic Light overrides */
:root:not(.dark) {
--color-bg: #ffffff;
--color-surface: #f9f9f9;
--color-surface-raised: #f7f7f7;
--color-border: #e0e0e0;
--color-border-bright: #d2d2d2;
--color-text: #000000;
--color-text-label: #505050;
--color-text-dim: #929292;
--color-accent-green: #036a07;
--color-accent-red: #d21f07;
--color-timeline-career: #036a07;
--color-timeline-education: #0433ff;
--color-timeline-cert: #957931;
--color-timeline-project: #6f42c1;
--color-timeline-homelab: #0000a2;
}
/* Default transitions — linear, fast */
a,
button {
transition: color 120ms linear, border-color 120ms linear,
background-color 120ms linear, opacity 120ms linear;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
-247
View File
@@ -1,247 +0,0 @@
import type { Metadata } from "next";
import Widget from "@/components/Widget";
import { services, categoryOrder, categoryLabels } from "@/data/services";
export const metadata: Metadata = {
title: "Homelab | Tyler Koenig",
description:
"Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services.",
};
const glanceStats = [
{ label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Intel N100)" },
{ label: "Switching", value: "TP-Link Omada (managed)" },
{ label: "ISP", value: "AT&T Fiber 1 Gbps" },
{ label: "VPN", value: "WireGuard (pfSense)" },
{ label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" },
{ label: "Auth", value: "Authentik SSO" },
{ label: "DNS", value: "Pi-hole → Unbound → Cloudflare" },
{ label: "Containers", value: "9 LXC + 2 VMs" },
];
const vlans = [
{ id: "1000", name: "MGMT", subnet: "10.0.0.0/24", purpose: "Network equipment only" },
{ id: "1010", name: "LAN", subnet: "10.1.0.0/24", purpose: "Trusted personal devices" },
{ id: "1020", name: "Homelab", subnet: "10.2.0.0/24", purpose: "All self-hosted services" },
{ id: "1030", name: "Guests", subnet: "10.3.0.0/24", purpose: "Internet only, RFC1918 blocked" },
{ id: "1040", name: "IoT", subnet: "10.4.0.0/24", purpose: "Smart home, isolated" },
{ id: "1050", name: "WFH", subnet: "10.5.0.0/24", purpose: "Work devices, no personal access" },
{ id: "DMZ", name: "DMZ", subnet: "10.99.0.0/24", purpose: "Public-facing, hard-blocked internally" },
{ id: "VPN", name: "VPN", subnet: "10.200.0.0/24", purpose: "WireGuard clients = LAN access" },
];
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.",
why: "Keeps the full pipeline internal — no GitHub Actions, no external runners. Build runs in an isolated Alpine container so the Gitea LXC isn't polluted. Portfolio LXC (10.99.0.23) just serves pre-built static files via nginx.",
},
{
title: "Authentik over Authelia",
decision: "Authentik as the SSO provider across all self-hosted services.",
why: "Full OIDC provider + forward auth in one. Lets services like Outline, Gitea, and Vikunja use real SSO rather than just a login gate. Authelia is forward-auth only — no OIDC provider capability.",
},
];
export default function HomelabPage() {
return (
<>
{/* Header */}
<div className="mb-16">
<p className="font-mono text-base font-bold text-[var(--color-text)] mb-3">
<span className="text-[var(--color-accent-green)] select-none mr-2" aria-hidden="true"></span>
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-4 py-3"
>
<p className="font-mono text-xs text-[var(--color-text-dim)] uppercase tracking-wider mb-1">
{label}
</p>
<p className="font-mono text-sm text-[var(--color-text)]">{value}</p>
</div>
))}
</div>
</Widget>
{/* VLAN table */}
<Widget
title="homelab/network"
meta="8 isolated vlans · default deny inter-vlan"
as="section"
>
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-[var(--color-border)]">
<th className="font-mono text-[var(--color-text-dim)] text-left py-2 pr-6 uppercase tracking-wider">
VLAN
</th>
<th className="font-mono text-[var(--color-text-dim)] text-left py-2 pr-6 uppercase tracking-wider">
Name
</th>
<th className="font-mono text-[var(--color-text-dim)] text-left py-2 pr-6 uppercase tracking-wider">
Subnet
</th>
<th className="font-mono text-[var(--color-text-dim)] text-left py-2 uppercase tracking-wider">
Purpose
</th>
</tr>
</thead>
<tbody>
{vlans.map((v) => (
<tr
key={v.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-surface)]"
>
<td className="font-mono text-[var(--color-accent-green)] py-2.5 pr-6">
{v.id}
</td>
<td className="font-mono text-[var(--color-text)] py-2.5 pr-6">{v.name}</td>
<td className="font-mono text-[var(--color-text-label)] py-2.5 pr-6">
{v.subnet}
</td>
<td className="font-mono text-sm text-[var(--color-text)] py-2.5 opacity-80">{v.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
</Widget>
{/* Services */}
<Widget
title="homelab/services"
badge={services.length}
as="section"
>
<div className="flex flex-col gap-8">
{categoryOrder.map((cat) => {
const catServices = services.filter((s) => s.category === cat);
return (
<div key={cat}>
<p className="font-mono text-xs text-[var(--color-text-dim)] uppercase tracking-wider mb-3">
{categoryLabels[cat]}
</p>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
{catServices.map((svc) => (
<div
key={svc.name}
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start gap-3 px-4 py-3"
>
<i
className={`${svc.icon} text-[var(--color-text-label)] text-xs mt-0.5 w-3.5 shrink-0`}
aria-hidden="true"
/>
<div>
<p className="font-mono text-xs text-[var(--color-text)] mb-0.5">
{svc.name}
</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
{svc.description}
</p>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</Widget>
{/* ADRs */}
<Widget
title="homelab/ADRs"
meta="why things are configured the way they are"
badge={adrs.length}
as="section"
>
<div className="flex flex-col gap-px bg-[var(--color-border)]">
{adrs.map((adr) => (
<div
key={adr.title}
className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-4 py-4"
>
<p className="font-mono text-sm text-[var(--color-text)] mb-2">{adr.title}</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed mb-1.5 opacity-75">
<span className="text-[var(--color-text-label)] opacity-100">decision: </span>
{adr.decision}
</p>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed opacity-75">
<span className="text-[var(--color-text-label)] opacity-100">why: </span>
{adr.why}
</p>
</div>
))}
</div>
</Widget>
{/* GitHub CTA */}
<section className="pt-2">
<p className="font-mono text-sm text-[var(--color-text-dim)] mb-1">homelab/docs github.com/lerko96/homelab-wip</p>
<p className="font-mono text-sm text-[var(--color-text)] mb-3 opacity-75">
VLAN maps, runbooks, service registry, config exports, and setup guides.
</p>
<a
href="https://github.com/lerko96/homelab-wip"
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
github.com/lerko96/homelab-wip
</a>
</section>
</>
);
}
-49
View File
@@ -1,49 +0,0 @@
import type { Metadata } from "next";
import { Source_Code_Pro } from "next/font/google";
import "./globals.css";
import ThemeScript from "@/components/ThemeScript";
import Nav from "@/components/Nav";
import Footer from "@/components/Footer";
import { ThemeProvider } from "@/context/ThemeContext";
const sourceCodePro = Source_Code_Pro({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "Tyler Koenig",
description:
"SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<head>
<ThemeScript />
</head>
<body
className={`${sourceCodePro.variable} bg-[var(--color-bg)] text-[var(--color-text)] font-mono min-h-screen`}
>
<ThemeProvider>
{/* Full-width sticky nav */}
<Nav />
{/* Centered content column — border-l/r makes centering always visible */}
<div className="max-w-[740px] mx-auto border-l border-r border-[var(--color-border)]">
<main className="px-8 py-14">
{children}
</main>
<Footer />
</div>
</ThemeProvider>
</body>
</html>
);
}
-30
View File
@@ -1,30 +0,0 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import Skills from "@/components/Skills";
import Timeline from "@/components/Timeline";
import ProjectCard from "@/components/ProjectCard";
import Widget from "@/components/Widget";
import { featuredProjects } from "@/data/projects";
export const metadata: Metadata = {
title: "Tyler Koenig",
description:
"SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
};
export default function Home() {
return (
<>
<Hero />
<Widget title="tyler/projects" badge={featuredProjects.length}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{featuredProjects.map((project) => (
<ProjectCard key={project.slug} project={project} />
))}
</div>
</Widget>
<Skills />
<Timeline />
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
---
const year = new Date().getFullYear();
---
<footer class="border-t border-[var(--color-border)] py-1lh mt-2lh">
<div class="px-4ch flex items-center justify-between text-[var(--color-text-dim)]">
<span>&copy; {year} Tyler Koenig</span>
<nav class="flex items-center gap-2ch">
<a
href="https://github.com/lerko96"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
GitHub
</a>
<a
href="https://gitea.lerkolabs.com/lerko"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
Gitea
</a>
<a
href="https://www.linkedin.com/in/tyler-koenig"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
LinkedIn
</a>
<a
href="mailto:tyler@lerkolabs.com"
target="_blank"
class="underline"
>
Email
</a>
</nav>
</div>
</footer>
-31
View File
@@ -1,31 +0,0 @@
export default function Footer() {
return (
<footer className="border-t border-[var(--color-border)] py-5 mt-8">
<div className="px-8 flex items-center justify-between">
<span className="font-mono text-sm text-[var(--color-text-dim)]">
&copy; {new Date().getFullYear()} Tyler Koenig
</span>
<div className="flex items-center gap-5">
<a
href="https://github.com/lerko96"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
[github]
</a>
<a
href="https://www.linkedin.com/in/tyler-koenig"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
[linkedin]
</a>
</div>
</div>
</footer>
);
}
+50
View File
@@ -0,0 +1,50 @@
---
import { services } from "@/data/services";
---
<section class="mb-3lh">
<h1 class="text-xl font-bold mb-half-lh">Tyler Koenig</h1>
<p class="text-[var(--color-text-label)] mb-1lh">
Security Operations · Self-Hosted Infrastructure
</p>
<p class="text-[var(--color-text-label)] leading-relaxed mb-1lh">
Homelab runs {services.length} services across segmented VLANs — pfSense, Authentik SSO,
full observability stack. Write software too: mobile apps, Go backends, open
protocols.
</p>
<nav class="flex flex-wrap items-center gap-x-2ch gap-y-half-lh text-[var(--color-text-label)]">
<a
href="https://github.com/lerko96"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
GitHub
</a>
<a
href="https://gitea.lerkolabs.com/lerko"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
Gitea
</a>
<a
href="https://www.linkedin.com/in/tyler-koenig"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
LinkedIn
</a>
<a
href="mailto:tyler@lerkolabs.com"
target="_blank"
class="underline"
>
Email
</a>
</nav>
</section>
-56
View File
@@ -1,56 +0,0 @@
export default function Hero() {
return (
<section className="mb-16">
<div className="flex flex-col gap-3">
<div>
<p className="font-mono text-base font-bold text-[var(--color-text)]">
<span className="text-[var(--color-accent-green)] select-none mr-2" aria-hidden="true"></span>
tyler koenig
</p>
<p className="font-mono text-sm text-[var(--color-text-label)] mt-0.5">
Security Operations · Self-Hosted Infrastructure
</p>
</div>
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed max-w-lg opacity-70">
Security operations and self-hosted infrastructure. Homelab runs 37
services across segmented VLANs pfSense, Authentik SSO, full
observability stack. Write software too: mobile apps, Go backends,
open protocols. Daily drivers, all of it.{' '}
<span className="animate-cursor text-[var(--color-accent-green)]" aria-hidden="true"></span>
</p>
<div className="flex flex-wrap items-center gap-x-5 gap-y-1">
<span className="font-mono text-sm text-[var(--color-accent-green)]">
available
</span>
<a
href="https://github.com/lerko96"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
[github]
</a>
<a
href="https://www.linkedin.com/in/tyler-koenig"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
[linkedin]
</a>
<a
href="mailto:tylerkoenig96@gmail.com"
aria-label="Email"
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
[email]
</a>
</div>
</div>
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
<header class="sticky top-0 z-50 bg-[var(--color-bg)] border-b border-[var(--color-border)]">
<nav class="max-w-[740px] mx-auto px-4ch h-11 flex items-center justify-between">
<a href="/" class="font-semibold">Tyler Koenig</a>
<div class="flex items-center gap-2ch">
<a href="#projects" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">projects</a>
<a href="#journey" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">journey</a>
<a href="#homelab" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">homelab</a>
<button
data-theme-toggle
aria-label="Switch to light mode"
class="text-[var(--color-text-label)] hover:text-[var(--color-text)] cursor-pointer"
>
light
</button>
</div>
</nav>
</header>
<script>
const themeBtn = document.querySelector("[data-theme-toggle]") as HTMLButtonElement;
function updateTheme() {
const isDark = document.documentElement.classList.contains("dark");
themeBtn.textContent = isDark ? "light" : "dark";
themeBtn.setAttribute(
"aria-label",
isDark ? "Switch to light mode" : "Switch to dark mode",
);
}
themeBtn.addEventListener("click", () => {
const next = !document.documentElement.classList.contains("dark");
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("lerko96-dark-mode", String(next));
updateTheme();
});
updateTheme();
const navLinks = document.querySelectorAll<HTMLAnchorElement>("[data-nav-link]");
const sections = Array.from(navLinks).map((link) => ({
link,
section: document.querySelector(link.hash) as HTMLElement,
}));
const atBottom = () =>
window.innerHeight + window.scrollY >= document.body.offsetHeight - 2;
const observer = new IntersectionObserver(
() => {
let active: HTMLAnchorElement | null = null;
if (atBottom()) {
active = sections[sections.length - 1].link;
} else {
for (const { link, section } of sections) {
if (section.getBoundingClientRect().top <= 80) active = link;
}
}
navLinks.forEach((l) => {
l.style.color = l === active ? "var(--color-text)" : "";
});
},
{ rootMargin: "-56px 0px 0px 0px", threshold: 0 },
);
sections.forEach(({ section }) => observer.observe(section));
let ticking = false;
window.addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
let active: HTMLAnchorElement | null = null;
if (atBottom()) {
active = sections[sections.length - 1].link;
} else {
for (const { link, section } of sections) {
if (section.getBoundingClientRect().top <= 80) active = link;
}
}
navLinks.forEach((l) => {
l.style.color = l === active ? "var(--color-text)" : "";
});
ticking = false;
});
}, { passive: true });
</script>
-59
View File
@@ -1,59 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "@/context/ThemeContext";
const links = [
{ href: "/", label: "tyler" },
{ href: "/homelab/", label: "homelab" },
{ href: "/archive/", label: "archive" },
];
export default function Nav() {
const pathname = usePathname();
const { isDark, toggle } = useTheme();
return (
<header className="sticky top-0 z-50 bg-[var(--color-surface)] border-b border-[var(--color-border)]">
<nav className="max-w-[740px] mx-auto px-8 h-11 flex items-center justify-between">
<Link
href="/"
className="font-mono text-sm font-bold text-[var(--color-text)] hover:text-[var(--color-text-label)]"
>
~/
</Link>
<ul className="flex items-center gap-6">
{links.map(({ href, label }) => {
const active =
pathname === href || pathname === href.replace(/\/$/, "");
return (
<li key={href}>
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`font-mono text-sm ${
active
? "text-[var(--color-text)]"
: "text-[var(--color-text-label)] hover:text-[var(--color-text)]"
}`}
>
{label}
</Link>
</li>
);
})}
<li>
<button
onClick={toggle}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)] cursor-pointer"
>
{isDark ? "[light]" : "[dark]"}
</button>
</li>
</ul>
</nav>
</header>
);
}
+35
View File
@@ -0,0 +1,35 @@
---
import type { Project } from "@/data/projects";
interface Props {
project: Project;
}
const { project } = Astro.props;
---
<article class="mb-2lh">
<div class="flex items-baseline gap-1ch mb-half-lh">
<h3>
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
class="font-semibold underline"
>
{project.title}
</a>
</h3>
{project.statusBadge && (
<span class="text-[var(--color-text-dim)]">({project.statusBadge})</span>
)}
</div>
<p class="text-[var(--color-text-label)] leading-relaxed mb-half-lh">
{project.description}
</p>
<p class="text-[var(--color-text-dim)]">
{project.tags.join(" · ")}
</p>
</article>
-67
View File
@@ -1,67 +0,0 @@
import type { Project } from "@/data/projects";
type Props = {
project: Project;
};
export default function ProjectCard({ project }: Props) {
return (
<article className="border border-[var(--color-border)] bg-[var(--color-surface)] flex flex-col gap-4 p-5 hover:bg-[var(--color-surface-raised)]">
<div className="flex items-start justify-between gap-3">
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm font-semibold text-[var(--color-text)] hover:text-[var(--color-accent-green)]"
>
{project.title}
</a>
<div className="flex items-center gap-3 shrink-0">
{project.stats && (
<span className="font-mono text-sm text-[var(--color-text-dim)]">
{project.stats}
</span>
)}
{project.externalUrl && (
<a
href={project.externalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`View ${project.title} externally`}
className="font-mono text-sm text-[var(--color-text-label)] hover:text-[var(--color-text)]"
>
</a>
)}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
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>
{project.statusBadge && (
<span className="font-mono text-xs text-[var(--color-accent-amber,#d4a027)] border border-[var(--color-accent-amber,#d4a027)] px-1.5 py-0.5 w-fit opacity-80">
{project.statusBadge}
</span>
)}
<p className="font-mono text-sm text-[var(--color-text)] leading-relaxed flex-1 opacity-70">
{project.description}
</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1">
{project.tags.map((tag) => (
<span key={tag} className="font-mono text-xs text-[var(--color-text-dim)]">
{tag}
</span>
))}
</div>
</article>
);
}
+43
View File
@@ -0,0 +1,43 @@
---
import Widget from "./Widget.astro";
const skillGroups = [
{
label: "Infrastructure",
skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"],
},
{
label: "Desktop & Tools",
skills: ["Git", "Docker", "TDD", "Node.js", "REST APIs"],
},
{
label: "Practices",
skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"],
},
{
label: "Languages",
skills: ["Go", "JavaScript", "TypeScript", "HTML", "CSS"],
},
{
label: "Frontend",
skills: ["React", "React Native", "Expo", "Next.js", "Three.js"],
},
];
const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
---
<Widget title="Skills" badge={totalCount} as="section">
<div class="flex flex-col">
{skillGroups.map(({ label, skills }) => (
<div class="flex flex-col xs:flex-row gap-1ch xs:gap-2ch py-half-lh">
<span class="text-[var(--color-text-dim)] w-28 shrink-0">
{label}
</span>
<span>
{skills.join(" · ")}
</span>
</div>
))}
</div>
</Widget>
-48
View File
@@ -1,48 +0,0 @@
import Widget from "@/components/Widget";
const skillGroups = [
{
label: "Languages",
skills: ["Go", "JavaScript", "TypeScript", "HTML", "CSS"],
},
{
label: "Frontend",
skills: ["React", "React Native", "Expo", "Next.js", "Three.js"],
},
{
label: "Desktop & Tools",
skills: ["Electron", "Node.js", "REST APIs", "Git", "Docker", "TDD"],
},
{
label: "Infrastructure",
skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"],
},
{
label: "Practices",
skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"],
},
];
const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
export default function Skills() {
return (
<Widget title="tyler/skills" badge={totalCount} as="section">
<div className="flex flex-col">
{skillGroups.map(({ label, skills }) => (
<div
key={label}
className="flex flex-col xs:flex-row gap-1 xs:gap-6 py-3"
>
<span className="font-mono text-sm text-[var(--color-text-dim)] w-28 shrink-0">
{label}
</span>
<span className="font-mono text-sm text-[var(--color-text)]">
{skills.join(" · ")}
</span>
</div>
))}
</div>
</Widget>
);
}
-12
View File
@@ -1,12 +0,0 @@
// Server component — renders a blocking inline script that sets the dark class
// on <html> before React hydrates, preventing flash of wrong theme.
export default function ThemeScript() {
const script = `
(function() {
var stored = localStorage.getItem('lerko96-dark-mode');
var dark = stored === null ? true : stored === 'true';
if (dark) document.documentElement.classList.add('dark');
})();
`;
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}
+38
View File
@@ -0,0 +1,38 @@
---
import Widget from "./Widget.astro";
import { timeline, type TimelineType } from "@/data/timeline";
const isDate = (d: string) => /^\d{4}/.test(d);
const typeLabel: Record<TimelineType, string> = {
career: "career",
education: "education",
cert: "cert",
project: "project",
homelab: "homelab",
};
---
<Widget title="Journey">
<ol class="flex flex-col">
{timeline.map((entry) => (
<li class="grid grid-cols-[10ch_1fr] gap-2ch py-half-lh border-b border-[var(--color-border)] last:border-b-0">
<div class="text-[var(--color-text-dim)] pt-[0.1em]">
{isDate(entry.date)
? <time datetime={entry.date}>{entry.date}</time>
: <span>{entry.date}</span>
}
</div>
<div>
<h3 class="font-semibold">
{entry.title}
<span class="font-normal text-[var(--color-text-dim)]"> · {typeLabel[entry.type]}</span>
</h3>
<p class="text-[var(--color-text-label)] leading-relaxed">
{entry.description}
</p>
</div>
</li>
))}
</ol>
</Widget>
-107
View File
@@ -1,107 +0,0 @@
'use client'
import { useEffect, useRef } from 'react'
import Widget from '@/components/Widget'
import { timeline, type TimelineType } from '@/data/timeline'
const typeColor: Record<TimelineType, string> = {
career: 'var(--color-timeline-career)',
education: 'var(--color-timeline-education)',
cert: 'var(--color-timeline-cert)',
project: 'var(--color-timeline-project)',
homelab: 'var(--color-timeline-homelab)',
}
const typeLabel: Record<TimelineType, string> = {
career: 'career',
education: 'education',
cert: 'cert',
project: 'project',
homelab: 'homelab',
}
export default function Timeline() {
const listRef = useRef<HTMLOListElement>(null)
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const entries = listRef.current?.querySelectorAll<HTMLLIElement>('[data-tl-entry]')
if (!entries) return
const observer = new IntersectionObserver(
(observed) => {
observed.forEach((entry) => {
if (entry.isIntersecting) {
;(entry.target as HTMLElement).style.opacity = '1'
;(entry.target as HTMLElement).style.transform = 'translateY(0)'
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.15 },
)
entries.forEach((el) => {
el.style.opacity = '0'
el.style.transform = 'translateY(8px)'
el.style.transition = 'opacity 240ms linear, transform 240ms linear'
observer.observe(el)
})
return () => observer.disconnect()
}, [])
return (
<Widget title="tyler/journey">
<ol ref={listRef} className="relative border-l border-[var(--color-border)] ml-1.5 flex flex-col gap-0">
{timeline.map((entry, i) => (
<li key={i} data-tl-entry className="pl-6 pb-8 last:pb-0 relative">
{/* Spine dot */}
<span
className="absolute -left-[7px] top-[3px] w-3 h-3 rounded-full border border-[var(--color-bg)] shrink-0"
style={{ backgroundColor: typeColor[entry.type] }}
aria-hidden="true"
/>
{/* Date + type badge */}
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-[var(--color-text-dim)]">{entry.date}</span>
<span
className="font-mono text-[10px] uppercase tracking-wider px-1 rounded-sm border"
style={{
color: typeColor[entry.type],
borderColor: typeColor[entry.type],
opacity: 0.7,
}}
>
{typeLabel[entry.type]}
</span>
</div>
{/* Title */}
<p className="font-mono text-sm font-semibold text-[var(--color-text)] mb-1">
{entry.title}
</p>
{/* Description */}
<p className="font-mono text-sm text-[var(--color-text)] opacity-70 leading-relaxed mb-2">
{entry.description}
</p>
{/* Tags */}
{entry.tags && entry.tags.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{entry.tags.map((tag) => (
<span key={tag} className="font-mono text-xs text-[var(--color-text-dim)]">
{tag}
</span>
))}
</div>
)}
</li>
))}
</ol>
</Widget>
)
}
+26
View File
@@ -0,0 +1,26 @@
---
interface Props {
title: string;
badge?: string | number;
meta?: string;
as?: "section" | "div" | "article";
class?: string;
}
const { title, badge, meta, as: Tag = "section", class: className } = Astro.props;
---
<Tag class:list={["mb-4lh", className]}>
<div class="mb-2lh">
<h2 class="text-lg font-semibold">
{title}
{badge !== undefined && (
<span class="text-[var(--color-text-dim)] font-normal"> ({badge})</span>
)}
</h2>
{meta && (
<p class="text-[var(--color-text-dim)]">{meta}</p>
)}
</div>
<slot />
</Tag>
-39
View File
@@ -1,39 +0,0 @@
type WidgetProps = {
title: string;
badge?: string | number;
meta?: string;
as?: "section" | "div" | "article";
className?: string;
children: React.ReactNode;
};
export default function Widget({
title,
badge,
meta,
as: Tag = "section",
className,
children,
}: WidgetProps) {
const slashIdx = title.lastIndexOf("/");
const prefix = slashIdx >= 0 ? title.slice(0, slashIdx + 1) : null;
const name = slashIdx >= 0 ? title.slice(slashIdx + 1) : title;
return (
<Tag className={`mb-16 ${className ?? ""}`}>
<div className="flex items-center gap-2 mb-8">
{prefix && (
<span className="font-mono text-sm text-[var(--color-text-dim)] select-none">{prefix}</span>
)}
<span className="font-mono text-sm font-semibold text-[var(--color-text)]">{name}</span>
{badge !== undefined && (
<span className="font-mono text-xs text-[var(--color-text-dim)]">[{badge}]</span>
)}
{meta && (
<span className="font-mono text-xs text-[var(--color-text-dim)] ml-1"> {meta}</span>
)}
</div>
{children}
</Tag>
);
}
-41
View File
@@ -1,41 +0,0 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type ThemeContextType = {
isDark: boolean;
toggle: () => void;
};
const ThemeContext = createContext<ThemeContextType>({
isDark: true,
toggle: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [isDark, setIsDark] = useState(true);
useEffect(() => {
const stored = localStorage.getItem("lerko96-dark-mode");
const dark = stored === null ? true : stored === "true";
setIsDark(dark);
document.documentElement.classList.toggle("dark", dark);
}, []);
function toggle() {
const next = !isDark;
setIsDark(next);
localStorage.setItem("lerko96-dark-mode", String(next));
document.documentElement.classList.toggle("dark", next);
}
return (
<ThemeContext.Provider value={{ isDark, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
+94 -56
View File
@@ -13,6 +13,75 @@ export type Project = {
export const projects: Project[] = [
// --- Featured ---
{
slug: "uptop",
title: "uptop",
description: "Live uptime monitoring dashboard for your terminal. SSH-accessible. HTTP, ping, TCP, DNS, push checks with alerts, clustering, and Prometheus metrics.",
tags: ["Go", "Bubbletea", "Monitoring", "Uptime"],
githubUrl: "https://github.com/lerkolabs/uptop",
tier: "featured",
year: 2026,
},
{
slug: "nib",
title: "nib",
description:
"Capture-first personal journal built with Go + SQLite. Currently developing in private when I have spare time.",
tags: ["Go", "JavaScript", "SQLite", "Stream-of-Thought"],
githubUrl: "https://gitea.lerkolabs.com/lerko/nib-v1",
tier: "featured",
year: 2026,
},
{
slug: "homelab",
title: "homelab",
description:
"7-VLAN segmented network, Wireguard VPN, Proxmox VMs/LXCs, SSO via Authentik, full monitoring stack (VictoriaMetrics + Grafana + Beszel + ntfy).",
tags: ["Markdown", "Mermaid", "Proxmox", "Monitor", "Backup"],
githubUrl: "https://gitea.lerkolabs.com/lerko/homelab",
tier: "featured",
year: 2026,
},
{
slug: "portfolio",
title: "portfolio",
description:
"Astro static site, self-hosted in a DMZ LXC behind Caddy, deployed via Gitea Actions CI.",
tags: ["Astro", "Typescript", "Dockerfile", "Caddy"],
githubUrl: "https://gitea.lerkolabs.com/lerko/portfolio",
tier: "featured",
year: 2021,
},
// --- Archive ---
{
slug: "open-pact",
title: "open-pact",
description: "Open protocol for AI agent identity, delegation, and portable memory. Ed25519 keypair identity, signed delegation",
tags: ["TypeScript", "Ed25519", "DID", "npm", "CC0"],
githubUrl: "https://github.com/lerko96/open-pact",
tier: "archive",
year: 2026,
},
{
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",
title: "golf-book-mobile",
@@ -20,10 +89,30 @@ export const projects: Project[] = [
"Offline-first mobile app for tracking golf rounds, managing your 14-club bag, and getting AI-powered club recommendations from a Smart Caddie. Covers 7 shot types per hole with full scorecard history.",
tags: ["React Native", "Expo", "Zustand", "AI", "Mobile"],
githubUrl: "https://github.com/lerko96/golf-book-mobile",
tier: "featured",
stats: "211 commits",
tier: "archive",
stats: "200+ commits",
statusBadge: "Pending App Store Approval",
externalUrl: "#",
year: 2025,
},
{
slug: "twitter-thread-ext",
title: "twitter-thread-ext",
description:
"Chrome extension (Manifest V3) that captures entire Twitter/X threads and exports them as HTML, Markdown, PDF, or image — with metadata preservation and preview before export.",
tags: ["Chrome Extension", "Manifest V3", "JavaScript", "jsPDF"],
githubUrl: "https://github.com/lerko96/twitter-thread-ext",
tier: "archive",
year: 2025,
},
{
slug: "notes-app-1.0",
title: "notes-app-1.0",
description:
"Lightweight canvas drawing app with color picker, adjustable brush size, and PNG export. Runs in the browser, no dependencies.",
tags: ["HTML5 Canvas", "JavaScript", "CSS"],
githubUrl: "https://github.com/lerko96/notes-app-1.0",
tier: "archive",
year: 2025,
},
{
slug: "plaiground",
@@ -55,57 +144,6 @@ export const projects: Project[] = [
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",
description:
"Chrome extension (Manifest V3) that captures entire Twitter/X threads and exports them as HTML, Markdown, PDF, or image — with metadata preservation and preview before export.",
tags: ["Chrome Extension", "Manifest V3", "JavaScript", "jsPDF"],
githubUrl: "https://github.com/lerko96/twitter-thread-ext",
tier: "archive",
year: 2023,
},
{
slug: "notes-app-1.0",
title: "notes-app-1.0",
description:
"Lightweight canvas drawing app with color picker, adjustable brush size, and PNG export. Runs in the browser, no dependencies.",
tags: ["HTML5 Canvas", "JavaScript", "CSS"],
githubUrl: "https://github.com/lerko96/notes-app-1.0",
tier: "archive",
year: 2022,
},
{
slug: "were-hooked",
title: "We're Hooked",
@@ -114,7 +152,7 @@ export const projects: Project[] = [
tags: ["Java", "Spring", "Thymeleaf", "HTML", "CSS"],
githubUrl: "https://github.com/lerko96/were-hooked-repo",
tier: "archive",
year: 2022,
year: 2021,
},
{
slug: "mystery-educator",
@@ -124,7 +162,7 @@ export const projects: Project[] = [
tags: ["JavaScript", "REST APIs", "HTML", "CSS"],
githubUrl: "https://github.com/lerko96/mystery-educator",
tier: "archive",
year: 2022,
year: 2021,
},
];
+41 -39
View File
@@ -2,56 +2,58 @@ export type Service = {
name: string;
description: string;
category: "infrastructure" | "security" | "monitoring" | "productivity" | "media";
icon: string; // Font Awesome class
hidden?: boolean;
};
export const services: Service[] = [
// Infrastructure
{ 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 — 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" },
{ name: "pfSense", description: "Firewall, DHCP, routing gateway on Netgate 1100", category: "infrastructure" },
{ name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure" },
{ name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure" },
{ name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure" },
{ name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure" },
{ name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure", hidden: true },
{ name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure" },
// Security / Auth
{ name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security", icon: "fas fa-id-badge" },
{ name: "Vaultwarden", description: "Self-hosted password manager, isolated in its own LXC", category: "security", icon: "fas fa-vault" },
{ name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security" },
{ name: "Vaultwarden", description: "Self-hosted password manager, isolated in its own LXC", category: "security" },
// Monitoring
{ name: "Victoria Metrics", description: "Long-term metrics storage and querying", category: "monitoring", icon: "fas fa-chart-line" },
{ name: "Grafana", description: "Dashboards and alerting across all hosts and services", category: "monitoring", icon: "fas fa-chart-bar" },
{ name: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring", icon: "fas fa-server" },
{ name: "ntfy", description: "Self-hosted push notifications", category: "monitoring", icon: "fas fa-bell" },
{ name: "Victoria Metrics", description: "Long-term metrics storage and querying", category: "monitoring" },
{ name: "Grafana", description: "Dashboards and alerting across all hosts and services", category: "monitoring" },
{ name: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring" },
{ name: "ntfy", description: "Self-hosted push notifications", category: "monitoring" },
{ name: "Uptime Kuma", description: "Self-hosted monitoring tool (GUI)", category: "monitoring" },
// Productivity
{ name: "Gitea", description: "Personal Git server", category: "productivity", icon: "fas fa-code-branch" },
{ name: "Outline", description: "Team wiki and knowledge base", category: "productivity", icon: "fas fa-book" },
{ name: "Vikunja", description: "Task management", category: "productivity", icon: "fas fa-list-check" },
{ name: "Actual Budget", description: "Personal budgeting", category: "productivity", icon: "fas fa-wallet" },
{ name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity", icon: "fas fa-coins" },
{ name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity", icon: "fas fa-bookmark" },
{ name: "FreshRSS", description: "RSS reader", category: "productivity", icon: "fas fa-rss" },
{ name: "Memos", description: "Quick notes and journal", category: "productivity", icon: "fas fa-note-sticky" },
{ 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" },
{ name: "Gitea", description: "Personal Git server", category: "productivity" },
{ name: "Outline", description: "Team wiki and knowledge base", category: "productivity" },
{ name: "Actual Budget", description: "Personal budgeting", category: "productivity" },
{ name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity" },
{ name: "Grist", description: "Spreadsheets and structured data", category: "productivity" },
{ name: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity" },
{ name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity", hidden: true },
{ name: "FreshRSS", description: "RSS reader", category: "productivity", hidden: true },
{ name: "Memos", description: "Quick notes and journal", category: "productivity", hidden: true },
{ name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity", hidden: true },
{ name: "Vikunja", description: "Task management", category: "productivity", hidden: true },
{ name: "Traggo", description: "Time tracking", category: "productivity", hidden: true },
{ name: "Filebrowser", description: "Web-based file manager", category: "productivity", hidden: true },
// Media
{ 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" },
{ name: "Immich", description: "Open-source photo library", category: "media"},
{ name: "Kavita", description: "Self-hosted manga and book reader", category: "media" },
{ name: "Jellyfin", description: "Open-source media streaming", category: "media" },
{ name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media" },
{ name: "*Arr stack", description: "Automated media management for streaming", category: "media" },
{ name: "Bazarr", description: "Automatic subtitle download and management", category: "media" },
{ name: "Lidarr", description: "Automated music management", category: "media", hidden: true },
{ name: "Radarr", description: "Automated movie management", category: "media", hidden: true },
{ name: "Plex", description: "Media streaming — movies, TV, music", category: "media", hidden: true },
{ name: "nzbget", description: "Usenet downloader", category: "media", hidden: true },
{ name: "qBittorrent", description: "Torrent client with web UI", category: "media", hidden: true },
{ name: "Openshelf", description: "Book library with auto-ingest", category: "media", hidden: true },
];
export const categoryOrder: Service["category"][] = [
+65 -52
View File
@@ -1,75 +1,88 @@
export type TimelineType = 'career' | 'cert' | 'project' | 'homelab' | 'education'
export type TimelineType =
| "career"
| "cert"
| "project"
| "homelab"
| "education";
export interface TimelineEntry {
date: string
title: string
type: TimelineType
description: string
tags?: string[]
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: "WIP",
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: "2025",
title: "Proxmox Backup Server",
type: "homelab",
description: "Deployed PBS on used desktop hardware for disaster recovery.",
tags: ["backup", "recovery", "retention"],
},
{
date: '2024',
title: 'CompTIA A+',
type: 'cert',
description: 'Earned A+ certification, formalizing hardware and OS fundamentals.',
tags: ['certification'],
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',
title: 'Project Helm',
type: 'project',
description: 'Full-stack task and project management tool built in Go + React.',
tags: ['go', 'react', 'typescript'],
date: "2024-06",
title: "CompTIA A+",
type: "cert",
description:
"Earned A+ certification, formalizing hardware and OS fundamentals.",
tags: ["certification"],
},
{
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: "2024-03",
title: "pfSense",
type: "homelab",
description:
"Netgate 1100 (Marvell ARMADA 3720) picked up on eBay — hands-on networking configuration, VLANs, firewall rules, and troubleshooting.",
tags: ["network", "firewall", "vlan", "dhcp"],
},
{
date: '2023-10',
title: 'SOC Analyst I — Fortress SRM',
type: 'career',
description: 'Threat monitoring, incident triage, and client-facing security operations in a managed SOC.',
tags: ['soc', 'security'],
date: "2023-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: "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: "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',
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'],
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"],
},
]
];
+67
View File
@@ -0,0 +1,67 @@
---
interface Props {
title?: string;
description?: string;
}
const {
title = "Tyler Koenig",
description = "Security operations, self-hosted infrastructure, and software projects. Homelab, Go, TypeScript, and more.",
} = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<title>{title}</title>
<meta name="description" content={description} />
<script is:inline>
(function () {
var stored = localStorage.getItem("lerko96-dark-mode");
var dark = stored === null ? true : stored === "true";
if (dark) document.documentElement.classList.add("dark");
})();
</script>
</head>
<body
class="bg-[var(--color-bg)] text-[var(--color-text)] min-h-screen"
>
<slot name="nav" />
<div class="max-w-[740px] mx-auto">
<main class="px-4ch py-3lh">
<slot />
</main>
<slot name="footer" />
</div>
</body>
</html>
<style is:global>
@import "../styles/globals.css";
@font-face {
font-family: "Source Code Pro";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/SourceCodePro-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: "Source Code Pro";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/SourceCodePro-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url=/#projects" />
<link rel="canonical" href="/" />
<title>Redirecting...</title>
</head>
<body>
<p>This page moved. <a href="/#projects">/#projects</a></p>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url=/#homelab" />
<link rel="canonical" href="/" />
<title>Redirecting...</title>
</head>
<body>
<p>This page moved. <a href="/#homelab">/#homelab</a></p>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
---
import Base from "@/layouts/Base.astro";
import Nav from "@/components/Nav.astro";
import Footer from "@/components/Footer.astro";
import Hero from "@/components/Hero.astro";
import Timeline from "@/components/Timeline.astro";
import Widget from "@/components/Widget.astro";
import ProjectCard from "@/components/ProjectCard.astro";
import { featuredProjects } from "@/data/projects";
import { services, categoryOrder, categoryLabels } from "@/data/services";
const glanceStats = [
{ label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Netgate 1100)" },
{ label: "Network", value: "7 VLANs, default deny, managed switching" },
{ label: "Services", value: `${services.length} self-hosted across ${categoryOrder.length} categories` },
];
---
<Base>
<Nav slot="nav" />
<Hero />
<section id="projects">
<Widget title="Projects" as="div">
<div class="flex flex-col">
{featuredProjects.map((project) => (
<ProjectCard project={project} />
))}
</div>
</Widget>
</section>
<section id="journey">
<Timeline />
</section>
<section id="homelab">
<Widget title="Homelab" as="div">
<p class="text-[var(--color-text-label)] leading-relaxed max-w-2xl mb-2lh">
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>
<dl class="flex flex-col mb-2lh">
{glanceStats.map(({ label, value }) => (
<div class="flex gap-2ch py-qtr-lh">
<dt class="text-[var(--color-text-dim)] w-[16ch] shrink-0">{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div class="flex flex-wrap gap-x-[3ch] gap-y-qtr-lh mb-2lh">
{categoryOrder.map((cat) => {
const count = services.filter((s) => s.category === cat).length;
return (
<span>
<span class="text-[var(--color-text-dim)]">{categoryLabels[cat]}</span>
<span class="text-[var(--color-text-label)]"> ({count})</span>
</span>
);
})}
</div>
<p class="text-[var(--color-text-label)] mb-half-lh">
Full documentation — network maps, ADRs, runbooks, and service configs.
</p>
<a
href="https://gitea.lerkolabs.com/lerko/homelab"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
gitea.lerkolabs.com/lerko/homelab →
</a>
</Widget>
</section>
<Footer slot="footer" />
</Base>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url=/#projects" />
<link rel="canonical" href="/" />
<title>Redirecting...</title>
</head>
<body>
<p>This page moved. <a href="/#projects">/#projects</a></p>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
/* Slate (dark, default) */
--color-bg: #1A1B1E;
--color-surface: #22242A;
--color-surface-raised: #2A2D34;
--color-border: #33363E;
--color-border-bright: #3E4148;
--color-text: #D4D4D8;
--color-text-label: #9CA0AA;
--color-text-dim: #888D9B;
--color-link: #8CAFC8;
--color-link-visited: #9A94AB;
/* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-serif: Charter, "Bitstream Charter", "Sitka Text", Cambria, serif;
--font-mono: "Source Code Pro", ui-monospace, monospace;
/* Breakpoints */
--breakpoint-xs: 576px;
/* Character-grid spacing — horizontal (ch) */
--spacing-1ch: 1ch;
--spacing-2ch: 2ch;
--spacing-3ch: 3ch;
--spacing-4ch: 4ch;
/* Character-grid spacing — vertical (lh, requires line-height:1.5 on html) */
--spacing-qtr-lh: 0.25lh;
--spacing-half-lh: 0.5lh;
--spacing-1lh: 1lh;
--spacing-2lh: 2lh;
--spacing-3lh: 3lh;
--spacing-4lh: 4lh;
}
/* Base */
html {
scroll-behavior: smooth;
font-size: 16px;
line-height: 1.5;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
}
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
/* Bone (light) overrides */
:root:not(.dark) {
--color-bg: #FAF8F5;
--color-surface: #F3F0EB;
--color-surface-raised: #EBE8E3;
--color-border: #E0DCD5;
--color-border-bright: #D4D0C8;
--color-text: #2C2C2C;
--color-text-label: #6B6560;
--color-text-dim: #787068;
--color-link: #4A6B8A;
--color-link-visited: #6B6080;
}
/* Link underlines */
a {
text-decoration-thickness: 1px;
text-underline-offset: 3px;
text-decoration-color: var(--color-border-bright);
}
a:hover {
text-decoration-color: currentColor;
}
a[target="_blank"] {
color: var(--color-link);
}
a[target="_blank"]:visited {
color: var(--color-link-visited);
}
a[target="_blank"]:hover {
color: var(--color-link);
text-decoration-color: currentColor;
}
/* Focus states */
a:focus-visible {
text-decoration-color: currentColor;
outline: 2px solid var(--color-border-bright);
outline-offset: 2px;
}
button:focus-visible {
outline: 2px solid var(--color-border-bright);
outline-offset: 2px;
}
/* Default transitions */
a,
button {
transition: color 120ms linear, border-color 120ms linear,
opacity 120ms linear, text-decoration-color 120ms linear,
outline-color 120ms linear;
}
/* Section anchors — clear sticky nav, visual rhythm */
section[id] {
scroll-margin-top: 3.5rem;
padding-top: 2lh;
border-top: 1px solid var(--color-border);
}
section[id]:first-of-type {
border-top: none;
padding-top: 0;
}
@media print {
html {
font-family: var(--font-serif);
font-size: 11pt;
line-height: 1.3;
color: #000;
background: #fff;
}
header, footer, button { display: none; }
a { color: inherit; text-decoration: underline; }
main { max-width: none; padding: 0; }
section, div, li { margin-bottom: 0.25em; padding-bottom: 0; }
h1, h2, h3 { margin-bottom: 0.15em; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+3 -28
View File
@@ -1,34 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
}