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
748
src/App.css
@@ -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 */
|
||||
54
src/App.js
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
@import './styles/mixins.scss';
|
||||
@import './styles/variables.scss';
|
||||
@import './styles/base.scss';
|
||||
@import './styles/animations.scss';
|
||||
|
||||
@import './styles/components.scss'
|
||||
|
||||
@@ -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
@@ -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
|
After Width: | Height: | Size: 25 KiB |
72
src/app/globals.css
Normal 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
@@ -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 1–2ms. 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. ~600–900 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 2–3 Gbps routing and 600–900 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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const Footer = () => (
|
||||
<footer>
|
||||
<span class='copyright'>© 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
@@ -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">
|
||||
© {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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
69
src/components/ProjectCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
12
src/components/ThemeScript.tsx
Normal 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 }} />;
|
||||
}
|
||||
41
src/context/ThemeContext.tsx
Normal 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
@@ -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
@@ -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 — 600–900 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",
|
||||
};
|
||||
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 1001 KiB |
|
Before Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 829 KiB |
@@ -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;
|
||||
}
|
||||
22
src/index.js
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
// }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/* No CSS *//*# sourceMappingURL=mixins.css.map */
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"mappings": "",
|
||||
"sources": [
|
||||
"mixins.scss"
|
||||
],
|
||||
"names": [],
|
||||
"file": "mixins.css"
|
||||
}
|
||||
@@ -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}.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/* No CSS *//*# sourceMappingURL=variables.css.map */
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"mappings": "",
|
||||
"sources": [
|
||||
"variables.scss"
|
||||
],
|
||||
"names": [],
|
||||
"file": "variables.css"
|
||||
}
|
||||
@@ -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;
|
||||