feat: terminal-noir redesign with centering fix and readability improvements
All checks were successful
Build and Deploy / deploy (push) Successful in 55s

This commit is contained in:
lerko96
2026-04-12 20:01:26 -04:00
13 changed files with 411 additions and 388 deletions

View File

@@ -27,12 +27,10 @@ npm run deploy # build + push out/ to master (GitHub mirror)
## How it deploys ## How it deploys
`npm run deploy` runs `predeploy` (build) then pushes the `out/` directory to `master` via `gh-pages`. That's what feeds the GitHub Pages backup at lerko96.com. `npm run deploy` runs `predeploy` (build) then pushes the `out/` directory to `master` via `gh-pages`. That's what feeds the GitHub Pages backup mirror.
`postbuild` drops `out/.nojekyll` so GitHub Pages doesn't ignore `_next/` assets. `postbuild` drops `out/.nojekyll` so GitHub Pages doesn't ignore `_next/` assets.
Custom domain is in `public/CNAME` — gets copied into `out/` on build.
--- ---
## Project layout ## Project layout
@@ -51,8 +49,7 @@ src/
data/ data/
projects.ts # all projects, featured + archive split projects.ts # all projects, featured + archive split
services.ts # homelab services with categories services.ts # homelab services with categories
public/ public/ # static assets copied into out/ on build
CNAME # www.lerko96.com
``` ```
> Tailwind v4 is CSS-first — no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`. > Tailwind v4 is CSS-first — no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`.

View File

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

View File

@@ -3,65 +3,55 @@
@variant dark (&:where(.dark, .dark *)); @variant dark (&:where(.dark, .dark *));
@theme { @theme {
/* Colors */ /* Terminal-Noir palette */
--color-green: #2bf3c4; --color-bg: #0a0a0a;
--color-green-dark: #27bb98; --color-surface: #111111;
--color-green-darker: #238770; --color-surface-raised: #1a1a1a;
--color-green-darkest: #1f4b40; --color-border: #2a2a2a;
--color-border-bright: #444444;
--color-bg: #272727; --color-text: #e8e8e8;
--color-bg-deep: #1b1b1b; --color-text-label: #666666;
--color-surface: #333333; --color-text-dim: #444444;
--color-accent-green: #00cc44;
--color-grey-1: #4b4b4b; --color-accent-red: #cc2200;
--color-grey-2: #707171;
--color-grey-3: #999a9a;
--color-grey-4: #c5c6c6;
--color-text: #c5c6c6;
--color-text-muted: #999a9a;
--color-text-light: #f7f9fb;
/* Typography */ /* Typography */
--font-mono: "Source Code Pro", ui-monospace, monospace; --font-mono: "Source Code Pro", ui-monospace, monospace;
--font-sans: "Montserrat", ui-sans-serif, system-ui, sans-serif; --font-sans: ui-sans-serif, system-ui, sans-serif;
/* Breakpoints */ /* Breakpoints */
--breakpoint-xs: 576px; --breakpoint-xs: 576px;
/* Animations */ /* Animations */
--animate-fade-in: fadeIn 0.6s ease forwards; --animate-fade-in: fadeIn 120ms linear forwards;
--animate-slide-up: slideUp 0.5s ease forwards;
--animate-app-scale: appScale 0.4s ease forwards;
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes appScale {
from { transform: scale(0.97); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
} }
/* Base */ /* Base */
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
background-color: var(--color-bg-deep); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-family: var(--font-sans); font-family: var(--font-mono);
} }
* { @layer base {
box-sizing: border-box; * {
margin: 0; box-sizing: border-box;
padding: 0; margin: 0;
padding: 0;
}
}
/* Default transitions — linear, fast */
a,
button {
transition: color 120ms linear, border-color 120ms linear,
background-color 120ms linear, opacity 120ms linear;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Widget from "@/components/Widget";
import { services, categoryOrder, categoryLabels } from "@/data/services"; import { services, categoryOrder, categoryLabels } from "@/data/services";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -7,15 +8,27 @@ export const metadata: Metadata = {
"Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services.", "Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services.",
}; };
const 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 = [ const vlans = [
{ id: "1000", name: "MGMT", subnet: "10.0.0.0/24", purpose: "Network equipment only" }, { 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: "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: "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: "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: "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: "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: "DMZ", name: "DMZ", subnet: "10.99.0.0/24", purpose: "Public-facing, hard-blocked internally" },
{ id: "VPN", name: "VPN", subnet: "10.200.0.0/24", purpose: "WireGuard clients = LAN access" }, { id: "VPN", name: "VPN", subnet: "10.200.0.0/24", purpose: "WireGuard clients = LAN access" },
]; ];
const adrs = [ const adrs = [
@@ -58,8 +71,8 @@ const adrs = [
{ {
title: "Gitea CI/CD: Self-hosted runner with container build + SSH rsync deploy", title: "Gitea CI/CD: Self-hosted runner with container build + SSH rsync deploy",
decision: decision:
"act_runner v0.3.1 on Gitea LXC (10.99.0.22). Push to dev → node:22-alpine container builds Next.js → rsync out/ to Portfolio LXC → SSH docker rebuild. Runner WorkingDirectory=/opt/docker/gitea. Feature branches for daily work; merge to dev to deploy.", "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. Runner must be registered with the LXC IP (10.99.0.22:3000), not localhost — containers can't resolve localhost to the host. The .runner file must live in WorkingDirectory or the daemon crashes on start.", 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.",
}, },
]; ];
@@ -68,114 +81,115 @@ export default function HomelabPage() {
<> <>
{/* Header */} {/* Header */}
<div className="mb-16"> <div className="mb-16">
<p className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2"> <div className="flex items-center gap-3 mb-4">
lerkolabs <span className="font-mono text-xs text-[var(--color-text-label)] tracking-widest uppercase">
</p> lerkolabs
<h1 className="font-mono text-2xl font-bold text-[var(--color-text-light)] mb-4"> </span>
<div className="flex-1 h-px bg-[var(--color-border)]" aria-hidden="true" />
</div>
<h1 className="font-mono text-lg font-bold text-[var(--color-text)] mb-3">
Home Infrastructure Lab Home Infrastructure Lab
</h1> </h1>
<p className="text-[var(--color-grey-3)] text-sm leading-relaxed max-w-2xl"> <p className="font-sans text-sm text-[var(--color-text)] leading-relaxed max-w-2xl opacity-80">
Personal infrastructure environment for learning, self-hosting, and operational practice. Personal infrastructure environment for learning, self-hosting, and operational
Running 24/7 on production-grade hardware with real network segmentation, SSO, practice. Running 24/7 on production-grade hardware with real network segmentation,
monitoring, and IaC-style documentation. SSO, monitoring, and IaC-style documentation.
</p> </p>
</div> </div>
{/* At a glance */} {/* At a Glance */}
<section className="mb-16" aria-labelledby="glance-heading"> <Widget title="at a glance" badge={glanceStats.length} as="section">
<h2 <div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
id="glance-heading" {glanceStats.map(({ label, value }) => (
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6"
>
At a Glance
</h2>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-4">
{[
{ label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Intel N100)" },
{ label: "Switching", value: "TP-Link Omada (managed)" },
{ label: "ISP", value: "AT&T Fiber 1 Gbps" },
{ label: "VPN", value: "WireGuard (pfSense)" },
{ label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" },
{ label: "Auth", value: "Authentik SSO" },
{ label: "DNS", value: "Pi-hole → Unbound → Cloudflare" },
{ label: "Containers", value: "9 LXC + 2 VMs" },
].map(({ label, value }) => (
<div <div
key={label} key={label}
className="border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)]" className="bg-[var(--color-surface)] px-4 py-3"
> >
<p className="font-mono text-xs text-[var(--color-grey-2)] mb-1">{label}</p> <p className="font-mono text-xs text-[var(--color-text-dim)] uppercase tracking-wider mb-1">
<p className="font-mono text-sm text-[var(--color-text-light)]">{value}</p> {label}
</p>
<p className="font-mono text-sm text-[var(--color-text)]">{value}</p>
</div> </div>
))} ))}
</div> </div>
</section> </Widget>
{/* VLAN table */} {/* VLAN table */}
<section className="mb-16" aria-labelledby="network-heading"> <Widget
<h2 title="network"
id="network-heading" meta="8 isolated vlans · default deny inter-vlan"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6" as="section"
> >
Network 8 Isolated VLANs
</h2>
<p className="text-xs text-[var(--color-grey-3)] mb-4">
Default deny inter-VLAN policy. Each VLAN has explicit firewall rules for what it can reach.
</p>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm border-collapse"> <table className="w-full text-xs border-collapse">
<thead> <thead>
<tr className="border-b border-[var(--color-grey-1)]"> <tr className="border-b border-[var(--color-border)]">
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">VLAN</th> <th className="font-mono text-[var(--color-text-dim)] text-left py-2 pr-6 uppercase tracking-wider">
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">Name</th> VLAN
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">Subnet</th> </th>
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 uppercase tracking-wider">Purpose</th> <th className="font-mono text-[var(--color-text-dim)] text-left py-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> </tr>
</thead> </thead>
<tbody> <tbody>
{vlans.map((v) => ( {vlans.map((v) => (
<tr key={v.id} className="border-b border-[var(--color-grey-1)] border-opacity-30 hover:bg-[var(--color-bg)] transition-colors"> <tr
<td className="font-mono text-xs text-[var(--color-green)] py-2.5 pr-4">{v.id}</td> key={v.id}
<td className="font-mono text-sm text-[var(--color-text-light)] py-2.5 pr-4">{v.name}</td> className="border-b border-[var(--color-border)] hover:bg-[var(--color-surface)]"
<td className="font-mono text-xs text-[var(--color-grey-3)] py-2.5 pr-4">{v.subnet}</td> >
<td className="text-xs text-[var(--color-grey-3)] py-2.5">{v.purpose}</td> <td className="font-mono text-[var(--color-accent-green)] py-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-sans text-sm text-[var(--color-text)] py-2.5 opacity-80">{v.purpose}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </Widget>
{/* Services */} {/* Services */}
<section className="mb-16" aria-labelledby="services-heading"> <Widget
<h2 title="services"
id="services-heading" badge={services.length}
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6" as="section"
> >
Self-Hosted Services
</h2>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
{categoryOrder.map((cat) => { {categoryOrder.map((cat) => {
const catServices = services.filter((s) => s.category === cat); const catServices = services.filter((s) => s.category === cat);
return ( return (
<div key={cat}> <div key={cat}>
<h3 className="font-mono text-xs text-[var(--color-grey-2)] uppercase tracking-wider mb-3"> <p className="font-mono text-xs text-[var(--color-text-dim)] uppercase tracking-wider mb-3">
{categoryLabels[cat]} {categoryLabels[cat]}
</h3> </p>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-3"> <div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
{catServices.map((svc) => ( {catServices.map((svc) => (
<div <div
key={svc.name} key={svc.name}
className="flex items-start gap-3 border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors" className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start gap-3 px-4 py-3"
> >
<i <i
className={`${svc.icon} text-[var(--color-green)] text-sm mt-0.5 w-4 shrink-0`} className={`${svc.icon} text-[var(--color-text-label)] text-xs mt-0.5 w-3.5 shrink-0`}
aria-hidden="true" aria-hidden="true"
/> />
<div> <div>
<p className="font-mono text-xs text-[var(--color-text-light)] mb-0.5">{svc.name}</p> <p className="font-mono text-xs text-[var(--color-text)] mb-0.5">
<p className="text-xs text-[var(--color-grey-2)] leading-relaxed">{svc.description}</p> {svc.name}
</p>
<p className="font-sans text-sm text-[var(--color-text)] leading-relaxed opacity-75">
{svc.description}
</p>
</div> </div>
</div> </div>
))} ))}
@@ -184,52 +198,48 @@ export default function HomelabPage() {
); );
})} })}
</div> </div>
</section> </Widget>
{/* ADRs */} {/* ADRs */}
<section className="mb-16" aria-labelledby="adr-heading"> <Widget
<h2 title="architecture decisions"
id="adr-heading" meta="why things are configured the way they are"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2" badge={adrs.length}
> as="section"
Architecture Decisions >
</h2> <div className="flex flex-col gap-px bg-[var(--color-border)]">
<p className="text-xs text-[var(--color-grey-3)] mb-6">
Short-form ADRs why things are configured the way they are.
</p>
<div className="flex flex-col gap-5">
{adrs.map((adr) => ( {adrs.map((adr) => (
<div <div
key={adr.title} key={adr.title}
className="border border-[var(--color-grey-1)] rounded-lg p-5 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors" className="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-4 py-4"
> >
<h3 className="font-mono text-sm text-[var(--color-text-light)] mb-2">{adr.title}</h3> <p className="font-mono text-sm text-[var(--color-text)] mb-2">{adr.title}</p>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed mb-2"> <p className="font-sans text-sm text-[var(--color-text)] leading-relaxed mb-1.5 opacity-75">
<span className="text-[var(--color-grey-2)]">Decision: </span>{adr.decision} <span className="text-[var(--color-text-label)] opacity-100">decision: </span>
{adr.decision}
</p> </p>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed"> <p className="font-sans text-sm text-[var(--color-text)] leading-relaxed opacity-75">
<span className="text-[var(--color-grey-2)]">Why: </span>{adr.why} <span className="text-[var(--color-text-label)] opacity-100">why: </span>
{adr.why}
</p> </p>
</div> </div>
))} ))}
</div> </div>
</section> </Widget>
{/* GitHub CTA */} {/* GitHub CTA */}
<section className="border border-[var(--color-grey-1)] rounded-lg p-6 bg-[var(--color-bg)] flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> <section className="border-t border-[var(--color-border)] pt-6">
<div> <p className="font-mono text-sm text-[var(--color-text-label)] mb-1">lerkolabs on GitHub</p>
<p className="font-mono text-sm text-[var(--color-text-light)] mb-1">lerkolabs on GitHub</p> <p className="font-sans text-sm text-[var(--color-text)] mb-3 opacity-75">
<p className="text-xs text-[var(--color-grey-3)]"> Full documentation: VLAN maps, runbooks, service registry, config exports, and setup guides.
Full documentation: VLAN maps, runbooks, service registry, config exports, and setup guides. </p>
</p>
</div>
<a <a
href="https://github.com/lerko96/homelab-wip" href="https://github.com/lerko96/homelab-wip"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="shrink-0 font-mono text-xs px-4 py-2 border border-[var(--color-green-darker)] text-[var(--color-green)] rounded hover:bg-[var(--color-green-darkest)] transition-colors" className="font-mono text-xs text-[var(--color-text-label)] hover:text-[var(--color-text)]"
> >
View repo <i className="fas fa-arrow-up-right-from-square ml-1 text-xs" aria-hidden="true" /> github.com/lerko96/homelab-wip
</a> </a>
</section> </section>
</> </>

View File

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

View File

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

View File

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

View File

@@ -2,61 +2,74 @@ import Image from "next/image";
export default function Hero() { export default function Hero() {
return ( return (
<section className="flex flex-col sm:flex-row items-center sm:items-start gap-8 mb-20"> <section className="mb-16">
<div className="shrink-0"> {/* Section header */}
<div className="flex items-center gap-3 mb-6">
<span className="font-mono text-xs text-[var(--color-text-label)] tracking-widest uppercase">
identity
</span>
<div className="flex-1 h-px bg-[var(--color-border)]" aria-hidden="true" />
</div>
<div className="flex items-start gap-5">
<Image <Image
src="/images/headshot-tyler_koenig.png" src="/images/headshot-tyler_koenig.png"
alt="Tyler Koenig" alt="Tyler Koenig"
width={120} width={56}
height={120} height={56}
className="rounded-full border-2 border-[var(--color-green-darker)]" className="shrink-0 object-cover"
priority priority
/> />
</div>
<div className="flex flex-col gap-4 text-center sm:text-left"> <div className="flex flex-col gap-3">
<div> <div>
<h1 className="font-mono text-2xl font-bold text-[var(--color-text-light)] tracking-wide"> <p className="font-mono text-base font-bold text-[var(--color-text)]">
Tyler Koenig Tyler Koenig
</h1> </p>
<p className="font-mono text-sm text-[var(--color-green)] tracking-widest uppercase mt-1"> <p className="font-mono text-xs text-[var(--color-text-label)] mt-0.5">
SOC Helpdesk I by day, building beyond the title SOC Helpdesk I · Homelab Operator
</p>
</div>
<p className="font-sans text-sm text-[var(--color-text)] leading-relaxed max-w-lg opacity-80">
I write software and run infrastructure that goes well past what my
job title implies. Games, AI tooling, mobile apps, and a homelab
running 20+ self-hosted services on segmented VLANs. Continuously
learning by building things that actually work.
</p> </p>
</div>
<p className="text-[var(--color-grey-3)] text-sm leading-relaxed max-w-lg"> <div className="flex flex-wrap items-center gap-x-5 gap-y-1">
I write software and run infrastructure that goes well past what my job <span className="font-mono text-xs text-[var(--color-accent-green)]">
title implies. Games, AI tooling, mobile apps, and a homelab running online
20+ self-hosted services on segmented VLANs. Continuously learning </span>
by building things that actually work. <a
</p> href="https://github.com/lerko96"
target="_blank"
<div className="flex items-center gap-5 justify-center sm:justify-start"> rel="noopener noreferrer"
<a aria-label="GitHub"
href="https://github.com/lerko96" className="font-mono text-xs text-[var(--color-text-label)] hover:text-[var(--color-text)]"
target="_blank" >
rel="noopener noreferrer" <i className="fab fa-github mr-1.5" aria-hidden="true" />
aria-label="GitHub" github
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" </a>
> <a
<i className="fab fa-github text-xl" aria-hidden="true" /> href="https://www.linkedin.com/in/tyler-koenig"
</a> target="_blank"
<a rel="noopener noreferrer"
href="https://www.linkedin.com/in/tyler-koenig" aria-label="LinkedIn"
target="_blank" className="font-mono text-xs text-[var(--color-text-label)] hover:text-[var(--color-text)]"
rel="noopener noreferrer" >
aria-label="LinkedIn" <i className="fab fa-linkedin mr-1.5" aria-hidden="true" />
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" linkedin
> </a>
<i className="fab fa-linkedin text-xl" aria-hidden="true" /> <a
</a> href="mailto:tylerkoenig96@gmail.com"
<a aria-label="Email"
href="mailto:tylerkoenig96@gmail.com" className="font-mono text-xs text-[var(--color-text-label)] hover:text-[var(--color-text)]"
aria-label="Email" >
className="text-[var(--color-grey-2)] hover:text-[var(--color-green)] transition-colors" @ email
> </a>
<i className="fas fa-envelope text-xl" aria-hidden="true" /> </div>
</a>
</div> </div>
</div> </div>
</section> </section>

View File

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

View File

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

View File

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

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

@@ -0,0 +1,39 @@
type WidgetProps = {
title: string;
badge?: string | number;
meta?: string;
as?: "section" | "div" | "article";
className?: string;
children: React.ReactNode;
};
export default function Widget({
title,
badge,
meta,
as: Tag = "section",
className,
children,
}: WidgetProps) {
return (
<Tag className={`mb-16 ${className ?? ""}`}>
<div className="flex items-center gap-3 mb-8">
<span className="font-mono text-xs text-[var(--color-text-label)] tracking-widest uppercase whitespace-nowrap">
{title}
</span>
{meta && (
<span className="font-mono text-xs text-[var(--color-text-dim)] whitespace-nowrap">
{meta}
</span>
)}
<div className="flex-1 h-px bg-[var(--color-border)]" aria-hidden="true" />
{badge !== undefined && (
<span className="font-mono text-xs text-[var(--color-text-dim)] whitespace-nowrap">
{badge}
</span>
)}
</div>
{children}
</Tag>
);
}

View File

@@ -4,9 +4,9 @@ export type Project = {
description: string; description: string;
tags: string[]; tags: string[];
githubUrl: string; githubUrl: string;
gradient: string; // Tailwind gradient classes for placeholder image tile
tier: "featured" | "archive"; tier: "featured" | "archive";
stats?: string; stats?: string;
year?: number;
}; };
export const projects: Project[] = [ export const projects: Project[] = [
@@ -18,7 +18,6 @@ export const projects: Project[] = [
"Offline-first mobile app for tracking golf rounds, managing your 14-club bag, and getting AI-powered club recommendations from a Smart Caddie. Covers 7 shot types per hole with full scorecard history.", "Offline-first mobile app for tracking golf rounds, managing your 14-club bag, and getting AI-powered club recommendations from a Smart Caddie. Covers 7 shot types per hole with full scorecard history.",
tags: ["React Native", "Expo", "Zustand", "AI", "Mobile"], tags: ["React Native", "Expo", "Zustand", "AI", "Mobile"],
githubUrl: "https://github.com/lerko96/golf-book-mobile", githubUrl: "https://github.com/lerko96/golf-book-mobile",
gradient: "from-[var(--color-green-darkest)] via-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "featured", tier: "featured",
stats: "211 commits", stats: "211 commits",
}, },
@@ -29,7 +28,6 @@ export const projects: Project[] = [
"Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.", "Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.",
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"], tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
githubUrl: "https://github.com/lerko96/plaiground", githubUrl: "https://github.com/lerko96/plaiground",
gradient: "from-[var(--color-green-darker)] via-[var(--color-surface)] to-[var(--color-bg-deep)]",
tier: "featured", tier: "featured",
}, },
{ {
@@ -39,7 +37,6 @@ export const projects: Project[] = [
"Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.", "Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.",
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"], tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
githubUrl: "https://github.com/lerko96/service-monitor", githubUrl: "https://github.com/lerko96/service-monitor",
gradient: "from-[var(--color-bg)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
tier: "featured", tier: "featured",
}, },
{ {
@@ -49,7 +46,6 @@ export const projects: Project[] = [
"3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.", "3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.",
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"], tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
githubUrl: "https://github.com/lerko96/tht-1.2", githubUrl: "https://github.com/lerko96/tht-1.2",
gradient: "from-[var(--color-surface)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
tier: "featured", tier: "featured",
}, },
@@ -61,8 +57,8 @@ export const projects: Project[] = [
"Chrome extension (Manifest V3) that captures entire Twitter/X threads and exports them as HTML, Markdown, PDF, or image — with metadata preservation and preview before export.", "Chrome extension (Manifest V3) that captures entire Twitter/X threads and exports them as HTML, Markdown, PDF, or image — with metadata preservation and preview before export.",
tags: ["Chrome Extension", "Manifest V3", "JavaScript", "jsPDF"], tags: ["Chrome Extension", "Manifest V3", "JavaScript", "jsPDF"],
githubUrl: "https://github.com/lerko96/twitter-thread-ext", githubUrl: "https://github.com/lerko96/twitter-thread-ext",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive", tier: "archive",
year: 2023,
}, },
{ {
slug: "notes-app-1.0", slug: "notes-app-1.0",
@@ -71,8 +67,8 @@ export const projects: Project[] = [
"Lightweight canvas drawing app with color picker, adjustable brush size, and PNG export. Runs in the browser, no dependencies.", "Lightweight canvas drawing app with color picker, adjustable brush size, and PNG export. Runs in the browser, no dependencies.",
tags: ["HTML5 Canvas", "JavaScript", "CSS"], tags: ["HTML5 Canvas", "JavaScript", "CSS"],
githubUrl: "https://github.com/lerko96/notes-app-1.0", githubUrl: "https://github.com/lerko96/notes-app-1.0",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive", tier: "archive",
year: 2022,
}, },
{ {
slug: "were-hooked", slug: "were-hooked",
@@ -81,8 +77,8 @@ export const projects: Project[] = [
"Fishing location discovery app built as a team of 5 during bootcamp. Java/Spring MVC backend with Thymeleaf templates.", "Fishing location discovery app built as a team of 5 during bootcamp. Java/Spring MVC backend with Thymeleaf templates.",
tags: ["Java", "Spring", "Thymeleaf", "HTML", "CSS"], tags: ["Java", "Spring", "Thymeleaf", "HTML", "CSS"],
githubUrl: "https://github.com/lerko96/were-hooked-repo", githubUrl: "https://github.com/lerko96/were-hooked-repo",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive", tier: "archive",
year: 2022,
}, },
{ {
slug: "mystery-educator", slug: "mystery-educator",
@@ -91,8 +87,8 @@ export const projects: Project[] = [
"Single-page app mashup of the MET Museum and NASA public APIs. Built as a team of 4 during bootcamp.", "Single-page app mashup of the MET Museum and NASA public APIs. Built as a team of 4 during bootcamp.",
tags: ["JavaScript", "REST APIs", "HTML", "CSS"], tags: ["JavaScript", "REST APIs", "HTML", "CSS"],
githubUrl: "https://github.com/lerko96/mystery-educator", githubUrl: "https://github.com/lerko96/mystery-educator",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive", tier: "archive",
year: 2022,
}, },
]; ];