Personal Website, vers. 1 (pushed far to late)
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
cache
|
||||||
|
test
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
cache/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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 (``), 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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 95 KiB |
@@ -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 `` einbinden.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -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.
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
draft: true
|
||||||
|
date: 2026-08
|
||||||
|
tags: [server, linux]
|
||||||
|
featured: true
|
||||||
|
cover: cover.jpg
|
||||||
|
After Width: | Height: | Size: 95 KiB |
@@ -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"
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
draft: true
|
||||||
|
date: 2026-07
|
||||||
|
tags: [scripts, automation]
|
||||||
|
cover: cover.jpg
|
||||||
|
# links:
|
||||||
|
# - label: Repo
|
||||||
|
# url: https://git.harlelux.de/Harlekin/...
|
||||||
|
After Width: | Height: | Size: 95 KiB |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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/...
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { locales, pathFor } from '../i18n.js';
|
||||||
|
|
||||||
|
const escapeXml = (value) =>
|
||||||
|
String(value).replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>© {{ 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>
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="bars">
|
||||||
|
{% for i in range(1, 9) %}<span class="bars__bar bars__bar--{{ i }}"></span>{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -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>
|
||||||