Personal Website, vers. 1 (pushed far to late)

This commit is contained in:
Harlekin committed 2026-10-25 16:02:12 +01:00
commit c8df66d3da
103 files changed
+8977

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.git
node_modules
dist
cache
test
.env
*.log
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+16
View File
@@ -0,0 +1,16 @@
# Öffentliche Adresse, wird für Canonical-Links, Sitemap und OpenGraph benutzt
SITE_URL=https://lux.harlelux.de
# Docker-Netzwerk des Reverse Proxy (Nginx Proxy Manager: meist nginx_default)
PROXY_NETWORK=nginx_default
# SMTP für das Kontaktformular. Ohne SMTP_HOST wird statt des Formulars nur die
# E-Mail-Adresse aus content/site.yml angezeigt.
SMTP_HOST=
SMTP_PORT=587
# true bei Port 465, sonst false (STARTTLS)
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
MAIL_FROM="Website <website@harlelux.de>"
MAIL_TO=
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
cache/
.env
*.log
.DS_Store
+1
View File
@@ -0,0 +1 @@
24
+27
View File
@@ -0,0 +1,27 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
COPY src ./src
COPY locales ./locales
COPY public ./public
COPY content ./content
RUN mkdir cache && chown node:node cache
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz > /dev/null || exit 1
CMD ["node", "src/server.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lux
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+83
View File
@@ -0,0 +1,83 @@
# lux.harlelux.de
Meine persönliche Website mit Portfolio. Node.js und Express, die Seiten werden mit Nunjucks auf dem Server gerendert. Inhalte liegen als YAML und Markdown in `content/`, ein CMS gibt es nicht.
## Entwicklung
Node 24 (siehe `.nvmrc`).
```sh
npm install
npm run dev # http://localhost:3000, baut CSS/JS bei Änderungen neu
npm test
```
Im Dev-Modus sind Einträge mit `draft: true` sichtbar und bekommen ein Entwurf-Badge. Ohne SMTP-Konfiguration landen Mails aus dem Kontaktformular in der Konsole.
## Inhalte
```
content/
site.yml Name, Pronomen, Links, Skills, "Gerade dran"
home.yml Texte und Kacheln der Startseite
journey.yml Stationen für den Werdegang
homelab.yml Hardware und Dienste
pages/ Werdegang, Homelab, Datenschutz als <name>.de.md / <name>.en.md
projects/<slug>/ ein Ordner pro Projekt
```
Texte, die in beiden Sprachen gleich sind, stehen einfach als String. Sonst:
```yaml
title:
de: Videoschnitt
en: Video editing
```
### Neues Projekt
`content/projects/mein-projekt/meta.yml`:
```yaml
date: 2026-09 # Jahr, Monat oder Tag
tags: [homelab, docker]
featured: true # auf der Startseite zeigen
draft: true # online erst sichtbar, wenn das weg ist
cover: cover.jpg
video: https://youtu.be/... # oder eine Datei aus dem Ordner, z.B. clip.mp4
gallery:
- screenshot.png
- src: rack.jpg
caption:
de: Das Rack
en: The rack
links:
- label: Repo
url: https://git.harlelux.de/Harlekin/...
```
Dazu `de.md` und `en.md` mit `title` und `summary` im Frontmatter und dem Text darunter. Fehlt eine Sprache, wird die andere angezeigt. Bilder liegen im selben Ordner und werden relativ eingebunden (`![Beschreibung](bild.jpg)`), die Seite erzeugt automatisch kleinere WebP-Versionen.
## Deployment
Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager.
```sh
git clone https://git.harlelux.de/Harlekin/Personal-Website.git /opt/stacks/personal-website
cd /opt/stacks/personal-website
cp .env.example .env # SMTP usw. eintragen
docker compose up -d --build
```
Im Nginx Proxy Manager einen Proxy Host für `lux.harlelux.de` auf `personal-website:3000` anlegen, mit Let's-Encrypt-Zertifikat und Force SSL. Der Container hängt im Netzwerk `nginx_default`, ein anderer Name lässt sich über `PROXY_NETWORK` in der `.env` setzen.
Updates:
- **Nur Inhalte geändert:** `git pull` reicht. `content/` ist in den Container gemountet und wird automatisch neu geladen.
- **Code geändert:** `git pull` und `docker compose up -d --build`.
Neu laden lässt sich der Inhalt auch von Hand mit `docker kill -s HUP personal-website`.
## Lizenz
Der Code steht unter der MIT-Lizenz (siehe `LICENSE`). Texte und Bilder in `content/` sind davon ausgenommen.
+173
View File
@@ -0,0 +1,173 @@
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
margin: 0;
}
img,
svg,
video,
canvas {
display: block;
max-width: 100%;
}
img,
video {
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
[hidden] {
display: none !important;
}
}
@layer base {
html {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 100%;
line-height: 1.6;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-padding-top: calc(var(--header-h) + 1rem);
hanging-punctuation: first;
}
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
body {
min-height: 100vh;
min-height: 100svh;
display: flex;
flex-direction: column;
background: var(--bg);
font-size: var(--step-0);
overflow-x: clip;
}
main {
flex: 1;
padding-bottom: var(--section);
}
main:focus {
outline: none;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 650;
line-height: 1.05;
letter-spacing: -0.025em;
text-wrap: balance;
overflow-wrap: break-word;
}
p,
li {
text-wrap: pretty;
}
a {
color: inherit;
text-underline-offset: 0.2em;
text-decoration-thickness: 1px;
}
:focus-visible {
outline: 2px solid var(--pink-text);
outline-offset: 3px;
border-radius: 4px;
}
::selection {
background: var(--pink);
color: var(--bg);
}
.icon {
width: 1.15em;
height: 1.15em;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon--play {
fill: currentColor;
stroke: none;
}
}
@layer utilities {
.visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.skip-link {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 100;
padding: 0.75rem 1rem;
border-radius: var(--radius-s);
background: var(--text);
color: var(--bg);
font-weight: 500;
text-decoration: none;
translate: 0 -200%;
transition: translate 0.2s;
&:focus {
translate: none;
}
}
}
+740
View File
@@ -0,0 +1,740 @@
@layer components {
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
min-height: 3rem;
padding-inline: 1.4rem;
border: 1px solid transparent;
border-radius: 99px;
font-size: 0.98rem;
font-weight: 500;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
transition:
background-color 0.25s,
border-color 0.25s,
color 0.25s,
translate 0.3s var(--ease-out);
& .icon {
transition: translate 0.3s var(--ease-out);
}
&:hover .icon--arrow-right {
translate: 3px 0;
}
}
.btn--primary {
background: var(--text);
color: var(--bg);
&:hover {
background: var(--pink-text);
}
}
.btn--ghost {
border-color: var(--line-strong);
background: rgb(12 10 18 / 0.35);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
&:hover {
border-color: var(--text);
}
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
border-radius: 99px;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.badge--draft {
border: 1px dashed rgb(255 141 180 / 0.6);
background: rgb(255 118 164 / 0.12);
color: var(--pink-text);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.tag {
display: inline-block;
padding: 0.2rem 0.65rem;
border: 1px solid var(--line-strong);
border-radius: 99px;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--muted);
text-decoration: none;
}
a.tag {
transition: color 0.2s, border-color 0.2s;
&:hover {
border-color: var(--pink-text);
color: var(--text);
}
}
.note {
margin-top: 1.5rem;
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--pink-text);
}
.empty {
padding: 3rem 0;
color: var(--muted);
}
/* Pointer-following border glow, the position comes from pointer.js */
[data-glow] {
--mx: 50%;
--my: 50%;
position: relative;
&::before {
content: "";
position: absolute;
inset: 0;
z-index: 2;
padding: 1px;
border-radius: inherit;
background: radial-gradient(26rem circle at var(--mx) var(--my), rgb(255 141 180 / 0.7), rgb(192 17 215 / 0.35) 40%, transparent 65%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
opacity: 0;
transition: opacity 0.4s;
pointer-events: none;
}
}
@media (hover: hover) and (pointer: fine) {
[data-glow]:hover::before {
opacity: 1;
}
}
/* Project cards */
.project-grid {
display: grid;
gap: clamp(1rem, 2vw, 1.5rem);
margin: 0;
padding: 0;
list-style: none;
@media (min-width: 40em) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.card {
height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-l);
background: var(--surface);
transition: border-color 0.3s, translate 0.4s var(--ease-out);
}
.card__link {
display: flex;
flex-direction: column;
height: 100%;
border-radius: inherit;
color: inherit;
text-decoration: none;
&:focus-visible {
outline-offset: 4px;
border-radius: var(--radius-l);
}
}
.card__media {
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: calc(var(--radius-l) - 1px) calc(var(--radius-l) - 1px) 0 0;
background: var(--surface-2);
}
.card__img {
width: 100%;
height: 100%;
object-fit: cover;
transition: scale 0.9s var(--ease-out);
}
.card__placeholder {
width: 100%;
height: 100%;
background:
radial-gradient(80% 70% at 20% 20%, rgb(255 118 164 / 0.45), transparent 70%),
radial-gradient(70% 70% at 80% 80%, rgb(47 60 190 / 0.6), transparent 70%),
radial-gradient(60% 60% at 60% 30%, rgb(192 17 215 / 0.4), transparent 70%),
var(--surface-2);
}
.card__body {
display: grid;
align-content: start;
gap: 0.6rem;
padding: 1.25rem 1.4rem 1.6rem;
}
.card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
min-height: 1.4rem;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--muted);
}
.card__title {
font-size: var(--step-2);
}
.card__summary {
color: var(--muted);
}
.card .tags {
margin-top: 0.35rem;
}
@media (hover: hover) and (pointer: fine) {
.card:hover {
border-color: transparent;
& .card__img {
scale: 1.045;
}
}
}
/* Filter chips */
.filter {
display: flex;
gap: 0.5rem;
margin-bottom: clamp(1.5rem, 3vw, 2.25rem);
@media (max-width: 39.99em) {
margin-inline: calc(-1 * var(--gutter));
padding-inline: var(--gutter);
padding-bottom: 0.25rem;
overflow-x: auto;
scroll-snap-type: x proximity;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@media (min-width: 40em) {
flex-wrap: wrap;
}
}
.chip {
display: inline-flex;
flex: none;
align-items: center;
gap: 0.5rem;
min-height: 2.75rem;
padding-inline: 1.1rem;
border: 1px solid var(--line-strong);
border-radius: 99px;
color: var(--muted);
font-size: 0.92rem;
text-decoration: none;
white-space: nowrap;
scroll-snap-align: start;
transition: color 0.2s, border-color 0.2s, background-color 0.2s;
&:hover {
border-color: var(--text);
color: var(--text);
}
&[aria-current="true"] {
border-color: transparent;
background: var(--text);
color: var(--bg);
& .chip__count {
color: rgb(12 10 18 / 0.6);
}
}
}
.chip__count {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--faint);
}
/* Link buttons (project links, contact) */
.link-button {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 3rem;
padding: 0.6rem 0.95rem;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: rgb(243 238 248 / 0.03);
color: var(--text);
text-decoration: none;
transition: border-color 0.2s, background-color 0.2s;
& span {
overflow-wrap: anywhere;
}
& .icon {
color: var(--pink-text);
}
&:hover {
border-color: rgb(255 141 180 / 0.6);
background: rgb(255 118 164 / 0.08);
}
}
/* Long-form text from Markdown */
.prose {
max-width: 42rem;
color: rgb(243 238 248 / 0.88);
& > * + * {
margin-top: 1.1em;
}
& > :first-child {
margin-top: 0;
}
& h2 {
margin-top: 2em;
font-size: var(--step-3);
color: var(--text);
}
& h3 {
margin-top: 1.75em;
font-size: var(--step-2);
color: var(--text);
}
& h2 + *,
& h3 + * {
margin-top: 0.75em;
}
& a {
color: var(--pink-text);
transition: color 0.2s;
&:hover {
color: var(--text);
}
}
& strong {
font-weight: 600;
color: var(--text);
}
& ul,
& ol {
padding-left: 1.25em;
}
& li + li {
margin-top: 0.4em;
}
& li::marker {
color: var(--pink-text);
}
& code {
padding: 0.12em 0.4em;
border-radius: 0.35rem;
background: rgb(243 238 248 / 0.08);
font-family: var(--font-mono);
font-size: 0.86em;
}
& pre {
padding: 1.1rem 1.25rem;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: #0f0c16;
font-size: 0.875rem;
line-height: 1.6;
& code {
padding: 0;
background: none;
font-size: inherit;
}
}
& img {
border: 1px solid var(--line);
border-radius: var(--radius);
}
& blockquote {
padding-left: 1.25rem;
border-left: 2px solid var(--violet-text);
color: var(--muted);
}
& hr {
height: 1px;
border: 0;
background: var(--line);
}
& table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
& th,
& td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--line);
text-align: left;
}
}
.prose--wide {
max-width: 52rem;
}
/* Forms */
.field {
display: grid;
gap: 0.45rem;
}
.field__label {
font-size: 0.92rem;
font-weight: 500;
color: var(--muted);
}
.field__input {
width: 100%;
min-height: 3.1rem;
padding: 0.8rem 1rem;
border: 1px solid var(--line-strong);
border-radius: 0.8rem;
background: var(--surface);
font-size: max(16px, 1rem);
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: rgb(243 238 248 / 0.32);
}
&:focus {
outline: none;
border-color: var(--pink-text);
box-shadow: 0 0 0 4px rgb(255 118 164 / 0.16);
}
}
.field__input--area {
min-height: 11rem;
resize: vertical;
}
.field--invalid .field__input {
border-color: var(--error);
}
.field__error {
font-size: 0.875rem;
color: var(--error);
}
.field--trap {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
.form-status {
padding: 1rem 1.25rem;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: var(--surface);
}
.form-status--ok {
border-color: rgb(143 152 255 / 0.5);
background: rgb(47 60 190 / 0.18);
}
.form-status--error {
border-color: rgb(255 154 169 / 0.5);
background: rgb(255 107 129 / 0.1);
}
/* Video */
.video {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-l);
background:
radial-gradient(60% 80% at 15% 20%, rgb(255 118 164 / 0.3), transparent 70%),
radial-gradient(60% 80% at 85% 80%, rgb(47 60 190 / 0.45), transparent 70%),
var(--surface);
& iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
}
.video--file {
width: 100%;
height: auto;
aspect-ratio: auto;
}
.video__poster {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.55) saturate(0.9);
}
.video__overlay {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: 1rem;
padding: 1rem;
text-align: center;
}
.video__play {
display: inline-flex;
align-items: center;
gap: 0.85rem;
padding: 0.45rem 1.3rem 0.45rem 0.45rem;
border: 1px solid var(--line-strong);
border-radius: 99px;
background: rgb(12 10 18 / 0.7);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
font-weight: 500;
text-decoration: none;
transition: border-color 0.2s;
&:hover {
border-color: var(--text);
& .video__play-icon {
scale: 1.06;
}
}
}
.video__play-icon {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background: var(--text);
color: var(--bg);
transition: scale 0.3s var(--ease-out);
}
.video__notice {
max-width: 34ch;
font-size: 0.8rem;
color: rgb(243 238 248 / 0.75);
}
/* Gallery + lightbox */
.gallery {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
@media (min-width: 60em) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
& figcaption {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--muted);
}
}
.gallery__link {
display: block;
aspect-ratio: 4 / 3;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: zoom-in;
}
.gallery__img {
width: 100%;
height: 100%;
object-fit: cover;
transition: scale 0.7s var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.gallery__link:hover .gallery__img {
scale: 1.05;
}
}
.lightbox {
width: 100vw;
max-width: none;
height: 100vh;
height: 100dvh;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: rgb(8 6 12 / 0.94);
color: var(--text);
&::backdrop {
background: rgb(8 6 12 / 0.6);
}
&[open] {
animation: fade-in 0.3s var(--ease-out);
}
}
.lightbox__figure {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: calc(4.5rem + env(safe-area-inset-top)) 1rem calc(4rem + env(safe-area-inset-bottom));
touch-action: pan-y pinch-zoom;
@media (min-width: 48em) {
padding-inline: 5.5rem;
}
}
.lightbox__img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
border-radius: 0.5rem;
object-fit: contain;
user-select: none;
}
.lightbox__btn {
position: absolute;
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 50%;
background: rgb(243 238 248 / 0.08);
transition: background-color 0.2s;
&:hover {
background: rgb(243 238 248 / 0.16);
}
}
.lightbox__close {
top: calc(1rem + env(safe-area-inset-top));
right: 1rem;
}
.lightbox__prev,
.lightbox__next {
top: 50%;
translate: 0 -50%;
@media (max-width: 47.99em) {
top: auto;
bottom: calc(1rem + env(safe-area-inset-bottom));
translate: none;
}
}
.lightbox__prev {
left: 1rem;
}
.lightbox__next {
right: 1rem;
}
.lightbox__count {
position: absolute;
bottom: calc(1.6rem + env(safe-area-inset-bottom));
left: 50%;
translate: -50% 0;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--muted);
}
}
+469
View File
@@ -0,0 +1,469 @@
@layer pages {
/* Hero */
.hero {
position: relative;
isolation: isolate;
display: grid;
align-items: end;
min-height: 100vh;
min-height: 100svh;
padding-block: calc(var(--header-h) + 3rem) clamp(2.5rem, 7vh, 5rem);
overflow: hidden;
}
.hero__bg {
position: absolute;
inset: 0;
z-index: -1;
background:
radial-gradient(60% 50% at 80% 18%, rgb(192 17 215 / 0.55), transparent 70%),
radial-gradient(50% 45% at 25% 8%, rgb(255 118 164 / 0.45), transparent 70%),
radial-gradient(70% 55% at 75% 75%, rgb(47 60 190 / 0.6), transparent 70%),
var(--bg);
/* readability: darken towards the text and fade into the page */
&::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background:
linear-gradient(180deg, rgb(12 10 18 / 0.25) 0%, rgb(12 10 18 / 0) 25%, rgb(12 10 18 / 0.45) 62%, var(--bg) 100%),
linear-gradient(90deg, rgb(12 10 18 / 0.6) 0%, rgb(12 10 18 / 0.15) 60%, transparent 100%);
}
/* film grain */
&::before {
content: "";
position: absolute;
inset: 0;
z-index: 2;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.12;
mix-blend-mode: overlay;
pointer-events: none;
}
}
.hero__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 1.6s var(--ease-out);
&.is-ready {
opacity: 1;
}
}
.hero__inner {
display: grid;
gap: clamp(1.25rem, 3vh, 2rem);
}
.hero__kicker {
display: flex;
align-items: center;
gap: 0.75rem;
font-family: var(--font-mono);
font-size: clamp(0.72rem, 0.68rem + 0.2vw, 0.82rem);
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgb(243 238 248 / 0.8);
&::before {
content: "";
flex: none;
width: 2rem;
height: 1px;
background: var(--fluid);
}
}
.hero__title {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.5rem 1.25rem;
font-weight: 700;
}
.hero__name {
display: block;
margin-left: -0.05em;
padding-bottom: 0.04em;
font-size: clamp(6rem, 2rem + 24vw, 19rem);
line-height: 0.8;
letter-spacing: -0.045em;
color: var(--text);
text-shadow: 0 0 60px rgb(12 10 18 / 0.35);
}
.hero__pronouns {
margin-top: clamp(0.25rem, 1.5vw, 1.5rem);
padding: 0.4rem 0.85rem;
border: 1px solid var(--line-strong);
border-radius: 99px;
background: rgb(12 10 18 / 0.45);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 1.4;
}
.hero__bottom {
display: grid;
gap: 2rem;
@media (min-width: 60em) {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
}
}
.hero__lead {
max-width: 40ch;
font-size: var(--step-1);
color: rgb(243 238 248 / 0.88);
}
.hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.75rem;
}
.now {
width: min(100%, 22rem);
padding: 1.1rem 1.25rem 1.2rem;
border: 1px solid var(--line);
border-radius: var(--radius-l);
background: rgb(12 10 18 / 0.55);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
}
.now__title {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.6rem;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.now__pulse {
position: relative;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--pink);
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--pink);
animation: ping 2.2s var(--ease-out) infinite;
}
}
.now__list {
display: grid;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
font-size: 0.95rem;
}
/* Bento */
.bento {
display: grid;
gap: clamp(0.75rem, 1.5vw, 1.25rem);
@media (min-width: 40em) {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"server server"
"homelab homelab"
"scripts study"
"video video";
}
@media (min-width: 64em) {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-areas:
"server server homelab homelab"
"server server scripts study"
"video video video video";
}
}
.tile {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 16rem;
padding: clamp(1.25rem, 2.5vw, 1.75rem);
border: 1px solid var(--line);
border-radius: var(--radius-l);
background: linear-gradient(180deg, var(--surface), var(--bg-raised));
}
/* only where the grid actually defines these areas, otherwise all tiles end up in one cell */
@media (min-width: 40em) {
.tile--server { grid-area: server; }
.tile--homelab { grid-area: homelab; }
.tile--scripts { grid-area: scripts; }
.tile--study { grid-area: study; }
.tile--video { grid-area: video; }
}
.tile__visual {
flex: 1;
display: grid;
place-items: center;
min-height: 8rem;
}
.tile__body {
display: grid;
gap: 0.6rem;
}
.tile__title {
font-size: var(--step-2);
}
.tile__text {
max-width: 48ch;
color: var(--muted);
}
.tile__link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: fit-content;
margin-top: 0.35rem;
font-size: 0.92rem;
font-weight: 500;
color: var(--pink-text);
text-decoration: none;
/* the whole tile is clickable */
&::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
}
&:focus-visible {
outline: none;
&::after {
outline: 2px solid var(--pink-text);
outline-offset: 3px;
}
}
& .icon {
transition: translate 0.3s var(--ease-out);
}
}
.tile:hover .tile__link .icon {
translate: 3px 0;
}
@media (min-width: 64em) {
.tile--server .tile__visual {
min-height: 14rem;
}
.tile--video {
flex-direction: row;
align-items: center;
min-height: 0;
& .tile__visual {
order: 2;
flex: 1.4;
}
& .tile__body {
flex: 1;
}
}
}
/* Featured projects: first card gets the full width */
.project-grid--featured > li:first-child {
@media (min-width: 40em) {
grid-column: 1 / -1;
}
@media (min-width: 64em) {
& .card__link {
flex-direction: row;
}
& .card__media {
flex: 1.35;
aspect-ratio: auto;
min-height: 24rem;
border-radius: calc(var(--radius-l) - 1px) 0 0 calc(var(--radius-l) - 1px);
}
& .card__body {
flex: 1;
align-content: center;
padding: clamp(1.5rem, 3vw, 3rem);
}
& .card__title {
font-size: var(--step-3);
}
}
}
/* Tools marquee */
.marquee {
display: flex;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
user-select: none;
&:hover .marquee__track {
animation-play-state: paused;
}
}
.marquee__track {
display: flex;
flex: none;
align-items: center;
margin: 0;
padding: 0;
list-style: none;
animation: marquee 45s linear infinite;
}
.marquee__item {
display: flex;
align-items: center;
gap: clamp(1.25rem, 3vw, 2.5rem);
padding-right: clamp(1.25rem, 3vw, 2.5rem);
font-family: var(--font-display);
font-size: var(--step-4);
font-weight: 600;
letter-spacing: -0.03em;
white-space: nowrap;
color: transparent;
-webkit-text-stroke: 1px rgb(243 238 248 / 0.45);
&:nth-child(3n + 1) {
color: var(--text);
-webkit-text-stroke: 0;
}
&::after {
content: "";
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--pink);
}
&:nth-child(3n + 2)::after {
background: var(--violet-text);
}
&:nth-child(3n)::after {
background: var(--blue-text);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee {
-webkit-mask-image: none;
mask-image: none;
padding-inline: var(--gutter);
}
.marquee__track {
flex-wrap: wrap;
flex-shrink: 1;
gap: 0.5rem 0;
animation: none;
&[aria-hidden] {
display: none;
}
}
.marquee__item {
font-size: var(--step-2);
}
}
/* Contact teaser */
.cta__inner {
position: relative;
overflow: hidden;
padding: clamp(2.25rem, 6vw, 5rem) clamp(1.25rem, 6vw, 5rem);
border: 1px solid var(--line);
border-radius: clamp(1.25rem, 3vw, 2rem);
background:
radial-gradient(70% 90% at 100% 0%, rgb(192 17 215 / 0.32), transparent 60%),
radial-gradient(60% 90% at 0% 100%, rgb(47 60 190 / 0.42), transparent 60%),
radial-gradient(40% 60% at 60% 110%, rgb(255 118 164 / 0.25), transparent 70%),
var(--surface);
}
.cta__title {
max-width: 18ch;
font-size: var(--step-4);
}
.cta__text {
max-width: 52ch;
margin-top: 1.1rem;
font-size: var(--step-1);
color: rgb(243 238 248 / 0.8);
}
.cta__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem 1.75rem;
margin-top: 2rem;
}
.cta__mail {
font-family: var(--font-mono);
font-size: 0.95rem;
overflow-wrap: anywhere;
transition: color 0.2s;
&:hover {
color: var(--pink-text);
}
}
}
+523
View File
@@ -0,0 +1,523 @@
@layer layout {
.container {
width: 100%;
max-width: calc(var(--container) + 2 * var(--gutter));
margin-inline: auto;
padding-inline: max(var(--gutter), env(safe-area-inset-left)) max(var(--gutter), env(safe-area-inset-right));
}
.container--wide {
max-width: calc(92rem + 2 * var(--gutter));
}
.section {
padding-top: var(--section);
}
.section--flush {
padding-top: 0;
}
.section--tight {
padding-top: calc(var(--section) * 0.75);
}
.section-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem 1.25rem;
margin-bottom: clamp(1.75rem, 4vw, 3.25rem);
padding-top: 1.25rem;
border-top: 1px solid var(--line);
}
.section-head__index {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--pink-text);
}
.section-head__title {
flex: 1 1 auto;
font-size: var(--step-4);
}
.section-head__more {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.75rem;
color: var(--muted);
font-size: 0.95rem;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: var(--text);
}
}
/* Header. The header itself is never transformed, otherwise it would become
the containing block for the fixed mobile menu inside it. */
.site-header {
position: fixed;
inset: 0 0 auto;
z-index: 50;
height: calc(var(--header-h) + env(safe-area-inset-top));
padding-top: env(safe-area-inset-top);
&::before {
content: "";
position: absolute;
inset: 0;
background: rgb(12 10 18 / 0.72);
-webkit-backdrop-filter: blur(14px) saturate(140%);
backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid var(--line);
opacity: 0;
transition: opacity 0.3s, translate 0.45s var(--ease-out);
}
&.is-scrolled::before {
opacity: 1;
}
&.is-hidden {
pointer-events: none;
&::before,
.site-header__inner {
translate: 0 -110%;
}
}
}
.site-header__inner {
position: relative;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
transition: translate 0.45s var(--ease-out);
}
.brand {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 0.6rem;
min-height: 2.75rem;
text-decoration: none;
}
.brand__mark {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--line-strong), 0 0 20px rgb(255 118 164 / 0.3);
transition: rotate 0.4s var(--ease-out), scale 0.4s var(--ease-out);
}
.brand:hover .brand__mark {
rotate: -8deg;
scale: 1.06;
}
.brand__name {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1;
}
.menu-toggle {
position: relative;
z-index: 1;
display: none;
align-items: center;
gap: 0.75rem;
min-height: 2.75rem;
padding: 0 0.25rem 0 0.9rem;
border: 1px solid var(--line-strong);
border-radius: 99px;
background: rgb(12 10 18 / 0.5);
font-size: 0.9rem;
}
.menu-toggle__icon {
position: relative;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
& span {
position: absolute;
width: 1rem;
height: 1.5px;
border-radius: 2px;
background: currentColor;
transition: translate 0.35s var(--ease-out), rotate 0.35s var(--ease-out);
}
& span:first-child {
translate: 0 -3px;
}
& span:last-child {
translate: 0 3px;
}
}
.menu-open .menu-toggle__icon {
& span:first-child {
translate: none;
rotate: 45deg;
}
& span:last-child {
translate: none;
rotate: -45deg;
}
}
.site-nav {
display: flex;
align-items: center;
gap: 2rem;
}
.site-nav__list {
display: flex;
gap: 1.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.site-nav__link {
position: relative;
display: inline-flex;
align-items: center;
min-height: 2.75rem;
color: var(--muted);
font-size: 0.95rem;
text-decoration: none;
transition: color 0.2s;
&::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0.55rem;
height: 1px;
background: var(--fluid);
scale: 0 1;
transform-origin: right;
transition: scale 0.4s var(--ease-out);
}
&:hover,
&[aria-current="page"] {
color: var(--text);
&::after {
scale: 1 1;
transform-origin: left;
}
}
}
.lang-switch {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 2.5rem;
padding-inline: 0.85rem;
border: 1px solid var(--line-strong);
border-radius: 99px;
font-family: var(--font-mono);
font-size: 0.78rem;
text-decoration: none;
transition: border-color 0.2s, background-color 0.2s;
&:hover {
border-color: var(--text);
background: rgb(243 238 248 / 0.06);
}
}
.lang-switch__current,
.lang-switch__sep {
color: var(--faint);
}
@media (max-width: 47.99em) {
.menu-toggle {
display: inline-flex;
}
.site-nav {
position: fixed;
inset: 0;
z-index: -1;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2.5rem;
padding: calc(var(--header-h) + env(safe-area-inset-top) + 1rem) var(--gutter) calc(2.5rem + env(safe-area-inset-bottom));
overflow-y: auto;
background:
radial-gradient(90% 60% at 100% 0%, rgb(192 17 215 / 0.28), transparent 70%),
radial-gradient(80% 60% at 0% 100%, rgb(47 60 190 / 0.35), transparent 70%),
var(--bg);
clip-path: circle(0 at calc(100% - 2.75rem) 2.1rem);
visibility: hidden;
transition: clip-path 0.55s var(--ease-in-out), visibility 0s 0.55s;
}
.menu-open .site-nav {
clip-path: circle(150% at calc(100% - 2.75rem) 2.1rem);
visibility: visible;
transition: clip-path 0.7s var(--ease-out), visibility 0s;
}
.menu-open .site-header::before {
opacity: 0;
}
.site-nav__list {
flex-direction: column;
gap: 0.25rem;
}
.site-nav__link {
font-family: var(--font-display);
font-size: clamp(2.25rem, 11vw, 3.25rem);
font-weight: 600;
letter-spacing: -0.03em;
color: var(--text);
opacity: 0;
translate: 0 1rem;
transition: opacity 0.4s, translate 0.5s var(--ease-out);
&::after {
bottom: 0.4rem;
}
}
.menu-open .site-nav__link {
opacity: 1;
translate: none;
}
.menu-open .site-nav__item:nth-child(1) .site-nav__link { transition-delay: 0.12s; }
.menu-open .site-nav__item:nth-child(2) .site-nav__link { transition-delay: 0.18s; }
.menu-open .site-nav__item:nth-child(3) .site-nav__link { transition-delay: 0.24s; }
.menu-open .site-nav__item:nth-child(4) .site-nav__link { transition-delay: 0.3s; }
.menu-open .site-nav__item:nth-child(5) .site-nav__link { transition-delay: 0.36s; }
.lang-switch {
min-height: 2.75rem;
font-size: 0.85rem;
}
}
/* Without JS the menu button can't do anything, so show the links inline */
@media (max-width: 47.99em) and (scripting: none) {
.site-header {
position: absolute;
height: auto;
}
.site-header__inner {
flex-wrap: wrap;
padding-block: 0.75rem;
}
.menu-toggle {
display: none;
}
.site-nav {
position: static;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding: 0;
background: none;
clip-path: none;
visibility: visible;
}
.site-nav__list {
flex-direction: row;
flex-wrap: wrap;
gap: 0 1rem;
}
.site-nav__link {
font-family: var(--font-body);
font-size: 0.95rem;
opacity: 1;
translate: none;
}
}
.menu-open,
.lightbox-open {
overflow: hidden;
}
/* Subpage heads */
.page-head {
position: relative;
isolation: isolate;
padding-block: calc(var(--header-h) + clamp(3rem, 9vw, 7.5rem)) clamp(2.5rem, 5vw, 4.5rem);
}
.page-head__glow {
position: absolute;
top: 0;
right: 0;
z-index: -1;
width: min(62rem, 100%);
height: min(36rem, 90%);
background:
radial-gradient(closest-side at 72% 32%, rgb(192 17 215 / 0.32), transparent),
radial-gradient(closest-side at 92% 12%, rgb(255 118 164 / 0.28), transparent),
radial-gradient(closest-side at 46% 4%, rgb(47 60 190 / 0.4), transparent);
filter: blur(28px);
pointer-events: none;
animation: drift 20s ease-in-out infinite alternate;
}
.page-head__kicker {
margin-bottom: 1rem;
font-family: var(--font-mono);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--pink-text);
}
/* long German compounds like "Datenschutzerklärung" don't fit on phones otherwise */
.page-head__title,
.project-head__title {
hyphens: auto;
}
.page-head__title {
max-width: 16ch;
font-size: var(--step-5);
letter-spacing: -0.045em;
line-height: 0.98;
}
.page-head__lead {
max-width: 56ch;
margin-top: 1.25rem;
font-size: var(--step-1);
color: var(--muted);
}
/* Footer */
.site-footer {
padding-block: clamp(3rem, 6vw, 5rem) calc(2rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: linear-gradient(180deg, transparent, rgb(47 60 190 / 0.07));
font-size: 0.95rem;
& ul {
display: grid;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
& a {
display: inline-block;
padding-block: 0.2rem;
color: var(--muted);
text-decoration: none;
overflow-wrap: anywhere;
transition: color 0.2s;
&:hover {
color: var(--text);
}
}
}
.site-footer__grid {
display: grid;
gap: 2.25rem;
@media (min-width: 40em) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 60em) {
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr);
}
}
.site-footer__intro {
@media (min-width: 40em) and (max-width: 59.99em) {
grid-column: 1 / -1;
}
}
.site-footer .brand {
display: inline-flex;
padding: 0;
color: var(--text);
}
.site-footer__note {
max-width: 36ch;
margin-top: 0.75rem;
color: var(--muted);
}
.site-footer__heading {
margin-bottom: 0.75rem;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--faint);
}
.site-footer__bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.5rem;
margin-top: clamp(2.5rem, 5vw, 4rem);
padding-top: 1.5rem;
border-top: 1px solid var(--line);
font-size: 0.875rem;
color: var(--faint);
}
.site-footer .site-footer__legal {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.5rem;
}
.to-top {
display: inline-flex !important;
align-items: center;
gap: 0.35rem;
}
}
+18
View File
@@ -0,0 +1,18 @@
@layer reset, base, layout, components, pages, motion, utilities;
@import "@fontsource-variable/bricolage-grotesque/opsz.css";
@import "@fontsource/ibm-plex-sans/latin-400.css";
@import "@fontsource/ibm-plex-sans/latin-400-italic.css";
@import "@fontsource/ibm-plex-sans/latin-500.css";
@import "@fontsource/ibm-plex-sans/latin-600.css";
@import "@fontsource/ibm-plex-mono/latin-400.css";
@import "@fontsource/ibm-plex-mono/latin-500.css";
@import "./tokens.css";
@import "./base.css";
@import "./layout.css";
@import "./components.css";
@import "./visuals.css";
@import "./home.css";
@import "./pages.css";
@import "./motion.css";
+134
View File
@@ -0,0 +1,134 @@
@layer motion {
/* Content only starts hidden when JS runs and nobody asked for less motion */
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
[data-reveal] {
opacity: 0;
translate: 0 1.75rem;
transition:
opacity 0.9s var(--ease-out) var(--delay, 0s),
translate 0.9s var(--ease-out) var(--delay, 0s);
}
[data-reveal="mask"] {
opacity: 1;
translate: none;
clip-path: inset(0 -0.2em 100% -0.2em);
transform: translateY(0.35em);
transition:
clip-path 1.1s var(--ease-out) var(--delay, 0s),
transform 1.1s var(--ease-out) var(--delay, 0s);
}
[data-reveal].is-visible {
opacity: 1;
translate: none;
}
[data-reveal="mask"].is-visible {
clip-path: inset(-0.2em -0.2em -0.3em -0.2em);
transform: none;
}
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.timeline::after {
animation: grow linear both;
animation-timeline: view();
animation-range: entry 20% cover 70%;
}
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
scroll-behavior: auto !important;
}
}
@keyframes drift {
from { translate: 0 0; scale: 1; }
to { translate: -6% 4%; scale: 1.08; }
}
@keyframes ping {
0% { scale: 1; opacity: 0.7; }
80%, 100% { scale: 3; opacity: 0; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
@keyframes load {
from { translate: -30% 0; }
to { translate: 60% 0; }
}
@keyframes flow {
from { stroke-dashoffset: 106; }
to { stroke-dashoffset: 0; }
}
@keyframes sort {
0%, 12% { height: var(--from); }
40%, 75% { height: var(--to); }
100% { height: var(--from); }
}
@keyframes playhead {
from { left: calc(0.9rem + var(--label)); }
to { left: calc(100% - 0.9rem); }
}
@keyframes marquee {
to { translate: -100% 0; }
}
@keyframes grow {
from { scale: 1 0; }
to { scale: 1 1; }
}
@keyframes fade-in {
from { opacity: 0; }
}
@keyframes vt-out {
to { opacity: 0; translate: 0 -0.75rem; }
}
@keyframes vt-in {
from { opacity: 0; translate: 0 1.25rem; }
}
}
/* Cross-document page transitions, outside of the layers on purpose */
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: none;
}
}
::view-transition-old(root) {
animation: 0.3s var(--ease-in-out) both vt-out;
}
::view-transition-new(root) {
animation: 0.55s var(--ease-out) both vt-in;
}
::view-transition-group(project-cover) {
animation-duration: 0.6s;
animation-timing-function: var(--ease-out);
}
+553
View File
@@ -0,0 +1,553 @@
@layer pages {
/* Project detail */
.project-head {
position: relative;
isolation: isolate;
padding-block: calc(var(--header-h) + clamp(2rem, 6vw, 5rem)) clamp(2rem, 4vw, 3rem);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: 2.75rem;
margin-bottom: 1.25rem;
color: var(--muted);
font-size: 0.95rem;
text-decoration: none;
transition: color 0.2s;
& .icon {
transition: translate 0.3s var(--ease-out);
}
&:hover {
color: var(--text);
& .icon {
translate: -3px 0;
}
}
}
.project-head__title {
max-width: 20ch;
font-size: var(--step-5);
letter-spacing: -0.045em;
line-height: 0.98;
}
.project-meta {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 3rem;
margin-top: 2rem;
& dt {
margin-bottom: 0.4rem;
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--faint);
}
}
.project-cover {
margin-bottom: clamp(2.5rem, 5vw, 4rem);
}
.project-cover__img {
width: 100%;
max-height: 80vh;
max-height: 80svh;
border: 1px solid var(--line);
border-radius: var(--radius-l);
object-fit: cover;
view-transition-name: project-cover;
}
.project-body {
display: grid;
gap: 3rem;
@media (min-width: 60em) {
grid-template-columns: minmax(0, 1fr) 18rem;
align-items: start;
}
}
.project-links {
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
@media (min-width: 60em) {
position: sticky;
top: calc(var(--header-h) + 1.5rem);
}
& ul {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
}
.project-links__title,
.contact__aside-title {
margin-bottom: 1rem;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--faint);
}
.project-section {
padding-top: clamp(3rem, 6vw, 5rem);
}
.project-section__title {
margin-bottom: 1.5rem;
font-size: var(--step-3);
}
.pager {
display: grid;
gap: 1rem;
padding-top: var(--section);
@media (min-width: 40em) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.pager__link {
display: grid;
gap: 0.5rem;
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: var(--radius-l);
text-decoration: none;
transition: border-color 0.2s, background-color 0.2s;
&:hover {
border-color: var(--line-strong);
background: var(--surface);
}
}
.pager__link--older {
text-align: right;
@media (min-width: 40em) {
grid-column: 2;
}
& .pager__label {
justify-content: flex-end;
}
}
.pager__label {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.pager__title {
font-family: var(--font-display);
font-size: var(--step-2);
font-weight: 600;
line-height: 1.15;
}
/* Homelab */
.hardware,
.services,
.skills {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.hardware {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
}
.services,
.skills {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
}
.machine,
.service-group,
.skill-group {
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: var(--radius-l);
background: linear-gradient(180deg, var(--surface), var(--bg-raised));
}
.machine__role {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--pink-text);
}
.machine__name {
margin-top: 0.4rem;
font-size: var(--step-2);
}
.machine__specs {
display: grid;
gap: 0.35rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
color: var(--muted);
font-size: 0.95rem;
& li {
display: flex;
gap: 0.6rem;
&::before {
content: "";
flex: none;
width: 0.35rem;
height: 0.35rem;
margin-top: 0.6em;
border-radius: 50%;
background: var(--violet-text);
}
}
}
.service-group__title,
.skill-group__title {
margin-bottom: 1rem;
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.service-group__list {
display: grid;
gap: 0.9rem;
margin: 0;
padding: 0;
list-style: none;
}
.service {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: baseline;
column-gap: 0.75rem;
}
.service__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--pink);
box-shadow: 0 0 0 4px rgb(255 118 164 / 0.14);
}
.service__name {
font-weight: 600;
& a {
color: inherit;
text-decoration-color: rgb(255 141 180 / 0.6);
&:hover {
color: var(--pink-text);
}
}
}
.service__text {
grid-column: 2;
font-size: 0.92rem;
color: var(--muted);
}
/* Journey */
.about {
display: grid;
gap: 2rem;
@media (min-width: 60em) {
grid-template-columns: 18rem minmax(0, 1fr);
column-gap: 4rem;
}
}
.about__head {
grid-column: 1 / -1;
margin-bottom: 0;
}
.about__portrait {
width: min(100%, 18rem);
}
.about__img {
width: 100%;
aspect-ratio: 4 / 5;
border: 1px solid var(--line);
border-radius: var(--radius-l);
object-fit: cover;
}
.about__text {
font-size: var(--step-1);
line-height: 1.55;
.about__head + & {
grid-column: 1 / -1;
max-width: 48rem;
}
}
.timeline {
--line-x: 0.45rem;
position: relative;
display: grid;
gap: 1.25rem;
margin: 0;
padding: 0 0 0 2.25rem;
list-style: none;
&::before,
&::after {
content: "";
position: absolute;
top: 0.5rem;
bottom: 0.5rem;
left: var(--line-x);
width: 2px;
border-radius: 2px;
}
&::before {
background: var(--line);
}
&::after {
background: linear-gradient(180deg, var(--pink), var(--violet-text), var(--blue-text));
transform-origin: top;
scale: 1 var(--progress, 1);
}
@media (min-width: 60em) {
--line-x: calc(50% - 1px);
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 5rem;
padding-left: 0;
}
}
.timeline__entry {
position: relative;
@media (min-width: 60em) {
&:nth-child(odd) {
grid-column: 1;
text-align: right;
}
&:nth-child(even) {
grid-column: 2;
margin-top: 3rem;
}
&:nth-child(even) + .timeline__entry {
margin-top: -3rem;
}
}
}
.timeline__dot {
position: absolute;
top: 1.5rem;
left: calc(-2.25rem + 0.45rem + 1px - 0.45rem);
width: 0.9rem;
height: 0.9rem;
border: 2px solid currentColor;
border-radius: 50%;
background: var(--bg);
box-shadow: 0 0 0 4px var(--bg);
@media (min-width: 60em) {
.timeline__entry:nth-child(odd) & {
left: auto;
right: calc(-2.5rem - 0.45rem);
}
.timeline__entry:nth-child(even) & {
left: calc(-2.5rem - 0.45rem);
}
}
}
.timeline__entry--edu { color: var(--blue-text); }
.timeline__entry--work { color: var(--pink-text); }
.timeline__entry--project,
.timeline__entry--other { color: var(--violet-text); }
.timeline__card {
padding: 1.25rem 1.4rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
}
.timeline__period {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--muted);
@media (min-width: 60em) {
.timeline__entry:nth-child(odd) & {
justify-content: flex-end;
}
}
}
.timeline__title {
margin-top: 0.4rem;
font-size: var(--step-2);
}
.timeline__place {
margin-top: 0.25rem;
color: var(--muted);
font-size: 0.95rem;
}
.timeline__text {
margin-top: 0.6rem;
color: var(--muted);
}
.skill-group__list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.skill {
padding: 0.35rem 0.8rem;
border: 1px solid var(--line);
border-radius: 99px;
background: rgb(243 238 248 / 0.05);
font-size: 0.92rem;
}
/* Contact */
.contact {
display: grid;
gap: clamp(2rem, 5vw, 4rem);
@media (min-width: 60em) {
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
align-items: start;
}
}
.contact-form {
position: relative;
display: grid;
gap: 1.25rem;
}
.contact-form__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem 1.5rem;
margin-top: 0.5rem;
}
.contact-form__note {
max-width: 42ch;
font-size: 0.85rem;
color: var(--muted);
}
.contact__aside {
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: var(--radius-l);
background: var(--surface);
}
.contact__links {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
/* Errors */
.error-page {
position: relative;
isolation: isolate;
display: grid;
align-items: center;
min-height: 80vh;
min-height: 80svh;
padding-block: calc(var(--header-h) + 3rem) 2rem;
& .btn {
margin-top: 2rem;
}
}
.error-page__code {
font-family: var(--font-display);
font-size: clamp(7rem, 4rem + 18vw, 16rem);
font-weight: 700;
line-height: 0.85;
letter-spacing: -0.06em;
background: var(--fluid);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.error-page__title {
margin-top: 1.25rem;
font-size: var(--step-4);
}
}
+53
View File
@@ -0,0 +1,53 @@
:root {
color-scheme: dark;
--bg: #0c0a12;
--bg-raised: #120f1a;
--surface: #16131f;
--surface-2: #1e1a2a;
--line: rgb(243 238 248 / 0.1);
--line-strong: rgb(243 238 248 / 0.2);
--text: #f3eef8;
--muted: #a8a0bb;
--faint: #857d99;
/* genderfluid flag */
--pink: #ff76a4;
--white: #ffffff;
--violet: #c011d7;
--black: #000000;
--blue: #2f3cbe;
/* lighter versions that are readable on the dark background */
--pink-text: #ff8db4;
--violet-text: #da7ff2;
--blue-text: #8f98ff;
--error: #ff9aa9;
--fluid: linear-gradient(110deg, var(--pink) 0%, var(--violet-text) 48%, var(--blue-text) 100%);
--font-display: "Bricolage Grotesque Variable", "Bricolage Grotesque", system-ui, sans-serif;
--font-body: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--step--1: clamp(0.84rem, 0.82rem + 0.1vw, 0.9rem);
--step-0: clamp(1rem, 0.97rem + 0.15vw, 1.1rem);
--step-1: clamp(1.15rem, 1.07rem + 0.4vw, 1.4rem);
--step-2: clamp(1.35rem, 1.2rem + 0.75vw, 1.85rem);
--step-3: clamp(1.7rem, 1.4rem + 1.5vw, 2.6rem);
--step-4: clamp(2.2rem, 1.6rem + 3vw, 4.25rem);
--step-5: clamp(2.8rem, 1.7rem + 5.5vw, 6.5rem);
--radius-s: 0.6rem;
--radius: 1rem;
--radius-l: 1.5rem;
--gutter: clamp(1rem, 4vw, 2.5rem);
--container: 78rem;
--section: clamp(4.5rem, 3rem + 7vw, 9rem);
--header-h: 4.25rem;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}
+274
View File
@@ -0,0 +1,274 @@
/* Small decorative animations inside the homepage tiles */
@layer components {
/* Server rack */
.rack {
display: grid;
gap: 0.5rem;
width: min(100%, 24rem);
@media (min-width: 64em) {
.tile--server & {
gap: 0.7rem;
width: min(100%, 30rem);
}
}
}
.rack__unit {
display: grid;
position: relative;
grid-template-columns: auto auto minmax(0, 1fr) 4.5rem;
align-items: center;
gap: 0.55rem;
height: 2.4rem;
padding-inline: 0.9rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: linear-gradient(180deg, #231e33, #1a1626);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.05);
}
.rack__led {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--pink);
box-shadow: 0 0 8px var(--pink);
animation: blink 1.6s steps(2, jump-none) infinite;
}
.rack__led--b1,
.rack__led--b2,
.rack__led--b3,
.rack__led--b4,
.rack__led--b5 {
background: var(--blue-text);
box-shadow: 0 0 8px var(--blue-text);
}
.rack__led--2 { animation-duration: 0.9s; }
.rack__led--3 { animation-duration: 2.3s; animation-delay: -0.6s; }
.rack__led--4 { animation-duration: 1.2s; animation-delay: -0.3s; }
.rack__led--b1 { animation-duration: 0.7s; animation-delay: -0.2s; }
.rack__led--b2 { animation-duration: 3.1s; }
.rack__led--b3 { animation-duration: 1.1s; animation-delay: -0.5s; }
.rack__led--b4 { animation-duration: 0.5s; }
.rack__led--5 { animation-duration: 1.9s; animation-delay: -1s; }
.rack__led--b5 { animation-duration: 0.8s; animation-delay: -0.1s; }
.rack__vents {
height: 0.9rem;
border-radius: 2px;
background: repeating-linear-gradient(90deg, rgb(255 255 255 / 0.09) 0 2px, transparent 2px 6px);
}
.rack__bar {
height: 0.3rem;
overflow: hidden;
border-radius: 9px;
background: rgb(255 255 255 / 0.08);
}
.rack__load {
display: block;
width: 45%;
height: 100%;
border-radius: inherit;
background: var(--fluid);
animation: load 5s var(--ease-in-out) infinite alternate;
}
.rack__load--2 { width: 70%; animation-duration: 3.4s; animation-delay: -1s; }
.rack__load--3 { width: 25%; animation-duration: 6s; animation-delay: -2.5s; }
.rack__load--4 { width: 55%; animation-duration: 4.2s; animation-delay: -0.4s; }
.rack__load--5 { width: 35%; animation-duration: 5.5s; animation-delay: -3s; }
@media (min-width: 64em) {
.tile--server .rack__unit {
height: 3.1rem;
grid-template-columns: auto auto minmax(0, 1fr) 6rem;
}
}
/* Network graph */
.net {
width: min(100%, 24rem);
height: auto;
overflow: visible;
}
.net__links path {
fill: none;
stroke: rgb(255 255 255 / 0.14);
stroke-width: 1.5;
}
.net__flow path {
fill: none;
stroke: var(--pink);
stroke-width: 2.5;
stroke-linecap: round;
stroke-dasharray: 6 100;
animation: flow 3s linear infinite;
}
.net__flow path:nth-child(2) { stroke: var(--violet-text); animation-duration: 2.4s; animation-delay: -1.2s; }
.net__flow path:nth-child(3) { stroke: var(--white); animation-duration: 3.6s; animation-delay: -0.4s; }
.net__flow path:nth-child(4) { stroke: var(--blue-text); animation-duration: 2.8s; animation-delay: -2s; }
.net__flow path:nth-child(5) { animation-duration: 4s; animation-delay: -3s; }
.net__nodes rect {
fill: #1e1a2a;
stroke: rgb(255 255 255 / 0.25);
stroke-width: 1.2;
}
.net__core {
fill: var(--bg);
stroke: var(--pink);
stroke-width: 2;
filter: drop-shadow(0 0 8px rgb(255 118 164 / 0.7));
}
/* Shell script */
.code {
width: 100%;
margin: 0;
padding: 1rem 1.1rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: #0f0c16;
color: #d9d1e7;
font-family: var(--font-mono);
font-size: 0.74rem;
line-height: 1.65;
white-space: pre;
& .c { color: var(--faint); }
& .k { color: var(--pink-text); }
& .s { color: var(--blue-text); }
}
.code__caret {
display: inline-block;
width: 0.55em;
height: 1.15em;
margin-left: 0.2em;
vertical-align: text-bottom;
background: var(--pink-text);
animation: blink 1.1s steps(2, jump-none) infinite;
}
/* Bars that keep sorting themselves */
.bars {
display: flex;
align-items: flex-end;
gap: 0.4rem;
width: min(100%, 15rem);
height: 7rem;
}
.bars__bar {
flex: 1;
height: var(--to);
border-radius: 0.3rem 0.3rem 0.15rem 0.15rem;
background: linear-gradient(180deg, var(--violet-text), var(--blue));
animation: sort 7s var(--ease-in-out) infinite;
}
.bars__bar--1 { --from: 70%; --to: 18%; }
.bars__bar--2 { --from: 30%; --to: 28%; animation-delay: 0.08s; }
.bars__bar--3 { --from: 95%; --to: 38%; animation-delay: 0.16s; }
.bars__bar--4 { --from: 18%; --to: 48%; animation-delay: 0.24s; }
.bars__bar--5 { --from: 58%; --to: 58%; animation-delay: 0.32s; }
.bars__bar--6 { --from: 40%; --to: 70%; animation-delay: 0.4s; }
.bars__bar--7 { --from: 82%; --to: 84%; animation-delay: 0.48s; }
.bars__bar--8 { --from: 48%; --to: 100%; animation-delay: 0.56s; }
.bars__bar--3,
.bars__bar--8 {
background: linear-gradient(180deg, var(--pink), var(--violet));
}
/* Video editor timeline */
.edit {
--label: 2.2rem;
position: relative;
display: grid;
gap: 0.45rem;
width: 100%;
padding: 0.9rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: #0f0c16;
}
.edit__ruler {
height: 0.6rem;
margin-left: var(--label);
background:
repeating-linear-gradient(90deg, rgb(255 255 255 / 0.22) 0 1px, transparent 1px 2.5rem),
repeating-linear-gradient(90deg, rgb(255 255 255 / 0.09) 0 1px, transparent 1px 0.5rem);
}
.edit__track {
position: relative;
height: 1.9rem;
margin-left: var(--label);
border-radius: 0.35rem;
background: rgb(255 255 255 / 0.03);
}
.edit__label {
position: absolute;
top: 50%;
left: calc(-1 * var(--label));
translate: 0 -50%;
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--faint);
}
.edit__clip {
position: absolute;
top: 3px;
bottom: 3px;
border-radius: 0.3rem;
}
.edit__clip--title { left: 30%; width: 22%; background: linear-gradient(90deg, rgb(255 255 255 / 0.85), rgb(255 255 255 / 0.55)); }
.edit__clip--a { left: 0; width: 34%; background: var(--pink); }
.edit__clip--b { left: 34.6%; width: 28%; background: var(--violet); }
.edit__clip--c { left: 63.2%; width: 36.8%; background: var(--blue); }
.edit__clip--wave {
left: 0;
width: 100%;
background: repeating-linear-gradient(90deg, var(--violet-text) 0 2px, transparent 2px 4px);
clip-path: polygon(0.0% 39%, 2.5% 26%, 5.0% 12%, 7.5% 14%, 10.0% 15%, 12.5% 28%, 15.0% 37%, 17.5% 21%, 20.0% 18%, 22.5% 16%, 25.0% 26%, 27.5% 36%, 30.0% 33%, 32.5% 23%, 35.0% 27%, 37.5% 29%, 40.0% 30%, 42.5% 33%, 45.0% 33%, 47.5% 33%, 50.0% 28%, 52.5% 36%, 55.0% 32%, 57.5% 39%, 60.0% 37%, 62.5% 34%, 65.0% 31%, 67.5% 28%, 70.0% 37%, 72.5% 34%, 75.0% 26%, 77.5% 23%, 80.0% 20%, 82.5% 28%, 85.0% 38%, 87.5% 31%, 90.0% 16%, 92.5% 12%, 95.0% 15%, 97.5% 23%, 100.0% 38%, 100.0% 62%, 97.5% 77%, 95.0% 85%, 92.5% 88%, 90.0% 84%, 87.5% 69%, 85.0% 62%, 82.5% 72%, 80.0% 80%, 77.5% 77%, 75.0% 74%, 72.5% 66%, 70.0% 63%, 67.5% 72%, 65.0% 69%, 62.5% 66%, 60.0% 63%, 57.5% 61%, 55.0% 68%, 52.5% 64%, 50.0% 72%, 47.5% 67%, 45.0% 67%, 42.5% 67%, 40.0% 70%, 37.5% 71%, 35.0% 73%, 32.5% 77%, 30.0% 67%, 27.5% 64%, 25.0% 74%, 22.5% 84%, 20.0% 82%, 17.5% 79%, 15.0% 63%, 12.5% 72%, 10.0% 85%, 7.5% 86%, 5.0% 88%, 2.5% 74%, 0.0% 61%);
}
.edit__playhead {
position: absolute;
top: 0.5rem;
bottom: 0.5rem;
left: calc(0.9rem + var(--label) + 30%);
width: 2px;
background: var(--pink-text);
box-shadow: 0 0 12px var(--pink);
animation: playhead 8s linear infinite;
&::before {
content: "";
position: absolute;
top: -2px;
left: -5px;
width: 12px;
height: 10px;
border-radius: 2px 2px 6px 6px;
background: var(--pink-text);
}
}
}
+66
View File
@@ -0,0 +1,66 @@
export function initContactForm(form) {
const status = form.querySelector('[data-form-status]');
const submit = form.querySelector('[type="submit"]');
const submitLabel = submit.innerHTML;
function setFieldError(name, message) {
const input = form.elements[name];
const error = form.querySelector(`[data-error-for="${name}"]`);
if (!input || !error) return;
input.closest('.field').classList.toggle('field--invalid', Boolean(message));
error.textContent = message || '';
error.hidden = !message;
if (message) {
input.setAttribute('aria-invalid', 'true');
input.setAttribute('aria-describedby', error.id);
} else {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
}
}
function showStatus(message, kind) {
status.textContent = message;
status.className = `form-status form-status--${kind}`;
status.hidden = false;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
form.querySelector('[role="alert"]')?.remove();
for (const name of ['name', 'email', 'message']) setFieldError(name, null);
status.hidden = true;
submit.disabled = true;
submit.textContent = form.dataset.sending;
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new URLSearchParams(new FormData(form)),
});
const data = await response.json();
if (data.ok) {
const done = document.createElement('p');
done.className = 'form-status form-status--ok';
done.setAttribute('role', 'status');
done.tabIndex = -1;
done.textContent = data.message;
form.replaceWith(done);
done.focus();
return;
}
for (const [name, message] of Object.entries(data.errors ?? {})) setFieldError(name, message);
showStatus(data.message, 'error');
form.querySelector('[aria-invalid="true"]')?.focus();
} catch {
showStatus(form.dataset.failed, 'error');
} finally {
submit.disabled = false;
submit.innerHTML = submitLabel;
}
});
}
+45
View File
@@ -0,0 +1,45 @@
// Portfolio tag filter. The chips are plain links (?tag=...), the server filters too,
// so this only makes it instant.
export function initFilter(nav) {
const list = document.querySelector('[data-filter-list]');
const empty = document.querySelector('[data-filter-empty]');
if (!list) return;
const items = [...list.children];
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
function apply(tag) {
for (const chip of nav.querySelectorAll('[data-tag]')) {
if (chip.dataset.tag === tag) chip.setAttribute('aria-current', 'true');
else chip.removeAttribute('aria-current');
}
let shown = 0;
for (const item of items) {
const match = !tag || item.dataset.tags.split(' ').includes(tag);
item.hidden = !match;
if (match) {
item.classList.add('is-visible');
shown++;
}
}
if (empty) empty.hidden = shown > 0;
}
nav.addEventListener('click', (event) => {
const chip = event.target.closest('[data-tag]');
if (!chip || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
history.replaceState(null, '', chip.href);
if (!document.startViewTransition || reducedMotion.matches) {
apply(chip.dataset.tag);
return;
}
// names only for the duration of the transition, so they don't clash with page navigation
items.forEach((item, index) => (item.style.viewTransitionName = `project-${index}`));
const transition = document.startViewTransition(() => apply(chip.dataset.tag));
transition.finished.finally(() => {
for (const item of items) item.style.viewTransitionName = '';
});
});
}
+195
View File
@@ -0,0 +1,195 @@
// The hero background: the five stripes of the genderfluid flag, bent by layered noise.
const vertexSource = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const fragmentSource = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 resolution;
uniform float time;
uniform vec2 pointer;
vec3 stripe(float i) {
if (i < 0.5) return vec3(1.0, 0.463, 0.643);
if (i < 1.5) return vec3(0.88, 0.85, 0.92);
if (i < 2.5) return vec3(0.753, 0.067, 0.843);
if (i < 3.5) return vec3(0.05, 0.04, 0.08);
return vec3(0.184, 0.235, 0.745);
}
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
float fbm(vec2 p) {
float value = 0.0;
float amp = 0.5;
for (int i = 0; i < 4; i++) {
value += amp * noise(p);
p = p * 2.02 + vec2(1.7, 9.2);
amp *= 0.5;
}
return value;
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
vec2 p = uv * vec2(resolution.x / resolution.y, 1.0);
float t = time * 0.035;
vec2 q = vec2(fbm(p * 0.9 + vec2(0.0, t)), fbm(p * 0.9 + vec2(5.2, -t)));
vec2 r = vec2(
fbm(p * 0.8 + 2.2 * q + vec2(1.7, 9.2) + t * 0.6),
fbm(p * 0.8 + 2.2 * q + vec2(8.3, 2.8) - t * 0.4)
);
float d = distance(uv, pointer);
float bend = 0.1 * exp(-d * d * 14.0);
float y = uv.y + (r.y - 0.5) * 0.9 + (q.x - 0.5) * 0.4 + bend;
float s = clamp(1.0 - y, 0.0, 0.9999) * 5.0;
float i = floor(s);
float f = fract(s);
vec3 col = mix(stripe(i), stripe(min(i + 1.0, 4.0)), smoothstep(0.3, 1.0, f));
col *= 0.55 + 0.45 * smoothstep(0.15, 0.85, r.x);
col = mix(vec3(0.047, 0.039, 0.071), col, 0.8);
gl_FragColor = vec4(col, 1.0);
}
`;
function compile(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.warn(gl.getShaderInfoLog(shader));
return null;
}
return shader;
}
export function initFluid(canvas) {
const gl = canvas.getContext('webgl', { antialias: false, depth: false, powerPreference: 'low-power' });
if (!gl) return;
const vertex = compile(gl, gl.VERTEX_SHADER, vertexSource);
const fragment = compile(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertex || !fragment) return;
const program = gl.createProgram();
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
gl.useProgram(program);
// one triangle that covers the whole viewport
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const uniforms = {
resolution: gl.getUniformLocation(program, 'resolution'),
time: gl.getUniformLocation(program, 'time'),
pointer: gl.getUniformLocation(program, 'pointer'),
};
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
const finePointer = matchMedia('(pointer: fine)').matches;
// The noise is soft anyway, rendering at a fraction of the size is barely visible and saves a lot
const scale = Math.min(devicePixelRatio || 1, 2) * (finePointer ? 0.5 : 0.35);
const offset = Math.random() * 200;
const pointer = { x: 0.7, y: 0.6, targetX: 0.7, targetY: 0.6 };
let visible = true;
let frame = 0;
let start = performance.now();
function resize() {
const width = Math.max(1, Math.round(canvas.clientWidth * scale));
const height = Math.max(1, Math.round(canvas.clientHeight * scale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, width, height);
}
}
function draw(now) {
resize();
pointer.x += (pointer.targetX - pointer.x) * 0.04;
pointer.y += (pointer.targetY - pointer.y) * 0.04;
gl.uniform2f(uniforms.resolution, canvas.width, canvas.height);
gl.uniform1f(uniforms.time, offset + (now - start) / 1000);
gl.uniform2f(uniforms.pointer, pointer.x, pointer.y);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
const animated = () => !reducedMotion.matches;
function loop(now) {
frame = 0;
if (!visible || document.hidden || !animated()) return;
draw(now);
frame = requestAnimationFrame(loop);
}
function play() {
if (!frame && visible && !document.hidden && animated()) frame = requestAnimationFrame(loop);
}
draw(start);
canvas.classList.add('is-ready');
new ResizeObserver(() => {
if (!animated()) draw(start);
}).observe(canvas);
new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
play();
}).observe(canvas);
document.addEventListener('visibilitychange', play);
reducedMotion.addEventListener('change', () => {
start = performance.now();
play();
});
if (finePointer) {
addEventListener(
'pointermove',
(event) => {
const rect = canvas.getBoundingClientRect();
pointer.targetX = (event.clientX - rect.left) / rect.width;
pointer.targetY = 1 - (event.clientY - rect.top) / rect.height;
},
{ passive: true },
);
}
play();
}
+98
View File
@@ -0,0 +1,98 @@
const paths = {
close: 'M6 6l12 12M18 6 6 18',
prev: 'M15 5l-7 7 7 7',
next: 'M9 5l7 7-7 7',
};
function button(className, name, label) {
const element = document.createElement('button');
element.type = 'button';
element.className = `lightbox__btn ${className}`;
element.setAttribute('aria-label', label);
element.innerHTML = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="${paths[name]}"/></svg>`;
return element;
}
export function initLightbox(gallery) {
const links = [...gallery.querySelectorAll('.gallery__link')];
if (!links.length || typeof HTMLDialogElement !== 'function') return;
const { labelPrev, labelNext, labelClose } = gallery.dataset;
const dialog = document.createElement('dialog');
dialog.className = 'lightbox';
const figure = document.createElement('figure');
figure.className = 'lightbox__figure';
const image = document.createElement('img');
image.className = 'lightbox__img';
image.decoding = 'async';
figure.append(image);
const count = document.createElement('p');
count.className = 'lightbox__count';
const close = button('lightbox__close', 'close', labelClose);
const prev = button('lightbox__prev', 'prev', labelPrev);
const next = button('lightbox__next', 'next', labelNext);
dialog.append(figure, count, close, prev, next);
document.body.append(dialog);
if (links.length < 2) {
prev.hidden = true;
next.hidden = true;
count.hidden = true;
}
let current = 0;
function show(index) {
current = (index + links.length) % links.length;
const link = links[current];
image.src = link.dataset.full || link.href;
image.width = Number(link.dataset.width) || 0;
image.height = Number(link.dataset.height) || 0;
image.alt = link.querySelector('img')?.alt ?? '';
count.textContent = `${current + 1} / ${links.length}`;
}
function open(index) {
show(index);
dialog.showModal();
document.documentElement.classList.add('lightbox-open');
}
links.forEach((link, index) => {
link.addEventListener('click', (event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
open(index);
});
});
dialog.addEventListener('close', () => {
document.documentElement.classList.remove('lightbox-open');
links[current].focus();
});
close.addEventListener('click', () => dialog.close());
prev.addEventListener('click', () => show(current - 1));
next.addEventListener('click', () => show(current + 1));
// a click on the dark area around the image closes it
figure.addEventListener('click', (event) => {
if (event.target === figure) dialog.close();
});
dialog.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') show(current - 1);
if (event.key === 'ArrowRight') show(current + 1);
});
let startX = null;
figure.addEventListener('pointerdown', (event) => {
if (event.pointerType !== 'mouse') startX = event.clientX;
});
figure.addEventListener('pointerup', (event) => {
if (startX === null) return;
const distance = event.clientX - startX;
startX = null;
if (Math.abs(distance) > 50 && links.length > 1) show(current + (distance < 0 ? 1 : -1));
});
figure.addEventListener('pointercancel', () => (startX = null));
}
+27
View File
@@ -0,0 +1,27 @@
import { initContactForm } from './contact.js';
import { initFilter } from './filter.js';
import { initFluid } from './fluid.js';
import { initLightbox } from './lightbox.js';
import { initNav } from './nav.js';
import { initPointerEffects } from './pointer.js';
import { initReveal, initTimeline } from './reveal.js';
import { initCoverTransitions } from './transitions.js';
import { initVideos } from './video.js';
initNav();
initReveal();
initTimeline();
initPointerEffects();
initCoverTransitions();
initVideos();
const canvas = document.querySelector('[data-fluid]');
if (canvas) initFluid(canvas);
const filter = document.querySelector('[data-filter]');
if (filter) initFilter(filter);
for (const gallery of document.querySelectorAll('[data-gallery]')) initLightbox(gallery);
const form = document.querySelector('[data-contact-form]');
if (form) initContactForm(form);
+61
View File
@@ -0,0 +1,61 @@
export function initNav() {
const header = document.querySelector('[data-header]');
if (!header) return;
const root = document.documentElement;
const toggle = header.querySelector('[data-menu-toggle]');
const nav = header.querySelector('[data-nav]');
const label = toggle.querySelector('.menu-toggle__label');
const outside = [document.querySelector('main'), document.querySelector('.site-footer')];
let lastY = scrollY;
let ticking = false;
function onScroll() {
const y = scrollY;
header.classList.toggle('is-scrolled', y > 24);
if (!root.classList.contains('menu-open')) {
// hide while scrolling down, come back as soon as the user scrolls up
if (y > lastY + 4 && y > 240) header.classList.add('is-hidden');
else if (y < lastY - 4 || y <= 240) header.classList.remove('is-hidden');
}
lastY = y;
ticking = false;
}
addEventListener(
'scroll',
() => {
if (!ticking) requestAnimationFrame(onScroll);
ticking = true;
},
{ passive: true },
);
onScroll();
header.addEventListener('focusin', () => header.classList.remove('is-hidden'));
function setOpen(open) {
toggle.setAttribute('aria-expanded', String(open));
root.classList.toggle('menu-open', open);
label.textContent = open ? label.dataset.labelClose : label.dataset.labelOpen;
for (const element of outside) if (element) element.inert = open;
if (open) header.classList.remove('is-hidden');
}
toggle.addEventListener('click', () => setOpen(toggle.getAttribute('aria-expanded') !== 'true'));
nav.addEventListener('click', (event) => {
if (event.target.closest('a')) setOpen(false);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && root.classList.contains('menu-open')) {
setOpen(false);
toggle.focus();
}
});
matchMedia('(min-width: 48em)').addEventListener('change', (event) => {
if (event.matches) setOpen(false);
});
}
+30
View File
@@ -0,0 +1,30 @@
// Mouse-only effects: border glow that follows the cursor and buttons that lean towards it
export function initPointerEffects() {
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
document.addEventListener(
'pointermove',
(event) => {
const element = event.target.closest?.('[data-glow]');
if (!element) return;
const rect = element.getBoundingClientRect();
element.style.setProperty('--mx', `${event.clientX - rect.left}px`);
element.style.setProperty('--my', `${event.clientY - rect.top}px`);
},
{ passive: true },
);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
for (const element of document.querySelectorAll('[data-magnetic]')) {
element.addEventListener('pointermove', (event) => {
const rect = element.getBoundingClientRect();
const x = (event.clientX - rect.left - rect.width / 2) * 0.2;
const y = (event.clientY - rect.top - rect.height / 2) * 0.35;
element.style.translate = `${x.toFixed(1)}px ${y.toFixed(1)}px`;
});
element.addEventListener('pointerleave', () => {
element.style.translate = '';
});
}
}
+58
View File
@@ -0,0 +1,58 @@
export function initReveal() {
const items = document.querySelectorAll('[data-reveal]');
if (!('IntersectionObserver' in window)) {
for (const item of items) item.classList.add('is-visible');
return;
}
const groups = new Map();
const observer = new IntersectionObserver(
(entries) => {
// things that come into view together get a small stagger
let index = 0;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
for (const item of groups.get(entry.target)) {
item.style.setProperty('--delay', `${Math.min(index++ * 90, 450)}ms`);
item.classList.add('is-visible');
}
observer.unobserve(entry.target);
}
},
{ rootMargin: '0px 0px -6% 0px' },
);
for (const item of items) {
// Chrome applies the element's own clip-path, so a fully clipped heading never
// counts as intersecting. Watch the parent for those instead.
const target = item.dataset.reveal === 'mask' ? item.parentElement : item;
if (!groups.has(target)) groups.set(target, []);
groups.get(target).push(item);
observer.observe(target);
}
}
// Fallback for browsers without scroll-driven animations
export function initTimeline() {
const timeline = document.querySelector('[data-timeline]');
if (!timeline || CSS.supports('animation-timeline: view()')) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let ticking = false;
const update = () => {
const rect = timeline.getBoundingClientRect();
const progress = (innerHeight * 0.75 - rect.top) / rect.height;
timeline.style.setProperty('--progress', Math.min(1, Math.max(0, progress)).toFixed(3));
ticking = false;
};
addEventListener(
'scroll',
() => {
if (!ticking) requestAnimationFrame(update);
ticking = true;
},
{ passive: true },
);
update();
}
+17
View File
@@ -0,0 +1,17 @@
// Cover image morphs into the project page (cross-document view transition)
export function initCoverTransitions() {
const clear = () => {
for (const img of document.querySelectorAll('.card__img')) img.style.viewTransitionName = '';
};
document.addEventListener('click', (event) => {
const link = event.target.closest('[data-cover-link]');
if (!link || event.metaKey || event.ctrlKey || event.shiftKey) return;
clear();
const img = link.querySelector('.card__img');
if (img) img.style.viewTransitionName = 'project-cover';
});
// coming back via bfcache would otherwise leave the name on the old card
addEventListener('pageshow', clear);
}
+19
View File
@@ -0,0 +1,19 @@
// YouTube is only contacted after a click, until then it's just a link with a poster
export function initVideos() {
for (const box of document.querySelectorAll('[data-youtube]')) {
const play = box.querySelector('[data-video-play]');
play?.addEventListener('click', (event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(box.dataset.youtube)}?autoplay=1&rel=0`;
iframe.title = box.dataset.title || 'YouTube';
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
iframe.allowFullscreen = true;
// YouTube refuses to play embeds without a referrer, helmet sets no-referrer for the page
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
box.replaceChildren(iframe);
iframe.focus();
});
}
}
+34
View File
@@ -0,0 +1,34 @@
services:
web:
build: .
image: personal-website:latest
container_name: personal-website
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
SITE_URL: ${SITE_URL:-https://lux.harlelux.de}
volumes:
# Inhalte kommen direkt aus dem Repo, nach einem git pull lädt die Seite sie neu
- ./content:/app/content:ro
- cache:/app/cache
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
networks:
- proxy
networks:
# Netzwerk des Reverse Proxy, muss schon existieren (Nginx Proxy Manager: nginx_default).
# Im Proxy Host dann personal-website:3000 als Ziel eintragen.
proxy:
external: true
name: ${PROXY_NETWORK:-nginx_default}
volumes:
cache:
+61
View File
@@ -0,0 +1,61 @@
kicker:
de: Informatik · Server-Administration · Homelab
en: Computer science · Server administration · Homelab
lead:
de: Ich studiere Informatik und helfe bei der Administration eines Roleplay-Servers. Zu Hause betreibe ich ein Homelab und schreibe kleine Skripte für alles, was ich nicht ständig von Hand machen will.
en: I study computer science and help run the servers of a roleplay community. At home I keep a homelab going and write small scripts for anything I don't want to keep doing by hand.
# visual: server, homelab, scripts, study oder video (die Grafik in der Kachel)
# link: eine Seite (homelab, journey, contact) oder portfolio?tag=...
areas:
- visual: server
title:
de: RP-Server-Administration
en: Roleplay server admin
text:
de: Ich helfe dabei, einen Roleplay-Server zu betreiben. Dazu gehören Updates, Fehlersuche und Absprachen mit dem restlichen Team, wenn etwas nicht so läuft wie geplant.
en: I help run a roleplay server. That means updates, troubleshooting and working things out with the rest of the team when something doesn't behave.
link: portfolio?tag=server
- visual: homelab
title: Homelab
text:
de: Ein paar Rechner, Docker und ein Reverse Proxy. Hier probiere ich neue Dienste aus und mache meine Fehler dort, wo sie niemandem schaden.
en: A few machines, Docker and a reverse proxy. This is where I try out new services and make my mistakes where they don't hurt anyone.
link: homelab
- visual: scripts
title:
de: Skripte & Tools
en: Scripts & tools
text:
de: Kleine Helfer für wiederkehrende Aufgaben. Meistens unspektakulär, aber sie nehmen mir jede Woche ein bisschen Arbeit ab.
en: Small helpers for recurring chores. Mostly unspectacular, but they take a bit of work off my plate every week.
link: portfolio?tag=scripts
- visual: study
title:
de: Informatik-Studium
en: Computer science
text:
de: Theorie, Mathe und viel Programmierung. Einiges davon begegnet mir im Homelab wieder, nur mit echten Fehlermeldungen.
en: Theory, maths and a lot of programming. Some of it shows up again in my homelab, just with real error messages.
link: journey
- visual: video
title:
de: Videoschnitt
en: Video editing
text:
de: Nebenbei versuche ich mich am Videoschnitt. Ich lerne noch, aber an Timing und Ton zu feilen, bis ein Schnitt sitzt, macht mir Spaß.
en: On the side I'm learning video editing. I'm still at it, but tweaking timing and sound until a cut feels right is fun.
link: portfolio?tag=video
contact:
title:
de: Suchst du jemanden für die IT?
en: Looking for someone in IT?
text:
de: Ich suche eine Stelle in der IT, am liebsten in der Administration, im Support oder dort, wo sich etwas automatisieren lässt. Schreib mir, wenn das zu euch passt.
en: I'm looking for a job in IT, ideally in administration, support or anywhere things can be automated. Get in touch if that fits your team.
+44
View File
@@ -0,0 +1,44 @@
# Solange draft: true gesetzt ist, ist die Homelab-Seite nur lokal sichtbar
draft: true
hardware:
- name: Server
role:
de: Docker-Host
en: Docker host
specs:
- CPU
- RAM
- de: Speicher
en: Storage
services:
- group:
de: Infrastruktur
en: Infrastructure
items:
- name: Dockge
text:
de: Verwaltet die Docker-Compose-Stacks
en: Manages the Docker Compose stacks
- name: Nginx Proxy Manager
text:
de: Reverse Proxy und TLS-Zertifikate
en: Reverse proxy and TLS certificates
- group:
de: Entwicklung
en: Development
items:
- name: Git-Server
url: https://git.harlelux.de
text:
de: Meine Repositories, auch das dieser Seite
en: My repositories, including the one for this site
- group: Web
items:
- name: lux.harlelux.de
text:
de: Diese Seite
en: This site
+29
View File
@@ -0,0 +1,29 @@
# Neueste Einträge zuerst.
# from/to: Jahr (2024) oder Monat (2024-10). Für laufende Sachen ongoing: true statt to.
# type: edu, work oder project (bestimmt nur die Farbe des Punkts)
# draft: true blendet einen Eintrag online aus
entries:
- title:
de: Informatik-Studium
en: Computer science degree
type: edu
from: 2024
ongoing: true
draft: true
- title:
de: Administration eines Roleplay-Servers
en: Administration of a roleplay server
type: work
from: 2024
ongoing: true
draft: true
- title:
de: Eigenes Homelab
en: My own homelab
type: project
from: 2023
ongoing: true
draft: true
+8
View File
@@ -0,0 +1,8 @@
---
title: Homelab
lead: Was bei mir zu Hause läuft und wofür. Das meiste davon bräuchte ich nicht, aber ich lerne dabei mehr als aus jedem Tutorial.
---
## Was ich dabei gelernt habe
Hier kommt rein, was im Homelab schiefgegangen ist und was ich daraus mitgenommen habe. Konkrete Beispiele sind besser als allgemeine Aussagen, zum Beispiel ein Update, das etwas kaputt gemacht hat, und wie ich es wieder hinbekommen habe.
+8
View File
@@ -0,0 +1,8 @@
---
title: Homelab
lead: What runs at my place and what it's for. I wouldn't strictly need most of it, but I learn more from it than from any tutorial.
---
## What I learned along the way
This is where the things that went wrong in the homelab go, and what I took away from them. Concrete examples work better than general statements, for example an update that broke something and how I got it running again.
+10
View File
@@ -0,0 +1,10 @@
---
title: Werdegang
lead: Wer ich bin, was ich bisher gemacht habe und womit ich mich auskenne.
---
Ich bin Lux und studiere Informatik. Neben dem Studium helfe ich bei der Administration eines Roleplay-Servers und betreibe zu Hause ein Homelab, in dem ich neue Dienste ausprobiere, bevor ich ihnen vertraue.
Am meisten Spaß macht mir Arbeit, bei der am Ende etwas zuverlässig läuft. Ein Dienst läuft oder er läuft nicht, und wenn nicht, gibt es einen Grund, den man finden kann.
Ab und zu schneide ich Videos. Das ist eine andere Art von Basteln, bei der es ebenfalls um Details geht, die am Ende niemandem auffallen sollen.
+10
View File
@@ -0,0 +1,10 @@
---
title: Journey
lead: Who I am, what I've done so far and what I know my way around.
---
I'm Lux and I study computer science. Alongside my degree I help administer a roleplay server, and at home I run a homelab where I try out new services before I trust them.
I enjoy work most when something runs reliably at the end of it. A service either works or it doesn't, and if it doesn't, there's a reason you can track down.
Every now and then I edit videos. It's a different kind of tinkering, but it's also about details nobody should notice in the end.
+37
View File
@@ -0,0 +1,37 @@
---
title: Datenschutzerklärung
lead: Wer hier welche Daten verarbeitet und warum. Kurz gesagt so wenig wie möglich.
---
## Verantwortlich
Vorname Nachname<br>
Straße Hausnummer<br>
PLZ Ort<br>
E-Mail: name@example.org
## Beim Aufruf der Seite
Wenn du diese Seite aufrufst, verarbeitet der Server technisch notwendige Daten: deine IP-Adresse, Datum und Uhrzeit, die aufgerufene Adresse, den Browser-Typ (User-Agent) und den HTTP-Statuscode. Das ist nötig, um die Seite auszuliefern und den Betrieb abzusichern. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Die Protokolle werden nach [Anzahl] Tagen gelöscht.
## Cookies, Tracking und Drittanbieter
Diese Seite setzt keine Cookies und verwendet keine Tracking- oder Analyse-Werkzeuge. Schriftarten, Skripte und Bilder kommen von diesem Server, nicht von Drittanbietern. Einzige Ausnahme sind YouTube-Videos, siehe unten.
## Kontaktformular
Wenn du das Kontaktformular nutzt, werden Name, E-Mail-Adresse und Nachricht per E-Mail an mich weitergeleitet. Ich nutze die Angaben nur, um deine Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn es um eine Bewerbung oder eine mögliche Zusammenarbeit geht, ansonsten Art. 6 Abs. 1 lit. f DSGVO. Auf dem Webserver selbst wird die Nachricht nicht gespeichert. Die E-Mail lösche ich, sobald sie nicht mehr gebraucht wird.
Für den Versand nutze ich den E-Mail-Dienst [Anbieter].
Zum Schutz vor Missbrauch zählt der Server, wie viele Nachrichten von einer IP-Adresse kommen. Dieser Zähler liegt nur im Arbeitsspeicher und wird nach einer Stunde verworfen.
## YouTube-Videos
Manche Projektseiten enthalten Videos von YouTube. Sie werden erst geladen, wenn du auf „Video laden“ klickst. Erst dann wird eine Verbindung zu YouTube (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland) aufgebaut, dabei werden unter anderem deine IP-Adresse und gegebenenfalls Cookies übertragen. Eingebunden wird der erweiterte Datenschutzmodus über youtube-nocookie.com. Rechtsgrundlage ist deine Einwilligung durch den Klick (Art. 6 Abs. 1 lit. a DSGVO). Details stehen in der [Datenschutzerklärung von Google](https://policies.google.com/privacy).
## Deine Rechte
Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) und Widerspruch (Art. 21). Außerdem kannst du dich bei einer Datenschutz-Aufsichtsbehörde beschweren, zum Beispiel bei der deines Bundeslandes.
Stand: Oktober 2026
+39
View File
@@ -0,0 +1,39 @@
---
title: Privacy policy
lead: Who processes which data here and why. In short, as little as possible.
---
The German version of this policy is the binding one. This translation is provided for convenience.
## Controller
First name Last name<br>
Street and number<br>
Postcode, city<br>
Email: name@example.org
## When you visit this site
When you open this site, the server processes technically necessary data: your IP address, date and time, the requested address, your browser type (user agent) and the HTTP status code. This is needed to deliver the site and keep it secure. The legal basis is Art. 6(1)(f) GDPR. Logs are deleted after [number] days.
## Cookies, tracking and third parties
This site doesn't set cookies and doesn't use any tracking or analytics tools. Fonts, scripts and images are served from this server, not from third parties. The only exception are YouTube videos, see below.
## Contact form
If you use the contact form, your name, email address and message are forwarded to me by email. I only use them to reply to your request. The legal basis is Art. 6(1)(b) GDPR when it's about a job application or a possible collaboration, otherwise Art. 6(1)(f) GDPR. The message is not stored on the web server itself. I delete the email once it's no longer needed.
Emails are sent through [provider].
To prevent abuse, the server counts how many messages come from an IP address. This counter only lives in memory and is discarded after one hour.
## YouTube videos
Some project pages contain YouTube videos. They are only loaded after you click "Load video". Only then does your browser connect to YouTube (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Ireland), which transfers your IP address and possibly cookies, among other things. Videos are embedded in privacy-enhanced mode via youtube-nocookie.com. The legal basis is your consent given by clicking (Art. 6(1)(a) GDPR). See [Google's privacy policy](https://policies.google.com/privacy) for details.
## Your rights
You have the right to access (Art. 15 GDPR), rectification (Art. 16), erasure (Art. 17), restriction of processing (Art. 18), data portability (Art. 20) and to object (Art. 21). You can also lodge a complaint with a data protection supervisory authority.
Last updated: October 2026
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+8
View File
@@ -0,0 +1,8 @@
---
title: Mein Homelab
summary: Docker-Host mit Dockge, Nginx Proxy Manager und eigenem Git-Server.
---
Hier beschreiben, wie das Homelab aufgebaut ist: welche Hardware, welches Betriebssystem, wie die Dienste verteilt sind und wie Updates und Backups laufen.
Bilder einfach in diesen Ordner legen und mit `![Beschreibung](foto.jpg)` einbinden.
+6
View File
@@ -0,0 +1,6 @@
---
title: My homelab
summary: Docker host with Dockge, Nginx Proxy Manager and a self-hosted Git server.
---
Describe how the homelab is set up: hardware, operating system, how services are split up and how updates and backups work.
+11
View File
@@ -0,0 +1,11 @@
draft: true
date: 2026-09
tags: [homelab, docker, linux]
featured: true
cover: cover.jpg
# gallery:
# - rack.jpg
# - src: grafana.png
# caption:
# de: Dashboard mit den wichtigsten Diensten
# en: Dashboard with the most important services
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+19
View File
@@ -0,0 +1,19 @@
---
title: Diese Website
summary: Eigene Seite mit Node.js und Express, Inhalte als Markdown-Dateien, ausgeliefert als Docker-Container.
---
Ich wollte eine Seite, die ich selbst verstehe und ohne CMS pflegen kann. Deshalb ist sie bewusst einfach aufgebaut.
## Aufbau
- **Server:** Node.js mit Express, die Seiten rendert Nunjucks auf dem Server.
- **Inhalte:** Jedes Projekt ist ein Ordner mit einer YAML-Datei für die Metadaten und einer Markdown-Datei pro Sprache. Neue Einträge kommen per `git pull` auf den Server und werden ohne Neustart übernommen.
- **Bilder:** sharp rechnet sie beim ersten Aufruf in passende Größen um und legt sie in einem Cache ab.
- **Betrieb:** Ein Docker-Container in Dockge, davor Nginx Proxy Manager für HTTPS. Der Container läuft mit schreibgeschütztem Dateisystem und ohne zusätzliche Rechte.
## Worauf ich geachtet habe
Die Seite setzt keine Cookies und lädt nichts von fremden Servern, auch die Schriftarten liegen lokal. Ohne JavaScript funktioniert alles weiterhin, die Animationen sind nur eine Ergänzung und halten sich an die Systemeinstellung für reduzierte Bewegung.
Der Hintergrund auf der Startseite ist ein kleiner WebGL-Shader, der die Streifen der Genderfluid-Flagge ineinanderfließen lässt.
+19
View File
@@ -0,0 +1,19 @@
---
title: This website
summary: My own site built with Node.js and Express, content as Markdown files, shipped as a Docker container.
---
I wanted a site I fully understand and can maintain without a CMS, so it's deliberately simple.
## How it's built
- **Server:** Node.js with Express, pages are rendered on the server with Nunjucks.
- **Content:** Every project is a folder with a YAML file for metadata and one Markdown file per language. New entries reach the server with `git pull` and are picked up without a restart.
- **Images:** sharp resizes them on first request and keeps the results in a cache.
- **Hosting:** A Docker container managed in Dockge, with Nginx Proxy Manager in front for HTTPS. The container runs with a read-only filesystem and no extra privileges.
## What I paid attention to
The site sets no cookies and doesn't load anything from third-party servers, fonts included. Everything still works without JavaScript, the animations are an extra and respect the system setting for reduced motion.
The background on the homepage is a small WebGL shader that lets the stripes of the genderfluid flag flow into each other.
@@ -0,0 +1,27 @@
date: 2026-10
tags: [web, docker]
featured: true
cover: cover.jpg
gallery:
- src: mobile.jpg
alt:
de: Startseite auf dem Smartphone
en: Homepage on a phone
caption:
de: Startseite auf dem Smartphone
en: Homepage on a phone
- src: bento.jpg
caption:
de: Übersicht auf der Startseite
en: Overview on the homepage
- src: portfolio.jpg
caption:
de: Portfolio mit Filter nach Themen
en: Portfolio with topic filter
links:
- label:
de: Quelltext
en: Source code
url: https://git.harlelux.de/Harlekin/Personal-Website
- label: lux.harlelux.de
url: https://lux.harlelux.de
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+6
View File
@@ -0,0 +1,6 @@
---
title: Administration eines Roleplay-Servers
summary: Updates, Fehlersuche und Betrieb für eine Roleplay-Community.
---
Hier beschreiben, um welches Spiel und welchen Server es geht, wie groß die Community ist und was genau deine Aufgaben im Team sind. Gut sind konkrete Beispiele, etwa ein Problem, das du gelöst hast, oder etwas, das du automatisiert hast.
+6
View File
@@ -0,0 +1,6 @@
---
title: Running a roleplay server
summary: Updates, troubleshooting and day-to-day operations for a roleplay community.
---
Describe the game and server, the size of the community and what exactly your tasks in the team are.
+5
View File
@@ -0,0 +1,5 @@
draft: true
date: 2026-08
tags: [server, linux]
featured: true
cover: cover.jpg
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+11
View File
@@ -0,0 +1,11 @@
---
title: Skripte
summary: Kleine Helfer für wiederkehrende Aufgaben.
---
Hier ein oder mehrere Skripte vorstellen: welches Problem sie lösen, in welcher Sprache sie geschrieben sind und was du dabei gelernt hast. Code lässt sich direkt einbinden:
```bash
#!/usr/bin/env bash
echo "Hallo"
```
+6
View File
@@ -0,0 +1,6 @@
---
title: Scripts
summary: Small helpers for recurring chores.
---
Introduce one or more scripts: what problem they solve, what they're written in and what you learned.
+7
View File
@@ -0,0 +1,7 @@
draft: true
date: 2026-07
tags: [scripts, automation]
cover: cover.jpg
# links:
# - label: Repo
# url: https://git.harlelux.de/Harlekin/...
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+6
View File
@@ -0,0 +1,6 @@
---
title: Videoschnitt
summary: Erste Schnittprojekte.
---
Hier beschreiben, was du geschnitten hast, mit welchem Programm und worauf du beim Schnitt geachtet hast.
+6
View File
@@ -0,0 +1,6 @@
---
title: Video editing
summary: First editing projects.
---
Describe what you edited, which software you used and what you paid attention to.
+6
View File
@@ -0,0 +1,6 @@
draft: true
date: 2026-06
tags: [video]
cover: cover.jpg
# YouTube-Link oder eine Datei aus diesem Ordner (clip.mp4)
# video: https://youtu.be/...
+45
View File
@@ -0,0 +1,45 @@
name: Lux
pronouns:
de: alle Pronomen
en: any pronouns
description:
de: Lux studiert Informatik, hilft bei der Administration eines Roleplay-Servers und betreibt ein Homelab. Portfolio, Werdegang und Kontakt.
en: Lux studies computer science, helps run a roleplay server and keeps a homelab going. Portfolio, background and contact.
# Leer lassen, wenn die Adresse nicht öffentlich sein soll
email: ''
links:
- label: git.harlelux.de
url: https://git.harlelux.de/Harlekin
# - label: LinkedIn
# url: https://www.linkedin.com/in/...
source: https://git.harlelux.de/Harlekin/Personal-Website
footer:
de: Läuft als Docker-Container hinter Nginx Proxy Manager. Ohne Cookies, ohne Tracking.
en: Runs as a Docker container behind Nginx Proxy Manager. No cookies, no tracking.
# Foto für den Werdegang, Pfad relativ zu content/
# avatar: images/portrait.jpg
now:
- de: Diese Website bauen und mit Inhalten füllen
en: Building this website and filling it with content
skills:
- group:
de: Betrieb
en: Operations
items: [Linux, Docker, Docker Compose, Dockge, Nginx Proxy Manager, Git]
- group:
de: Entwicklung
en: Development
items: [JavaScript, Node.js, HTML, CSS]
- group:
de: Medien
en: Media
items:
- de: Videoschnitt
en: Video editing
+117
View File
@@ -0,0 +1,117 @@
{
"nav": {
"skip": "Zum Inhalt springen",
"label": "Hauptnavigation",
"menu": "Menü",
"close": "Schließen",
"home": "Startseite",
"portfolio": "Portfolio",
"homelab": "Homelab",
"journey": "Werdegang",
"contact": "Kontakt",
"switchLanguage": "English version"
},
"home": {
"now": "Gerade dran",
"ctaPortfolio": "Zum Portfolio",
"ctaContact": "Kontakt aufnehmen",
"areas": "Was ich mache",
"featured": "Ausgewählte Projekte",
"allProjects": "Alle Projekte",
"tools": "Werkzeuge",
"more": "Mehr dazu"
},
"portfolio": {
"title": "Portfolio",
"lead": "Projekte aus Homelab, Server-Administration, Studium und Videoschnitt. Manche groß, manche nur ein Skript, das mir Arbeit abnimmt.",
"filterLabel": "Nach Thema filtern",
"all": "Alle",
"empty": "Zu diesem Thema gibt es noch nichts.",
"back": "Alle Projekte",
"links": "Links",
"video": "Video",
"gallery": "Bilder",
"date": "Zeitraum",
"tags": "Themen",
"fallbackNote": "Diesen Eintrag gibt es bisher nur auf Englisch.",
"newer": "Neuer",
"older": "Älter",
"draft": "Entwurf",
"loadVideo": "Video laden",
"videoNotice": "Beim Abspielen wird eine Verbindung zu YouTube hergestellt.",
"openOnYoutube": "Auf YouTube ansehen",
"openVideo": "Video öffnen",
"showImage": "Bild vergrößern",
"prevImage": "Vorheriges Bild",
"nextImage": "Nächstes Bild",
"closeImage": "Schließen"
},
"homelab": {
"hardware": "Hardware",
"services": "Dienste"
},
"journey": {
"about": "Über mich",
"timeline": "Stationen",
"skills": "Kenntnisse",
"present": "heute",
"since": "seit {from}"
},
"contact": {
"title": "Kontakt",
"lead": "Du hast eine Stelle, eine Frage zu einem Projekt oder willst einfach Hallo sagen? Schreib mir, ich melde mich.",
"name": "Name",
"email": "E-Mail",
"message": "Nachricht",
"send": "Nachricht senden",
"sending": "Wird gesendet …",
"success": "Danke, deine Nachricht ist angekommen. Ich antworte so bald wie möglich.",
"privacyNote": "Deine Angaben nutze ich nur, um dir zu antworten. Details stehen in der",
"privacyLink": "Datenschutzerklärung",
"direct": "Oder direkt",
"mailOnly": "Das Formular ist gerade nicht aktiv. Schreib mir einfach eine Mail:",
"honeypot": "Dieses Feld bitte leer lassen",
"errors": {
"check": "Bitte prüf deine Eingaben.",
"rateLimited": "Zu viele Nachrichten in kurzer Zeit. Versuch es später noch einmal oder schreib mir direkt eine Mail.",
"sendFailed": "Die Nachricht konnte gerade nicht verschickt werden. Versuch es später noch einmal oder schreib mir direkt.",
"unavailable": "Das Kontaktformular ist gerade nicht verfügbar."
},
"fieldErrors": {
"required": "Bitte ausfüllen.",
"invalid": "Das sieht nicht nach einer E-Mail-Adresse aus.",
"tooShort": "Ein bisschen mehr Text bitte (mindestens 10 Zeichen).",
"tooLong": "Das ist leider zu lang."
}
},
"footer": {
"pages": "Seiten",
"elsewhere": "Woanders",
"source": "Quelltext dieser Seite",
"privacy": "Datenschutz",
"top": "Nach oben"
},
"error": {
"404": {
"title": "Seite nicht gefunden",
"text": "Unter dieser Adresse gibt es nichts. Vielleicht hat sich der Link geändert."
},
"500": {
"title": "Da ist etwas schiefgelaufen",
"text": "Der Fehler liegt bei mir, nicht bei dir. Versuch es gleich noch einmal."
},
"home": "Zur Startseite"
},
"tags": {
"homelab": "Homelab",
"server": "Server",
"scripts": "Skripte",
"video": "Video",
"web": "Web",
"uni": "Studium",
"linux": "Linux",
"docker": "Docker",
"network": "Netzwerk",
"automation": "Automatisierung"
}
}
+117
View File
@@ -0,0 +1,117 @@
{
"nav": {
"skip": "Skip to content",
"label": "Main navigation",
"menu": "Menu",
"close": "Close",
"home": "Home",
"portfolio": "Portfolio",
"homelab": "Homelab",
"journey": "Journey",
"contact": "Contact",
"switchLanguage": "Deutsche Version"
},
"home": {
"now": "Currently",
"ctaPortfolio": "See the portfolio",
"ctaContact": "Get in touch",
"areas": "What I do",
"featured": "Selected projects",
"allProjects": "All projects",
"tools": "Tools",
"more": "Read more"
},
"portfolio": {
"title": "Portfolio",
"lead": "Projects from my homelab, server administration, university and video editing. Some are big, some are just a script that saves me time.",
"filterLabel": "Filter by topic",
"all": "All",
"empty": "Nothing here yet.",
"back": "All projects",
"links": "Links",
"video": "Video",
"gallery": "Images",
"date": "When",
"tags": "Topics",
"fallbackNote": "This entry is only available in German so far.",
"newer": "Newer",
"older": "Older",
"draft": "Draft",
"loadVideo": "Load video",
"videoNotice": "Playing the video connects to YouTube.",
"openOnYoutube": "Watch on YouTube",
"openVideo": "Open video",
"showImage": "Enlarge image",
"prevImage": "Previous image",
"nextImage": "Next image",
"closeImage": "Close"
},
"homelab": {
"hardware": "Hardware",
"services": "Services"
},
"journey": {
"about": "About me",
"timeline": "Milestones",
"skills": "Skills",
"present": "today",
"since": "since {from}"
},
"contact": {
"title": "Contact",
"lead": "Got a job opening, a question about one of my projects or just want to say hi? Send me a message and I'll get back to you.",
"name": "Name",
"email": "Email",
"message": "Message",
"send": "Send message",
"sending": "Sending …",
"success": "Thanks, your message arrived. I'll reply as soon as I can.",
"privacyNote": "I only use your details to reply to you. More in the",
"privacyLink": "privacy policy",
"direct": "Or directly",
"mailOnly": "The form is currently switched off. Just send me an email:",
"honeypot": "Please leave this field empty",
"errors": {
"check": "Please check your input.",
"rateLimited": "Too many messages in a short time. Please try again later or email me directly.",
"sendFailed": "The message couldn't be sent right now. Please try again later or email me directly.",
"unavailable": "The contact form is currently unavailable."
},
"fieldErrors": {
"required": "Please fill this in.",
"invalid": "That doesn't look like an email address.",
"tooShort": "A little more text please (at least 10 characters).",
"tooLong": "That's too long, sorry."
}
},
"footer": {
"pages": "Pages",
"elsewhere": "Elsewhere",
"source": "Source code of this site",
"privacy": "Privacy",
"top": "Back to top"
},
"error": {
"404": {
"title": "Page not found",
"text": "There's nothing at this address. Maybe the link changed."
},
"500": {
"title": "Something went wrong",
"text": "That's on me, not you. Please try again in a moment."
},
"home": "Back to the homepage"
},
"tags": {
"homelab": "Homelab",
"server": "Server",
"scripts": "Scripts",
"video": "Video",
"web": "Web",
"uni": "University",
"linux": "Linux",
"docker": "Docker",
"network": "Networking",
"automation": "Automation"
}
}
+2359
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "personal-website",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "node scripts/dev.js",
"build": "node scripts/build.js",
"start": "node src/server.js",
"test": "node --test test/*.test.js"
},
"dependencies": {
"compression": "^1.8.2",
"express": "^5.3.0",
"express-rate-limit": "^8.7.1",
"helmet": "^8.3.0",
"marked": "^18.1.1",
"nodemailer": "^10.2.1",
"nunjucks": "^3.2.4",
"sharp": "^0.35.5",
"yaml": "^2.9.1"
},
"devDependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"esbuild": "^0.28.2"
},
"allowScripts": {
"esbuild@0.28.2": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+45
View File
@@ -0,0 +1,45 @@
import * as esbuild from 'esbuild';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
export const outdir = 'dist/assets';
export function buildOptions({ dev = false } = {}) {
return {
entryPoints: [
{ in: 'client/js/main.js', out: 'app' },
{ in: 'client/css/main.css', out: 'app' },
],
outdir,
bundle: true,
format: 'esm',
minify: !dev,
sourcemap: dev ? 'linked' : false,
metafile: true,
entryNames: '[name]-[hash]',
assetNames: '[name]-[hash]',
loader: { '.woff2': 'file', '.woff': 'file' },
// Old enough that esbuild lowers CSS nesting for Safari 15/16
target: ['chrome109', 'firefox115', 'safari15.4'],
logLevel: 'info',
};
}
// Maps "app.js" -> "/assets/app-HASH.js" so templates don't need to know the hash.
export async function writeManifest(metafile) {
const manifest = {};
for (const [file, output] of Object.entries(metafile.outputs)) {
if (!output.entryPoint) continue;
const base = path.basename(file);
const key = base.replace(/-[A-Z0-9]{8}(\.\w+)$/, '$1');
manifest[key] = '/assets/' + base;
}
await writeFile('dist/manifest.json', JSON.stringify(manifest, null, 2));
}
if (import.meta.url === `file://${process.argv[1]}`) {
await rm('dist', { recursive: true, force: true });
await mkdir(outdir, { recursive: true });
const result = await esbuild.build(buildOptions());
await writeManifest(result.metafile);
}
+62
View File
@@ -0,0 +1,62 @@
import * as esbuild from 'esbuild';
import { spawn } from 'node:child_process';
import { existsSync, watch } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises';
import { buildOptions, outdir, writeManifest } from './build.js';
await rm('dist', { recursive: true, force: true });
await mkdir(outdir, { recursive: true });
const ctx = await esbuild.context({
...buildOptions({ dev: true }),
plugins: [
{
name: 'manifest',
setup(build) {
build.onEnd((result) => result.metafile && writeManifest(result.metafile));
},
},
],
});
await ctx.watch();
// Restarting the server ourselves instead of node --watch-path: on Node 24 the
// watch-mode parent sometimes crashes on shutdown and leaves the server running.
const args = [...(existsSync('.env') ? ['--env-file=.env'] : []), 'src/server.js'];
let server;
function start() {
server = spawn(process.execPath, args, { stdio: 'inherit', env: { ...process.env, NODE_ENV: 'development' } });
}
function restart() {
if (!server || server.exitCode !== null) return start();
server.once('exit', start);
server.kill();
}
let timer;
for (const dir of ['src', 'locales']) {
watch(dir, { recursive: true }, (event, file) => {
if (file?.endsWith('.njk')) return; // templates are re-read on every request anyway
clearTimeout(timer);
timer = setTimeout(() => {
console.log(`[dev] ${dir}/${file} changed, restarting`);
restart();
}, 150);
});
}
start();
async function stop() {
await ctx.dispose();
if (server?.exitCode === null) {
server.once('exit', () => process.exit(0));
server.kill();
} else {
process.exit(0);
}
}
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
+121
View File
@@ -0,0 +1,121 @@
import compression from 'compression';
import express from 'express';
import helmet from 'helmet';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import nunjucks from 'nunjucks';
import { loadConfig, rootDir } from './config.js';
import { createContentStore } from './content.js';
import { formatDate, localize, locales, pickLocale } from './i18n.js';
import { mediaHandler } from './images.js';
import { createMailer } from './mail.js';
import { localeRouter, renderError } from './routes/pages.js';
import { seoRouter } from './routes/seo.js';
function manifestReader(production) {
const file = path.join(rootDir, 'dist', 'manifest.json');
const read = () => {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return {};
}
};
if (!production) return read;
const manifest = read();
if (!Object.keys(manifest).length) console.warn('[assets] dist/manifest.json missing, run npm run build');
return () => manifest;
}
function setupViews(app, config) {
const env = nunjucks.configure(path.join(rootDir, 'src', 'views'), {
autoescape: true,
express: app,
noCache: !config.production,
throwOnUndefined: false,
});
app.set('view engine', 'njk');
const manifest = manifestReader(config.production);
env.addGlobal('asset', (name) => manifest()[name] ?? `/assets/${name}`);
// Filters run with the template context as `this`, which carries the current language
env.addFilter('l', function (value) {
return localize(value, this.ctx.lang);
});
env.addFilter('date', function (value, style) {
return formatDate(value, this.ctx.lang, style);
});
env.addFilter('tag', function (tag) {
const label = this.ctx.t(`tags.${tag}`);
return label === `tags.${tag}` ? tag : label;
});
return env;
}
export async function createApp(options = {}) {
const config = options.config ?? loadConfig();
const content = options.content ?? createContentStore(config);
if (!content.get()) await content.reload();
const mailer = options.mailer ?? createMailer(config);
const app = express();
app.set('trust proxy', 'loopback, uniquelocal');
setupViews(app, config);
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'"],
mediaSrc: ["'self'"],
connectSrc: ["'self'"],
frameSrc: ['https://www.youtube-nocookie.com'],
formAction: ["'self'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: config.production ? [] : null,
},
},
// HSTS is set by the reverse proxy
strictTransportSecurity: false,
}),
);
app.use(compression());
app.get('/healthz', (req, res) => res.type('text').send('ok'));
app.use(
'/assets',
express.static(path.join(rootDir, 'dist', 'assets'), { immutable: true, maxAge: '365d', index: false }),
);
app.use(express.static(path.join(rootDir, 'public'), { maxAge: '1d', index: false }));
app.get('/media/*file', mediaHandler(config));
const deps = { config, content, mailer };
app.use(seoRouter(deps));
app.get('/', (req, res) => {
res.vary('Accept-Language');
res.redirect(302, `/${pickLocale(req.get('accept-language'))}/`);
});
for (const lang of locales) app.use(`/${lang}`, localeRouter(lang, deps));
app.use((req, res) => {
const lang = locales.find((l) => req.path.startsWith(`/${l}/`)) ?? pickLocale(req.get('accept-language'));
renderError(req, res, 404, lang, deps);
});
app.use((err, req, res, next) => {
console.error(err);
if (res.headersSent) return;
const lang = locales.find((l) => req.path.startsWith(`/${l}/`)) ?? 'de';
renderError(req, res, err.status && err.status < 500 ? err.status : 500, lang, deps);
});
return app;
}
+31
View File
@@ -0,0 +1,31 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const env = process.env;
export function loadConfig(overrides = {}) {
const production = env.NODE_ENV === 'production';
const smtp = env.SMTP_HOST
? {
host: env.SMTP_HOST,
port: Number(env.SMTP_PORT) || 587,
secure: env.SMTP_SECURE === 'true',
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
}
: null;
return {
production,
port: Number(env.PORT) || 3000,
siteUrl: (env.SITE_URL || 'http://localhost:3000').replace(/\/+$/, ''),
contentDir: path.resolve(rootDir, env.CONTENT_DIR || 'content'),
cacheDir: path.resolve(rootDir, env.CACHE_DIR || 'cache'),
showDrafts: !production,
smtp,
mailFrom: env.MAIL_FROM || '',
mailTo: env.MAIL_TO || '',
...overrides,
};
}
+343
View File
@@ -0,0 +1,343 @@
import { watch } from 'node:fs';
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { Marked } from 'marked';
import sharp from 'sharp';
import YAML from 'yaml';
import { locales, otherLocale } from './i18n.js';
import { isImage, isResizable, mediaUrl, resolveMedia, widths } from './images.js';
const textPages = ['homelab', 'journey', 'privacy'];
export function parseFrontmatter(source) {
const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source);
if (!match) return { data: {}, body: source };
return { data: YAML.parse(match[1]) ?? {}, body: source.slice(match[0].length) };
}
export function youtubeId(url) {
const match =
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/))([\w-]{11})/.exec(
url,
);
return match ? match[1] : null;
}
const isRelative = (href) => href && !/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(href);
const escapeAttr = (value) =>
String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
class Loader {
constructor(dir, { showDrafts }) {
this.dir = dir;
this.showDrafts = showDrafts;
this.warnings = [];
}
warn(message) {
this.warnings.push(message);
}
rel(file) {
return path.relative(this.dir, file).split(path.sep).join('/');
}
async readText(file) {
try {
return await readFile(file, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
async readYaml(file) {
const source = await this.readText(file);
if (source === null) return null;
try {
return YAML.parse(source) ?? {};
} catch (err) {
throw new Error(`${this.rel(file)}: ${err.message.split('\n')[0].replace(/:$/, '')}`);
}
}
// Relative path from a content file -> served URL, or null if it points somewhere we don't serve.
async media(baseDir, href) {
const file = path.resolve(baseDir, decodeURI(href));
const rel = this.rel(file);
if (!resolveMedia(this.dir, rel)) {
this.warn(`${rel}: outside of content/ or unsupported file type`);
return null;
}
try {
const info = await stat(file);
return { file, rel, version: Math.round(info.mtimeMs).toString(36) };
} catch {
this.warn(`${rel}: file not found`);
return null;
}
}
async image(baseDir, href, alt = '') {
if (!href) return null;
const media = await this.media(baseDir, href);
if (!media) return null;
if (!isImage(media.file)) {
this.warn(`${media.rel}: not an image`);
return null;
}
let width;
let height;
try {
const meta = await sharp(media.file).metadata();
width = meta.autoOrient?.width ?? meta.width;
height = meta.autoOrient?.height ?? meta.height;
} catch (err) {
this.warn(`${media.rel}: ${err.message}`);
return null;
}
const { rel, version } = media;
if (!isResizable(media.file) || width <= widths[0]) {
return { src: mediaUrl(rel, { version }), width, height, alt };
}
const srcset = [];
for (const w of widths) {
// withoutEnlargement keeps the original size, so describe what actually comes back
srcset.push(`${mediaUrl(rel, { width: w, version })} ${Math.min(w, width)}w`);
if (w >= width) break;
}
const src = srcset.at(-1).split(' ')[0];
return { src, srcset: srcset.join(', '), width, height, alt };
}
async markdown(body, baseDir) {
const loader = this;
const marked = new Marked({
async: true,
gfm: true,
async walkTokens(token) {
if (token.type === 'image' && isRelative(token.href)) {
token.media = await loader.image(baseDir, token.href, token.text);
} else if (token.type === 'link' && isRelative(token.href)) {
const media = await loader.media(baseDir, token.href);
if (media) token.href = mediaUrl(media.rel, { version: media.version });
}
},
renderer: {
image({ href, text, media }) {
if (!media) return `<img src="${escapeAttr(href)}" alt="${escapeAttr(text)}" loading="lazy">`;
const srcset = media.srcset
? ` srcset="${escapeAttr(media.srcset)}" sizes="(min-width: 60em) 42rem, 100vw"`
: '';
return (
`<img src="${escapeAttr(media.src)}"${srcset} width="${media.width}" height="${media.height}"` +
` alt="${escapeAttr(text)}" loading="lazy" decoding="async">`
);
},
},
});
return marked.parse(body);
}
// Reads <name>.de.md / <name>.en.md and fills a missing language from the other one.
async localizedMarkdown(baseDir, name) {
const texts = {};
for (const lang of locales) {
const source = await this.readText(path.join(baseDir, `${name}.${lang}.md`));
if (source === null) continue;
const { data, body } = parseFrontmatter(source);
texts[lang] = { ...data, html: await this.markdown(body, baseDir) };
}
for (const lang of locales) {
const other = otherLocale(lang);
if (!texts[lang] && texts[other]) texts[lang] = { ...texts[other], fallback: other };
}
return Object.keys(texts).length ? texts : null;
}
async video(baseDir, value) {
if (!value) return null;
const { src, poster } = typeof value === 'object' ? value : { src: value };
const id = youtubeId(src ?? '');
if (id) {
return {
type: 'youtube',
id,
url: `https://www.youtube.com/watch?v=${id}`,
poster: await this.image(baseDir, poster),
};
}
if (/^https?:\/\//.test(src)) return { type: 'link', url: src };
const media = await this.media(baseDir, src);
if (!media) return null;
return {
type: 'file',
src: mediaUrl(media.rel, { version: media.version }),
mime: media.file.endsWith('.webm') ? 'video/webm' : 'video/mp4',
poster: await this.image(baseDir, poster),
};
}
async project(slug) {
const dir = path.join(this.dir, 'projects', slug);
const meta = await this.readYaml(path.join(dir, 'meta.yml'));
if (!meta) {
this.warn(`projects/${slug}: meta.yml missing, skipped`);
return null;
}
const texts = {};
for (const lang of locales) {
const source = await this.readText(path.join(dir, `${lang}.md`));
if (source === null) continue;
const { data, body } = parseFrontmatter(source);
if (!data.title) this.warn(`projects/${slug}/${lang}.md: no title`);
texts[lang] = {
title: data.title ?? slug,
summary: data.summary ?? '',
html: await this.markdown(body, dir),
};
}
if (!texts.de && !texts.en) {
this.warn(`projects/${slug}: neither de.md nor en.md found, skipped`);
return null;
}
for (const lang of locales) {
const other = otherLocale(lang);
if (!texts[lang]) texts[lang] = { ...texts[other], fallback: other };
}
const gallery = [];
for (const item of meta.gallery ?? []) {
const entry = typeof item === 'string' ? { src: item } : item;
const image = await this.image(dir, entry.src, entry.alt ?? '');
if (image) gallery.push({ ...image, caption: entry.caption });
}
const links = [];
for (const link of meta.links ?? []) {
if (!link?.url) continue;
let url = link.url;
if (isRelative(url)) {
const media = await this.media(dir, url);
if (!media) continue;
url = mediaUrl(media.rel, { version: media.version });
}
links.push({ label: link.label ?? url, url });
}
return {
slug,
date: meta.date ? String(meta.date) : '',
tags: (meta.tags ?? []).map((tag) => String(tag).toLowerCase()),
featured: Boolean(meta.featured),
draft: Boolean(meta.draft),
cover: await this.image(dir, meta.cover),
gallery,
links,
video: await this.video(dir, meta.video),
text: texts,
};
}
async projects() {
let entries = [];
try {
entries = await readdir(path.join(this.dir, 'projects'), { withFileTypes: true });
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
const projects = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
try {
const project = await this.project(entry.name);
if (project && (this.showDrafts || !project.draft)) projects.push(project);
} catch (err) {
this.warn(`${err.message}, skipped`);
}
}
return projects.sort((a, b) => b.date.localeCompare(a.date) || a.slug.localeCompare(b.slug));
}
async load() {
const site = (await this.readYaml(path.join(this.dir, 'site.yml'))) ?? {};
if (!site.name) this.warn('site.yml: name missing');
site.avatar = await this.image(this.dir, site.avatar, site.name ?? '');
const home = (await this.readYaml(path.join(this.dir, 'home.yml'))) ?? {};
const journey = ((await this.readYaml(path.join(this.dir, 'journey.yml')))?.entries ?? []).filter(
(entry) => entry && (this.showDrafts || !entry.draft),
);
let homelab = (await this.readYaml(path.join(this.dir, 'homelab.yml'))) ?? null;
if (homelab?.draft && !this.showDrafts) homelab = null;
const pages = {};
for (const name of textPages) {
pages[name] = await this.localizedMarkdown(path.join(this.dir, 'pages'), name);
}
const projects = await this.projects();
const tagCounts = new Map();
for (const project of projects) {
for (const tag of project.tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
const tags = [...tagCounts]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
return { site, home, journey, homelab, pages, projects, tags, loadedAt: new Date() };
}
}
export async function loadContent(dir, options = {}) {
const loader = new Loader(dir, { showDrafts: false, ...options });
const content = await loader.load();
return { content, warnings: loader.warnings };
}
export function createContentStore({ contentDir, showDrafts }) {
let current = null;
let timer = null;
let watcher = null;
async function reload() {
const { content, warnings } = await loadContent(contentDir, { showDrafts });
for (const warning of warnings) console.warn(`[content] ${warning}`);
current = content;
return content;
}
// git pull touches a bunch of files at once, so wait until things settle
function schedule() {
clearTimeout(timer);
timer = setTimeout(() => {
reload().then(
(content) => console.log(`[content] reloaded, ${content.projects.length} projects`),
(err) => console.error(`[content] reload failed, keeping the previous version: ${err.message}`),
);
}, 300);
}
return {
get: () => current,
reload,
watch() {
watcher = watch(contentDir, { recursive: true }, schedule);
watcher.on('error', (err) => console.error(`[content] watcher: ${err.message}`));
process.on('SIGHUP', schedule);
},
close() {
clearTimeout(timer);
watcher?.close();
process.off('SIGHUP', schedule);
},
};
}
+76
View File
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { rootDir } from './config.js';
export const locales = ['de', 'en'];
export const defaultLocale = 'de';
// Page id -> URL segment per language. The portfolio detail pages hang off "portfolio".
export const routes = {
home: { de: '', en: '' },
portfolio: { de: 'portfolio', en: 'portfolio' },
homelab: { de: 'homelab', en: 'homelab' },
journey: { de: 'werdegang', en: 'journey' },
contact: { de: 'kontakt', en: 'contact' },
privacy: { de: 'datenschutz', en: 'privacy' },
};
const strings = Object.fromEntries(
locales.map((lang) => [
lang,
JSON.parse(readFileSync(path.join(rootDir, 'locales', `${lang}.json`), 'utf8')),
]),
);
export function otherLocale(lang) {
return lang === 'de' ? 'en' : 'de';
}
export function pathFor(lang, page, slug) {
const segment = routes[page][lang];
let url = `/${lang}/` + segment;
if (slug) url += '/' + encodeURIComponent(slug);
return url;
}
export function translator(lang) {
const dict = strings[lang];
return (key, vars) => {
let value = key.split('.').reduce((obj, part) => obj?.[part], dict);
if (typeof value !== 'string') return key;
if (vars) value = value.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? '');
return value;
};
}
// YAML fields may be a plain value or { de, en }.
export function localize(value, lang) {
if (value && typeof value === 'object' && !Array.isArray(value) && locales.some((l) => l in value)) {
return value[lang] ?? value[otherLocale(lang)];
}
return value;
}
export function pickLocale(acceptLanguage = '') {
const ranked = acceptLanguage
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().split(';');
const q = params.find((p) => p.trim().startsWith('q='));
return { lang: tag.slice(0, 2).toLowerCase(), q: q ? Number(q.trim().slice(2)) : 1 };
})
.filter((entry) => entry.q > 0)
.sort((a, b) => b.q - a.q);
return ranked.find((entry) => locales.includes(entry.lang))?.lang ?? defaultLocale;
}
// Accepts what people actually type in YAML: 2024, 2024-10 or 2024-10-01.
export function formatDate(value, lang, style = 'month') {
if (value === undefined || value === null || value === '') return '';
const text = String(value);
if (/^\d{4}$/.test(text)) return text;
const date = new Date(text);
if (Number.isNaN(date.getTime())) return text;
const options = style === 'year' ? { year: 'numeric' } : { month: 'long', year: 'numeric' };
return new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-GB', { ...options, timeZone: 'UTC' }).format(date);
}
+95
View File
@@ -0,0 +1,95 @@
import { createHash } from 'node:crypto';
import { mkdir, rename, stat, unlink } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
// Only these widths are allowed, so nobody can fill the cache with ?w=1,2,3...
export const widths = [480, 960, 1600];
const resizable = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
const allowed = new Set([...resizable, '.gif', '.svg', '.mp4', '.webm', '.pdf']);
export function isResizable(file) {
return resizable.has(path.extname(file).toLowerCase());
}
export function isImage(file) {
return isResizable(file) || ['.gif', '.svg'].includes(path.extname(file).toLowerCase());
}
export function mediaUrl(rel, { width, version } = {}) {
const encoded = rel.split('/').map(encodeURIComponent).join('/');
const params = new URLSearchParams();
if (width) params.set('w', width);
if (version) params.set('v', version);
const query = params.toString();
return `/media/${encoded}${query ? '?' + query : ''}`;
}
// Returns an absolute path inside contentDir, or null for anything we don't serve.
export function resolveMedia(contentDir, rel) {
if (!rel || rel.split('/').some((part) => !part || part.startsWith('.'))) return null;
if (!allowed.has(path.extname(rel).toLowerCase())) return null;
const file = path.resolve(contentDir, rel);
return file.startsWith(contentDir + path.sep) ? file : null;
}
export function mediaHandler({ contentDir, cacheDir }) {
const pending = new Map();
return async (req, res, next) => {
const rel = [].concat(req.params.file).join('/');
const file = resolveMedia(contentDir, rel);
if (!file) return next();
let info;
try {
info = await stat(file);
} catch {
return next();
}
if (!info.isFile()) return next();
// URLs built by the content loader carry ?v=<mtime>, those can be cached for good
const versioned = 'v' in req.query;
const cache = { maxAge: versioned ? '365d' : '1h', immutable: versioned };
const width = Number(req.query.w);
if (!req.query.w || !isResizable(file)) return res.sendFile(file, cache);
if (!widths.includes(width)) return next();
const key = createHash('sha1').update(`${rel}:${info.mtimeMs}:${width}`).digest('hex');
const target = path.join(cacheDir, key.slice(0, 2), key + '.webp');
if (!pending.has(key)) {
pending.set(key, resize(file, target, width).finally(() => pending.delete(key)));
}
try {
await pending.get(key);
} catch (err) {
return next(err);
}
res.sendFile(target, { ...cache, headers: { 'Content-Type': 'image/webp' } });
};
}
async function resize(file, target, width) {
try {
await stat(target);
return;
} catch {
// not cached yet
}
await mkdir(path.dirname(target), { recursive: true });
const tmp = `${target}.${process.pid}.tmp`;
try {
await sharp(file)
.rotate()
.resize({ width, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(tmp);
await rename(tmp, target);
} catch (err) {
await unlink(tmp).catch(() => {});
throw err;
}
}
+32
View File
@@ -0,0 +1,32 @@
import nodemailer from 'nodemailer';
export function createMailer(config) {
if (config.smtp && config.mailTo) {
const transport = nodemailer.createTransport(config.smtp);
return {
enabled: true,
send: (message) => transport.sendMail({ from: config.mailFrom || config.smtp.auth?.user, to: config.mailTo, ...message }),
};
}
if (config.smtp) console.warn('[mail] SMTP_HOST is set but MAIL_TO is empty, contact form disabled');
// Without SMTP in development, print the mail instead of sending it
if (!config.production) {
const transport = nodemailer.createTransport({ jsonTransport: true });
return {
enabled: true,
send: async (message) => {
const info = await transport.sendMail({ from: 'dev@localhost', to: 'dev@localhost', ...message });
console.log('[mail]', JSON.parse(info.message));
},
};
}
return {
enabled: false,
send: async () => {
throw new Error('mail is not configured');
},
};
}
+118
View File
@@ -0,0 +1,118 @@
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import { render } from './pages.js';
const maxLength = { name: 100, email: 200, message: 5000 };
const minMessage = 10;
// Humans need a few seconds to fill in three fields, most bots don't bother waiting
const minFillTime = 3000;
export function validate(body) {
const values = {
name: String(body.name ?? '').trim(),
email: String(body.email ?? '').trim(),
message: String(body.message ?? '').trim(),
};
const errors = {};
if (!values.name) errors.name = 'required';
else if (values.name.length > maxLength.name) errors.name = 'tooLong';
if (!values.email) errors.email = 'required';
else if (values.email.length > maxLength.email) errors.email = 'tooLong';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = 'invalid';
if (values.message.length < minMessage) errors.message = 'tooShort';
else if (values.message.length > maxLength.message) errors.message = 'tooLong';
return { values, errors };
}
export function looksLikeBot(body, now = Date.now()) {
if (body.website) return true;
const started = Number(body.ts);
return !started || now - started < minFillTime;
}
function sameOrigin(req, res, next) {
const origin = req.get('origin');
if (!origin) return next();
try {
if (new URL(origin).host === req.get('host')) return next();
} catch {
// malformed Origin header, treat as foreign
}
res.status(403).type('text').send('Forbidden');
}
const wantsJson = (req) => req.accepts(['html', 'json']) === 'json';
export function contactRouter(lang, deps) {
const router = express.Router();
// One limiter for both languages, otherwise /de and /en would each get their own budget
deps.contactLimiter ??= rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: 'draft-8',
legacyHeaders: false,
handler: (req, res) => respond(req, res, 429, { error: 'rateLimited' }),
});
function respond(req, res, status, { values = {}, errors = {}, error = null, sent = false }) {
const { t } = res.locals;
res.status(status);
if (wantsJson(req)) {
return res.json({
ok: sent,
message: sent ? t('contact.success') : error ? t(`contact.errors.${error}`) : t('contact.errors.check'),
errors: Object.fromEntries(Object.entries(errors).map(([field, code]) => [field, t(`contact.fieldErrors.${code}`)])),
});
}
render(res, 'pages/contact.njk', 'contact', {
mailEnabled: deps.mailer.enabled,
form: { values, errors, error, sent },
startedAt: Date.now(),
});
}
router.get('/', (req, res) => {
render(res, 'pages/contact.njk', 'contact', {
mailEnabled: deps.mailer.enabled,
form: { values: {}, errors: {} },
startedAt: Date.now(),
});
});
router.post(
'/',
express.urlencoded({ extended: false, limit: '20kb' }),
express.json({ limit: '20kb' }),
sameOrigin,
deps.contactLimiter,
async (req, res) => {
if (!deps.mailer.enabled) return respond(req, res, 503, { error: 'unavailable' });
const body = req.body ?? {};
const { values, errors } = validate(body);
if (Object.keys(errors).length) return respond(req, res, 422, { values, errors });
// Pretend it worked so bots don't learn anything
if (looksLikeBot(body)) return respond(req, res, 200, { sent: true });
const name = values.name.replace(/\s+/g, ' ');
try {
await deps.mailer.send({
replyTo: { name, address: values.email },
subject: `Kontaktformular: ${name}`,
text: `${values.message}\n\n-- \n${name} <${values.email}>\n${deps.config.siteUrl}/${lang}/`,
});
} catch (err) {
console.error(`[mail] sending failed: ${err.message}`);
return respond(req, res, 502, { values, error: 'sendFailed' });
}
respond(req, res, 200, { sent: true });
},
);
return router;
}
+123
View File
@@ -0,0 +1,123 @@
import express from 'express';
import { otherLocale, pathFor, routes, translator } from '../i18n.js';
import { contactRouter } from './contact.js';
function locals(lang, deps) {
const content = deps.content.get();
return {
lang,
altLang: otherLocale(lang),
t: translator(lang),
content,
site: content.site,
siteUrl: deps.config.siteUrl,
showDrafts: deps.config.showDrafts,
year: new Date().getFullYear(),
url: (page, slug, inLang = lang) => pathFor(inLang, page, slug),
// Content files can link to a page id ("homelab", "portfolio?tag=video") or a full URL.
// Returns null for targets that don't exist yet, e.g. a draft page or a tag without projects.
link: (value) => {
if (!value) return null;
const [page, query] = String(value).split('?');
if (!routes[page]) return value;
if (page === 'homelab' && !content.homelab) return null;
if (page === 'privacy' && !content.pages.privacy) return null;
const tag = new URLSearchParams(query).get('tag');
if (tag && !content.tags.some((entry) => entry.name === tag)) return null;
return pathFor(lang, page) + (query ? `?${query}` : '');
},
};
}
function navItems(lang, t, content, current) {
return [
['portfolio', t('nav.portfolio')],
content.homelab ? ['homelab', t('nav.homelab')] : null,
['journey', t('nav.journey')],
['contact', t('nav.contact')],
]
.filter(Boolean)
.map(([page, label]) => ({ page, label, href: pathFor(lang, page), current: page === current }));
}
export function render(res, view, page, data = {}) {
const { lang, t, content } = res.locals;
const routed = page in routes;
res.render(view, {
page,
nav: navItems(lang, t, content, page),
currentPath: routed ? pathFor(lang, page, data.slug) : undefined,
altPath: routed ? pathFor(otherLocale(lang), page, data.slug) : undefined,
...data,
});
}
export function renderError(req, res, status, lang, deps) {
Object.assign(res.locals, locals(lang, deps));
res.status(status);
render(res, 'pages/error.njk', 'error', {
status,
currentPath: req.path,
altPath: pathFor(otherLocale(lang), 'home'),
noindex: true,
});
}
export function localeRouter(lang, deps) {
const router = express.Router();
const segment = (page) => `/${routes[page][lang]}`;
router.use((req, res, next) => {
Object.assign(res.locals, locals(lang, deps));
next();
});
router.get('/', (req, res) => {
const { projects } = res.locals.content;
let featured = projects.filter((project) => project.featured);
if (!featured.length) featured = projects;
render(res, 'pages/home.njk', 'home', { featured: featured.slice(0, 3) });
});
router.get(segment('portfolio'), (req, res) => {
const { projects, tags } = res.locals.content;
const tag = typeof req.query.tag === 'string' ? req.query.tag.toLowerCase() : '';
const activeTag = tags.some((entry) => entry.name === tag) ? tag : null;
render(res, 'pages/portfolio.njk', 'portfolio', { projects, tags, activeTag });
});
router.get(`${segment('portfolio')}/:slug`, (req, res, next) => {
const { projects } = res.locals.content;
const index = projects.findIndex((project) => project.slug === req.params.slug);
if (index === -1) return next();
const project = projects[index];
render(res, 'pages/project.njk', 'portfolio', {
project,
text: project.text[lang],
slug: project.slug,
newer: projects[index - 1],
older: projects[index + 1],
});
});
router.get(segment('homelab'), (req, res, next) => {
const { homelab, pages } = res.locals.content;
if (!homelab) return next();
render(res, 'pages/homelab.njk', 'homelab', { homelab, text: pages.homelab?.[lang] ?? {} });
});
router.get(segment('journey'), (req, res) => {
const { journey, pages } = res.locals.content;
render(res, 'pages/journey.njk', 'journey', { journey, text: pages.journey?.[lang] ?? {} });
});
router.get(segment('privacy'), (req, res, next) => {
const text = res.locals.content.pages.privacy?.[lang];
if (!text) return next();
render(res, 'pages/privacy.njk', 'privacy', { text });
});
router.use(segment('contact'), contactRouter(lang, deps));
return router;
}
+42
View File
@@ -0,0 +1,42 @@
import express from 'express';
import { locales, pathFor } from '../i18n.js';
const escapeXml = (value) =>
String(value).replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c]);
function urlEntry(siteUrl, page, slug) {
const alternates = locales
.map((lang) => ` <xhtml:link rel="alternate" hreflang="${lang}" href="${escapeXml(siteUrl + pathFor(lang, page, slug))}"/>`)
.join('\n');
return locales
.map((lang) => ` <url>\n <loc>${escapeXml(siteUrl + pathFor(lang, page, slug))}</loc>\n${alternates}\n </url>`)
.join('\n');
}
export function seoRouter({ config, content }) {
const router = express.Router();
router.get('/robots.txt', (req, res) => {
res.type('text').send(`User-agent: *\nAllow: /\n\nSitemap: ${config.siteUrl}/sitemap.xml\n`);
});
router.get('/sitemap.xml', (req, res) => {
const { homelab, projects, pages } = content.get();
const ids = ['home', 'portfolio', 'journey', 'contact'];
if (homelab) ids.push('homelab');
if (pages.privacy) ids.push('privacy');
const entries = [
...ids.map((page) => urlEntry(config.siteUrl, page)),
...projects.map((project) => urlEntry(config.siteUrl, 'portfolio', project.slug)),
];
res.type('application/xml').send(
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n' +
entries.join('\n') +
'\n</urlset>\n',
);
});
return router;
}
+27
View File
@@ -0,0 +1,27 @@
import { createApp } from './app.js';
import { loadConfig } from './config.js';
import { createContentStore } from './content.js';
const config = loadConfig();
const content = createContentStore(config);
try {
await content.reload();
} catch (err) {
console.error(`[content] could not load ${config.contentDir}: ${err.message}`);
process.exit(1);
}
content.watch();
const app = await createApp({ config, content });
const server = app.listen(config.port, () => {
console.log(`listening on http://localhost:${config.port} (${config.production ? 'production' : 'development'})`);
});
function shutdown() {
content.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
{%- set fullTitle = (pageTitle + ' · ' + site.name) if pageTitle else site.name %}
{%- set description = pageDescription or (site.description | l) %}
<title>{{ fullTitle }}</title>
<meta name="description" content="{{ description }}">
<meta name="theme-color" content="#0c0a12">
<meta name="color-scheme" content="dark">
{% if noindex %}<meta name="robots" content="noindex">{% endif %}
<link rel="canonical" href="{{ siteUrl }}{{ currentPath }}">
{% if not noindex %}
<link rel="alternate" hreflang="{{ lang }}" href="{{ siteUrl }}{{ currentPath }}">
<link rel="alternate" hreflang="{{ altLang }}" href="{{ siteUrl }}{{ altPath }}">
<link rel="alternate" hreflang="x-default" href="{{ siteUrl }}/">
{% endif %}
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{ site.name }}">
<meta property="og:locale" content="{{ 'de_DE' if lang == 'de' else 'en_GB' }}">
<meta property="og:url" content="{{ siteUrl }}{{ currentPath }}">
<meta property="og:title" content="{{ fullTitle }}">
<meta property="og:description" content="{{ description }}">
<meta property="og:image" content="{{ siteUrl }}/og.jpg">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.png" type="image/png" sizes="64x64">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="{{ asset('app.css') }}">
<script type="module" src="{{ asset('app.js') }}"></script>
</head>
<body class="page-{{ page }}">
<a class="skip-link" href="#main">{{ t('nav.skip') }}</a>
{% include "partials/header.njk" %}
<main id="main" tabindex="-1">
{% block content %}{% endblock %}
</main>
{% include "partials/footer.njk" %}
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
{% extends "layout.njk" %}
{% from "partials/macros.njk" import icon %}
{% set pageTitle = t('contact.title') %}
{% set pageDescription = t('contact.lead') %}
{% block content %}
{% set headTitle = t('contact.title') %}
{% set headLead = t('contact.lead') %}
{% include "partials/page-head.njk" %}
<div class="container section section--flush contact">
<div class="contact__main" data-reveal>
{% if form.sent %}
<p class="form-status form-status--ok" role="status">{{ t('contact.success') }}</p>
{% elif mailEnabled %}
<form class="contact-form" method="post" action="{{ url('contact') }}" data-contact-form data-sending="{{ t('contact.sending') }}" data-failed="{{ t('contact.errors.sendFailed') }}">
{% if form.error or form.errors | length %}
<p class="form-status form-status--error" role="alert">{{ t('contact.errors.' + (form.error or 'check')) }}</p>
{% endif %}
<p class="form-status" role="status" data-form-status hidden></p>
{% for field in ['name', 'email'] %}
<div class="field{% if form.errors[field] %} field--invalid{% endif %}">
<label class="field__label" for="contact-{{ field }}">{{ t('contact.' + field) }}</label>
<input class="field__input" id="contact-{{ field }}" name="{{ field }}" type="{{ 'email' if field == 'email' else 'text' }}"
autocomplete="{{ 'email' if field == 'email' else 'name' }}" required maxlength="{{ 200 if field == 'email' else 100 }}"
value="{{ form.values[field] }}" {% if form.errors[field] %}aria-invalid="true" aria-describedby="contact-{{ field }}-error"{% endif %}>
<p class="field__error" id="contact-{{ field }}-error" data-error-for="{{ field }}" {% if not form.errors[field] %}hidden{% endif %}>{% if form.errors[field] %}{{ t('contact.fieldErrors.' + form.errors[field]) }}{% endif %}</p>
</div>
{% endfor %}
<div class="field{% if form.errors.message %} field--invalid{% endif %}">
<label class="field__label" for="contact-message">{{ t('contact.message') }}</label>
<textarea class="field__input field__input--area" id="contact-message" name="message" rows="7" required minlength="10" maxlength="5000"
{% if form.errors.message %}aria-invalid="true" aria-describedby="contact-message-error"{% endif %}>{{ form.values.message }}</textarea>
<p class="field__error" id="contact-message-error" data-error-for="message" {% if not form.errors.message %}hidden{% endif %}>{% if form.errors.message %}{{ t('contact.fieldErrors.' + form.errors.message) }}{% endif %}</p>
</div>
<div class="field field--trap" aria-hidden="true">
<label for="contact-website">{{ t('contact.honeypot') }}</label>
<input id="contact-website" name="website" type="text" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="ts" value="{{ startedAt }}">
<div class="contact-form__footer">
<button class="btn btn--primary" type="submit" data-magnetic>{{ t('contact.send') }} {{ icon('arrow-right') }}</button>
{% if content.pages.privacy %}
<p class="contact-form__note">{{ t('contact.privacyNote') }} <a href="{{ url('privacy') }}">{{ t('contact.privacyLink') }}</a>.</p>
{% endif %}
</div>
</form>
{% else %}
<p class="form-status">{{ t('contact.mailOnly') }}</p>
{% if site.email %}<p><a class="cta__mail" href="mailto:{{ site.email }}">{{ site.email }}</a></p>{% endif %}
{% endif %}
</div>
{% if site.email or site.links.length %}
<aside class="contact__aside" aria-labelledby="direct-title" data-reveal>
<h2 class="contact__aside-title" id="direct-title">{{ t('contact.direct') }}</h2>
<ul class="contact__links" role="list">
{% if site.email %}
<li><a class="link-button" href="mailto:{{ site.email }}">{{ icon('mail') }}<span>{{ site.email }}</span></a></li>
{% endif %}
{% for link in site.links %}
<li><a class="link-button" href="{{ link.url }}" rel="me noopener">{{ icon('branch' if 'git' in link.url else 'external') }}<span>{{ link.label | l }}</span></a></li>
{% endfor %}
</ul>
</aside>
{% endif %}
</div>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "layout.njk" %}
{% from "partials/macros.njk" import icon %}
{% set key = '404' if status == 404 else '500' %}
{% set pageTitle = t('error.' + key + '.title') %}
{% block content %}
<section class="error-page">
<div class="page-head__glow" aria-hidden="true"></div>
<div class="container">
<p class="error-page__code" aria-hidden="true">{{ status }}</p>
<h1 class="error-page__title">{{ t('error.' + key + '.title') }}</h1>
<p class="page-head__lead">{{ t('error.' + key + '.text') }}</p>
<a class="btn btn--primary" href="{{ url('home') }}">{{ t('error.home') }} {{ icon('arrow-right') }}</a>
</div>
</section>
{% endblock %}
+119
View File
@@ -0,0 +1,119 @@
{% extends "layout.njk" %}
{% from "partials/macros.njk" import icon %}
{% set home = content.home %}
{% block content %}
<section class="hero" aria-labelledby="hero-title">
<div class="hero__bg" aria-hidden="true">
<canvas class="hero__canvas" data-fluid></canvas>
</div>
<div class="container hero__inner">
<p class="hero__kicker" data-reveal>{{ home.kicker | l }}</p>
<h1 class="hero__title" id="hero-title">
<span class="hero__name" data-reveal="mask">{{ site.name }}</span>
{% if site.pronouns %}<span class="hero__pronouns" data-reveal>{{ site.pronouns | l }}</span>{% endif %}
</h1>
<div class="hero__bottom">
<div class="hero__text">
<p class="hero__lead" data-reveal>{{ home.lead | l }}</p>
<div class="hero__actions" data-reveal>
<a class="btn btn--primary" href="{{ url('portfolio') }}" data-magnetic>{{ t('home.ctaPortfolio') }} {{ icon('arrow-right') }}</a>
<a class="btn btn--ghost" href="{{ url('contact') }}">{{ t('home.ctaContact') }}</a>
</div>
</div>
{% if site.now and site.now.length %}
<aside class="now" aria-labelledby="now-title" data-reveal>
<h2 class="now__title" id="now-title"><span class="now__pulse" aria-hidden="true"></span>{{ t('home.now') }}</h2>
<ul class="now__list">
{% for item in site.now %}<li>{{ item | l }}</li>{% endfor %}
</ul>
</aside>
{% endif %}
</div>
</div>
</section>
{% if home.areas and home.areas.length %}
<section class="section" aria-labelledby="areas-title">
<div class="container">
<header class="section-head">
<span class="section-head__index" aria-hidden="true">01</span>
<h2 class="section-head__title" id="areas-title" data-reveal="mask">{{ t('home.areas') }}</h2>
</header>
<div class="bento">
{% for area in home.areas %}
<article class="tile tile--{{ area.visual or 'plain' }}" data-reveal data-glow>
{% if area.visual %}
<div class="tile__visual" aria-hidden="true">{% include "partials/visuals/" + area.visual + ".njk" ignore missing %}</div>
{% endif %}
<div class="tile__body">
<h3 class="tile__title">{{ area.title | l }}</h3>
<p class="tile__text">{{ area.text | l }}</p>
{% set href = link(area.link) %}
{% if href %}
<a class="tile__link" href="{{ href }}">{{ (area.linkLabel | l) or t('home.more') }}<span class="visually-hidden">: {{ area.title | l }}</span> {{ icon('arrow-right') }}</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% if featured.length %}
<section class="section" aria-labelledby="featured-title">
<div class="container">
<header class="section-head">
<span class="section-head__index" aria-hidden="true">02</span>
<h2 class="section-head__title" id="featured-title" data-reveal="mask">{{ t('home.featured') }}</h2>
<a class="section-head__more" href="{{ url('portfolio') }}">{{ t('home.allProjects') }} {{ icon('arrow-right') }}</a>
</header>
<ul class="project-grid project-grid--featured" role="list">
{% for project in featured %}
<li data-reveal>{% set level = 3 %}{% include "partials/project-card.njk" %}</li>
{% endfor %}
</ul>
</div>
</section>
{% endif %}
{% if site.skills and site.skills.length %}
<section class="section section--tight tools" aria-labelledby="tools-title">
<div class="container">
<header class="section-head">
<span class="section-head__index" aria-hidden="true">03</span>
<h2 class="section-head__title" id="tools-title" data-reveal="mask">{{ t('home.tools') }}</h2>
</header>
</div>
<div class="marquee" data-reveal>
{% for pass in [1, 2] %}
<ul class="marquee__track" role="list" {% if pass == 2 %}aria-hidden="true"{% endif %}>
{% for group in site.skills %}{% for item in group.items %}
<li class="marquee__item">{{ item | l }}</li>
{% endfor %}{% endfor %}
</ul>
{% endfor %}
</div>
</section>
{% endif %}
{% if home.contact %}
<section class="section cta" aria-labelledby="cta-title">
<div class="container">
<div class="cta__inner" data-reveal>
<h2 class="cta__title" id="cta-title">{{ home.contact.title | l }}</h2>
<p class="cta__text">{{ home.contact.text | l }}</p>
<div class="cta__actions">
<a class="btn btn--primary" href="{{ url('contact') }}" data-magnetic>{{ t('nav.contact') }} {{ icon('arrow-right') }}</a>
{% if site.email %}<a class="cta__mail" href="mailto:{{ site.email }}">{{ site.email }}</a>{% endif %}
</div>
</div>
</div>
</section>
{% endif %}
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% extends "layout.njk" %}
{% set pageTitle = text.title or t('nav.homelab') %}
{% set pageDescription = text.lead %}
{% block content %}
{% set headTitle = text.title or t('nav.homelab') %}
{% set headLead = text.lead %}
{% include "partials/page-head.njk" %}
{% if homelab.draft %}
<div class="container"><p class="note"><span class="badge badge--draft">{{ t('portfolio.draft') }}</span></p></div>
{% endif %}
{% if homelab.hardware and homelab.hardware.length %}
<section class="section section--flush" aria-labelledby="hardware-title">
<div class="container">
<header class="section-head">
<span class="section-head__index" aria-hidden="true">01</span>
<h2 class="section-head__title" id="hardware-title" data-reveal="mask">{{ t('homelab.hardware') }}</h2>
</header>
<ul class="hardware" role="list">
{% for machine in homelab.hardware %}
<li class="machine" data-reveal data-glow>
<p class="machine__role">{{ machine.role | l }}</p>
<h3 class="machine__name">{{ machine.name }}</h3>
{% if machine.specs %}
<ul class="machine__specs" role="list">
{% for spec in machine.specs %}<li>{{ spec | l }}</li>{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</section>
{% endif %}
{% if homelab.services and homelab.services.length %}
<section class="section" aria-labelledby="services-title">
<div class="container">
<header class="section-head">
<span class="section-head__index" aria-hidden="true">02</span>
<h2 class="section-head__title" id="services-title" data-reveal="mask">{{ t('homelab.services') }}</h2>
</header>
<div class="services">
{% for group in homelab.services %}
<section class="service-group" data-reveal>
<h3 class="service-group__title">{{ group.group | l }}</h3>
<ul class="service-group__list" role="list">
{% for item in group.items %}
<li class="service">
<span class="service__dot" aria-hidden="true"></span>
<span class="service__name">{% if item.url %}<a href="{{ item.url }}">{{ item.name }}</a>{% else %}{{ item.name }}{% endif %}</span>
{% if item.text %}<span class="service__text">{{ item.text | l }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% if text.html %}
<section class="section" {% if text.fallback %}lang="{{ text.fallback }}"{% endif %}>
<div class="container">
<div class="prose prose--wide" data-reveal>{{ text.html | safe }}</div>
</div>
</section>
{% endif %}
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "layout.njk" %}
{% from "partials/macros.njk" import picture %}
{% set pageTitle = text.title or t('nav.journey') %}
{% set pageDescription = text.lead %}
{% block content %}
{% set headTitle = text.title or t('nav.journey') %}
{% set headLead = text.lead %}
{% include "partials/page-head.njk" %}
{% if text.html %}
<section class="section section--flush" aria-labelledby="about-title">
<div class="container about">
<header class="section-head about__head">
<span class="section-head__index" aria-hidden="true">01</span>
<h2 class="section-head__title" id="about-title" data-reveal="mask">{{ t('journey.about') }}</h2>
</header>
{% if site.avatar %}
<figure class="about__portrait" data-reveal>{{ picture(site.avatar, '(min-width: 60em) 20rem, 60vw', 'about__img') }}</figure>
{% endif %}
<div class="prose about__text" data-reveal {% if text.fallback %}lang="{{ text.fallback }}"{% endif %}>{{ text.html | safe }}</div>
</div>
</section>
{% endif %}
{% if journey.length %}
<section class="section" aria-labelledby="timeline-title">
<div class="container">
<header class="section-head">
<span class="section-head__index" aria-hidden="true">02</span>
<h2 class="section-head__title" id="timeline-title" data-reveal="mask">{{ t('journey.timeline') }}</h2>
</header>
<ol class="timeline" role="list" data-timeline>
{% for entry in journey %}
<li class="timeline__entry timeline__entry--{{ entry.type or 'other' }}" data-reveal>
<span class="timeline__dot" aria-hidden="true"></span>
<div class="timeline__card">
<p class="timeline__period">
{% if entry.to %}{{ entry.from | date('year') }} – {{ entry.to | date('year') }}
{% elif entry.ongoing %}{{ t('journey.since', { from: entry.from | date('year') }) }}
{% else %}{{ entry.from | date('year') }}{% endif %}
{% if entry.draft %}<span class="badge badge--draft">{{ t('portfolio.draft') }}</span>{% endif %}
</p>
<h3 class="timeline__title">{{ entry.title | l }}</h3>
{% if entry.place %}<p class="timeline__place">{{ entry.place | l }}</p>{% endif %}
{% if entry.text %}<p class="timeline__text">{{ entry.text | l }}</p>{% endif %}
</div>
</li>
{% endfor %}
</ol>
</div>
</section>
{% endif %}
{% if site.skills and site.skills.length %}
<section class="section" aria-labelledby="skills-title">
<div class="container">
<header class="section-head">
<span class="section-head__index" aria-hidden="true">03</span>
<h2 class="section-head__title" id="skills-title" data-reveal="mask">{{ t('journey.skills') }}</h2>
</header>
<div class="skills">
{% for group in site.skills %}
<section class="skill-group" data-reveal>
<h3 class="skill-group__title">{{ group.group | l }}</h3>
<ul class="skill-group__list" role="list">
{% for item in group.items %}<li class="skill">{{ item | l }}</li>{% endfor %}
</ul>
</section>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "layout.njk" %}
{% set pageTitle = t('portfolio.title') %}
{% set pageDescription = t('portfolio.lead') %}
{% block content %}
{% set headTitle = t('portfolio.title') %}
{% set headLead = t('portfolio.lead') %}
{% include "partials/page-head.njk" %}
<div class="container section section--flush">
{% if tags.length > 1 %}
<nav class="filter" aria-label="{{ t('portfolio.filterLabel') }}" data-filter>
<a class="chip" href="{{ url('portfolio') }}" data-tag="" {% if not activeTag %}aria-current="true"{% endif %}>
{{ t('portfolio.all') }} <span class="chip__count">{{ projects.length }}</span>
</a>
{% for tag in tags %}
<a class="chip" href="{{ url('portfolio') }}?tag={{ tag.name | urlencode }}" data-tag="{{ tag.name }}" {% if activeTag == tag.name %}aria-current="true"{% endif %}>
{{ tag.name | tag }} <span class="chip__count">{{ tag.count }}</span>
</a>
{% endfor %}
</nav>
{% endif %}
<ul class="project-grid" role="list" data-filter-list>
{% for project in projects %}
<li data-tags="{{ project.tags | join(' ') }}" {% if activeTag and not (activeTag in project.tags) %}hidden{% endif %} data-reveal>
{% set level = 2 %}{% include "partials/project-card.njk" %}
</li>
{% endfor %}
</ul>
<p class="empty" data-filter-empty {% if projects.length %}hidden{% endif %}>{{ t('portfolio.empty') }}</p>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "layout.njk" %}
{% set pageTitle = text.title %}
{% block content %}
{% set headTitle = text.title %}
{% set headLead = text.lead %}
{% include "partials/page-head.njk" %}
<div class="container section section--flush">
<div class="prose" {% if text.fallback %}lang="{{ text.fallback }}"{% endif %}>
{{ text.html | safe }}
</div>
</div>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "layout.njk" %}
{% from "partials/macros.njk" import icon, picture, linkIcon %}
{% set pageTitle = text.title %}
{% set pageDescription = text.summary %}
{% block content %}
<article class="project">
<header class="project-head">
<div class="page-head__glow" aria-hidden="true"></div>
<div class="container">
<a class="back-link" href="{{ url('portfolio') }}">{{ icon('arrow-left') }} {{ t('portfolio.back') }}</a>
<h1 class="project-head__title" data-reveal="mask">{{ text.title }}</h1>
{% if text.summary %}<p class="page-head__lead" data-reveal>{{ text.summary }}</p>{% endif %}
<dl class="project-meta" data-reveal>
{% if project.date %}
<div><dt>{{ t('portfolio.date') }}</dt><dd><time datetime="{{ project.date }}">{{ project.date | date }}</time></dd></div>
{% endif %}
{% if project.tags.length %}
<div><dt>{{ t('portfolio.tags') }}</dt><dd><ul class="tags" role="list">{% for tag in project.tags %}<li><a class="tag" href="{{ url('portfolio') }}?tag={{ tag | urlencode }}">{{ tag | tag }}</a></li>{% endfor %}</ul></dd></div>
{% endif %}
{% if project.draft %}<div><dt class="visually-hidden">Status</dt><dd><span class="badge badge--draft">{{ t('portfolio.draft') }}</span></dd></div>{% endif %}
</dl>
{% if text.fallback %}<p class="note" lang="{{ text.fallback }}">{{ t('portfolio.fallbackNote') }}</p>{% endif %}
</div>
</header>
{% if project.cover %}
<figure class="project-cover container container--wide" data-reveal>
{{ picture(project.cover, '(min-width: 90em) 86rem, 100vw', 'project-cover__img', eager=true, alt='') }}
</figure>
{% endif %}
<div class="container project-body">
<div class="prose" {% if text.fallback %}lang="{{ text.fallback }}"{% endif %}>
{{ text.html | safe }}
</div>
{% if project.links.length %}
<aside class="project-links" aria-labelledby="links-title">
<h2 class="project-links__title" id="links-title">{{ t('portfolio.links') }}</h2>
<ul role="list">
{% for link in project.links %}
<li><a class="link-button" href="{{ link.url }}">{{ icon(linkIcon(link.url)) }}<span>{{ link.label | l }}</span></a></li>
{% endfor %}
</ul>
</aside>
{% endif %}
</div>
{% if project.video %}
<section class="container project-section" aria-labelledby="video-title">
<h2 class="project-section__title" id="video-title">{{ t('portfolio.video') }}</h2>
{% set video = project.video %}
{% if video.type == 'youtube' %}
<div class="video" data-youtube="{{ video.id }}" data-title="{{ text.title }}">
{% if video.poster or project.cover %}{{ picture(video.poster or project.cover, '(min-width: 75em) 72rem, 100vw', 'video__poster', alt='') }}{% endif %}
<div class="video__overlay">
<a class="video__play" href="{{ video.url }}" data-video-play>
<span class="video__play-icon">{{ icon('play') }}</span>
<span class="video__play-label">{{ t('portfolio.loadVideo') }}</span>
</a>
<p class="video__notice">{{ t('portfolio.videoNotice') }}</p>
</div>
</div>
{% elif video.type == 'file' %}
<video class="video video--file" controls preload="metadata" playsinline {% if video.poster %}poster="{{ video.poster.src }}"{% endif %}>
<source src="{{ video.src }}" type="{{ video.mime }}">
</video>
{% else %}
<a class="link-button" href="{{ video.url }}">{{ icon('play') }}<span>{{ t('portfolio.openVideo') }}</span></a>
{% endif %}
</section>
{% endif %}
{% if project.gallery.length %}
<section class="container project-section" aria-labelledby="gallery-title">
<h2 class="project-section__title" id="gallery-title">{{ t('portfolio.gallery') }}</h2>
<ul class="gallery" role="list" data-gallery data-label-prev="{{ t('portfolio.prevImage') }}" data-label-next="{{ t('portfolio.nextImage') }}" data-label-close="{{ t('portfolio.closeImage') }}">
{% for image in project.gallery %}
<li class="gallery__item" data-reveal>
<figure>
<a class="gallery__link" href="{{ image.src }}" data-full="{{ image.src }}" data-width="{{ image.width }}" data-height="{{ image.height }}">
{{ picture(image, '(min-width: 60em) 24rem, (min-width: 40em) 50vw, 100vw', 'gallery__img') }}
<span class="visually-hidden">{{ t('portfolio.showImage') }}</span>
</a>
{% if image.caption %}<figcaption>{{ image.caption | l }}</figcaption>{% endif %}
</figure>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if newer or older %}
<nav class="container pager" aria-label="{{ t('portfolio.title') }}">
{% if newer %}
<a class="pager__link pager__link--newer" href="{{ url('portfolio', newer.slug) }}" rel="prev">
<span class="pager__label">{{ icon('arrow-left') }} {{ t('portfolio.newer') }}</span>
<span class="pager__title">{{ newer.text[lang].title }}</span>
</a>
{% endif %}
{% if older %}
<a class="pager__link pager__link--older" href="{{ url('portfolio', older.slug) }}" rel="next">
<span class="pager__label">{{ t('portfolio.older') }} {{ icon('arrow-right') }}</span>
<span class="pager__title">{{ older.text[lang].title }}</span>
</a>
{% endif %}
</nav>
{% endif %}
</article>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% from "partials/macros.njk" import icon %}
<footer class="site-footer">
<div class="container">
<div class="site-footer__grid">
<div class="site-footer__intro">
<a class="brand brand--footer" href="{{ url('home') }}">
<img class="brand__mark" src="/avatar.png" width="36" height="36" alt="">
<span class="brand__name">{{ site.name | lower }}</span>
</a>
{% if site.footer %}<p class="site-footer__note">{{ site.footer | l }}</p>{% endif %}
</div>
<nav class="site-footer__col" aria-label="{{ t('footer.pages') }}">
<h2 class="site-footer__heading">{{ t('footer.pages') }}</h2>
<ul>
{% for item in nav %}<li><a href="{{ item.href }}">{{ item.label }}</a></li>{% endfor %}
</ul>
</nav>
{% if site.email or site.links.length %}
<div class="site-footer__col">
<h2 class="site-footer__heading">{{ t('footer.elsewhere') }}</h2>
<ul>
{% if site.email %}<li><a href="mailto:{{ site.email }}">{{ site.email }}</a></li>{% endif %}
{% for link in site.links %}
<li><a href="{{ link.url }}" rel="me noopener">{{ link.label | l }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div class="site-footer__bottom">
<p>&copy; {{ year }} {{ site.name }}</p>
<ul class="site-footer__legal">
{% if content.pages.privacy %}<li><a href="{{ url('privacy') }}">{{ t('footer.privacy') }}</a></li>{% endif %}
{% if site.source %}<li><a href="{{ site.source }}">{{ t('footer.source') }}</a></li>{% endif %}
<li><a href="#main" class="to-top">{{ t('footer.top') }} {{ icon('arrow-up') }}</a></li>
</ul>
</div>
</div>
</footer>
+29
View File
@@ -0,0 +1,29 @@
<header class="site-header" data-header>
<div class="container site-header__inner">
<a class="brand" href="{{ url('home') }}" {% if page == 'home' %}aria-current="page"{% endif %}>
<img class="brand__mark" src="/avatar.png" width="36" height="36" alt="">
<span class="brand__name">{{ site.name | lower }}</span>
<span class="visually-hidden">, {{ t('nav.home') }}</span>
</a>
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="site-nav" data-menu-toggle>
<span class="menu-toggle__label" data-label-open="{{ t('nav.menu') }}" data-label-close="{{ t('nav.close') }}">{{ t('nav.menu') }}</span>
<span class="menu-toggle__icon" aria-hidden="true"><span></span><span></span></span>
</button>
<nav class="site-nav" id="site-nav" aria-label="{{ t('nav.label') }}" data-nav>
<ul class="site-nav__list">
{% for item in nav %}
<li class="site-nav__item">
<a class="site-nav__link" href="{{ item.href }}" {% if item.current %}aria-current="page"{% endif %}>{{ item.label }}</a>
</li>
{% endfor %}
</ul>
<a class="lang-switch" href="{{ altPath }}" hreflang="{{ altLang }}" lang="{{ altLang }}" aria-label="{{ t('nav.switchLanguage') }}">
<span class="lang-switch__current" aria-hidden="true">{{ lang | upper }}</span>
<span class="lang-switch__sep" aria-hidden="true">/</span>
<span>{{ altLang | upper }}</span>
</a>
</nav>
</div>
</header>
+26
View File
@@ -0,0 +1,26 @@
{% macro icon(name) -%}
{%- set paths = {
'arrow-right': 'M5 12h14M13 6l6 6-6 6',
'arrow-left': 'M19 12H5M11 6l-6 6 6 6',
'arrow-up': 'M12 19V5M6 11l6-6 6 6',
'external': 'M7 17 17 7M8 7h9v9',
'mail': 'M3.5 6.5h17v11h-17zM4 7l8 6.5L20 7',
'branch': 'M6 3v12M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9',
'close': 'M6 6l12 12M18 6 6 18',
'prev': 'M15 5l-7 7 7 7',
'next': 'M9 5l7 7-7 7',
'file': 'M14 3H7a1.5 1.5 0 0 0-1.5 1.5v15A1.5 1.5 0 0 0 7 21h10a1.5 1.5 0 0 0 1.5-1.5V7.5zM14 3v4.5h4.5',
'play': 'M8 5.5v13l10.5-6.5z'
} -%}
<svg class="icon icon--{{ name }}" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
{%- endmacro %}
{% macro picture(image, sizes='100vw', class='', eager=false, alt=undefined) -%}
{%- if image -%}
<img{% if class %} class="{{ class }}"{% endif %} src="{{ image.src }}"{% if image.srcset %} srcset="{{ image.srcset }}" sizes="{{ sizes }}"{% endif %} width="{{ image.width }}" height="{{ image.height }}" alt="{{ alt if alt is defined else image.alt }}"{% if eager %} fetchpriority="high"{% else %} loading="lazy"{% endif %} decoding="async">
{%- endif -%}
{%- endmacro %}
{% macro linkIcon(url) -%}
{%- if url.indexOf('mailto:') == 0 %}mail{% elif url.indexOf('/media/') == 0 %}file{% elif url.indexOf('git') != -1 %}branch{% else %}external{% endif -%}
{%- endmacro %}
+8
View File
@@ -0,0 +1,8 @@
<header class="page-head">
<div class="page-head__glow" aria-hidden="true"></div>
<div class="container">
{% if headKicker %}<p class="page-head__kicker" data-reveal>{{ headKicker }}</p>{% endif %}
<h1 class="page-head__title" data-reveal="mask">{{ headTitle }}</h1>
{% if headLead %}<p class="page-head__lead" data-reveal>{{ headLead }}</p>{% endif %}
</div>
</header>
+26
View File
@@ -0,0 +1,26 @@
{% from "partials/macros.njk" import picture %}
{% set text = project.text[lang] %}
<article class="card" data-glow>
<a class="card__link" href="{{ url('portfolio', project.slug) }}" data-cover-link>
<div class="card__media">
{% if project.cover %}
{{ picture(project.cover, '(min-width: 75em) 36rem, (min-width: 40em) 50vw, 100vw', 'card__img', alt='') }}
{% else %}
<div class="card__placeholder" aria-hidden="true"></div>
{% endif %}
</div>
<div class="card__body">
<p class="card__meta">
{% if project.date %}<time datetime="{{ project.date }}">{{ project.date | date }}</time>{% endif %}
{% if project.draft %}<span class="badge badge--draft">{{ t('portfolio.draft') }}</span>{% endif %}
</p>
<h{{ level or 2 }} class="card__title">{{ text.title }}</h{{ level or 2 }}>
{% if text.summary %}<p class="card__summary">{{ text.summary }}</p>{% endif %}
{% if project.tags.length %}
<ul class="tags" role="list">
{% for tag in project.tags %}<li class="tag">{{ tag | tag }}</li>{% endfor %}
</ul>
{% endif %}
</div>
</a>
</article>
+25
View File
@@ -0,0 +1,25 @@
<svg class="net" viewBox="0 0 320 160" preserveAspectRatio="xMidYMid meet">
<g class="net__links">
<path d="M160 80 L48 34"/>
<path d="M160 80 L52 128"/>
<path d="M160 80 L272 30"/>
<path d="M160 80 L278 124"/>
<path d="M160 80 L160 16"/>
<path d="M272 30 L278 124"/>
</g>
<g class="net__flow">
<path d="M48 34 L160 80" pathLength="100"/>
<path d="M160 80 L52 128" pathLength="100"/>
<path d="M272 30 L160 80" pathLength="100"/>
<path d="M160 80 L278 124" pathLength="100"/>
<path d="M160 16 L160 80" pathLength="100"/>
</g>
<g class="net__nodes">
<rect x="34" y="22" width="28" height="24" rx="5"/>
<rect x="38" y="116" width="28" height="24" rx="5"/>
<rect x="258" y="18" width="28" height="24" rx="5"/>
<rect x="264" y="112" width="28" height="24" rx="5"/>
<rect x="148" y="4" width="24" height="20" rx="5"/>
<circle class="net__core" cx="160" cy="80" r="15"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 956 B

+6
View File
@@ -0,0 +1,6 @@
<pre class="code"><code><span class="c">#!/usr/bin/env bash</span>
<span class="k">set</span> -euo pipefail
<span class="k">for</span> h <span class="k">in</span> $(cat hosts); <span class="k">do</span>
ssh <span class="s">"$h"</span> uptime
<span class="k">done</span><span class="code__caret"></span></code></pre>
+10
View File
@@ -0,0 +1,10 @@
<div class="rack">
{% for unit in [1, 2, 3, 4, 5] %}
<div class="rack__unit">
<span class="rack__led rack__led--{{ loop.index }}"></span>
<span class="rack__led rack__led--b{{ loop.index }}"></span>
<span class="rack__vents"></span>
<span class="rack__bar"><span class="rack__load rack__load--{{ loop.index }}"></span></span>
</div>
{% endfor %}
</div>
+3
View File
@@ -0,0 +1,3 @@
<div class="bars">
{% for i in range(1, 9) %}<span class="bars__bar bars__bar--{{ i }}"></span>{% endfor %}
</div>
+18
View File
@@ -0,0 +1,18 @@
<div class="edit">
<div class="edit__ruler"></div>
<div class="edit__track">
<span class="edit__label">V2</span>
<span class="edit__clip edit__clip--title"></span>
</div>
<div class="edit__track">
<span class="edit__label">V1</span>
<span class="edit__clip edit__clip--a"></span>
<span class="edit__clip edit__clip--b"></span>
<span class="edit__clip edit__clip--c"></span>
</div>
<div class="edit__track edit__track--audio">
<span class="edit__label">A1</span>
<span class="edit__clip edit__clip--wave"></span>
</div>
<span class="edit__playhead"></span>
</div>
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more