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

This commit is contained in:
lerko96
2026-04-12 18:52:54 -04:00
parent 798027bb9d
commit 05a32492ac
61 changed files with 8078 additions and 19882 deletions

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

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

@@ -0,0 +1,59 @@
import type { Metadata } from "next";
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-14">
<p className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2">
Archive
</p>
<h1 className="font-mono text-2xl font-bold text-[var(--color-text-light)] mb-4">
Earlier Work
</h1>
<p className="text-[var(--color-grey-3)] text-sm leading-relaxed max-w-xl">
Experiments, browser extensions, and bootcamp projects. Kept here for context not representative of current work.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{archiveProjects.map((project) => (
<a
key={project.slug}
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="group border border-[var(--color-grey-1)] rounded-lg p-6 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors flex flex-col gap-4"
>
<div className="flex items-start justify-between gap-2">
<h2 className="font-mono text-sm font-semibold text-[var(--color-text-light)] group-hover:text-[var(--color-green)] transition-colors">
{project.title}
</h2>
<i className="fas fa-arrow-up-right-from-square text-xs text-[var(--color-grey-2)] shrink-0 mt-0.5 group-hover:text-[var(--color-green)] transition-colors" aria-hidden="true" />
</div>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed flex-1">
{project.description}
</p>
<div className="flex flex-wrap gap-1.5">
{project.tags.map((tag) => (
<span
key={tag}
className="font-mono text-xs px-2 py-0.5 border border-[var(--color-grey-1)] text-[var(--color-grey-2)] rounded"
>
{tag}
</span>
))}
</div>
</a>
))}
</div>
</>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

@@ -0,0 +1,72 @@
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
/* Colors */
--color-green: #2bf3c4;
--color-green-dark: #27bb98;
--color-green-darker: #238770;
--color-green-darkest: #1f4b40;
--color-bg: #272727;
--color-bg-deep: #1b1b1b;
--color-surface: #333333;
--color-grey-1: #4b4b4b;
--color-grey-2: #707171;
--color-grey-3: #999a9a;
--color-grey-4: #c5c6c6;
--color-text: #c5c6c6;
--color-text-muted: #999a9a;
--color-text-light: #f7f9fb;
/* Typography */
--font-mono: "Source Code Pro", ui-monospace, monospace;
--font-sans: "Montserrat", ui-sans-serif, system-ui, sans-serif;
/* Breakpoints */
--breakpoint-xs: 576px;
/* Animations */
--animate-fade-in: fadeIn 0.6s ease forwards;
--animate-slide-up: slideUp 0.5s ease forwards;
--animate-app-scale: appScale 0.4s ease forwards;
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes appScale {
from { transform: scale(0.97); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
}
/* Base */
html {
scroll-behavior: smooth;
background-color: var(--color-bg-deep);
color: var(--color-text);
font-family: var(--font-sans);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

237
src/app/homelab/page.tsx Normal file
View File

@@ -0,0 +1,237 @@
import type { Metadata } from "next";
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 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. Runner WorkingDirectory=/opt/docker/gitea. Feature branches for daily work; merge to dev to deploy.",
why: "Keeps the full pipeline internal — no GitHub Actions, no external runners. Build runs in an isolated Alpine container so the Gitea LXC isn't polluted. Portfolio LXC (10.99.0.23) just serves pre-built static files via nginx. Runner must be registered with the LXC IP (10.99.0.22:3000), not localhost — containers can't resolve localhost to the host. The .runner file must live in WorkingDirectory or the daemon crashes on start.",
},
];
export default function HomelabPage() {
return (
<>
{/* Header */}
<div className="mb-16">
<p className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2">
lerkolabs
</p>
<h1 className="font-mono text-2xl font-bold text-[var(--color-text-light)] mb-4">
Home Infrastructure Lab
</h1>
<p className="text-[var(--color-grey-3)] text-sm leading-relaxed max-w-2xl">
Personal infrastructure environment for learning, self-hosting, and operational practice.
Running 24/7 on production-grade hardware with real network segmentation, SSO,
monitoring, and IaC-style documentation.
</p>
</div>
{/* At a glance */}
<section className="mb-16" aria-labelledby="glance-heading">
<h2
id="glance-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6"
>
At a Glance
</h2>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-4">
{[
{ label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Intel N100)" },
{ label: "Switching", value: "TP-Link Omada (managed)" },
{ label: "ISP", value: "AT&T Fiber 1 Gbps" },
{ label: "VPN", value: "WireGuard (pfSense)" },
{ label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" },
{ label: "Auth", value: "Authentik SSO" },
{ label: "DNS", value: "Pi-hole → Unbound → Cloudflare" },
{ label: "Containers", value: "9 LXC + 2 VMs" },
].map(({ label, value }) => (
<div
key={label}
className="border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)]"
>
<p className="font-mono text-xs text-[var(--color-grey-2)] mb-1">{label}</p>
<p className="font-mono text-sm text-[var(--color-text-light)]">{value}</p>
</div>
))}
</div>
</section>
{/* VLAN table */}
<section className="mb-16" aria-labelledby="network-heading">
<h2
id="network-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6"
>
Network 8 Isolated VLANs
</h2>
<p className="text-xs text-[var(--color-grey-3)] mb-4">
Default deny inter-VLAN policy. Each VLAN has explicit firewall rules for what it can reach.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-[var(--color-grey-1)]">
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">VLAN</th>
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">Name</th>
<th className="font-mono text-xs text-[var(--color-grey-2)] text-left py-2 pr-4 uppercase tracking-wider">Subnet</th>
<th className="font-mono text-xs text-[var(--color-grey-2)] 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-grey-1)] border-opacity-30 hover:bg-[var(--color-bg)] transition-colors">
<td className="font-mono text-xs text-[var(--color-green)] py-2.5 pr-4">{v.id}</td>
<td className="font-mono text-sm text-[var(--color-text-light)] py-2.5 pr-4">{v.name}</td>
<td className="font-mono text-xs text-[var(--color-grey-3)] py-2.5 pr-4">{v.subnet}</td>
<td className="text-xs text-[var(--color-grey-3)] py-2.5">{v.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* Services */}
<section className="mb-16" aria-labelledby="services-heading">
<h2
id="services-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-6"
>
Self-Hosted Services
</h2>
<div className="flex flex-col gap-8">
{categoryOrder.map((cat) => {
const catServices = services.filter((s) => s.category === cat);
return (
<div key={cat}>
<h3 className="font-mono text-xs text-[var(--color-grey-2)] uppercase tracking-wider mb-3">
{categoryLabels[cat]}
</h3>
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-3">
{catServices.map((svc) => (
<div
key={svc.name}
className="flex items-start gap-3 border border-[var(--color-grey-1)] rounded-lg p-4 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors"
>
<i
className={`${svc.icon} text-[var(--color-green)] text-sm mt-0.5 w-4 shrink-0`}
aria-hidden="true"
/>
<div>
<p className="font-mono text-xs text-[var(--color-text-light)] mb-0.5">{svc.name}</p>
<p className="text-xs text-[var(--color-grey-2)] leading-relaxed">{svc.description}</p>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</section>
{/* ADRs */}
<section className="mb-16" aria-labelledby="adr-heading">
<h2
id="adr-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-2"
>
Architecture Decisions
</h2>
<p className="text-xs text-[var(--color-grey-3)] mb-6">
Short-form ADRs why things are configured the way they are.
</p>
<div className="flex flex-col gap-5">
{adrs.map((adr) => (
<div
key={adr.title}
className="border border-[var(--color-grey-1)] rounded-lg p-5 bg-[var(--color-bg)] hover:border-[var(--color-green-darker)] transition-colors"
>
<h3 className="font-mono text-sm text-[var(--color-text-light)] mb-2">{adr.title}</h3>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed mb-2">
<span className="text-[var(--color-grey-2)]">Decision: </span>{adr.decision}
</p>
<p className="text-xs text-[var(--color-grey-3)] leading-relaxed">
<span className="text-[var(--color-grey-2)]">Why: </span>{adr.why}
</p>
</div>
))}
</div>
</section>
{/* GitHub CTA */}
<section className="border border-[var(--color-grey-1)] rounded-lg p-6 bg-[var(--color-bg)] flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<p className="font-mono text-sm text-[var(--color-text-light)] mb-1">lerkolabs on GitHub</p>
<p className="text-xs text-[var(--color-grey-3)]">
Full documentation: VLAN maps, runbooks, service registry, config exports, and setup guides.
</p>
</div>
<a
href="https://github.com/lerko96/homelab-wip"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 font-mono text-xs px-4 py-2 border border-[var(--color-green-darker)] text-[var(--color-green)] rounded hover:bg-[var(--color-green-darkest)] transition-colors"
>
View repo <i className="fas fa-arrow-up-right-from-square ml-1 text-xs" aria-hidden="true" />
</a>
</section>
</>
);
}

56
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { Montserrat, 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 montserrat = Montserrat({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const sourceCodePro = Source_Code_Pro({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "Tyler Koenig | Portfolio",
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 />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
</head>
<body
className={`${montserrat.variable} ${sourceCodePro.variable} bg-[var(--color-bg-deep)] text-[var(--color-text)] font-sans min-h-screen`}
>
<ThemeProvider>
<Nav />
<main className="max-w-5xl mx-auto px-6 py-16">
{children}
</main>
<Footer />
</ThemeProvider>
</body>
</html>
);
}

32
src/app/page.tsx Normal file
View File

@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import Skills from "@/components/Skills";
import ProjectCard from "@/components/ProjectCard";
import { featuredProjects } from "@/data/projects";
export const metadata: Metadata = {
title: "Tyler Koenig | Portfolio",
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 />
<Skills />
<section aria-labelledby="projects-heading">
<h2
id="projects-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-10"
>
Projects
</h2>
{featuredProjects.map((project, i) => (
<ProjectCard key={project.slug} project={project} reversed={i % 2 !== 0} />
))}
</section>
</>
);
}

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;

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

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

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

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

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

@@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
{ href: "/", label: "Home" },
{ href: "/homelab/", label: "Homelab" },
{ href: "/archive/", label: "Archive" },
];
export default function Nav() {
const pathname = usePathname();
return (
<header className="sticky top-0 z-50 bg-[var(--color-bg-deep)] border-b border-[var(--color-grey-1)]">
<nav className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<Link
href="/"
className="font-mono text-xl font-bold text-[var(--color-green)] tracking-widest hover:opacity-80 transition-opacity"
>
tk
</Link>
<div className="flex items-center gap-6">
<ul className="flex 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={`text-xs font-mono tracking-widest uppercase transition-colors ${
active
? "text-[var(--color-green)]"
: "text-[var(--color-grey-3)] hover:text-[var(--color-grey-4)]"
}`}
>
{label}
</Link>
</li>
);
})}
</ul>
</div>
</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,69 @@
import type { Project } from "@/data/projects";
type Props = {
project: Project;
reversed?: boolean;
};
export default function ProjectCard({ project, reversed = false }: Props) {
return (
<article className={`group flex flex-col ${reversed ? "sm:flex-row-reverse" : "sm:flex-row"} gap-6 mb-16`}>
{/* Gradient image tile */}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 sm:w-56 h-36 rounded-lg overflow-hidden"
aria-label={`View ${project.title} on GitHub`}
tabIndex={-1}
>
<div
className={`w-full h-full bg-gradient-to-br ${project.gradient} flex items-center justify-center transition-transform duration-300 group-hover:scale-105`}
>
<span className="font-mono text-xs text-[var(--color-green)] opacity-60 tracking-widest">
{project.slug}
</span>
</div>
</a>
{/* Content */}
<div className={`flex flex-col justify-center gap-3 ${reversed ? "sm:text-right sm:items-end" : ""}`}>
{/* Animated accent bar */}
<div
className={`h-0.5 w-8 bg-[var(--color-grey-1)] rounded-full transition-all duration-300 group-hover:w-16 group-hover:bg-[var(--color-green-darker)] ${reversed ? "self-end" : ""}`}
/>
<div>
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-base font-semibold text-[var(--color-text-light)] hover:text-[var(--color-green)] transition-colors"
>
{project.title}
</a>
{project.stats && (
<span className="font-mono text-xs text-[var(--color-green)] ml-3 opacity-70">
{project.stats}
</span>
)}
</div>
<p className="text-sm text-[var(--color-grey-3)] leading-relaxed max-w-md">
{project.description}
</p>
<div className={`flex flex-wrap gap-2 ${reversed ? "justify-end" : ""}`}>
{project.tags.map((tag) => (
<span
key={tag}
className="font-mono text-xs px-2 py-0.5 border border-[var(--color-grey-1)] text-[var(--color-grey-2)] rounded"
>
{tag}
</span>
))}
</div>
</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;

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

@@ -0,0 +1,54 @@
const skillGroups = [
{
label: "Languages",
skills: ["JavaScript", "TypeScript", "HTML", "CSS"],
},
{
label: "Frontend & Mobile",
skills: ["React", "React Native", "Expo", "Next.js", "Three.js", "Responsive Design"],
},
{
label: "Desktop & Tools",
skills: ["Electron", "Node.js", "REST APIs", "Git", "Docker", "TDD"],
},
{
label: "Infrastructure",
skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"],
},
{
label: "Practices",
skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"],
},
];
export default function Skills() {
return (
<section className="mb-20" aria-labelledby="skills-heading">
<h2
id="skills-heading"
className="font-mono text-xs text-[var(--color-green)] tracking-widest uppercase mb-8"
>
Skills
</h2>
<div className="flex flex-col gap-5">
{skillGroups.map(({ label, skills }) => (
<div key={label} className="flex flex-col xs:flex-row gap-2 xs:items-start">
<span className="font-mono text-xs text-[var(--color-grey-2)] w-36 shrink-0 pt-0.5">
{label}
</span>
<div className="flex flex-wrap gap-2">
{skills.map((skill) => (
<span
key={skill}
className="text-xs font-mono px-3 py-1 border border-[var(--color-grey-1)] text-[var(--color-grey-3)] rounded hover:border-[var(--color-green-darker)] hover:text-[var(--color-grey-4)] transition-colors"
>
{skill}
</span>
))}
</div>
</div>
))}
</div>
</section>
);
}

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 }} />;
}

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

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

@@ -0,0 +1,100 @@
export type Project = {
slug: string;
title: string;
description: string;
tags: string[];
githubUrl: string;
gradient: string; // Tailwind gradient classes for placeholder image tile
tier: "featured" | "archive";
stats?: 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",
gradient: "from-[var(--color-green-darkest)] via-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "featured",
stats: "211 commits",
},
{
slug: "plaiground",
title: "plAIground",
description:
"Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.",
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
githubUrl: "https://github.com/lerko96/plaiground",
gradient: "from-[var(--color-green-darker)] via-[var(--color-surface)] to-[var(--color-bg-deep)]",
tier: "featured",
},
{
slug: "service-monitor",
title: "service-monitor",
description:
"Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.",
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
githubUrl: "https://github.com/lerko96/service-monitor",
gradient: "from-[var(--color-bg)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
tier: "featured",
},
{
slug: "tht-1.2",
title: "ThoughtSpace",
description:
"3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.",
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
githubUrl: "https://github.com/lerko96/tht-1.2",
gradient: "from-[var(--color-surface)] via-[var(--color-green-darkest)] to-[var(--color-bg-deep)]",
tier: "featured",
},
// --- Archive ---
{
slug: "twitter-thread-ext",
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",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive",
},
{
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",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive",
},
{
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",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive",
},
{
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",
gradient: "from-[var(--color-bg)] to-[var(--color-bg-deep)]",
tier: "archive",
},
];
export const featuredProjects = projects.filter((p) => p.tier === "featured");
export const archiveProjects = projects.filter((p) => p.tier === "archive");

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

@@ -0,0 +1,58 @@
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, WireGuard VPN", 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 — 600900 Mbps on N100, full LAN access for clients", category: "infrastructure", icon: "fas fa-lock" },
// 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" },
// Media
{ name: "Plex + Jellyfin", description: "Media streaming", category: "media", icon: "fas fa-film" },
{ name: "Sonarr / Radarr / Lidarr", description: "Automated media management", category: "media", icon: "fas fa-download" },
{ name: "Calibre-Web", description: "Book library with auto-ingest", category: "media", icon: "fas fa-book-open" },
];
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",
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1001 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;