Compare commits

...

18 Commits

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

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

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

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

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

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

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

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

Data: remove gradient field from Project type; add optional year field
2026-04-12 19:23:50 -04:00
lerko96
05a32492ac rebuild portfolio: Next.js 16, React 19, Tailwind v4, homelab page, CI/CD
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s
2026-04-12 18:52:54 -04:00
65 changed files with 8433 additions and 19883 deletions

View File

@@ -0,0 +1,70 @@
name: Build and Deploy
on:
push:
branches:
- dev
jobs:
deploy:
runs-on: ubuntu-latest
container: node:22-alpine
steps:
- name: Install SSH and rsync
run: apk add --no-cache openssh-client rsync git
- name: Checkout
uses: actions/checkout@v4
- name: Build
run: npm ci && npm run build
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H 10.99.0.23 >> ~/.ssh/known_hosts
- name: Sync out/ to Portfolio LXC
run: |
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key" \
out/ root@10.99.0.23:/opt/lerkolabs/out/
- name: Rebuild and restart container
run: |
ssh -i ~/.ssh/deploy_key root@10.99.0.23 \
"cd /opt/lerkolabs && \
docker build -t portfolio . && \
docker stop portfolio 2>/dev/null || true && \
docker rm portfolio 2>/dev/null || true && \
docker run -d --name portfolio -p 80:80 --restart unless-stopped portfolio"
- name: Tag release (CalVer)
run: |
git fetch --tags
if git describe --exact-match --tags HEAD 2>/dev/null; then
echo "Commit already tagged, skipping."
else
YEAR=$(date +%Y)
MONTH=$(date +%m)
LATEST=$(git tag --sort=-v:refname | grep -E '^[0-9]{4}\.[0-9]{2}\.[0-9]+$' | head -1)
if [ -n "$LATEST" ]; then
LATEST_YEAR=$(echo "$LATEST" | cut -d. -f1)
LATEST_MONTH=$(echo "$LATEST" | cut -d. -f2)
LATEST_MICRO=$(echo "$LATEST" | cut -d. -f3)
if [ "$YEAR" = "$LATEST_YEAR" ] && [ "$MONTH" = "$LATEST_MONTH" ]; then
MICRO=$((LATEST_MICRO + 1))
else
MICRO=1
fi
else
MICRO=1
fi
NEW_TAG="${YEAR}.$(printf '%02d' $MONTH).${MICRO}"
git tag "$NEW_TAG"
git push origin "$NEW_TAG"
echo "Tagged $NEW_TAG"
fi

44
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- dev
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Build
run: npm ci && npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: out/
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4

22
.gitignore vendored
View File

@@ -1,18 +1,21 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# testing
/coverage
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
@@ -21,3 +24,14 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# claude code
CLAUDE.md
.claude/
# docs
/docs

3
Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY out/ /usr/share/nginx/html
EXPOSE 80

View File

@@ -1,70 +1,59 @@
# Getting Started with Create React App
# Tyler Koenig portfolio
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
Personal portfolio site. Live at [lerkolabs.com](https://lerkolabs.com) — self-hosted.
## Available Scripts
Source: [gitea.lerkolabs.com/lerko/portfolio](https://gitea.lerkolabs.com/lerko/portfolio)
In the project directory, you can run:
**Stack:** Next.js 16 · React 19 · TypeScript · Tailwind v4
### `npm start`
---
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
## Branches
The page will reload if you make edits.\
You will also see any lint errors in the console.
- `dev` — source code; pushing here updates lerkolabs.com
- `master` — reserved for future GitHub mirror; don't touch manually
### `npm test`
---
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
## Commands
### `npm run build`
```bash
npm run dev # dev server at localhost:3000
npm run build # static export into out/
```
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
---
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
## Deploy
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
```bash
git checkout dev && git merge <branch> && git push gitea dev
```
### `npm run eject`
Push to `dev` triggers Gitea Actions (`.gitea/workflows/deploy.yml`):
1. Builds the static site (`npm run build`)
2. rsyncs `out/` to the portfolio LXC
3. Rebuilds and restarts the Docker container serving lerkolabs.com
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
---
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
## Project layout
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
```
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
data/
projects.ts # all projects, featured + archive split
services.ts # homelab services with categories
public/ # static assets copied into out/ on build
```
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
> Tailwind v4 is CSS-first — no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`.

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

11
next.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
};
export default nextConfig;

23168
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +1,31 @@
{
"name": "react-scss2",
"homepage": "https://lerko96.github.io",
"name": "lerko96-portfolio",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.2.7",
"@testing-library/user-event": "^12.8.3",
"gh-pages": "^3.2.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"web-vitals": "^1.1.2"
},
"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 build",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"deploy": "gh-pages -b master -d out -t"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"dependencies": {
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"node-sass": "^6.0.1"
"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"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1001 KiB

After

Width:  |  Height:  |  Size: 1001 KiB

View File

@@ -1,58 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!-- Everything I added -->
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200;0,400;0,600;1,200;1,400;1,600&display=swap"
rel="stylesheet"
/>
<style>
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@300;600;700&display=swap');
</style>
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;700&display=swap');
</style>
<style>
@import url('https://fonts.googleapis.com/css?family=Muli&display=swap');
</style>
<script src="https://use.fontawesome.com/e427b1b41a.js"></script>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>TK | Portfolio</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
--></body>
</html>

View File

@@ -1,25 +0,0 @@
{
"short_name": "lerko96 portfolio",
"name": "Tyler Koenig Portfolio",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

1
public/next.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

1
public/vercel.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,748 +0,0 @@
* {
margin: 0;
padding: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
font-size: 16px;
}
body {
background: #f7f9fb;
color: #1b1b1b;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
font-weight: 400;
text-align: start;
text-rendering: optimizeLegibility;
-webkit-transition: background 0.2s linear;
transition: background 0.2s linear;
}
body a {
color: #1b1b1b;
text-decoration: none;
}
.dark {
background: #272727;
color: #fafafa;
}
.dark #logo a {
color: #2bf3c4;
}
.dark a {
color: #2bf3c4;
}
.dark .profile__card .card__img #headshot {
border: 3px solid #999a9a;
}
.dark .project {
background: #1b1b1b;
color: #fafafa;
-webkit-box-shadow: 0 7px 30px -10px rgba(150, 170, 180, 0.2);
box-shadow: 0 7px 30px -10px rgba(150, 170, 180, 0.2);
}
.dark .project .project__title a {
color: #fafafa;
}
.dark .project .project__info {
color: #fafafa;
}
@media (min-width: 768px) {
.dark .project .project__title a {
color: #999a9a;
}
.dark .project .project__info {
color: #999a9a;
}
}
.dark .project:hover .project__bar {
background-color: #c5c6c6;
}
.dark .project:hover .project__title a {
color: #fafafa;
}
.dark .project:hover .project__info {
color: #fafafa;
}
.dark .project .project__tagbox .tag__item {
background: #c5c6c6;
}
.dark .project .project__tagbox .tag__item :hover {
background: #707171;
}
.App {
margin: 0 auto;
}
.App ul {
list-style-type: none;
}
@media (min-width: 576px) {
.App {
max-width: 990px;
}
}
.app__wrapper {
margin: auto 1rem;
}
header {
line-height: 1.7;
padding-top: 30px;
min-height: 150px;
}
@media (max-width: 575px) and (prefers-reduced-motion: no-preference) {
.App {
-webkit-animation: appScale 500ms alternate 1 ease forwards;
animation: appScale 500ms alternate 1 ease forwards;
}
}
@media (max-width: 575px) {
@-webkit-keyframes appScale {
from {
-webkit-transform: scale(1.09);
transform: scale(1.09);
}
to {
-webkit-transform: scale(1);
transform: scale(1);
}
}
@keyframes appScale {
from {
-webkit-transform: scale(1.09);
transform: scale(1.09);
}
to {
-webkit-transform: scale(1);
transform: scale(1);
}
}
}
@media (min-width: 576px) and (prefers-reduced-motion: no-preference) {
#headshot {
-webkit-animation: appScale 333ms alternate 3 ease forwards;
animation: appScale 333ms alternate 3 ease forwards;
}
}
@media (min-width: 576px) {
@-webkit-keyframes appScale {
from {
-webkit-transform: scale(1);
transform: scale(1);
}
to {
-webkit-transform: scale(1.4);
transform: scale(1.4);
}
}
@keyframes appScale {
from {
-webkit-transform: scale(1);
transform: scale(1);
}
to {
-webkit-transform: scale(1.4);
transform: scale(1.4);
}
}
}
@media (max-width: 575px) {
.profile__about,
.profile__skills,
#projects {
-webkit-animation: lowerApp 1000ms normal 1 ease-out backwards;
animation: lowerApp 1000ms normal 1 ease-out backwards;
}
@-webkit-keyframes lowerApp {
from {
-webkit-transform: translateY(300px);
transform: translateY(300px);
}
to {
-webkit-transform: translateY(0px);
transform: translateY(0px);
}
}
@keyframes lowerApp {
from {
-webkit-transform: translateY(300px);
transform: translateY(300px);
}
to {
-webkit-transform: translateY(0px);
transform: translateY(0px);
}
}
}
.bio__contacts {
-webkit-animation: fadeInAnimation ease-out 2000ms;
animation: fadeInAnimation ease-out 2000ms;
-webkit-animation-iteration-count: 1;
animation-iteration-count: 1;
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
}
@-webkit-keyframes fadeInAnimation {
0% {
opacity: 0.1;
}
100% {
opacity: 1;
}
}
@keyframes fadeInAnimation {
0% {
opacity: 0.1;
}
100% {
opacity: 1;
}
}
nav {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
}
nav #logo {
font-size: 2.3rem;
font-weight: 700;
letter-spacing: 0.2rem;
margin-left: 8px;
}
nav #logo a {
color: #707171;
}
nav #logo a:hover {
color: #272727;
}
nav #nav__list {
text-align: end;
font-weight: 500;
font-size: 0.8rem;
letter-spacing: 0.02rem;
}
nav a {
color: #272727;
}
nav a:hover {
background-color: #2bf3c4;
color: #1b1b1b;
-webkit-transition: 150ms ease;
transition: 150ms ease;
}
.switch-container {
position: relative;
}
.switch-wrap {
position: absolute;
right: 0;
bottom: 25px;
cursor: pointer;
background: #4b4b4b;
padding: 6px;
width: 50px;
height: 31px;
border-radius: 15.5px;
}
.switch-wrap input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch {
height: 100%;
display: -ms-grid;
display: grid;
-ms-grid-columns: 0fr 1fr 1fr;
grid-template-columns: 0fr 1fr 1fr;
-webkit-transition: 0.2s;
transition: 0.2s;
}
.switch::after {
content: '';
border-radius: 50%;
background: #ccc;
-ms-grid-column: 2;
grid-column: 2;
-webkit-transition: background 0.2s;
transition: background 0.2s;
}
input:checked + .switch {
-ms-grid-columns: 1fr 1fr 0fr;
grid-template-columns: 1fr 1fr 0fr;
}
input:checked + .switch::after {
background-color: #27bb98;
}
#profile h2 {
display: none;
}
.profile__card {
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
text-align: center;
}
.profile__card .card__img {
margin-bottom: 8px;
}
.profile__card .card__img #headshot {
height: 125px;
width: 125px;
border: 3px solid #272727;
border-radius: 50%;
-webkit-box-shadow: 0 7px 30px -10px rgba(150, 170, 180, 0.5);
box-shadow: 0 7px 30px -10px rgba(150, 170, 180, 0.5);
}
.profile__card .card__bio {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-transition: padding-top 800ms ease;
transition: padding-top 800ms ease;
}
.profile__card .card__bio .bio__name {
line-height: 0.9em;
font-size: 2.3rem;
font-weight: 700;
}
.profile__card .card__bio .bio__name a {
padding: 0;
}
.profile__card .card__bio .bio__desc {
margin: 8px auto 14px;
font-weight: 600;
font-size: 1.1rem;
}
.profile__card .card__bio .bio__contacts {
width: 125px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
}
.profile__card .card__bio .bio__contacts a {
color: #c5c6c6;
margin: 0;
}
.profile__card .card__bio .bio__contacts a:hover {
color: #707171;
-webkit-transition: 150ms ease;
transition: 150ms ease;
}
@media (min-width: 576px) {
.profile__card .card__bio {
padding-top: 36px;
}
}
@media (max-width: 575px) {
.profile__card .card__bio {
-webkit-transition: none;
transition: none;
}
}
.profile__about {
margin: 2em auto;
line-height: 1.8em;
max-width: 764px;
-webkit-transition: padding-top 800ms ease;
transition: padding-top 800ms ease;
}
@media (min-width: 576px) {
.profile__about {
padding-top: 10px;
padding-bottom: 30px;
}
}
.profile__skills ul {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.profile__skills li {
border: 1px solid #707171;
border-radius: 2px;
padding: 4px 12px;
margin: 5px 3px;
}
@media (min-width: 576px) {
.profile__skills {
margin: 0 auto;
max-width: 613px;
}
}
#projects {
padding: 70px 0 60px;
}
#projects h2 {
display: none;
color: #707171;
font-size: 2.3rem;
}
.project {
-ms-flex-wrap: wrap;
flex-wrap: wrap;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-shadow: 0 7px 30px -10px rgba(150, 170, 180, 0.5);
box-shadow: 0 7px 30px -10px rgba(150, 170, 180, 0.5);
border-radius: 10px;
margin: 3rem auto;
overflow: hidden;
position: relative;
}
.project a,
.project a:hover {
color: #272727;
}
.project h3,
.project .h3 {
margin-bottom: 0.5rem;
font-weight: 700;
line-height: 1.2;
}
.project .project__subtitle_small {
font-size: 80%;
}
.project .project__subtitle_small i {
margin-right: 5px;
}
.project .project__title {
font-size: 1.75rem;
}
.project .project__title a {
-webkit-transition: color 0.1s ease;
transition: color 0.1s ease;
-webkit-transition: background 250ms ease-in-out;
transition: background 250ms ease-in-out;
}
.project .project__title :hover {
background: #2bf3c4;
}
.project .project__img {
max-height: 180px;
width: 100%;
-o-object-fit: cover;
object-fit: cover;
position: relative;
}
.project .project__img_link {
display: contents;
}
.project .project__bar {
width: 50px;
height: 8px;
margin: 10px 0;
border-radius: 5px;
background-color: #c9cacc;
-webkit-transition: background-color 0.1s ease;
transition: background-color 0.1s ease;
-webkit-transition: width 0.2s ease;
transition: width 0.2s ease;
}
.project .project__text {
padding: 1.5rem;
position: relative;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.project .project__info {
overflow: hidden;
text-overflow: ellipsis;
text-align: justify;
height: 100%;
color: #272727;
-webkit-transition: color 0.1s ease;
transition: color 0.1s ease;
}
.project .project__tagbox {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-flow: row wrap;
flex-flow: row wrap;
font-size: 14px;
margin: 20px 0 0 0;
padding: 0;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.project .project__tagbox .tag__item {
display: inline-block;
background: #c9cacc;
color: #272727;
border-radius: 3px;
padding: 5px 10px;
margin: 0 5px 5px 0;
cursor: default;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-transition: background 0.1s ease-in-out;
transition: background 0.1s ease-in-out;
-webkit-transition: color 0.05s ease-out;
transition: color 0.05s ease-out;
}
.project .project__tagbox .tag__item:hover {
background: #707171;
color: #fafafa;
}
.project:before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 1;
border-radius: 10px;
}
.project:hover .project__bar {
width: 100px;
background-color: #707171;
}
.project:hover .project__title a {
color: #272727;
}
.project:hover .project__info {
color: #272727;
}
@media (min-width: 768px) {
.project {
-ms-flex-wrap: inherit;
flex-wrap: inherit;
}
.project a {
color: #707171;
}
.project .project__title {
font-size: 2rem;
}
.project .project__tagbox {
-webkit-box-pack: start;
-ms-flex-pack: start;
justify-content: start;
}
.project .project__img {
max-width: 250px;
max-height: 100%;
-webkit-transition: -webkit-transform 0.2s ease;
transition: -webkit-transform 0.2s ease;
transition: transform 0.2s ease;
transition: transform 0.2s ease, -webkit-transform 0.2s ease;
}
.project .project__text {
padding: 3rem;
width: 100%;
}
.project .project__info {
color: #707171;
}
.project .media.project__text:before {
content: '';
position: absolute;
display: block;
top: -20%;
height: 130%;
width: 55px;
}
.project:hover .project__img {
-webkit-transform: scale(1.1);
transform: scale(1.1);
}
.project:nth-child(2n + 1) {
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
}
.project:nth-child(2n + 0) {
-webkit-box-orient: horizontal;
-webkit-box-direction: reverse;
-ms-flex-direction: row-reverse;
flex-direction: row-reverse;
}
.project:nth-child(2n + 1) .project__text::before {
left: -12px !important;
-webkit-transform: rotate(4deg);
transform: rotate(4deg);
}
.project:nth-child(2n + 0) .project__text::before {
right: -12px !important;
-webkit-transform: rotate(-4deg);
transform: rotate(-4deg);
}
}
footer {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: reverse;
-ms-flex-direction: column-reverse;
flex-direction: column-reverse;
text-align: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
margin: 0 auto;
max-width: 500px;
}
footer .foot__links {
font-size: 2rem;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: space-evenly;
-ms-flex-pack: space-evenly;
justify-content: space-evenly;
margin-bottom: 50px;
}
footer .foot__links a {
color: #27bb98;
}
footer .foot__links a :hover {
color: #238770;
}
footer .copyright {
margin-bottom: 20px;
}
/*# sourceMappingURL=App.css.map */

File diff suppressed because one or more lines are too long

View File

@@ -1,54 +0,0 @@
// import logo from './logo.svg';
import React from 'react';
import './App.scss';
import Nav from './components/Nav';
import Profile from './components/Profile';
import Projects from './components/Projects';
import Footer from './components/Footer';
function App() {
const [darkMode, setDarkMode] = React.useState(false);
React.useEffect(() => {
const json = localStorage.getItem('lerko96-dark-mode');
const currentMode = JSON.parse(json);
if (currentMode) {
setDarkMode(true);
} else {
setDarkMode(false);
}
}, []);
React.useEffect(() => {
if (darkMode) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
const json = JSON.stringify(darkMode);
localStorage.setItem('lerko96-dark-mode', json);
}, [darkMode]);
return (
<div className='App'>
<div class='app__wrapper'>
<Nav />
<div class='switch-container'>
<label class='switch-wrap'>
<input
type='checkbox'
onClick={() => setDarkMode(!darkMode)}
/>
<div class='switch'></div>
</label>
</div>
<Profile />
<Projects />
<Footer />
</div>
</div>
);
}
export default App;

View File

@@ -1,7 +0,0 @@
@import './styles/mixins.scss';
@import './styles/variables.scss';
@import './styles/base.scss';
@import './styles/animations.scss';
@import './styles/components.scss'

View File

@@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

68
src/app/archive/page.tsx Normal file
View File

@@ -0,0 +1,68 @@
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>
</>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

93
src/app/globals.css Normal file
View File

@@ -0,0 +1,93 @@
@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
src/app/homelab/page.tsx Normal file
View File

@@ -0,0 +1,247 @@
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
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,49 @@
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
src/app/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
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 />
</>
);
}

View File

@@ -1,35 +0,0 @@
import React from 'react';
const Footer = () => (
<footer>
<span class='copyright'>&copy; 2021 Tyler Koenig</span>
<div class='foot__links'>
<a
href='https://github.com/lerko96'
rel='noreferrer'
target='_blank'
title='github'
>
<i class='fa fa-github fa-2x' aria-hidden='true'></i>
</a>
<a
href='https://www.linkedin.com/in/tyler-koenig-72607a18b/'
rel='noreferrer'
target='_blank'
title='LinkedIn'
>
<i class='fa fa-linkedin-square fa-2x' aria-hidden='true'></i>
</a>
<a
href='mailto:tylerkng96@icloud.com'
rel='noreferrer'
target='_blank'
title='email'
>
<i class='fa fa-envelope-o fa-2x' aria-hidden='true'></i>
</a>
</div>
</footer>
);
export default Footer;

31
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,31 @@
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>
);
}

56
src/components/Hero.tsx Normal file
View File

@@ -0,0 +1,56 @@
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>
);
}

View File

@@ -1,42 +0,0 @@
import React from 'react';
const Nav = () => (
<header>
<nav>
<div id='logo'>
<a href='index.html'>tk</a>
</div>
<div id='nav__list'>
<ul>
{/* <li id='nav__contact'>
<a href='#contact' target='_self'>
CONTACT
</a>
</li> */}
<li>
<a href='#profile' target='_self'>
PROFILE
</a>
</li>
<li>
<a href='#projects' target='_self'>
PROJECTS
</a>
</li>
{/* <li>
<button onClick={() => setDarkMode(!darkMode)}>
Toggle Dark Mode
</button>
</li> */}
{/* <li>
<a href='#skills' target='_self'>
SKILLS
</a>
</li> */}
</ul>
</div>
</nav>
</header>
);
export default Nav;

59
src/components/Nav.tsx Normal file
View File

@@ -0,0 +1,59 @@
"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>
);
}

View File

@@ -1,96 +0,0 @@
import React from 'react';
import headshot from '../images/headshot-tyler_koenig.png';
// function Greet() {
// return <p>Hello TK</p>
// }
const Profile = () => (
<section id='profile'>
<article class='profile__card'>
<div class='card__img'>
<img id='headshot' src={headshot} alt='tyler' />
</div>
<div class='card__bio'>
<div class='bio__name'>
<a href='index.html'>TYLER KOENIG</a>
</div>
<div class='bio__desc'>
<p>SOFTWARE DEVELOPER</p>
</div>
<div class='bio__contacts' id='contact'>
<a
href='https://www.linkedin.com/in/tyler-koenig-72607a18b/'
rel='noreferrer'
target='_blank'
title='LinkedIn'
>
<i
class='fa fa-linkedin-square fa-2x'
aria-hidden='true'
></i>
</a>
<a
href='https://github.com/lerko96'
rel='noreferrer'
target='_blank'
title='github'
>
<i class='fa fa-github fa-2x' aria-hidden='true'></i>
</a>
<a
href='mailto:tylerkng96@icloud.com'
rel='noreferrer'
target='_blank'
title='email'
>
<i
class='fa fa-envelope-o fa-2x'
aria-hidden='true'
></i>
</a>
</div>
</div>
</article>
<article class='profile__about'>
<h2>about</h2>
<p>
Full-Stack Java Developer, with a focus on Front-End
Development. I graduated with an Associate of Arts from Lorain
County Community College in Spring of 2018. I began building
HTML, CSS and JavaScript projects by the Summer of 2020 from
courses provided on Udemy. I received my Certificate in Software
Development from We Can Code IT in Fall of 2021. Thanks to the
courses I've taken, I have developed strong skills needed for
working in remote team environments, and ones that utilize Scrum
and Agile practices. My passion comes from seeing ideas be
brought to life. Let's get to work.
</p>
</article>
<article class='profile__skills' id='skills'>
<h2>skills</h2>
<ul>
<li>Java</li>
<li>Spring</li>
<li>MVC</li>
<li>JavaScript</li>
<li>JSON</li>
<li>Restful APIs</li>
<li>Test Driven Development</li>
<li>Relational Databases</li>
<li>Git</li>
<li>Agile/ Scrum</li>
<li>HTML</li>
<li>CSS</li>
<li>SCSS</li>
<li>React</li>
<li>Responsive Design</li>
<li>Thymeleaf</li>
{/* <li>Object Oriented Programming</li> */}
</ul>
</article>
</section>
);
export default Profile;

View File

@@ -0,0 +1,67 @@
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>
);
}

View File

@@ -1,287 +0,0 @@
import React from 'react';
import wereHooked from '../images/were-hooked.png';
import donutClicker from '../images/donut-clicker.png';
import mysteryEducator from '../images/mystery-educator.png';
import trekkingSite from '../images/trek.png';
// import reviewSite from '../images/review-site.png';
const Projects = () => (
<section id='projects'>
<h2>projects</h2>
<article class='project'>
<a
class='project__img_link'
href='https://github.com/lerko96/were-hooked-repo'
rel='noreferrer'
target='_blank'
>
<img
src={wereHooked}
class='project__img'
alt='were-hooked-img'
/>
</a>
<div class='project__text'>
<h3 class='project__title'>
<a
href='https://github.com/lerko96/were-hooked-repo'
rel='noreferrer'
target='_blank'
>
We're Hooked
</a>
</h3>
<div class='project__subtitle_small'>
<time datetime='2021-08-20 12:00:00'>
<i class='fa fa-calendar mr-2'></i>Fri, August 20th 2021
</time>
</div>
<div class='project__bar'></div>
<div class='project__info'>
<p>
Designed, built and tested an MVC application that
allows users to discover fishing locations in Ohio, as
well as teach beginners how to get started fishing and
the current regulations that are needed to follow. This
project was built between a team of five in a completely
remote environment with the help of Github, Zoom and
Slack.
</p>
</div>
<ul class='project__tagbox'>
<li class='tag__item'>Java</li>
<li class='tag__item'>Spring</li>
<li class='tag__item'>JavaScript</li>
<li class='tag__item'>Restful API</li>
<li class='tag__item'>Thymeleaf</li>
<li class='tag__item'>HTML</li>
<li class='tag__item'>CSS</li>
<li class='tag__item'>Responsive Design</li>
{/* <li class='tag__item'>TDD</li> */}
{/* <li class='tag__item'>VS Code</li> */}
<li class='tag__item'>Git</li>
<li class='tag__item'>Agile</li>
<li class='tag__item'>Scrum</li>
<li class='tag__item'>Zoom</li>
<li class='tag__item'>Slack</li>
</ul>
</div>
</article>
<article class='project'>
<a
class='project__img_link'
href='https://github.com/lerko96/mystery-educator'
rel='noreferrer'
target='_blank'
>
<img class='project__img' src={mysteryEducator} alt='' />
</a>
<div class='project__text'>
<h3 class='project__title'>
<a
href='https://github.com/lerko96/mystery-educator'
rel='noreferrer'
target='_blank'
>
Mystery Educator
</a>
</h3>
<div class='project__subtitle_small'>
<time datetime='2021-07-23 12:00:00'>
<i class='fas fa-calendar-alt mr-2'></i>Fri, July 23rd
2021
</time>
</div>
<div class='project__bar'></div>
<div class='project__info'>
<p>
Designed, built and tested a single page application
that renders unique data from the MET Museum and NASA
APIs each time you visit. We also built a local backend
database to store historical information. This project
was built between a team of four in a completely remote
environemnt with the help of Github, Zoom, and Slack.
</p>
</div>
<ul class='project__tagbox'>
<li class='tag__item'>JavaScript</li>
<li class='tag__item'>Node.js</li>
<li class='tag__item'>Spring</li>
<li class='tag__item'>RESTful APIs</li>
<li class='tag__item'>Java</li>
<li class='tag__item'>HTML</li>
<li class='tag__item'>CSS</li>
<li class='tag__item'>Responsive Design</li>
{/* <li class='tag__item'>TDD</li> */}
{/* <li class='tag__item'>VS Code</li> */}
<li class='tag__item'>Git</li>
<li class='tag__item'>Agile</li>
<li class='tag__item'>Scrum</li>
<li class='tag__item'>Zoom</li>
<li class='tag__item'>Slack</li>
</ul>
</div>
</article>
<article class='project'>
<a
class='project__img_link'
href='https://github.com/lerko96/donut-clicker-lerko96'
rel='noreferrer'
target='_blank'
>
<img
class='project__img'
src={donutClicker}
alt='donut-clicker-img'
/>
</a>
<div class='project__text'>
<h3 class='project__title'>
<a
href='https://github.com/lerko96/donut-clicker-lerko96'
rel='noreferrer'
target='_blank'
>
Donut Clicker
</a>
</h3>
<div class='project__subtitle_small'>
<time datetime='2021-07-09 12:00:00'>
<i class='fas fa-calendar-alt mr-2'></i>Fri, July 9th
2021
</time>
</div>
<div class='project__bar'></div>
<div class='project__info'>
<p>
Designed, built and tested a single page application
that lets users make virtual donuts via clicking. Once
enough donuts are made you have the ability to purchase
upgrades such as auto-clickers and clicking-multipliers.
</p>
</div>
<ul class='project__tagbox'>
<li class='tag__item'>JavaScript</li>
<li class='tag__item'>Node.js</li>
<li class='tag__item'>HTML</li>
<li class='tag__item'>CSS</li>
<li class='tag__item'>Responsive Design</li>
<li class='tag__item'>TDD</li>
{/* <li class='tag__item'>VS Code</li> */}
<li class='tag__item'>Git</li>
</ul>
</div>
</article>
<article class='project'>
<a
class='project__img_link'
href='https://github.com/lerko96/trek'
rel='noreferrer'
target='_blank'
>
<img
class='project__img'
src={trekkingSite}
alt='trekking-img'
/>
</a>
<div class='project__text'>
<h3 class='project__title'>
<a
href='https://github.com/lerko96/trek'
rel='noreferrer'
target='_blank'
>
Trekking Site
</a>
</h3>
<div class='project__subtitle_small'>
<time datetime='2021-06-18 12:00:00'>
<i class='fas fa-calendar-alt mr-2'></i>Fri, June 18th
2021
</time>
</div>
<div class='project__bar'></div>
<div class='project__info'>
<p>
Designed, built and tested an MVC application that lets
users discover treks located by continent, region and
type. This project was built between a team of four in a
completely remote environemnt with the help of Github,
Zoom and Slack.
</p>
</div>
<ul class='project__tagbox'>
<li class='tag__item'>Java</li>
<li class='tag__item'>Spring</li>
{/* <li class='tag__item'>OOP</li> */}
<li class='tag__item'>HTML</li>
<li class='tag__item'>CSS</li>
<li class='tag__item'>Responsive Design</li>
<li class='tag__item'>TDD</li>
{/* <li class='tag__item'>IntelliJ</li> */}
<li class='tag__item'>Git</li>
<li class='tag__item'>Agile</li>
<li class='tag__item'>Scrum</li>
<li class='tag__item'>Zoom</li>
<li class='tag__item'>Slack</li>
</ul>
</div>
</article>
{/* <article class='project'>
<a
class='project__img_link'
href='https://github.com/lerko96/reviews-mvc'
rel='noreferrer'
target='_blank'
>
<img
class='project__img'
src={reviewSite}
alt='review-site-img'
/>
</a>
<div class='project__text'>
<h3 class='project__title'>
<a
href='https://github.com/lerko96/reviews-mvc'
rel='noreferrer'
target='_blank'
>
Movie Reviews
</a>
</h3>
<div class='project__subtitle_small'>
<time datetime='2021-06-04 12:00:00'>
<i class='fas fa-calendar-alt mr-2'></i>Fri, June 4th
2021
</time>
</div>
<div class='project__bar'></div>
<div class='project__info'>
<p>
Designed, built and tested a Java application for movie
reviews.
</p>
</div>
<ul class='project__tagbox'>
<li class='tag__item'>Java</li>
<li class='tag__item'>SpringJPA</li>
<li class='tag__item'>OOP</li>
<li class='tag__item'>Thymeleaf</li>
<li class='tag__item'>HTML</li>
<li class='tag__item'>CSS</li>
<li class='tag__item'>Responsive Design</li>
<li class='tag__item'>TDD</li>
<li class='tag__item'>IntelliJ</li>
<li class='tag__item'>Github</li>
</ul>
</div>
</article> */}
</section>
);
export default Projects;

48
src/components/Skills.tsx Normal file
View File

@@ -0,0 +1,48 @@
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>
);
}

View File

@@ -0,0 +1,12 @@
// Server component — renders a blocking inline script that sets the dark class
// on <html> before React hydrates, preventing flash of wrong theme.
export default function ThemeScript() {
const script = `
(function() {
var stored = localStorage.getItem('lerko96-dark-mode');
var dark = stored === null ? true : stored === 'true';
if (dark) document.documentElement.classList.add('dark');
})();
`;
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}

107
src/components/Timeline.tsx Normal file
View File

@@ -0,0 +1,107 @@
'use client'
import { useEffect, useRef } from 'react'
import Widget from '@/components/Widget'
import { timeline, type TimelineType } from '@/data/timeline'
const typeColor: Record<TimelineType, string> = {
career: 'var(--color-timeline-career)',
education: 'var(--color-timeline-education)',
cert: 'var(--color-timeline-cert)',
project: 'var(--color-timeline-project)',
homelab: 'var(--color-timeline-homelab)',
}
const typeLabel: Record<TimelineType, string> = {
career: 'career',
education: 'education',
cert: 'cert',
project: 'project',
homelab: 'homelab',
}
export default function Timeline() {
const listRef = useRef<HTMLOListElement>(null)
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const entries = listRef.current?.querySelectorAll<HTMLLIElement>('[data-tl-entry]')
if (!entries) return
const observer = new IntersectionObserver(
(observed) => {
observed.forEach((entry) => {
if (entry.isIntersecting) {
;(entry.target as HTMLElement).style.opacity = '1'
;(entry.target as HTMLElement).style.transform = 'translateY(0)'
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.15 },
)
entries.forEach((el) => {
el.style.opacity = '0'
el.style.transform = 'translateY(8px)'
el.style.transition = 'opacity 240ms linear, transform 240ms linear'
observer.observe(el)
})
return () => observer.disconnect()
}, [])
return (
<Widget title="tyler/journey">
<ol ref={listRef} className="relative border-l border-[var(--color-border)] ml-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>
)
}

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) {
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>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type ThemeContextType = {
isDark: boolean;
toggle: () => void;
};
const ThemeContext = createContext<ThemeContextType>({
isDark: true,
toggle: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [isDark, setIsDark] = useState(true);
useEffect(() => {
const stored = localStorage.getItem("lerko96-dark-mode");
const dark = stored === null ? true : stored === "true";
setIsDark(dark);
document.documentElement.classList.toggle("dark", dark);
}, []);
function toggle() {
const next = !isDark;
setIsDark(next);
localStorage.setItem("lerko96-dark-mode", String(next));
document.documentElement.classList.toggle("dark", next);
}
return (
<ThemeContext.Provider value={{ isDark, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}

132
src/data/projects.ts Normal file
View File

@@ -0,0 +1,132 @@
export type Project = {
slug: string;
title: string;
description: string;
tags: string[];
githubUrl: string;
tier: "featured" | "archive";
stats?: string;
year?: number;
statusBadge?: string;
externalUrl?: string;
};
export const projects: Project[] = [
// --- Featured ---
{
slug: "golf-book-mobile",
title: "golf-book-mobile",
description:
"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",
statusBadge: "Pending App Store Approval",
externalUrl: "#",
},
{
slug: "plaiground",
title: "plAIground",
description:
"Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.",
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
githubUrl: "https://github.com/lerko96/plaiground",
tier: "archive",
year: 2025,
},
{
slug: "service-monitor",
title: "service-monitor",
description:
"Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.",
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
githubUrl: "https://github.com/lerko96/service-monitor",
tier: "archive",
year: 2025,
},
{
slug: "tht-1.2",
title: "ThoughtSpace",
description:
"3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.",
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
githubUrl: "https://github.com/lerko96/tht-1.2",
tier: "archive",
year: 2025,
},
{
slug: "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",
description:
"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"],
githubUrl: "https://github.com/lerko96/were-hooked-repo",
tier: "archive",
year: 2022,
},
{
slug: "mystery-educator",
title: "Mystery Educator",
description:
"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"],
githubUrl: "https://github.com/lerko96/mystery-educator",
tier: "archive",
year: 2022,
},
];
export const featuredProjects = projects.filter((p) => p.tier === "featured");
export const archiveProjects = projects.filter((p) => p.tier === "archive");

71
src/data/services.ts Normal file
View File

@@ -0,0 +1,71 @@
export type Service = {
name: string;
description: string;
category: "infrastructure" | "security" | "monitoring" | "productivity" | "media";
icon: string; // Font Awesome class
};
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" },
// 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" },
// 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" },
// 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" },
// 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" },
];
export const categoryOrder: Service["category"][] = [
"infrastructure",
"security",
"monitoring",
"productivity",
"media",
];
export const categoryLabels: Record<Service["category"], string> = {
infrastructure: "Core Infrastructure",
security: "Security & Auth",
monitoring: "Monitoring",
productivity: "Productivity",
media: "Media",
};

75
src/data/timeline.ts Normal file
View File

@@ -0,0 +1,75 @@
export type TimelineType = 'career' | 'cert' | 'project' | 'homelab' | 'education'
export interface TimelineEntry {
date: string
title: string
type: TimelineType
description: string
tags?: string[]
}
export const timeline: TimelineEntry[] = [
{
date: '2026',
title: 'CompTIA Network+ — in progress',
type: 'cert',
description: 'Studying for Network+ to formalize networking knowledge built through the homelab.',
tags: ['networking', 'certification'],
},
{
date: '2025',
title: 'Portfolio Site v2',
type: 'project',
description: 'Next.js 16 static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.',
tags: ['next.js', 'tailwind', 'self-hosted'],
},
{
date: '2024',
title: 'CompTIA A+',
type: 'cert',
description: 'Earned A+ certification, formalizing hardware and OS fundamentals.',
tags: ['certification'],
},
{
date: '2024',
title: 'Project Helm',
type: 'project',
description: 'Full-stack task and project management tool built in Go + React.',
tags: ['go', 'react', 'typescript'],
},
{
date: 'ongoing',
title: 'Homelab — Proxmox Cluster',
type: 'homelab',
description: '8-VLAN segmented network, Proxmox VMs/LXCs, SSO via Authentik, full monitoring stack (VictoriaMetrics + Grafana + Beszel + ntfy).',
tags: ['proxmox', 'networking', 'monitoring', 'sso'],
},
{
date: '2023-10',
title: 'SOC Analyst I — Fortress SRM',
type: 'career',
description: 'Threat monitoring, incident triage, and client-facing security operations in a managed SOC.',
tags: ['soc', 'security'],
},
{
date: '2023-03',
title: 'Config Tech II — MCPc',
type: 'career',
description: 'Promoted to Config Tech II. Led imaging workflows and expanded into scripting for endpoint provisioning.',
tags: ['sysadmin', 'scripting'],
},
{
date: '2022-07',
title: 'Config Tech I — MCPc',
type: 'career',
description: 'Hardware configuration, OS imaging, and deployment at scale for enterprise clients.',
tags: ['sysadmin', 'hardware'],
},
{
date: '2021',
title: 'We Can Code IT — Java Bootcamp',
type: 'education',
description: '9-month intensive bootcamp covering Java, OOP, SQL, REST APIs, and Agile development practices.',
tags: ['java', 'sql', 'agile'],
},
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 KiB

View File

@@ -1,13 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -1,22 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@@ -1,73 +0,0 @@
.App {
@include respond-below(xs) {
@media (prefers-reduced-motion: no-preference) {
animation: appScale 500ms alternate 1 ease forwards;
}
@keyframes appScale {
from {
transform: scale(1.09);
}
to {
transform: scale(1);
}
}
}
}
#headshot {
@include respond-above(xs) {
@media (prefers-reduced-motion: no-preference) {
// animation: App-logo-spin 1 1250ms ease-in-out;
animation: appScale 333ms alternate 3 ease forwards;
}
// @keyframes App-logo-spin {
// from {
// transform: rotateY(0deg);
// }
// to {
// transform: rotateY(360deg);
// }
// }
@keyframes appScale {
from {
transform: scale(1);
}
to {
transform: scale(1.4);
}
}
}
}
.profile__about,
.profile__skills,
#projects {
@include respond-below(xs) {
animation: lowerApp 1000ms normal 1 ease-out backwards;
@keyframes lowerApp {
from {
transform: translateY(300px);
}
to {
transform: translateY(0px);
}
}
}
}
.bio__contacts {
animation: fadeInAnimation ease-out 2000ms;
animation-iteration-count: 1;
animation-fill-mode: forwards;
@keyframes fadeInAnimation {
0% {
opacity: 0.1;
}
100% {
opacity: 1;
}
}
}

View File

@@ -1,120 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
font-size: 16px;
}
body {
background: $white2;
color: $black;
display: flex;
flex-direction: column;
font-weight: $fw-4;
text-align: start;
text-rendering: optimizeLegibility;
transition: background 0.2s linear;
a {
color: $black;
text-decoration: none;
}
}
.dark {
background: $black2;
color: $color-light;
#logo {
a {
color: $green;
}
}
a {
color: $color-green;
}
.profile__card {
.card__img {
#headshot {
border: 3px solid $grey3;
}
}
}
.project {
background: $black;
color: $white;
box-shadow: 0 7px 30px -10px rgba(150, 170, 180, 0.2);
.project__title a {
color: $white;
}
.project__info {
color: $white;
}
@include respond-above(sm) {
.project__title a {
color: $grey3;
}
.project__info {
color: $grey3;
}
}
&:hover {
.project__bar {
background-color: $grey4;
}
.project__title a {
color: $white;
}
.project__info {
color: $white;
}
}
.project__tagbox {
.tag__item {
background: $grey4;
:hover {
background: $grey2;
}
}
}
}
}
.App {
margin: 0 auto;
ul {
list-style-type: none;
}
@include respond-above(xs) {
// margin: 0 1rem;
max-width: 990px;
}
}
.app__wrapper {
margin: auto 1rem;
}
header {
line-height: 1.7;
// margin: 0 1rem;
padding-top: 30px;
min-height: 150px;
}
// .wrapper_main {
// // margin: 0 1rem;
// // max-width: 768px;
// }

View File

@@ -1,424 +0,0 @@
nav {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
#logo {
font-size: $fs-xl;
font-weight: $fw-7;
letter-spacing: 0.2rem;
margin-left: 8px;
a {
color: $grey2;
}
a:hover {
color: $black2;
}
}
#nav__list {
text-align: end;
font-weight: $fw-5;
font-size: 0.8rem;
letter-spacing: 0.02rem;
}
a {
color: $black2;
}
a:hover {
background-color: $green;
color: $black;
transition: $transition-hl;
}
}
.switch-container {
position: relative;
}
.switch-wrap {
position: absolute;
right: 0;
bottom: 25px;
cursor: pointer;
background: $grey;
padding: $sw-padding;
width: $sw-width;
height: $sw-height;
border-radius: $sw-height / 2;
input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
}
.switch {
height: 100%;
display: grid;
grid-template-columns: 0fr 1fr 1fr;
transition: 0.2s;
//ICYMI, pseudo elements are treated as grid items
&::after {
content: '';
border-radius: 50%;
background: #ccc;
grid-column: 2;
transition: background 0.2s;
}
}
input:checked {
+ .switch {
grid-template-columns: 1fr 1fr 0fr;
&::after {
background-color: $green2;
}
}
}
#profile {
// min-height: 80vh;
h2 {
display: none;
}
}
.profile__card {
flex-direction: column;
text-align: center;
.card__img {
margin-bottom: 8px;
#headshot {
height: 125px;
width: 125px;
border: 3px solid $black2;
border-radius: 50%;
box-shadow: $shadow-360;
}
}
.card__bio {
display: flex;
flex-direction: column;
align-items: center;
transition: padding-top 800ms ease;
.bio__name {
line-height: 0.9em;
font-size: $fs-xl;
font-weight: $fw-7;
a {
padding: 0;
}
}
.bio__desc {
margin: 8px auto 14px;
font-weight: $fw-6;
font-size: 1.1rem;
}
.bio__contacts {
width: 125px;
display: flex;
flex-direction: row;
justify-content: space-between;
a {
color: $grey4;
margin: 0;
&:hover {
color: $grey2;
transition: $transition-hl;
}
}
}
@include respond-above(xs) {
padding-top: 36px;
}
@include respond-below(xs) {
transition: none;
}
}
}
.profile__about {
margin: 2em auto;
line-height: 1.8em;
max-width: 764px;
@include respond-above(xs) {
padding-top: 10px;
padding-bottom: 30px;
}
@include respond-above(sm) {
// margin: 2em 3rem;
}
transition: padding-top 800ms ease;
}
.profile__skills {
ul {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
li {
border: 1px solid $color-grey;
border-radius: 2px;
padding: 4px 12px;
margin: 5px 3px;
}
@include respond-above(xs) {
margin: 0 auto;
max-width: 613px;
}
}
#projects {
padding: 70px 0 60px;
h2 {
display: none;
color: $color-grey;
font-size: $fs-xl;
}
// @include respond-above(sm) {
// max-width: 990px;
// margin: 3rem auto;
// }
}
.project {
flex-wrap: wrap;
display: flex;
flex-direction: column;
box-shadow: $shadow-360;
border-radius: 10px;
margin: 3rem auto;
overflow: hidden;
position: relative;
// max-width: 800px;
a,
a:hover {
color: $color-dark;
}
h3,
.h3 {
margin-bottom: 0.5rem;
font-weight: $weight-heavy;
line-height: 1.2;
}
.project__subtitle_small {
font-size: 80%;
i {
margin-right: 5px;
}
}
.project__title {
font-size: 1.75rem;
a {
transition: color 0.1s ease;
transition: background 250ms ease-in-out;
}
:hover {
background: $color-green;
}
}
.project__img {
max-height: 180px;
width: 100%;
object-fit: cover;
position: relative;
}
.project__img_link {
display: contents;
}
.project__bar {
width: 50px;
height: 8px;
margin: 10px 0;
border-radius: 5px;
background-color: $color-lgrey;
transition: background-color 0.1s ease;
transition: width 0.2s ease;
}
.project__text {
padding: 1.5rem;
position: relative;
display: flex;
flex-direction: column;
// background: $color-light;
}
.project__info {
overflow: hidden;
text-overflow: ellipsis;
text-align: justify;
height: 100%;
color: $color-dark;
transition: color 0.1s ease;
}
.project__tagbox {
display: flex;
flex-flow: row wrap;
font-size: 14px;
margin: 20px 0 0 0;
padding: 0;
justify-content: center;
.tag__item {
display: inline-block;
background: $color-lgrey;
color: $color-dark;
border-radius: 3px;
padding: 5px 10px;
margin: 0 5px 5px 0;
cursor: default;
user-select: none;
transition: background 0.1s ease-in-out;
transition: color 0.05s ease-out;
&:hover {
background: $color-grey;
color: $color-light;
}
}
}
&:before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 1;
border-radius: 10px;
}
&:hover {
.project__bar {
width: 100px;
background-color: $color-grey;
}
.project__title {
a {
color: $color-dark;
}
}
.project__info {
color: $color-dark;
}
}
@include respond-above(sm) {
flex-wrap: inherit;
a {
color: $color-grey;
}
.project__title {
font-size: 2rem;
}
.project__tagbox {
justify-content: start;
}
.project__img {
max-width: 250px;
max-height: 100%;
transition: transform 0.2s ease;
}
.project__text {
padding: 3rem;
width: 100%;
}
.project__info {
color: $color-grey;
}
.media.project__text:before {
content: '';
position: absolute;
display: block;
top: -20%;
height: 130%;
width: 55px;
}
&:hover .project__img {
transform: scale(1.1);
}
&:nth-child(2n + 1) {
flex-direction: row;
}
&:nth-child(2n + 0) {
flex-direction: row-reverse;
}
&:nth-child(2n + 1) .project__text::before {
left: -12px !important;
transform: rotate(4deg);
}
&:nth-child(2n + 0) .project__text::before {
right: -12px !important;
transform: rotate(-4deg);
}
}
}
footer {
display: flex;
flex-direction: column-reverse;
text-align: center;
justify-content: center;
margin: 0 auto;
// height: 200px;
max-width: 500px;
.foot__links {
// border: 1px solid yellow;
font-size: 2rem;
display: flex;
justify-content: space-evenly;
margin-bottom: 50px;
a {
color: $green2;
:hover {
color: $green3;
}
}
}
.copyright {
margin-bottom: 20px;
}
}

View File

@@ -1 +0,0 @@
/* No CSS *//*# sourceMappingURL=mixins.css.map */

View File

@@ -1,9 +0,0 @@
{
"version": 3,
"mappings": "",
"sources": [
"mixins.scss"
],
"names": [],
"file": "mixins.css"
}

View File

@@ -1,66 +0,0 @@
// A map of breakpoints.
$breakpoints: (
xs: 576px,
sm: 768px,
md: 992px,
lg: 1200px,
);
// RESPOND ABOVE
//
// @include respond-above(sm) {}
@mixin respond-above($breakpoint) {
// If the breakpoint exists in the map.
@if map-has-key($breakpoints, $breakpoint) {
// Get the breakpoint value.
$breakpoint-value: map-get($breakpoints, $breakpoint);
// Write the media query.
@media (min-width: $breakpoint-value) {
@content;
}
// If the breakpoint doesn't exist in the map.
} @else {
// Log a warning.
@warn 'Invalid breakpoint: #{$breakpoint}.';
}
}
// RESPOND BELOW
//
// @include respond-below(sm) {}
@mixin respond-below($breakpoint) {
@if map-has-key($breakpoints, $breakpoint) {
$breakpoint-value: map-get($breakpoints, $breakpoint);
@media (max-width: ($breakpoint-value - 1)) {
@content;
}
} @else {
@warn 'Invalid breakpoint: #{$breakpoint}.';
}
}
// RESPOND BETWEEN
//
// @include respond-between(sm, md) {}
@mixin respond-between($lower, $upper) {
// If both the lower and upper breakpoints exist in the map.
@if map-has-key($breakpoints, $lower) and map-has-key($breakpoints, $upper)
{
// Get the lower and upper breakpoints.
$lower-breakpoint: map-get($breakpoints, $lower);
$upper-breakpoint: map-get($breakpoints, $upper);
@media (min-width: $lower-breakpoint) and (max-width: ($upper-breakpoint - 1)) {
@content;
}
} @else {
@if (map-has-key($breakpoints, $lower) == false) {
@warn 'Your lower breakpoint was invalid: #{$lower}.';
}
@if (map-has-key($breakpoints, $upper) == false) {
@warn 'Your upper breakpoint was invalid: #{$upper}.';
}
}
}

View File

@@ -1 +0,0 @@
/* No CSS *//*# sourceMappingURL=variables.css.map */

View File

@@ -1,9 +0,0 @@
{
"version": 3,
"mappings": "",
"sources": [
"variables.scss"
],
"names": [],
"file": "variables.css"
}

View File

@@ -1,57 +0,0 @@
$background-dark: #272727;
$background-light: #f7f9fb;
$color-dark: #272727;
$color-light: #fafafa;
$color-dark2: #1b1b1b;
$color-grey: #707171;
$color-lgrey: #c9cacc;
$color-blue: #61dafb;
$color-green: #2bf3c4;
$green: #2bf3c4;
$green2: #27bb98;
$green3: #238770;
$green4: #1f4b40;
$black: #1b1b1b;
$black2: #272727;
$grey: #4b4b4b;
$grey2: #707171;
$grey3: #999a9a;
$grey4: #c5c6c6;
$white: #fafafa;
$white2: #f7f9fb;
$fs-xs: 0.5rem;
$fs-sm: 0.8rem;
$fs-md: 1rem;
$fs-lg: 1.8rem;
$fs-xl: 2.3rem;
$fw-2: 200;
$fw-3: 300;
$fw-4: 400;
$fw-5: 500;
$fw-6: 600;
$fw-7: 700;
$fw-8: 800;
$weight-norm: 400;
$weight-mid: 500;
$weight-heavy: 700;
$transition-hl: 150ms ease;
$shadow-360: 0 7px 30px -10px rgba(150, 170, 180, 0.5);
$shadow-360-og: 0 10px 25px 5px rgba(0, 0, 0, 0.2);
$shadow-360-inset: inset -10px -20px 20px 1px rgba(0, 0, 0, 0.2);
$sw-width: 50px;
$sw-padding: 6px;
$sw-height: $sw-width / 2 + $sw-padding;

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"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"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}