commit 160ec017eb35e91e5fd8251548af16438f76e661 Author: opencode Date: Sat Sep 19 16:02:16 2026 +0200 feat: launch desfoto.de as a standalone photography site and retire the old redirect diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf856ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Fetched, non-shipped build inputs: reproduce with scripts/fetch-assets.py. +assets-src/ + +# Python +__pycache__/ +*.pyc +.venv/ + +# Local scratch +.ui-review/ +*.tar.gz diff --git a/.ocauto/deploy b/.ocauto/deploy new file mode 100755 index 0000000..42983f2 --- /dev/null +++ b/.ocauto/deploy @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Release deployment for desfoto.de. +# +# .ocauto/deploy +# +# Ships the committed static build to the production host, starts the stack +# behind the existing Traefik instance, and removes the obsolete +# "desfoto.de -> dennyschulz.de" redirect from the shared /srv/stack project. +# +# Rollback: the state before every release is snapshotted to +# /home/denny/stacks/desfoto-releases/-/ +# including the shared stack file that the release modified. Restore it with +# ROLLBACK_TO= .ocauto/deploy rollback +# The snapshot is written even for the very first release, when no site is live +# yet; rolling back to that snapshot then removes the new stack again and puts +# the previous /srv/stack configuration back. +set -euo pipefail + +cd "$(dirname "$0")/.." +root="$(pwd)" +release_sha="${1:-}" +remote="${DESFOTO_REMOTE:-prod-main}" +live_dir="/home/denny/stacks/desfoto" +releases_dir="/home/denny/stacks/desfoto-releases" +stack_dir="/srv/stack" + +log() { printf '[deploy] %s\n' "$1"; } +die() { printf '[deploy] ERROR: %s\n' "$1" >&2; exit 1; } + +if [ "$release_sha" = "rollback" ]; then + target="${ROLLBACK_TO:-}" + [ -n "$target" ] || die "ROLLBACK_TO must name a snapshot directory" + log "rolling back to $target" + # shellcheck disable=SC2029 + ssh "$remote" "set -euo pipefail + sudo test -d '$target' + if sudo test -d '$target/site'; then + sudo rsync -a --delete --chown=denny:denny '$target/site/' '$live_dir/site/' + sudo cp '$target/nginx.conf' '$live_dir/nginx.conf' + sudo cp '$target/compose.yml' '$live_dir/compose.yml' + sudo cp '$target/compose.vps.yml' '$live_dir/compose.vps.yml' + cd '$live_dir' + sudo docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans + else + echo '[deploy] initial-state snapshot: removing the desfoto stack again' + if sudo test -d '$live_dir'; then + ( cd '$live_dir' && sudo docker compose -f compose.yml -f compose.vps.yml down --remove-orphans ) + fi + fi + if sudo test -f '$target/docker-compose.yml.stack-backup'; then + echo '[deploy] restoring the previous shared stack configuration' + sudo cp '$target/docker-compose.yml.stack-backup' '$stack_dir/docker-compose.yml' + cd '$stack_dir' + sudo docker compose up -d --no-deps landing + fi" + log "rollback started" + exit 0 +fi + +[ -n "$release_sha" ] || die "usage: .ocauto/deploy " +[ "$(git rev-parse HEAD)" = "$release_sha" ] || die "HEAD does not match $release_sha" +[ -z "$(git status --porcelain -- site nginx.conf compose.yml compose.vps.yml)" ] \ + || die "the deployable files have uncommitted changes" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +log "packing $release_sha" +tar -czf "$tmp/desfoto-$release_sha.tgz" -C "$root" site nginx.conf compose.yml compose.vps.yml + +log "uploading" +scp -q "$tmp/desfoto-$release_sha.tgz" "$remote:/tmp/desfoto-$release_sha.tgz" + +log "deploying on $remote" +# shellcheck disable=SC2029 +ssh "$remote" "REMOTE_SHA='$release_sha' LIVE_DIR='$live_dir' RELEASES_DIR='$releases_dir' STACK_DIR='$stack_dir' bash -s" <<'REMOTE' +set -euo pipefail +stamp="$(date -u +%Y%m%dT%H%M%SZ)" +snapshot="$RELEASES_DIR/$stamp-$REMOTE_SHA" +archive="/tmp/desfoto-$REMOTE_SHA.tgz" + +# 1. Snapshot the current state so a rollback stays possible. This also runs for +# the very first release (no site live yet) so ROLLBACK_TO always has a target. +sudo mkdir -p "$snapshot" "$RELEASES_DIR" +if sudo test -d "$LIVE_DIR"; then + echo "[deploy] snapshot -> $snapshot" + sudo cp -a "$LIVE_DIR/." "$snapshot/" +else + echo "[deploy] no previous release; $snapshot records the initial state" +fi +sudo ln -sfn "$snapshot" "$RELEASES_DIR/previous" + +# 2. Unpack the new release. +echo "[deploy] unpacking $archive" +sudo mkdir -p "$LIVE_DIR" +sudo rm -rf "$LIVE_DIR/site" +sudo tar -xzf "$archive" -C "$LIVE_DIR" +sudo chown -R denny:denny "$LIVE_DIR" +sudo rm -f "$archive" +printf '%s\n' "$REMOTE_SHA" | sudo tee "$LIVE_DIR/RELEASE" >/dev/null + +# 3. Start or update the stack. +cd "$LIVE_DIR" +sudo docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans + +# 4. Wait for the container health check. +for _ in $(seq 1 30); do + status="$(sudo docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' desfoto-web-1 2>/dev/null || echo missing)" + [ "$status" = "healthy" ] && break + [ "$status" = "missing" ] && break + sleep 2 +done +echo "[deploy] desfoto-web-1: $status" +if [ "$status" != "healthy" ]; then + echo "[deploy] ERROR: container is not healthy" >&2 + sudo docker logs --tail 40 desfoto-web-1 >&2 || true + exit 1 +fi + +# 5. Remove the obsolete desfoto.de redirect from the shared stack project. +# The new stack already owns desfoto.de through a higher Traefik priority, +# this only deletes the now-dead labels so Traefik no longer advertises them. +# Stripped are Traefik label lines that mention desfoto (the old routers +# "desfoto-redirect"/"desfoto-redirect-http", the middleware +# "desfoto-to-website" and any related key) plus the comment that grouped +# them; every other line is copied verbatim. The result is validated as a +# Compose project *before* it replaces the live file, and the replacement +# itself is a single atomic rename. +stack_file="$STACK_DIR/docker-compose.yml" +if sudo grep -q 'desfoto' "$stack_file"; then + echo "[deploy] removing obsolete desfoto redirect labels from $stack_file" + sudo cp "$stack_file" "$STACK_DIR/docker-compose.yml.bak.$stamp" + sudo cp "$stack_file" "$snapshot/docker-compose.yml.stack-backup" + + # The temp file lives next to the original (same filesystem, hidden name that + # Compose ignores) so the final mv is atomic. + stack_tmp="$(sudo mktemp "$STACK_DIR/.docker-compose.yml.desfoto.XXXXXX")" + set +e + # Strip only lines that are Traefik labels mentioning desfoto (list or map + # syntax, key or value side) plus the comment that grouped the old redirect. + # The generic "desfoto" substring filter of the previous version could have + # deleted unrelated lines; this one is anchored to label lines. + sudo awk ' + /^[[:space:]]*-?[[:space:]]*"?traefik\..*desfoto/ { next } + /# desfoto\.de -> kanonische Fotografen-Website/ { next } + { print } + ' "$stack_file" | sudo tee "$stack_tmp" >/dev/null + strip_status="${PIPESTATUS[0]}" + set -e + if [ "$strip_status" -ne 0 ]; then + sudo rm -f "$stack_tmp" + echo "[deploy] ERROR: cannot read $stack_file (awk exit $strip_status)" >&2 + exit 1 + fi + sudo chown --reference="$stack_file" "$stack_tmp" + sudo chmod --reference="$stack_file" "$stack_tmp" + + # Validate the stripped file as the same Compose project before touching the + # live configuration. The project directory stays $STACK_DIR because the first + # -f file lives there. + validate=(docker compose -f "$stack_tmp") + if sudo test -f "$STACK_DIR/docker-compose.override.yml"; then + validate+=(-f "$STACK_DIR/docker-compose.override.yml") + fi + validate+=(config --quiet) + if ! ( cd "$STACK_DIR" && sudo "${validate[@]}" ); then + sudo rm -f "$stack_tmp" + echo "[deploy] ERROR: stripped stack file is not valid; live file left untouched" >&2 + exit 1 + fi + + sudo mv "$stack_tmp" "$stack_file" + remaining="$(sudo grep -n 'desfoto' "$stack_file" || true)" + if [ -n "$remaining" ]; then + echo "[deploy] WARNING: desfoto references remain in $stack_file:" >&2 + printf '%s\n' "$remaining" >&2 + fi + + ( cd "$STACK_DIR" && sudo docker compose up -d --no-deps landing ) + echo "[deploy] landing recreated without the desfoto redirect" +else + echo "[deploy] obsolete redirect labels already absent" +fi + +# 6. Record the release marker. +echo "[deploy] done: $REMOTE_SHA" +REMOTE + +log "release $release_sha deployed" diff --git a/.ocauto/qa b/.ocauto/qa new file mode 100755 index 0000000..257d5a0 --- /dev/null +++ b/.ocauto/qa @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Authoritative deterministic QA for the desfoto.de static site. +# +# ./qa -> build the site, prove the build is reproducible, validate the +# nginx configuration, and run the structural test suite. +# +# Exit code 0 means the repository is releasable as-is. +set -euo pipefail + +cd "$(dirname "$0")/.." +root="$(pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +step() { printf '\n== %s\n' "$1"; } + +step "build" +python3 scripts/build-site.py + +step "build is reproducible" +# sitemap.xml and .well-known/security.txt embed the build date on purpose and +# are therefore excluded from the byte-for-byte comparison. +hash_tree() { + (cd "$root/site" && find . -type f \ + ! -name sitemap.xml ! -path './.well-known/*' -print0 | + sort -z | xargs -0 sha256sum) +} +hash_tree >"$tmp/before.txt" +python3 scripts/build-site.py >/dev/null +hash_tree >"$tmp/after.txt" +if ! diff -u "$tmp/before.txt" "$tmp/after.txt"; then + echo "FAIL: the build output is not reproducible" >&2 + exit 1 +fi +echo "rebuild produced byte-identical output for $(wc -l <"$tmp/after.txt") files" + +step "nginx configuration" +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker run --rm \ + -v "$root/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \ + -v "$root/site:/usr/share/nginx/html:ro" \ + nginx:1.28-alpine nginx -t +else + echo "SKIP: no usable docker daemon; nginx -t cannot be verified here" >&2 + exit 1 +fi + +step "compose configuration" +if docker compose version >/dev/null 2>&1; then + DESFOTO_CHECK_ONLY=1 docker compose -f compose.yml -f compose.vps.yml config --quiet + echo "compose.yml + compose.vps.yml are valid" +fi + +step "structural tests" +python3 -m unittest discover -s tests -p 'test_*.py' -v + +step "shellcheck" +if command -v shellcheck >/dev/null 2>&1; then + mapfile -t scripts < <(git ls-files '*.sh' '.ocauto/*') + if [ "${#scripts[@]}" -gt 0 ]; then + shellcheck "${scripts[@]}" + fi +fi + +printf '\nQA PASS\n' diff --git a/.ocauto/verify b/.ocauto/verify new file mode 100755 index 0000000..8a92bf2 --- /dev/null +++ b/.ocauto/verify @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Post-release verification for desfoto.de. +# +# .ocauto/verify +# +# Proves that (a) the live desfoto.de serves the released build and assets, +# (b) the security posture matches the promises on /datenschutz/, and +# (c) the neighbouring sites (dennyschulz.de, dennyapp.de) are untouched. +set -euo pipefail + +cd "$(dirname "$0")/.." +root="$(pwd)" +release_sha="${1:-unknown}" +remote="${DESFOTO_REMOTE:-prod-main}" +base="https://desfoto.de" + +failures=0 +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n' "$1" >&2; failures=$((failures + 1)); } + +check_status() { + local url="$1" want="$2" got + got="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$url" || echo 000)" + if [ "$got" = "$want" ]; then pass "$url -> $got"; else fail "$url -> $got (want $want)"; fi +} + +check_contains() { + local url="$1" needle="$2" body + body="$(curl -sS --max-time 30 "$url" || true)" + if printf '%s' "$body" | grep -qF -- "$needle"; then + pass "$url contains '$needle'" + else + fail "$url does not contain '$needle'" + fi +} + +check_header() { + local url="$1" header="$2" headers + headers="$(curl -sSI --max-time 30 "$url" || true)" + if printf '%s' "$headers" | grep -qi "^$header:"; then + pass "$url sends $header" + else + fail "$url is missing the $header header" + fi +} + +printf '\n== release identity\n' +if [ "$release_sha" != "unknown" ] && git -C "$root" rev-parse --verify --quiet "$release_sha" >/dev/null; then + local_hash="$(cd "$root/site" && find . -type f ! -name sitemap.xml ! -path './.well-known/*' -print0 | + sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1)" + remote_hash="$(ssh "$remote" "sudo sh -c 'cd /home/denny/stacks/desfoto/site && find . -type f ! -name sitemap.xml ! -path ./.well-known/\* -print0 | sort -z | xargs -0 sha256sum | sha256sum'" | cut -d' ' -f1)" + if [ "$local_hash" = "$remote_hash" ]; then + pass "deployed site tree matches the release ($local_hash)" + else + fail "deployed site tree differs from the release ($local_hash != $remote_hash)" + fi + remote_release="$(ssh "$remote" 'sudo cat /home/denny/stacks/desfoto/RELEASE' | tr -d '\r\n')" + if [ "$remote_release" = "$release_sha" ]; then + pass "release marker is $release_sha" + else + fail "release marker is '$remote_release' (want $release_sha)" + fi +fi + +printf '\n== routes\n' +for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familien-und-paare/ \ + /minishootings/ /video/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/; do + check_status "$base$route" 200 +done + +printf '\n== canonical host and redirects\n' +www_status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 https://www.desfoto.de/ || echo 000)" +www_target="$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 https://www.desfoto.de/ || true)" +if [ "$www_status" = "301" ] && [ "$www_target" = "https://desfoto.de/" ]; then + pass "www.desfoto.de -> https://desfoto.de/ (301)" +else + fail "www.desfoto.de -> $www_status $www_target" +fi +http_target="$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 http://desfoto.de/ || true)" +case "$http_target" in + https://desfoto.de/*) pass "http://desfoto.de/ -> $http_target" ;; + *) fail "http://desfoto.de/ redirects to '$http_target'" ;; +esac + +printf '\n== content\n' +check_contains "$base/" "Bilder und Filme, die nicht beliebig aussehen" +check_contains "$base/impressum/" "§ 5 DDG" +check_contains "$base/impressum/" "DE462149560" +check_contains "$base/datenschutz/" "keine Zugriffsprotokolle" +check_contains "$base/datenschutz/" "youtube-nocookie.com" +check_contains "$base/video/" "NWWFTf7l8g0" +check_contains "$base/sitemap.xml" "https://desfoto.de/video/" + +printf '\n== error handling and metadata\n' +check_status "$base/diese-seite-gibt-es-nicht/" 404 +check_contains "$base/diese-seite-gibt-es-nicht/" "Diese Seite gibt es nicht" +check_status "$base/.well-known/security.txt" 200 +check_status "$base/robots.txt" 200 + +printf '\n== assets\n' +check_status "$base/assets/img/hero-studio-1200.webp" 200 +check_status "$base/assets/img/favicon-32.png" 200 +check_status "$base/assets/site.css" 200 +check_status "$base/assets/fonts/fraunces-latin.woff2" 200 +check_status "$base/assets/img/og-desfoto.jpg" 200 + +printf '\n== privacy promises and headers\n' +check_header "$base/" "Strict-Transport-Security" +check_header "$base/" "Content-Security-Policy" +check_header "$base/" "X-Content-Type-Options" +check_header "$base/" "Referrer-Policy" +if curl -sSI --max-time 30 "$base/" | grep -qi '^set-cookie:'; then + fail "a cookie is set on the homepage" +else + pass "no Set-Cookie on the homepage" +fi + +printf '\n== neighbouring sites untouched\n' +check_status "https://www.dennyschulz.de/" 200 +check_contains "https://www.dennyschulz.de/impressum" "Denny Schulz" +check_status "https://dennyapp.de/" 200 + +printf '\n== TLS\n' +if command -v openssl >/dev/null 2>&1; then + if echo | openssl s_client -servername desfoto.de -connect desfoto.de:443 2>/dev/null | + openssl x509 -noout -checkend 604800 -subject 2>/dev/null; then + pass "certificate for desfoto.de is valid for at least 7 more days" + else + fail "certificate for desfoto.de is missing or expires within 7 days" + fi +fi + +if [ "$failures" -ne 0 ]; then + printf '\nVERIFY FAIL (%d problem(s)) release=%s\n' "$failures" "$release_sha" >&2 + exit 1 +fi +printf '\nVERIFY PASS release=%s\n' "$release_sha" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fbb179d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# desfoto.de — Agent Guidelines + +## What this repository is +The complete source of **https://desfoto.de**, the photography and video brand of +Denny Schulz (Neumünster). It is a hand-written static site: no framework, no CMS, +no runtime dependencies. `src/` holds Python builders and the editorial content, +`scripts/build-site.py` renders everything into the committed `site/` directory, +and nginx serves `site/` from a container behind the shared VPS Traefik instance. + +`dennyschulz.de` is a **separate** site and must never be modified or linked from here. + +## Commands +- `python3 scripts/fetch-assets.py` — download pool images, Google Fonts subsets and + licences into `assets-src/` (idempotent; `assets-src/` is not committed). +- `python3 scripts/build-site.py` — rebuild all of `site/`. Prints `build ok` and a + link/alt check; exits non-zero on any broken internal reference. +- `.ocauto/qa` — authoritative deterministic QA: builds twice to prove the output is + reproducible, runs `nginx -t` and `docker compose config`, then + `python3 -m unittest discover -s tests`. +- Local runtime: `docker compose -f compose.yml up -d` → http://127.0.0.1:18430 +- UI smoke: `/home/king/bin/oc-ui-smoke http://127.0.0.1:18430/` +- Release: `/home/king/bin/oc-release /home/king/projects/desfoto "" ` + +## Editing rules +- **Content lives in `src/content.py` and `src/pages.py`; never hand-edit `site/`.** + `site/` is generated output that happens to be committed for deployment. +- Never invent facts. Prices, testimonials, client names, awards and biography dates + are intentionally absent; do not add them without verified information. Legal data + in `src/content.py:LEGAL` mirrors the operator's own Impressum (`§ 5 DDG`). +- Keep the design language: paper/ink/rust/teal tokens from `src/theme.py`, rounded + cards, hairline dividers, Fraunces for display and Manrope for text. +- Images come from the operator's own pool through `src/images.json`. Only add an + entry if the operator holds the rights; always give a meaningful German `alt`. +- Two-space indentation, `from __future__ import annotations`, type hints on public + functions, no new third-party Python packages (Pillow is the only dependency). + +## Privacy contract +The `/datenschutz/` page makes concrete promises. Any change that would break one of +them is a defect, not a style question: +- no cookies, no `localStorage`, no analytics, no tracking; +- `access_log off` in `nginx.conf` — never enable request logging; +- `error_log /dev/null crit;` in `nginx.conf` — never write an error-log file either; +- fonts, scripts, styles and images are served from this origin only; +- YouTube is embedded exclusively via the click-to-load facade + (`youtube-nocookie.com`) — no iframe before the click, no preconnect; +- the contact form never posts anywhere; it composes a local `mailto:` draft + (the non-JS fallback is a `mailto:` form action, which also stays on the device). + +`tests/test_site.py` enforces these promises; extend it when you add a new flow. + +## Deployment +`.ocauto/deploy ` ships the committed build to `/home/denny/stacks/desfoto` on +`prod-main`, snapshots the previous release into `/home/denny/stacks/desfoto-releases/` +and removes the obsolete `desfoto.de → dennyschulz.de` labels from `/srv/stack`. +`.ocauto/verify ` proves the live site matches the release. Details, including +rollback, are in `docs/deployment.md`. Traefik is shared infrastructure: route through +labels, never restart or recreate the proxy. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9523955 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# desfoto.de + +Quellcode und Deployment für **https://desfoto.de** — die Foto- und Videoproduktion +von Denny Schulz aus Neumünster. + +Die Seite ist bewusst als statische Website gebaut: kein Framework, kein CMS, keine +Datenbank. `scripts/build-site.py` erzeugt aus `src/` und den heruntergeladenen +Originaldateien in `assets-src/` das vollständige, auslieferbare Verzeichnis `site/`. + +## Aufbau + +| Pfad | Inhalt | +| --- | --- | +| `src/content.py` | Alle redaktionellen Inhalte, Kontaktdaten und Pflichtangaben | +| `src/pages.py` | Die 13 Seiten und die 404-Seite als HTML-Fabriken | +| `src/layout.py` | Grundgerüst, Navigation, ``, Bild-Helfer | +| `src/theme.py` | Design-Tokens und das komplette Stylesheet | +| `src/images.json` | Zuordnung der Bildmotive aus dem eigenen Bilderpool | +| `scripts/fetch-assets.py` | Lädt Bilderpool, Google-Fonts-Subsets und Lizenzen | +| `scripts/build-site.py` | Erzeugt `site/` inkl. WebP-Varianten, OG-Bildern, Sitemap | +| `tests/test_site.py` | Struktur-, Datenschutz- und Inhaltstests | +| `site/` | Generiertes Ergebnis — wird so ausgeliefert (committet) | +| `assets-src/` | Heruntergeladene Quellen (nicht im Git, per Skript reproduzierbar) | +| `.ocauto/` | QA-, Deploy- und Verify-Hooks | +| `docs/deployment.md` | Produktionsablauf, Rollback, Infrastruktur | + +## Lokal bauen und prüfen + +```bash +python3 scripts/fetch-assets.py # einmalig, braucht Zugriff auf den Bilderpool +python3 scripts/build-site.py # schreibt site/ +.ocauto/qa # vollständige deterministische Prüfung +``` + +Der lokale Server läuft über Docker Compose: + +```bash +docker compose -f compose.yml up -d +open http://127.0.0.1:18430/ +``` + +## Seiten + +Startseite · Fotografie · Businessfotografie · Portrait & Model · Familien & Paare · +Minishootings · Video · Social Media · Projekte · Über · Kontakt · Impressum · +Datenschutz — plus eine eigene 404-Seite. + +## Technische Entscheidungen + +- **Schriften selbst gehostet.** Fraunces (Display) und Manrope (Text) werden als + Google-Fonts-`woff2`-Subsets mitgeliefert, dazu die OFL-Lizenzen in `docs/`. + Es wird nichts von `fonts.googleapis.com` geladen. +- **Bilder als WebP in mehreren Breiten.** Erzeugt werden nur Breiten, die kleiner + oder gleich dem Original sind — nichts wird hochskaliert. +- **YouTube erst nach Klick.** Statt eines vorab geladenen iframes gibt es ein + Vorschaubild; das eigentliche Video kommt per Klick über `youtube-nocookie.com`. +- **Kontaktformular ohne Server.** Das Formular setzt aus den Eingaben lokal einen + E-Mail-Entwurf zusammen. Ohne JavaScript greift `mailto:` als Fallback. + Es gibt keinen API-Endpunkt und keine Datenübertragung an den Server. +- **Keine Zugriffsprotokolle.** `nginx.conf` schaltet das Access-Log ab; es gibt + keine Cookies, keine Analyse und keine externen Ressourcen. + +## Herkunft der Inhalte + +Die Bilder stammen aus dem eigenen Bilderpool des Betreibers und werden als +bereits abgeleitete `web`-Variante geladen. Es werden nur Motive gezeigt, für die +die Rechte vorliegen. Alle gezeigten Personen sind mit der Veröffentlichung +einverstanden. diff --git a/compose.vps.yml b/compose.vps.yml new file mode 100644 index 0000000..76a482d --- /dev/null +++ b/compose.vps.yml @@ -0,0 +1,38 @@ +# VPS overlay: attaches the static web service to the shared Traefik network. +# Applied together with compose.yml on the production host: +# docker compose -f compose.yml -f compose.vps.yml up -d +services: + web: + labels: + traefik.enable: 'true' + traefik.docker.network: web + + # HTTPS routers ------------------------------------------------------- + traefik.http.routers.desfoto.rule: Host(`desfoto.de`) + traefik.http.routers.desfoto.entrypoints: https + traefik.http.routers.desfoto.tls.certresolver: le + traefik.http.routers.desfoto.priority: '200' + traefik.http.routers.desfoto.service: desfoto + + traefik.http.routers.desfoto-www.rule: Host(`www.desfoto.de`) + traefik.http.routers.desfoto-www.entrypoints: https + traefik.http.routers.desfoto-www.tls.certresolver: le + traefik.http.routers.desfoto-www.priority: '200' + traefik.http.routers.desfoto-www.middlewares: desfoto-canonical + traefik.http.routers.desfoto-www.service: desfoto + + # HTTP is already redirected to HTTPS by the global Traefik entrypoint + # redirection (see /srv/stack/docker-compose.yml); the ACME http-challenge + # is answered by Traefik itself before the redirect. + traefik.http.middlewares.desfoto-canonical.redirectregex.regex: ^https?://(?:www\.)?desfoto\.de/(.*) + traefik.http.middlewares.desfoto-canonical.redirectregex.replacement: https://desfoto.de/$${1} + traefik.http.middlewares.desfoto-canonical.redirectregex.permanent: 'true' + + traefik.http.services.desfoto.loadbalancer.server.port: '80' + networks: + - default + - web + +networks: + web: + external: true diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..0564bd5 --- /dev/null +++ b/compose.yml @@ -0,0 +1,20 @@ +name: desfoto +services: + web: + image: nginx:1.28-alpine + restart: unless-stopped + volumes: + - ./site:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - 127.0.0.1:18430:80 + healthcheck: + test: [CMD, wget, -q, --spider, http://127.0.0.1/] + interval: 15s + timeout: 5s + retries: 5 + logging: + driver: json-file + options: + max-size: 5m + max-file: '2' diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..637e644 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,112 @@ +# Deployment — desfoto.de + +## Zielumgebung + +| Baustein | Wert | +| --- | --- | +| Produktionshost | `prod-main` = `opencode-prod@87.106.24.138` (NOPASSWD sudo) | +| Live-Verzeichnis | `/home/denny/stacks/desfoto/` (Owner `denny:denny`) | +| Release-Snapshots | `/home/denny/stacks/desfoto-releases/-/` | +| Compose-Dateien | `compose.yml` + `compose.vps.yml`, Projektname `desfoto` | +| Container | `desfoto-web-1` (`nginx:1.28-alpine`), gebunden an `127.0.0.1:18430` | +| Netzwerk | externes Docker-Netzwerk `web` (Traefik-Docker-Provider) | +| Reverse Proxy | Traefik v3 im Projekt `stack` (`/srv/stack/docker-compose.yml`) | +| Entrypoints | `http` und `https`, ACME-Resolver `le`, HTTP-01-Challenge | +| Zertifikate | `/srv/traefik/acme.json` | +| Domains | `desfoto.de` (kanonisch) und `www.desfoto.de` → 301 auf Apex | + +Der Proxy wird **niemals** neu gestartet oder neu erzeugt. Die Anbindung erfolgt +ausschließlich über Labels in `compose.vps.yml`. + +## Ablauf einer Veröffentlichung + +```bash +/home/king/bin/oc-release /home/king/projects/desfoto \ + "feat: ..." +``` + +`oc-release` committet und pusht die genannten Dateien und ruft danach die +getrackten Hooks auf: + +1. `.ocauto/deploy ` + - prüft, dass `HEAD` dem Release entspricht und `site/`, `nginx.conf`, + `compose.yml`, `compose.vps.yml` keine uncommitteten Änderungen haben; + - packt diese Pfade in ein Archiv und lädt es nach `prod-main:/tmp`; + - legt auf dem Server zuerst einen Snapshot der aktuell laufenden Version an + (`/home/denny/stacks/desfoto-releases/-/`, zusätzlich als + `previous` verlinkt); + - entpackt die neue Version nach `/home/denny/stacks/desfoto/`, setzt den + Owner auf `denny:denny` und schreibt die Release-Kennung nach `RELEASE`; + - `docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans` + und wartet auf `healthy`; + - entfernt einmalig die veralteten `desfoto.de`-Weiterleitungs-Labels aus + `/srv/stack/docker-compose.yml`: Es werden ausschließlich Traefik-Label-Zeilen + mit `desfoto`-Bezug sowie der zugehörige Kommentar entfernt, das Ergebnis wird + zuerst als Compose-Projekt validiert (`config --quiet`) und danach per atomarem + `mv` an die Stelle der Live-Datei gesetzt. Eine Vorher-Fassung liegt im + Snapshot (`docker-compose.yml.stack-backup`) und daneben als + `docker-compose.yml.bak.`. Danach wird ausschließlich der + `landing`-Container mit `--no-deps` neu erzeugt, damit `dennyapp.de` und + `dennyschulz.de` unverändert weiterlaufen. Bleibt eine `desfoto`-Referenz + übrig (z. B. ein eigener Service-Block), wird sie als WARNUNG ausgegeben. + +2. `.ocauto/verify ` + - vergleicht den Dateibaum unter `/home/denny/stacks/desfoto/site` mit dem + committeten `site/` (SHA-256 über alle Dateien außer den datierten + `sitemap.xml`/`security.txt`); + - prüft alle 13 Routen, die 404-Seite, `robots.txt`, `sitemap.xml`, + `.well-known/security.txt`, Bilder und Schriften; + - prüft die Weiterleitungen `http → https` und `www → Apex`; + - prüft die Sicherheits-Header, dass kein `Set-Cookie` gesetzt wird und dass + das Zertifikat noch mindestens sieben Tage gültig ist; + - prüft, dass `www.dennyschulz.de` und `dennyapp.de` weiterhin erreichbar sind. + +## Routing + +`desfoto.de` wird vor dem Aufräumen bereits vom neuen Container bedient, weil die +Routers in `compose.vps.yml` eine explizite Traefik-Priorität `200` tragen und die +alten Weiterleitungsrouters keine Priorität setzen (damit gilt dort die +Regel-Länge). Der Container wird also zuerst gesund geprüft, und erst danach werden +die alten Labels entfernt — der Übergang hat damit kein Fenster ohne Antwort. + +`www.desfoto.de` wird über die Middleware `desfoto-canonical` dauerhaft auf +`https://desfoto.de/...` umgeschrieben. Für `http` greift die globale +Entrypoint-Weiterleitung von Traefik auf `https`; die ACME-HTTP-01-Challenge +beantwortet Traefik selbst, bevor diese Weiterleitung greift. + +## Rollback + +```bash +ROLLBACK_TO=/home/denny/stacks/desfoto-releases/- \ + /home/king/projects/desfoto/.ocauto/deploy rollback +``` + +Der Befehl synchronisiert `site/`, `nginx.conf` und beide Compose-Dateien aus dem +Snapshot zurück nach `/home/denny/stacks/desfoto/` und startet den Stack neu. Die +frühere Version bleibt so lange verfügbar, bis sie bewusst gelöscht wird. + +Jeder Release legt den Snapshot an, auch der allererste. Enthält der Snapshot noch +kein `site/` (weil vorher nichts ausgeliefert wurde), entfernt der Rollback den +desfoto-Stack wieder und stellt den Zustand vor der Veröffentlichung her. + +Wurde `/srv/stack/docker-compose.yml` verändert, liegt die Vorher-Fassung sowohl im +Snapshot als `docker-compose.yml.stack-backup` als auch daneben unter +`docker-compose.yml.bak.`. Beide werden vom Rollback automatisch +zurückgespielt, gefolgt von `docker compose up -d --no-deps landing`; das Backup +`.bak.` bleibt zusätzlich für einen manuellen Eingriff liegen. + +## Betrieb + +```bash +ssh prod-main +sudo docker compose -f /home/denny/stacks/desfoto/compose.yml \ + -f /home/denny/stacks/desfoto/compose.vps.yml ps +sudo docker logs --tail 50 desfoto-web-1 +sudo docker inspect --format '{{.State.Health.Status}}' desfoto-web-1 +``` + +Access-Logs gibt es bewusst nicht (`access_log off` in `nginx.conf`). Auch Fehler +schreibt nginx nicht weg: `error_log /dev/null crit;` verwirft sie vollständig, +statt sie in eine Datei zu schreiben. Der JSON-Log-Treiber des Containers ist +zusätzlich auf 5 MB × 2 Dateien begrenzt. Das entspricht der Zusage in der +Datenschutzerklärung. diff --git a/docs/font-fraunces-OFL.txt b/docs/font-fraunces-OFL.txt new file mode 100644 index 0000000..22fa7ce --- /dev/null +++ b/docs/font-fraunces-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/font-manrope-OFL.txt b/docs/font-manrope-OFL.txt new file mode 100644 index 0000000..e271172 --- /dev/null +++ b/docs/font-manrope-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..46140ee --- /dev/null +++ b/nginx.conf @@ -0,0 +1,86 @@ +# desfoto.de - static site behind the existing VPS Traefik (Docker provider). +# +# Truthfulness contract (see /datenschutz/): this server writes no access log, +# sets no cookies, runs no analytics and loads no third-party assets. YouTube is +# only embedded after an explicit click (youtube-nocookie.com). +map $uri $desfoto_cache { + default "public, max-age=0, must-revalidate"; + ~^/assets/img/ "public, max-age=604800, stale-while-revalidate=86400"; + ~^/assets/fonts/ "public, max-age=604800, stale-while-revalidate=86400"; + ~^/assets/site\.(css|js)$ "public, max-age=0, must-revalidate"; + ~^/(favicon\.svg|site\.webmanifest)$ "public, max-age=86400"; +} + +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + charset utf-8; + server_tokens off; + # Redirects stay relative so they keep the https scheme terminated by Traefik. + absolute_redirect off; + + # No stored visitor data: the privacy page promises log-free operation. + # Access logging is off and error messages are discarded completely, so this + # server never writes a log file that could contain a visitor IP address. + access_log off; + error_log /dev/null crit; + + etag on; + gzip on; + gzip_vary on; + gzip_comp_level 6; + gzip_min_length 512; + gzip_types text/css text/javascript application/javascript application/json application/ld+json application/xml application/manifest+json image/svg+xml; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "DENY" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Strict-Transport-Security "max-age=31536000" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self' mailto:; frame-ancestors 'none'; upgrade-insecure-requests" always; + add_header Cache-Control $desfoto_cache always; + + error_page 404 /404.html; + + location = /404.html { + internal; + } + + location = / { + try_files /index.html =404; + } + + # Keep canonical URLs: no /index.html duplicates in the index. The check uses + # $request_uri so the internal redirect from the index module (which still + # carries the original URI) is not caught in a loop. + if ($request_uri ~ ^/(.*/)?index\.html(\?.*)?$) { + return 301 /$1; + } + + location = /impressum.html { return 301 /impressum/; } + location = /datenschutz.html { return 301 /datenschutz/; } + location = /kontakt.html { return 301 /kontakt/; } + + location ^~ /.well-known/ { + allow all; + autoindex off; + types { } + default_type text/plain; + } + + location /assets/ { + try_files $uri =404; + } + + location / { + try_files $uri $uri/ =404; + } + + location ~ /\.(?!well-known/) { + deny all; + } +} diff --git a/scripts/build-site.py b/scripts/build-site.py new file mode 100755 index 0000000..d71d082 --- /dev/null +++ b/scripts/build-site.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +"""Build the committed static site in site/ from src/ and assets-src/. + +Steps +----- +1. Responsive WebP derivatives for every pool image (no upscaling). +2. Self-hosted font CSS from the downloaded Google Fonts subsets. +3. Brand assets: favicon, touch icon, per-page OpenGraph images. +4. HTML for every route, robots.txt, sitemap.xml, webmanifest, security.txt. +5. A build report with size totals and an internal-link check. +""" + +from __future__ import annotations + +import datetime as dt +import json +import shutil +import sys +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +ASSETS = ROOT / "assets-src" +SITE = ROOT / "site" +IMG_OUT = SITE / "assets" / "img" +FONT_OUT = SITE / "assets" / "fonts" +TTF = ASSETS / "fonts-ttf" + +sys.path.insert(0, str(SRC)) + +import layout # noqa: E402 +import pages # noqa: E402 +from content import LEGAL, SITE as SITE_META, VIDEO # noqa: E402 +from theme import CSS, TOKENS # noqa: E402 + +WIDTHS = (480, 768, 1200, 1600, 2000) +WEBP_QUALITY = 84 + +INK = (23, 20, 15) +PAPER = (246, 242, 234) +ACCENT = (192, 67, 28) + +# OpenGraph image per route, built from the page's own key visual. +ROUTE_OG = { + "/": ("hero-studio", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."), + "/fotografie/": ("editorial-earring", "og-fotografie.jpg", "Fotografie aus Neumünster"), + "/businessfotografie/": ("business-portrait-studio", "og-businessfotografie.jpg", "Businessfotografie"), + "/portrait-und-model/": ("editorial-colour", "og-portrait.jpg", "Portrait & Model"), + "/familien-und-paare/": ("people-joy", "og-familien.jpg", "Familien & Paare"), + "/minishootings/": ("portrait-natural", "og-minishootings.jpg", "Minishootings"), + "/video/": ("studio-lens", "og-video.jpg", "Videoprojekte"), + "/social-media/": ("event-motion", "og-social-media.jpg", "Social-Media-Inhalte"), + "/projekte/": ("travel-norway-pano", "og-projekte.jpg", "Eigene Projekte"), + "/ueber/": ("business-portrait-dark", "og-ueber.jpg", "Über desfoto"), + "/kontakt/": ("event-venue", "og-kontakt.jpg", "Kontakt"), + "/impressum/": ("gear-cases", "og-impressum.jpg", "Impressum"), + "/datenschutz/": ("event-venue", "og-datenschutz.jpg", "Datenschutz"), +} + + +def load_manifest() -> list[dict]: + data = json.loads((SRC / "images.json").read_text(encoding="utf-8")) + entries = list(data["images"]) + entries.append( + { + "slug": VIDEO["poster"].replace(".jpg", ""), + "raw": VIDEO["poster"], + "alt": f"Vorschaubild zum Musikvideo {VIDEO['title']}", + "focus": "50% 50%", + "local": True, + } + ) + return entries + + +def source_path(entry: dict) -> Path: + webp = ASSETS / "images" / f"{entry['slug']}.webp" + if webp.exists(): + return webp + for suffix in (".src", ".jpg", ".jpeg", ".png"): + candidate = ASSETS / "images" / f"{entry['slug']}{suffix}" + if candidate.exists(): + return candidate + raise SystemExit(f"missing source asset for {entry['slug']}") + + +def build_images(entries: list[dict]) -> dict[str, dict]: + if IMG_OUT.exists(): + for stale in IMG_OUT.glob("*.webp"): + stale.unlink() + IMG_OUT.mkdir(parents=True, exist_ok=True) + + table: dict[str, dict] = {} + total = 0 + for entry in entries: + src = Image.open(source_path(entry)).convert("RGB") + src_w, src_h = src.size + widths = sorted({width for width in WIDTHS if width <= src_w} or {min(src_w, WIDTHS[0])}) + if src_w < widths[-1]: + widths.append(src_w) + widths = sorted(set(widths)) + for width in widths: + height = round(src_h * width / src_w) + out = IMG_OUT / f"{entry['slug']}-{width}.webp" + resized = src if width >= src_w else src.resize((width, height), Image.Resampling.LANCZOS) + resized.save(out, "WebP", quality=WEBP_QUALITY, method=6) + total += out.stat().st_size + table[entry["slug"]] = { + "base": f"/assets/img/{entry['slug']}", + "alt": entry["alt"], + "focus": entry.get("focus", "50% 50%"), + "widths": widths, + "width": widths[-1], + "height": round(src_h * widths[-1] / src_w), + } + print(f" [img] {entry['slug']:<26} {src_w}x{src_h} -> {len(widths)} widths") + print(f" image payload: {total / 1024 / 1024:.1f} MiB across {len(table)} images") + return table + + +def build_fonts() -> None: + FONT_OUT.mkdir(parents=True, exist_ok=True) + for existing in FONT_OUT.glob("*.woff2"): + existing.unlink() + blocks = [] + for family in ("fraunces", "manrope"): + index = json.loads((ASSETS / f"font-index-{family}.json").read_text(encoding="utf-8")) + for item in index: + shutil.copyfile(ASSETS / "fonts" / item["file"], FONT_OUT / item["file"]) + for item in index: + weight = "300 700" if family == "fraunces" else "200 800" + blocks.append( + "@font-face{" + f"font-family:'{family.capitalize()}';font-style:normal;font-weight:{weight};" + "font-display:swap;" + f"src:url('/assets/fonts/{item['file']}') format('woff2');" + f"unicode-range:{item['range']};" + "}" + ) + print(f" [font] {family}: {len(index)} subsets") + header = ( + "/* Self-hosted open licensed fonts (SIL Open Font License 1.1).\n" + " Fraunces + Manrope, sourced from Google Fonts. Licences: docs/font-*-OFL.txt */\n" + ) + (SITE / "assets" / "fonts.css").write_text(header + "\n".join(blocks) + "\n", encoding="utf-8") + + +def build_stylesheet() -> None: + (SITE / "assets" / "site.css").write_text( + f"/* desfoto.de - generated by scripts/build-site.py */\n:root{{{TOKENS}}}{CSS}", + encoding="utf-8", + ) + print(" [css] site.css written") + + +def _font(name: str, size: int) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(str(TTF / name), size) + + +def draw_mark(size: int, background: bool = True) -> Image.Image: + """The desfoto aperture mark, drawn deterministically with PIL.""" + scale = 8 + s = size * scale + img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + if background: + d.rounded_rectangle((0, 0, s - 1, s - 1), radius=int(s * 0.22), fill=INK + (255,)) + cx = cy = s / 2 + ring_w = max(2, int(s * 0.055)) + d.ellipse( + (cx - s * 0.27, cy - s * 0.27, cx + s * 0.27, cy + s * 0.27), + outline=PAPER + (255,), + width=ring_w, + ) + d.ellipse( + (cx - s * 0.105, cy - s * 0.105, cx + s * 0.105, cy + s * 0.105), + outline=PAPER + (255,), + width=ring_w, + ) + blade_w = max(2, int(s * 0.058)) + import math + + for angle in (270, 30, 150): + rad = math.radians(angle) + x1 = cx + math.cos(rad) * s * 0.27 + y1 = cy + math.sin(rad) * s * 0.27 + x2 = cx + math.cos(rad) * s * 0.45 + y2 = cy + math.sin(rad) * s * 0.45 + d.line((x1, y1, x2, y2), fill=ACCENT + (255,), width=blade_w) + for (px, py) in ((x1, y1), (x2, y2)): + r = blade_w / 2 + d.ellipse((px - r, py - r, px + r, py + r), fill=ACCENT + (255,)) + return img.resize((size, size), Image.Resampling.LANCZOS) + + +def build_brand_assets(table: dict[str, dict]) -> None: + (SITE / "favicon.svg").write_text( + """ + + + + + + + +""", + encoding="utf-8", + ) + draw_mark(180).save(IMG_OUT / "apple-touch-icon.png") + draw_mark(64).save(IMG_OUT / "favicon-32.png") + draw_mark(64).resize((16, 16), Image.Resampling.LANCZOS).save(IMG_OUT / "favicon-16.png") + print(" [brand] favicon.svg, apple-touch-icon.png, favicon-32.png") + + og_done = set() + for slug, filename, headline in ROUTE_OG.values(): + if filename in og_done: + continue + og_done.add(filename) + build_og(table, slug, filename, headline) + + +def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) -> None: + meta = table[slug] + source = Image.open(IMG_OUT / f"{slug}-{meta['widths'][-1]}.webp").convert("RGB") + target_w, target_h = 1200, 630 + ratio = target_w / target_h + src_ratio = source.width / source.height + if src_ratio > ratio: + new_w = int(source.height * ratio) + left = (source.width - new_w) // 2 + source = source.crop((left, 0, left + new_w, source.height)) + else: + new_h = int(source.width / ratio) + top = int((source.height - new_h) * 0.35) + source = source.crop((0, top, source.width, top + new_h)) + source = source.resize((target_w, target_h), Image.Resampling.LANCZOS) + + scrim = Image.new("L", (target_w, target_h), 0) + sd = ImageDraw.Draw(scrim) + for y in range(target_h): + alpha = int(40 + 175 * (y / target_h) ** 1.35) + sd.line((0, y, target_w, y), fill=alpha) + source = Image.composite(Image.new("RGB", (target_w, target_h), (12, 11, 9)), source, scrim) + + d = ImageDraw.Draw(source) + d.rectangle((0, 0, target_w, 6), fill=ACCENT) + brand_font = _font("fraunces-600.ttf", 92) + head_font = _font("fraunces-400.ttf", 54) + small_font = _font("manrope-600.ttf", 26) + d.text((72, 74), "desfoto", font=brand_font, fill=PAPER) + d.text((76, 186), "FOTOGRAFIE & VIDEO · NEUMÜNSTER", font=small_font, fill=(230, 161, 132)) + lines = wrap_text(headline, head_font, target_w - 150) + y = target_h - 90 - len(lines) * 64 + for line in lines: + d.text((76, y), line, font=head_font, fill=PAPER) + y += 64 + source.save(IMG_OUT / filename, "JPEG", quality=88, optimize=True, progressive=True) + + +def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> list[str]: + words = text.split() + lines: list[str] = [] + current = "" + for word in words: + candidate = f"{current} {word}".strip() + if font.getlength(candidate) <= max_width: + current = candidate + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) + return lines[:3] + + +def build_html(table: dict[str, dict]) -> list[tuple[str, str]]: + layout.IMAGE_TABLE = table + written: list[tuple[str, str]] = [] + for path, rel, factory, _x, _p in pages.ROUTES: + html = factory() + if path in ROUTE_OG: + html = html.replace("og-desfoto.jpg", ROUTE_OG[path][1]) + target = SITE / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(html, encoding="utf-8") + written.append((path, rel)) + print(f" [html] {rel}") + not_found = pages.not_found().replace("og-desfoto.jpg", "og-desfoto.jpg") + (SITE / "404.html").write_text(not_found, encoding="utf-8") + print(" [html] 404.html") + return written + + +def build_meta_files(routes: list[tuple[str, str]]) -> None: + today = dt.date.today().isoformat() + urls = [] + for path, _rel, _f, _x, priority in pages.ROUTES: + urls.append( + f" {SITE_META['url']}{path}{today}" + f"{'weekly' if priority == '1.0' else 'monthly'}" + f"{priority}" + ) + (SITE / "sitemap.xml").write_text( + '\n' + '\n' + + "\n".join(urls) + + "\n\n", + encoding="utf-8", + ) + (SITE / "robots.txt").write_text( + "User-agent: *\nAllow: /\n\n" + f"Sitemap: {SITE_META['url']}/sitemap.xml\n", + encoding="utf-8", + ) + (SITE / "site.webmanifest").write_text( + json.dumps( + { + "name": "desfoto", + "short_name": "desfoto", + "description": SITE_META["default_description"], + "start_url": "/", + "display": "standalone", + "background_color": "#F6F2EA", + "theme_color": SITE_META["theme_color"], + "icons": [ + {"src": "/assets/img/favicon-32.png", "sizes": "32x32", "type": "image/png"}, + {"src": "/assets/img/apple-touch-icon.png", "sizes": "180x180", "type": "image/png"}, + ], + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + wellknown = SITE / ".well-known" + wellknown.mkdir(parents=True, exist_ok=True) + (wellknown / "security.txt").write_text( + "Contact: mailto:info@dennyschulz.de\n" + f"Expires: {(dt.date.today() + dt.timedelta(days=365)).isoformat()}T00:00:00.000Z\n" + "Preferred-Languages: de, en\n" + f"Canonical: {SITE_META['url']}/.well-known/security.txt\n", + encoding="utf-8", + ) + print(" [meta] sitemap.xml, robots.txt, site.webmanifest, .well-known/security.txt") + + +def build_script() -> None: + (SITE / "assets" / "site.js").write_text( + """/* desfoto.de - progressive enhancement only. */ +(function () { + 'use strict'; + + /* Mobile navigation */ + var toggle = document.querySelector('[data-nav-toggle]'); + var nav = document.getElementById('site-nav'); + if (toggle && nav) { + toggle.addEventListener('click', function () { + var open = nav.classList.toggle('is-open'); + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + nav.addEventListener('click', function (event) { + if (event.target.closest('a') && window.innerWidth < 832) { + nav.classList.remove('is-open'); + toggle.setAttribute('aria-expanded', 'false'); + } + }); + } + + /* Click-to-load YouTube (youtube-nocookie) */ + document.querySelectorAll('[data-video-id]').forEach(function (facade) { + facade.addEventListener('click', function () { + var id = facade.getAttribute('data-video-id'); + var iframe = document.createElement('iframe'); + iframe.setAttribute('src', 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + + '?autoplay=1&rel=0&modestbranding=1&playsinline=1'); + iframe.setAttribute('title', facade.getAttribute('aria-label') || 'Video'); + iframe.setAttribute('loading', 'eager'); + iframe.setAttribute('allow', 'accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture'); + iframe.setAttribute('allowfullscreen', ''); + iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + var shell = facade.parentElement; + shell.replaceChild(iframe, facade); + iframe.focus(); + }); + }); + + /* Contact form -> local mail draft, nothing is transmitted by this page */ + var form = document.querySelector('[data-contact-form]'); + if (form) { + var status = form.querySelector('[data-contact-status]'); + form.addEventListener('submit', function (event) { + event.preventDefault(); + var data = new FormData(form); + var name = (data.get('name') || '').toString().trim(); + var email = (data.get('email') || '').toString().trim(); + var topic = (data.get('topic') || '').toString().trim(); + var date = (data.get('date') || '').toString().trim(); + var message = (data.get('message') || '').toString().trim(); + if (!name || !email || !message) { + if (status) { + status.hidden = false; + status.textContent = 'Bitte Name, E-Mail und Nachricht ausfüllen.'; + status.style.color = '#95330F'; + } + return; + } + var subject = 'Anfrage über desfoto.de: ' + topic; + var body = 'Name: ' + name + '\\nE-Mail: ' + email + '\\nBereich: ' + topic + + (date ? '\\nWunschtermin: ' + date : '') + '\\n\\n' + message + '\\n'; + window.location.href = 'mailto:info@dennyschulz.de?subject=' + + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body); + if (status) { + status.hidden = false; + status.textContent = 'Dein Mailprogramm sollte sich mit dem fertigen Entwurf öffnen.'; + status.style.color = ''; + } + }); + } + + /* Soft reveal on scroll, disabled for reduced motion */ + var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + var items = document.querySelectorAll('.reveal'); + if (reduce || !('IntersectionObserver' in window)) { + items.forEach(function (item) { item.classList.add('is-in'); }); + } else { + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add('is-in'); + observer.unobserve(entry.target); + } + }); + }, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 }); + items.forEach(function (item) { observer.observe(item); }); + } +}()); +""", + encoding="utf-8", + ) + print(" [js] site.js written") + + +def link_check(routes: list[tuple[str, str]]) -> list[str]: + """Verify that every internal href/src written into the build resolves.""" + import html as html_mod + import re + + problems: list[str] = [] + built = {f"/{rel}".replace("/index.html", "/") for _p, rel in routes} + built.add("/404.html") + for rel in ["sitemap.xml", "robots.txt", "site.webmanifest", "favicon.svg", ".well-known/security.txt", + "assets/site.css", "assets/site.js", "assets/fonts.css"]: + built.add(f"/{rel}") + for file in SITE.rglob("*"): + if file.is_dir() or file.suffix not in (".html", ".xml", ".json", ".txt", ".webmanifest", ".css", ".js"): + continue + text = file.read_text(encoding="utf-8", errors="replace") + if file.suffix in (".html", ".css"): + for match in re.finditer(r'(?:href|src)="(/[^"#?]*)"', text): + target = html_mod.unescape(match.group(1)) + if target in built: + continue + if target.startswith("/assets/img/") or target.startswith("/assets/fonts/"): + if (SITE / target.lstrip("/")).exists(): + continue + if target == "/assets/fonts/fonts.css": + continue + problems.append(f"{file.relative_to(SITE)} -> {target}") + if file.suffix == ".html": + for match in re.finditer(r'srcset="([^"]+)"', text): + for candidate in re.findall(r'(/assets/img/[^\s,]+)', match.group(1)): + if not (SITE / candidate.lstrip("/")).exists(): + problems.append(f"{file.relative_to(SITE)} -> srcset {candidate}") + for match in re.finditer(r'url\((/(?:assets/fonts/)[^)]+)\)', text): + if not (SITE / match.group(1).lstrip("/")).exists(): + problems.append(f"{file.relative_to(SITE)} -> font {match.group(1)}") + for match in re.finditer(r'([^<]+)', (SITE / "sitemap.xml").read_text(encoding="utf-8")): + path = match.group(1).replace(SITE_META["url"], "") + if path not in built: + problems.append(f"sitemap.xml -> {path}") + for match in re.finditer(r"url\('(/assets/fonts/[^']+)'\)", (SITE / "assets" / "fonts.css").read_text(encoding="utf-8")): + if not (SITE / match.group(1).lstrip("/")).exists(): + problems.append(f"fonts.css -> missing {match.group(1)}") + return problems + + +def normalize_permissions() -> None: + """Make the build output readable for the serving nginx worker. + + The image and font files are written with the ambient umask, so a build run + with a restrictive umask would leave 0600 files that an unprivileged nginx + worker cannot read (HTTP 403). The build therefore fixes the modes itself: + directories 0755, files 0644. + """ + for path in sorted(SITE.rglob("*"), key=lambda p: len(p.parts), reverse=True): + path.chmod(0o755 if path.is_dir() else 0o644) + SITE.chmod(0o755) + + +def main() -> int: + print("== images ==") + entries = load_manifest() + table = build_images(entries) + print("== fonts ==") + build_fonts() + build_stylesheet() + print("== brand ==") + build_brand_assets(table) + print("== pages ==") + routes = build_html(table) + build_meta_files(routes) + build_script() + print("== checks ==") + problems = link_check(routes) + html_files = sorted(SITE.rglob("*.html")) + broken_alt = [] + import re + + for file in html_files: + text = file.read_text(encoding="utf-8") + for tag in re.finditer(r"]*>", text): + if 'alt="' not in tag.group(0): + broken_alt.append(f"{file.relative_to(SITE)}: img without alt") + if problems: + print(" internal link problems:") + for problem in problems: + print(" -", problem) + if broken_alt: + print(" images without alt:") + for problem in broken_alt: + print(" -", problem) + total = sum(f.stat().st_size for f in SITE.rglob("*") if f.is_file()) + normalize_permissions() + print(f" pages: {len(html_files)} | site size: {total / 1024 / 1024:.1f} MiB") + print(f" link problems: {len(problems)} | alt problems: {len(broken_alt)}") + if problems or broken_alt: + return 1 + print("build ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fetch-assets.py b/scripts/fetch-assets.py new file mode 100755 index 0000000..df4973c --- /dev/null +++ b/scripts/fetch-assets.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Fetch real source assets for desfoto.de. + +Sources +------- +* Own image pool (Orgatool volume, served through the photographer's own API). + We pull the pre-derived `web` variant (long edge 2560 px) instead of the + 20-60 MB camera originals. Only assets we are allowed to publish as the + rights holder are listed in src/images.json. +* Google Fonts (open licensed, OFL) - downloaded once, then self-hosted. + Licences are written to docs/. +* YouTube poster frame for the own music video "Thjodroerir - Skogamor". + +Everything lands in assets-src/ which is *not* shipped. scripts/build-site.py +turns assets-src/ into the committed site/ output. +""" +from __future__ import annotations + +import json +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "assets-src" +IMG_OUT = SRC / "images" +FONT_OUT = SRC / "fonts" +TTF_OUT = SRC / "fonts-ttf" +DOCS = ROOT / "docs" + +UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" +# Old Safari advertises truetype-only support, so Google serves real .ttf +# files (the MSIE 6 UA returns EOT, which PIL/FreeType cannot read). +UA_TTF = ( + "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_8; en-US) " + "AppleWebKit/533.18.1 (KHTML, like Gecko) Version/5.0.2 Safari/533.18.1" +) + +# family -> (google css2 query, wanted unicode-range comments) +FAMILIES = { + "fraunces": { + "query": "family=Fraunces:opsz,wght@9..144,300..700", + "keep": ("latin", "latin-ext"), + "license": "https://raw.githubusercontent.com/google/fonts/main/ofl/fraunces/OFL.txt", + }, + "manrope": { + "query": "family=Manrope:wght@200..800", + "keep": ("latin", "latin-ext"), + "license": "https://raw.githubusercontent.com/google/fonts/main/ofl/manrope/OFL.txt", + }, +} + +TTF_QUERIES = ( + "family=Fraunces:opsz,wght@9..144,400", + "family=Fraunces:opsz,wght@9..144,600", + "family=Manrope:wght@400", + "family=Manrope:wght@600", +) +VIDEO_ID = "NWWFTf7l8g0" + + +def fetch(url: str, *, ua: str = UA, timeout: int = 120) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": "*/*"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() + + +def try_fetch(url: str, *, ua: str = UA) -> bytes | None: + try: + return fetch(url, ua=ua) + except urllib.error.HTTPError as exc: + if exc.code in (403, 404, 410): + return None + raise + except urllib.error.URLError: + return None + + +def fetch_images() -> None: + manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8")) + base = manifest["pool_base"].rstrip("/") + ok = skipped = 0 + for entry in manifest["images"]: + slug, raw = entry["slug"], entry["raw"] + rel = raw.replace("\\", "/") + target = IMG_OUT / f"{slug}.webp" + source = IMG_OUT / f"{slug}.src" + if target.exists() or source.exists(): + skipped += 1 + continue + data = try_fetch(f"{base}/_derived/web/{rel}.webp") + if data is None: + print(f" [!] web variant missing, falling back to original: {rel}") + data = fetch(f"{base}/{rel}") + source.write_bytes(data) + else: + target.write_bytes(data) + ok += 1 + print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)") + print(f"images: {ok} fetched, {skipped} already present") + + poster = IMG_OUT / "video-skogamor-poster.jpg" + if not poster.exists(): + data = try_fetch(f"https://i.ytimg.com/vi/{VIDEO_ID}/maxresdefault.jpg") + if data is None: + data = fetch(f"https://i.ytimg.com/vi/{VIDEO_ID}/hqdefault.jpg") + poster.write_bytes(data) + print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)") + + +def fetch_fonts() -> None: + FONT_OUT.mkdir(parents=True, exist_ok=True) + for family, spec in FAMILIES.items(): + css = fetch(f"https://fonts.googleapis.com/css2?{spec['query']}&display=swap").decode() + blocks = re.findall( + r"/\* ([a-z0-9-]+) \*/\s*@font-face \{(.*?)\}", css, flags=re.S + ) + index = [] + for subset, body in blocks: + if subset not in spec["keep"]: + continue + url = re.search(r"url\((https://[^)]+\.woff2)\)", body) + rng = re.search(r"unicode-range:\s*([^;]+);", body) + if not url: + continue + name = f"{family}-{subset}.woff2" + target = FONT_OUT / name + if not target.exists(): + target.write_bytes(fetch(url.group(1))) + index.append({"file": name, "range": rng.group(1).strip() if rng else ""}) + print(f" [ok] font {name} ({target.stat().st_size / 1024:.0f} KiB)") + (SRC / f"font-index-{family}.json").write_text( + json.dumps(index, indent=2) + "\n", encoding="utf-8" + ) + + license_target = DOCS / f"font-{family}-OFL.txt" + if not license_target.exists(): + license_target.write_bytes(fetch(spec["license"])) + print(f" [ok] licence {license_target.name}") + + # Build-time only TTFs (used to render the OG image and brand PNGs). + TTF_OUT.mkdir(parents=True, exist_ok=True) + for query in TTF_QUERIES: + css = fetch(f"https://fonts.googleapis.com/css2?{query}", ua=UA_TTF).decode() + blocks = re.findall(r"@font-face \{(.*?)\}", css, flags=re.S) + for block in blocks: + url_match = re.search(r"url\((https://[^)]+)\)", block) + if not url_match: + continue + family = (re.search(r"font-family: '([^']+)'", block) or [None, "font"])[1] + weight = (re.search(r"font-weight: (\d+)", block) or [None, "400"])[1] + name = f"{family.lower()}-{weight}.ttf" + target = TTF_OUT / name + if target.exists(): + continue + target.write_bytes(fetch(url_match.group(1), ua=UA_TTF)) + print(f" [ok] build font {name} ({target.stat().st_size / 1024:.0f} KiB)") + + +def main() -> int: + for path in (IMG_OUT, FONT_OUT, DOCS): + path.mkdir(parents=True, exist_ok=True) + print("== images ==") + fetch_images() + print("== fonts ==") + fetch_fonts() + print("done") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/.well-known/security.txt b/site/.well-known/security.txt new file mode 100644 index 0000000..380ff64 --- /dev/null +++ b/site/.well-known/security.txt @@ -0,0 +1,4 @@ +Contact: mailto:info@dennyschulz.de +Expires: 2027-09-19T00:00:00.000Z +Preferred-Languages: de, en +Canonical: https://desfoto.de/.well-known/security.txt diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..4b34699 --- /dev/null +++ b/site/404.html @@ -0,0 +1,124 @@ + + + + + +Seite nicht gefunden · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ Fehler 404 +

Diese Seite gibt es nicht.

+

Vielleicht hat sich die Adresse geändert oder es ist ein Tippfehler passiert. + Hier geht es zurück zu den Bildern.

+ +
+
+
+ + + + diff --git a/site/assets/fonts.css b/site/assets/fonts.css new file mode 100644 index 0000000..24ef833 --- /dev/null +++ b/site/assets/fonts.css @@ -0,0 +1,6 @@ +/* Self-hosted open licensed fonts (SIL Open Font License 1.1). + Fraunces + Manrope, sourced from Google Fonts. Licences: docs/font-*-OFL.txt */ +@font-face{font-family:'Fraunces';font-style:normal;font-weight:300 700;font-display:swap;src:url('/assets/fonts/fraunces-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;} +@font-face{font-family:'Fraunces';font-style:normal;font-weight:300 700;font-display:swap;src:url('/assets/fonts/fraunces-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} +@font-face{font-family:'Manrope';font-style:normal;font-weight:200 800;font-display:swap;src:url('/assets/fonts/manrope-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;} +@font-face{font-family:'Manrope';font-style:normal;font-weight:200 800;font-display:swap;src:url('/assets/fonts/manrope-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} diff --git a/site/assets/fonts/fraunces-latin-ext.woff2 b/site/assets/fonts/fraunces-latin-ext.woff2 new file mode 100644 index 0000000..b6e3e3a Binary files /dev/null and b/site/assets/fonts/fraunces-latin-ext.woff2 differ diff --git a/site/assets/fonts/fraunces-latin.woff2 b/site/assets/fonts/fraunces-latin.woff2 new file mode 100644 index 0000000..cb295bf Binary files /dev/null and b/site/assets/fonts/fraunces-latin.woff2 differ diff --git a/site/assets/fonts/manrope-latin-ext.woff2 b/site/assets/fonts/manrope-latin-ext.woff2 new file mode 100644 index 0000000..bd24140 Binary files /dev/null and b/site/assets/fonts/manrope-latin-ext.woff2 differ diff --git a/site/assets/fonts/manrope-latin.woff2 b/site/assets/fonts/manrope-latin.woff2 new file mode 100644 index 0000000..71eb731 Binary files /dev/null and b/site/assets/fonts/manrope-latin.woff2 differ diff --git a/site/assets/img/apple-touch-icon.png b/site/assets/img/apple-touch-icon.png new file mode 100644 index 0000000..65af43e Binary files /dev/null and b/site/assets/img/apple-touch-icon.png differ diff --git a/site/assets/img/business-portrait-dark-1200.webp b/site/assets/img/business-portrait-dark-1200.webp new file mode 100644 index 0000000..621c2b7 Binary files /dev/null and b/site/assets/img/business-portrait-dark-1200.webp differ diff --git a/site/assets/img/business-portrait-dark-1600.webp b/site/assets/img/business-portrait-dark-1600.webp new file mode 100644 index 0000000..652ded2 Binary files /dev/null and b/site/assets/img/business-portrait-dark-1600.webp differ diff --git a/site/assets/img/business-portrait-dark-480.webp b/site/assets/img/business-portrait-dark-480.webp new file mode 100644 index 0000000..f8ea72b Binary files /dev/null and b/site/assets/img/business-portrait-dark-480.webp differ diff --git a/site/assets/img/business-portrait-dark-768.webp b/site/assets/img/business-portrait-dark-768.webp new file mode 100644 index 0000000..d02bb0f Binary files /dev/null and b/site/assets/img/business-portrait-dark-768.webp differ diff --git a/site/assets/img/business-portrait-sit-1200.webp b/site/assets/img/business-portrait-sit-1200.webp new file mode 100644 index 0000000..5e8d6a4 Binary files /dev/null and b/site/assets/img/business-portrait-sit-1200.webp differ diff --git a/site/assets/img/business-portrait-sit-1600.webp b/site/assets/img/business-portrait-sit-1600.webp new file mode 100644 index 0000000..7a5b173 Binary files /dev/null and b/site/assets/img/business-portrait-sit-1600.webp differ diff --git a/site/assets/img/business-portrait-sit-2000.webp b/site/assets/img/business-portrait-sit-2000.webp new file mode 100644 index 0000000..8abca21 Binary files /dev/null and b/site/assets/img/business-portrait-sit-2000.webp differ diff --git a/site/assets/img/business-portrait-sit-480.webp b/site/assets/img/business-portrait-sit-480.webp new file mode 100644 index 0000000..e2b0be8 Binary files /dev/null and b/site/assets/img/business-portrait-sit-480.webp differ diff --git a/site/assets/img/business-portrait-sit-768.webp b/site/assets/img/business-portrait-sit-768.webp new file mode 100644 index 0000000..e11e987 Binary files /dev/null and b/site/assets/img/business-portrait-sit-768.webp differ diff --git a/site/assets/img/business-portrait-stool-1200.webp b/site/assets/img/business-portrait-stool-1200.webp new file mode 100644 index 0000000..151d831 Binary files /dev/null and b/site/assets/img/business-portrait-stool-1200.webp differ diff --git a/site/assets/img/business-portrait-stool-1600.webp b/site/assets/img/business-portrait-stool-1600.webp new file mode 100644 index 0000000..b5242b0 Binary files /dev/null and b/site/assets/img/business-portrait-stool-1600.webp differ diff --git a/site/assets/img/business-portrait-stool-480.webp b/site/assets/img/business-portrait-stool-480.webp new file mode 100644 index 0000000..6a03a92 Binary files /dev/null and b/site/assets/img/business-portrait-stool-480.webp differ diff --git a/site/assets/img/business-portrait-stool-768.webp b/site/assets/img/business-portrait-stool-768.webp new file mode 100644 index 0000000..2eb5044 Binary files /dev/null and b/site/assets/img/business-portrait-stool-768.webp differ diff --git a/site/assets/img/business-portrait-studio-1200.webp b/site/assets/img/business-portrait-studio-1200.webp new file mode 100644 index 0000000..5d1c146 Binary files /dev/null and b/site/assets/img/business-portrait-studio-1200.webp differ diff --git a/site/assets/img/business-portrait-studio-1600.webp b/site/assets/img/business-portrait-studio-1600.webp new file mode 100644 index 0000000..99f42cd Binary files /dev/null and b/site/assets/img/business-portrait-studio-1600.webp differ diff --git a/site/assets/img/business-portrait-studio-480.webp b/site/assets/img/business-portrait-studio-480.webp new file mode 100644 index 0000000..62d06b7 Binary files /dev/null and b/site/assets/img/business-portrait-studio-480.webp differ diff --git a/site/assets/img/business-portrait-studio-768.webp b/site/assets/img/business-portrait-studio-768.webp new file mode 100644 index 0000000..4a10bef Binary files /dev/null and b/site/assets/img/business-portrait-studio-768.webp differ diff --git a/site/assets/img/editorial-back-1200.webp b/site/assets/img/editorial-back-1200.webp new file mode 100644 index 0000000..7151e1d Binary files /dev/null and b/site/assets/img/editorial-back-1200.webp differ diff --git a/site/assets/img/editorial-back-1600.webp b/site/assets/img/editorial-back-1600.webp new file mode 100644 index 0000000..c255671 Binary files /dev/null and b/site/assets/img/editorial-back-1600.webp differ diff --git a/site/assets/img/editorial-back-480.webp b/site/assets/img/editorial-back-480.webp new file mode 100644 index 0000000..f852ca8 Binary files /dev/null and b/site/assets/img/editorial-back-480.webp differ diff --git a/site/assets/img/editorial-back-768.webp b/site/assets/img/editorial-back-768.webp new file mode 100644 index 0000000..eb1631d Binary files /dev/null and b/site/assets/img/editorial-back-768.webp differ diff --git a/site/assets/img/editorial-black-1200.webp b/site/assets/img/editorial-black-1200.webp new file mode 100644 index 0000000..199c2c0 Binary files /dev/null and b/site/assets/img/editorial-black-1200.webp differ diff --git a/site/assets/img/editorial-black-1600.webp b/site/assets/img/editorial-black-1600.webp new file mode 100644 index 0000000..177933c Binary files /dev/null and b/site/assets/img/editorial-black-1600.webp differ diff --git a/site/assets/img/editorial-black-480.webp b/site/assets/img/editorial-black-480.webp new file mode 100644 index 0000000..e7fd482 Binary files /dev/null and b/site/assets/img/editorial-black-480.webp differ diff --git a/site/assets/img/editorial-black-768.webp b/site/assets/img/editorial-black-768.webp new file mode 100644 index 0000000..230970f Binary files /dev/null and b/site/assets/img/editorial-black-768.webp differ diff --git a/site/assets/img/editorial-colour-1200.webp b/site/assets/img/editorial-colour-1200.webp new file mode 100644 index 0000000..5abde79 Binary files /dev/null and b/site/assets/img/editorial-colour-1200.webp differ diff --git a/site/assets/img/editorial-colour-1600.webp b/site/assets/img/editorial-colour-1600.webp new file mode 100644 index 0000000..a68da54 Binary files /dev/null and b/site/assets/img/editorial-colour-1600.webp differ diff --git a/site/assets/img/editorial-colour-480.webp b/site/assets/img/editorial-colour-480.webp new file mode 100644 index 0000000..dda5c55 Binary files /dev/null and b/site/assets/img/editorial-colour-480.webp differ diff --git a/site/assets/img/editorial-colour-768.webp b/site/assets/img/editorial-colour-768.webp new file mode 100644 index 0000000..f8f5475 Binary files /dev/null and b/site/assets/img/editorial-colour-768.webp differ diff --git a/site/assets/img/editorial-earring-1200.webp b/site/assets/img/editorial-earring-1200.webp new file mode 100644 index 0000000..7854b9e Binary files /dev/null and b/site/assets/img/editorial-earring-1200.webp differ diff --git a/site/assets/img/editorial-earring-1600.webp b/site/assets/img/editorial-earring-1600.webp new file mode 100644 index 0000000..912ce14 Binary files /dev/null and b/site/assets/img/editorial-earring-1600.webp differ diff --git a/site/assets/img/editorial-earring-480.webp b/site/assets/img/editorial-earring-480.webp new file mode 100644 index 0000000..b412d57 Binary files /dev/null and b/site/assets/img/editorial-earring-480.webp differ diff --git a/site/assets/img/editorial-earring-768.webp b/site/assets/img/editorial-earring-768.webp new file mode 100644 index 0000000..6319f0d Binary files /dev/null and b/site/assets/img/editorial-earring-768.webp differ diff --git a/site/assets/img/editorial-pose-1200.webp b/site/assets/img/editorial-pose-1200.webp new file mode 100644 index 0000000..6f4268c Binary files /dev/null and b/site/assets/img/editorial-pose-1200.webp differ diff --git a/site/assets/img/editorial-pose-1600.webp b/site/assets/img/editorial-pose-1600.webp new file mode 100644 index 0000000..7d38d20 Binary files /dev/null and b/site/assets/img/editorial-pose-1600.webp differ diff --git a/site/assets/img/editorial-pose-2000.webp b/site/assets/img/editorial-pose-2000.webp new file mode 100644 index 0000000..a394249 Binary files /dev/null and b/site/assets/img/editorial-pose-2000.webp differ diff --git a/site/assets/img/editorial-pose-480.webp b/site/assets/img/editorial-pose-480.webp new file mode 100644 index 0000000..8afb146 Binary files /dev/null and b/site/assets/img/editorial-pose-480.webp differ diff --git a/site/assets/img/editorial-pose-768.webp b/site/assets/img/editorial-pose-768.webp new file mode 100644 index 0000000..cdebd6c Binary files /dev/null and b/site/assets/img/editorial-pose-768.webp differ diff --git a/site/assets/img/event-dance-1200.webp b/site/assets/img/event-dance-1200.webp new file mode 100644 index 0000000..c798a2c Binary files /dev/null and b/site/assets/img/event-dance-1200.webp differ diff --git a/site/assets/img/event-dance-1600.webp b/site/assets/img/event-dance-1600.webp new file mode 100644 index 0000000..5d2be36 Binary files /dev/null and b/site/assets/img/event-dance-1600.webp differ diff --git a/site/assets/img/event-dance-480.webp b/site/assets/img/event-dance-480.webp new file mode 100644 index 0000000..b8c7b2e Binary files /dev/null and b/site/assets/img/event-dance-480.webp differ diff --git a/site/assets/img/event-dance-768.webp b/site/assets/img/event-dance-768.webp new file mode 100644 index 0000000..9289c36 Binary files /dev/null and b/site/assets/img/event-dance-768.webp differ diff --git a/site/assets/img/event-motion-1200.webp b/site/assets/img/event-motion-1200.webp new file mode 100644 index 0000000..88468b3 Binary files /dev/null and b/site/assets/img/event-motion-1200.webp differ diff --git a/site/assets/img/event-motion-1600.webp b/site/assets/img/event-motion-1600.webp new file mode 100644 index 0000000..c0e1174 Binary files /dev/null and b/site/assets/img/event-motion-1600.webp differ diff --git a/site/assets/img/event-motion-2000.webp b/site/assets/img/event-motion-2000.webp new file mode 100644 index 0000000..b88b16e Binary files /dev/null and b/site/assets/img/event-motion-2000.webp differ diff --git a/site/assets/img/event-motion-480.webp b/site/assets/img/event-motion-480.webp new file mode 100644 index 0000000..92dd508 Binary files /dev/null and b/site/assets/img/event-motion-480.webp differ diff --git a/site/assets/img/event-motion-768.webp b/site/assets/img/event-motion-768.webp new file mode 100644 index 0000000..0a779ad Binary files /dev/null and b/site/assets/img/event-motion-768.webp differ diff --git a/site/assets/img/event-speaker-1200.webp b/site/assets/img/event-speaker-1200.webp new file mode 100644 index 0000000..344c89a Binary files /dev/null and b/site/assets/img/event-speaker-1200.webp differ diff --git a/site/assets/img/event-speaker-1600.webp b/site/assets/img/event-speaker-1600.webp new file mode 100644 index 0000000..8a634de Binary files /dev/null and b/site/assets/img/event-speaker-1600.webp differ diff --git a/site/assets/img/event-speaker-2000.webp b/site/assets/img/event-speaker-2000.webp new file mode 100644 index 0000000..1cd4611 Binary files /dev/null and b/site/assets/img/event-speaker-2000.webp differ diff --git a/site/assets/img/event-speaker-480.webp b/site/assets/img/event-speaker-480.webp new file mode 100644 index 0000000..3f2b27b Binary files /dev/null and b/site/assets/img/event-speaker-480.webp differ diff --git a/site/assets/img/event-speaker-768.webp b/site/assets/img/event-speaker-768.webp new file mode 100644 index 0000000..6264498 Binary files /dev/null and b/site/assets/img/event-speaker-768.webp differ diff --git a/site/assets/img/event-venue-1200.webp b/site/assets/img/event-venue-1200.webp new file mode 100644 index 0000000..f6f7286 Binary files /dev/null and b/site/assets/img/event-venue-1200.webp differ diff --git a/site/assets/img/event-venue-1600.webp b/site/assets/img/event-venue-1600.webp new file mode 100644 index 0000000..68d678c Binary files /dev/null and b/site/assets/img/event-venue-1600.webp differ diff --git a/site/assets/img/event-venue-2000.webp b/site/assets/img/event-venue-2000.webp new file mode 100644 index 0000000..62ed551 Binary files /dev/null and b/site/assets/img/event-venue-2000.webp differ diff --git a/site/assets/img/event-venue-480.webp b/site/assets/img/event-venue-480.webp new file mode 100644 index 0000000..c4d1b81 Binary files /dev/null and b/site/assets/img/event-venue-480.webp differ diff --git a/site/assets/img/event-venue-768.webp b/site/assets/img/event-venue-768.webp new file mode 100644 index 0000000..dc6b103 Binary files /dev/null and b/site/assets/img/event-venue-768.webp differ diff --git a/site/assets/img/favicon-16.png b/site/assets/img/favicon-16.png new file mode 100644 index 0000000..a16aa86 Binary files /dev/null and b/site/assets/img/favicon-16.png differ diff --git a/site/assets/img/favicon-32.png b/site/assets/img/favicon-32.png new file mode 100644 index 0000000..bc87cae Binary files /dev/null and b/site/assets/img/favicon-32.png differ diff --git a/site/assets/img/gear-cases-1200.webp b/site/assets/img/gear-cases-1200.webp new file mode 100644 index 0000000..b730997 Binary files /dev/null and b/site/assets/img/gear-cases-1200.webp differ diff --git a/site/assets/img/gear-cases-1600.webp b/site/assets/img/gear-cases-1600.webp new file mode 100644 index 0000000..2f3a5b0 Binary files /dev/null and b/site/assets/img/gear-cases-1600.webp differ diff --git a/site/assets/img/gear-cases-2000.webp b/site/assets/img/gear-cases-2000.webp new file mode 100644 index 0000000..efe0bbf Binary files /dev/null and b/site/assets/img/gear-cases-2000.webp differ diff --git a/site/assets/img/gear-cases-480.webp b/site/assets/img/gear-cases-480.webp new file mode 100644 index 0000000..e220b99 Binary files /dev/null and b/site/assets/img/gear-cases-480.webp differ diff --git a/site/assets/img/gear-cases-768.webp b/site/assets/img/gear-cases-768.webp new file mode 100644 index 0000000..2bba961 Binary files /dev/null and b/site/assets/img/gear-cases-768.webp differ diff --git a/site/assets/img/group-feier-1200.webp b/site/assets/img/group-feier-1200.webp new file mode 100644 index 0000000..005df1c Binary files /dev/null and b/site/assets/img/group-feier-1200.webp differ diff --git a/site/assets/img/group-feier-1600.webp b/site/assets/img/group-feier-1600.webp new file mode 100644 index 0000000..d77817c Binary files /dev/null and b/site/assets/img/group-feier-1600.webp differ diff --git a/site/assets/img/group-feier-480.webp b/site/assets/img/group-feier-480.webp new file mode 100644 index 0000000..96bbf12 Binary files /dev/null and b/site/assets/img/group-feier-480.webp differ diff --git a/site/assets/img/group-feier-768.webp b/site/assets/img/group-feier-768.webp new file mode 100644 index 0000000..dfa87cb Binary files /dev/null and b/site/assets/img/group-feier-768.webp differ diff --git a/site/assets/img/group-outdoor-1200.webp b/site/assets/img/group-outdoor-1200.webp new file mode 100644 index 0000000..049ba95 Binary files /dev/null and b/site/assets/img/group-outdoor-1200.webp differ diff --git a/site/assets/img/group-outdoor-1600.webp b/site/assets/img/group-outdoor-1600.webp new file mode 100644 index 0000000..92d28a3 Binary files /dev/null and b/site/assets/img/group-outdoor-1600.webp differ diff --git a/site/assets/img/group-outdoor-2000.webp b/site/assets/img/group-outdoor-2000.webp new file mode 100644 index 0000000..ee06fe3 Binary files /dev/null and b/site/assets/img/group-outdoor-2000.webp differ diff --git a/site/assets/img/group-outdoor-480.webp b/site/assets/img/group-outdoor-480.webp new file mode 100644 index 0000000..c20b6a0 Binary files /dev/null and b/site/assets/img/group-outdoor-480.webp differ diff --git a/site/assets/img/group-outdoor-768.webp b/site/assets/img/group-outdoor-768.webp new file mode 100644 index 0000000..95cb08b Binary files /dev/null and b/site/assets/img/group-outdoor-768.webp differ diff --git a/site/assets/img/group-posiert-1200.webp b/site/assets/img/group-posiert-1200.webp new file mode 100644 index 0000000..2e0b86d Binary files /dev/null and b/site/assets/img/group-posiert-1200.webp differ diff --git a/site/assets/img/group-posiert-1600.webp b/site/assets/img/group-posiert-1600.webp new file mode 100644 index 0000000..552bb45 Binary files /dev/null and b/site/assets/img/group-posiert-1600.webp differ diff --git a/site/assets/img/group-posiert-2000.webp b/site/assets/img/group-posiert-2000.webp new file mode 100644 index 0000000..6a0a36a Binary files /dev/null and b/site/assets/img/group-posiert-2000.webp differ diff --git a/site/assets/img/group-posiert-480.webp b/site/assets/img/group-posiert-480.webp new file mode 100644 index 0000000..474463e Binary files /dev/null and b/site/assets/img/group-posiert-480.webp differ diff --git a/site/assets/img/group-posiert-768.webp b/site/assets/img/group-posiert-768.webp new file mode 100644 index 0000000..c6396ef Binary files /dev/null and b/site/assets/img/group-posiert-768.webp differ diff --git a/site/assets/img/hero-studio-1200.webp b/site/assets/img/hero-studio-1200.webp new file mode 100644 index 0000000..198ec6f Binary files /dev/null and b/site/assets/img/hero-studio-1200.webp differ diff --git a/site/assets/img/hero-studio-1600.webp b/site/assets/img/hero-studio-1600.webp new file mode 100644 index 0000000..1f4b2fe Binary files /dev/null and b/site/assets/img/hero-studio-1600.webp differ diff --git a/site/assets/img/hero-studio-480.webp b/site/assets/img/hero-studio-480.webp new file mode 100644 index 0000000..f3bb96c Binary files /dev/null and b/site/assets/img/hero-studio-480.webp differ diff --git a/site/assets/img/hero-studio-768.webp b/site/assets/img/hero-studio-768.webp new file mode 100644 index 0000000..70e9a8f Binary files /dev/null and b/site/assets/img/hero-studio-768.webp differ diff --git a/site/assets/img/movement-light-1200.webp b/site/assets/img/movement-light-1200.webp new file mode 100644 index 0000000..5579c31 Binary files /dev/null and b/site/assets/img/movement-light-1200.webp differ diff --git a/site/assets/img/movement-light-1600.webp b/site/assets/img/movement-light-1600.webp new file mode 100644 index 0000000..15196e4 Binary files /dev/null and b/site/assets/img/movement-light-1600.webp differ diff --git a/site/assets/img/movement-light-480.webp b/site/assets/img/movement-light-480.webp new file mode 100644 index 0000000..04eeee1 Binary files /dev/null and b/site/assets/img/movement-light-480.webp differ diff --git a/site/assets/img/movement-light-768.webp b/site/assets/img/movement-light-768.webp new file mode 100644 index 0000000..58de31a Binary files /dev/null and b/site/assets/img/movement-light-768.webp differ diff --git a/site/assets/img/og-businessfotografie.jpg b/site/assets/img/og-businessfotografie.jpg new file mode 100644 index 0000000..f57b06b Binary files /dev/null and b/site/assets/img/og-businessfotografie.jpg differ diff --git a/site/assets/img/og-datenschutz.jpg b/site/assets/img/og-datenschutz.jpg new file mode 100644 index 0000000..f2f0da2 Binary files /dev/null and b/site/assets/img/og-datenschutz.jpg differ diff --git a/site/assets/img/og-desfoto.jpg b/site/assets/img/og-desfoto.jpg new file mode 100644 index 0000000..3e6c10d Binary files /dev/null and b/site/assets/img/og-desfoto.jpg differ diff --git a/site/assets/img/og-familien.jpg b/site/assets/img/og-familien.jpg new file mode 100644 index 0000000..30d9c05 Binary files /dev/null and b/site/assets/img/og-familien.jpg differ diff --git a/site/assets/img/og-fotografie.jpg b/site/assets/img/og-fotografie.jpg new file mode 100644 index 0000000..3f74b3e Binary files /dev/null and b/site/assets/img/og-fotografie.jpg differ diff --git a/site/assets/img/og-impressum.jpg b/site/assets/img/og-impressum.jpg new file mode 100644 index 0000000..b77083f Binary files /dev/null and b/site/assets/img/og-impressum.jpg differ diff --git a/site/assets/img/og-kontakt.jpg b/site/assets/img/og-kontakt.jpg new file mode 100644 index 0000000..0e71a59 Binary files /dev/null and b/site/assets/img/og-kontakt.jpg differ diff --git a/site/assets/img/og-minishootings.jpg b/site/assets/img/og-minishootings.jpg new file mode 100644 index 0000000..a70e809 Binary files /dev/null and b/site/assets/img/og-minishootings.jpg differ diff --git a/site/assets/img/og-portrait.jpg b/site/assets/img/og-portrait.jpg new file mode 100644 index 0000000..69d6420 Binary files /dev/null and b/site/assets/img/og-portrait.jpg differ diff --git a/site/assets/img/og-projekte.jpg b/site/assets/img/og-projekte.jpg new file mode 100644 index 0000000..7e67e6c Binary files /dev/null and b/site/assets/img/og-projekte.jpg differ diff --git a/site/assets/img/og-social-media.jpg b/site/assets/img/og-social-media.jpg new file mode 100644 index 0000000..4381ab0 Binary files /dev/null and b/site/assets/img/og-social-media.jpg differ diff --git a/site/assets/img/og-ueber.jpg b/site/assets/img/og-ueber.jpg new file mode 100644 index 0000000..e56e367 Binary files /dev/null and b/site/assets/img/og-ueber.jpg differ diff --git a/site/assets/img/og-video.jpg b/site/assets/img/og-video.jpg new file mode 100644 index 0000000..bff7f9b Binary files /dev/null and b/site/assets/img/og-video.jpg differ diff --git a/site/assets/img/people-forest-1200.webp b/site/assets/img/people-forest-1200.webp new file mode 100644 index 0000000..52a5a06 Binary files /dev/null and b/site/assets/img/people-forest-1200.webp differ diff --git a/site/assets/img/people-forest-1600.webp b/site/assets/img/people-forest-1600.webp new file mode 100644 index 0000000..625cd35 Binary files /dev/null and b/site/assets/img/people-forest-1600.webp differ diff --git a/site/assets/img/people-forest-480.webp b/site/assets/img/people-forest-480.webp new file mode 100644 index 0000000..8e69735 Binary files /dev/null and b/site/assets/img/people-forest-480.webp differ diff --git a/site/assets/img/people-forest-768.webp b/site/assets/img/people-forest-768.webp new file mode 100644 index 0000000..cd51628 Binary files /dev/null and b/site/assets/img/people-forest-768.webp differ diff --git a/site/assets/img/people-joy-1200.webp b/site/assets/img/people-joy-1200.webp new file mode 100644 index 0000000..e4622d1 Binary files /dev/null and b/site/assets/img/people-joy-1200.webp differ diff --git a/site/assets/img/people-joy-1600.webp b/site/assets/img/people-joy-1600.webp new file mode 100644 index 0000000..a16f8f4 Binary files /dev/null and b/site/assets/img/people-joy-1600.webp differ diff --git a/site/assets/img/people-joy-2000.webp b/site/assets/img/people-joy-2000.webp new file mode 100644 index 0000000..fd5e3b9 Binary files /dev/null and b/site/assets/img/people-joy-2000.webp differ diff --git a/site/assets/img/people-joy-480.webp b/site/assets/img/people-joy-480.webp new file mode 100644 index 0000000..e25cde5 Binary files /dev/null and b/site/assets/img/people-joy-480.webp differ diff --git a/site/assets/img/people-joy-768.webp b/site/assets/img/people-joy-768.webp new file mode 100644 index 0000000..c117688 Binary files /dev/null and b/site/assets/img/people-joy-768.webp differ diff --git a/site/assets/img/people-kiss-1200.webp b/site/assets/img/people-kiss-1200.webp new file mode 100644 index 0000000..8a18455 Binary files /dev/null and b/site/assets/img/people-kiss-1200.webp differ diff --git a/site/assets/img/people-kiss-1600.webp b/site/assets/img/people-kiss-1600.webp new file mode 100644 index 0000000..2a10fb1 Binary files /dev/null and b/site/assets/img/people-kiss-1600.webp differ diff --git a/site/assets/img/people-kiss-2000.webp b/site/assets/img/people-kiss-2000.webp new file mode 100644 index 0000000..7b996f9 Binary files /dev/null and b/site/assets/img/people-kiss-2000.webp differ diff --git a/site/assets/img/people-kiss-480.webp b/site/assets/img/people-kiss-480.webp new file mode 100644 index 0000000..8e8aaba Binary files /dev/null and b/site/assets/img/people-kiss-480.webp differ diff --git a/site/assets/img/people-kiss-768.webp b/site/assets/img/people-kiss-768.webp new file mode 100644 index 0000000..09ad2db Binary files /dev/null and b/site/assets/img/people-kiss-768.webp differ diff --git a/site/assets/img/people-veil-1200.webp b/site/assets/img/people-veil-1200.webp new file mode 100644 index 0000000..4916f63 Binary files /dev/null and b/site/assets/img/people-veil-1200.webp differ diff --git a/site/assets/img/people-veil-1600.webp b/site/assets/img/people-veil-1600.webp new file mode 100644 index 0000000..b95c9a2 Binary files /dev/null and b/site/assets/img/people-veil-1600.webp differ diff --git a/site/assets/img/people-veil-480.webp b/site/assets/img/people-veil-480.webp new file mode 100644 index 0000000..dc43973 Binary files /dev/null and b/site/assets/img/people-veil-480.webp differ diff --git a/site/assets/img/people-veil-768.webp b/site/assets/img/people-veil-768.webp new file mode 100644 index 0000000..3d84dc4 Binary files /dev/null and b/site/assets/img/people-veil-768.webp differ diff --git a/site/assets/img/people-walk-1200.webp b/site/assets/img/people-walk-1200.webp new file mode 100644 index 0000000..5a5d462 Binary files /dev/null and b/site/assets/img/people-walk-1200.webp differ diff --git a/site/assets/img/people-walk-1600.webp b/site/assets/img/people-walk-1600.webp new file mode 100644 index 0000000..288d5a6 Binary files /dev/null and b/site/assets/img/people-walk-1600.webp differ diff --git a/site/assets/img/people-walk-480.webp b/site/assets/img/people-walk-480.webp new file mode 100644 index 0000000..2ec1418 Binary files /dev/null and b/site/assets/img/people-walk-480.webp differ diff --git a/site/assets/img/people-walk-768.webp b/site/assets/img/people-walk-768.webp new file mode 100644 index 0000000..a09a0f7 Binary files /dev/null and b/site/assets/img/people-walk-768.webp differ diff --git a/site/assets/img/portrait-couple-1200.webp b/site/assets/img/portrait-couple-1200.webp new file mode 100644 index 0000000..576cff1 Binary files /dev/null and b/site/assets/img/portrait-couple-1200.webp differ diff --git a/site/assets/img/portrait-couple-1600.webp b/site/assets/img/portrait-couple-1600.webp new file mode 100644 index 0000000..754da45 Binary files /dev/null and b/site/assets/img/portrait-couple-1600.webp differ diff --git a/site/assets/img/portrait-couple-480.webp b/site/assets/img/portrait-couple-480.webp new file mode 100644 index 0000000..477f3cb Binary files /dev/null and b/site/assets/img/portrait-couple-480.webp differ diff --git a/site/assets/img/portrait-couple-768.webp b/site/assets/img/portrait-couple-768.webp new file mode 100644 index 0000000..0c46dde Binary files /dev/null and b/site/assets/img/portrait-couple-768.webp differ diff --git a/site/assets/img/portrait-natural-1200.webp b/site/assets/img/portrait-natural-1200.webp new file mode 100644 index 0000000..5b8fd02 Binary files /dev/null and b/site/assets/img/portrait-natural-1200.webp differ diff --git a/site/assets/img/portrait-natural-1600.webp b/site/assets/img/portrait-natural-1600.webp new file mode 100644 index 0000000..aec3350 Binary files /dev/null and b/site/assets/img/portrait-natural-1600.webp differ diff --git a/site/assets/img/portrait-natural-2000.webp b/site/assets/img/portrait-natural-2000.webp new file mode 100644 index 0000000..a2b37f7 Binary files /dev/null and b/site/assets/img/portrait-natural-2000.webp differ diff --git a/site/assets/img/portrait-natural-480.webp b/site/assets/img/portrait-natural-480.webp new file mode 100644 index 0000000..326d690 Binary files /dev/null and b/site/assets/img/portrait-natural-480.webp differ diff --git a/site/assets/img/portrait-natural-768.webp b/site/assets/img/portrait-natural-768.webp new file mode 100644 index 0000000..cc4477f Binary files /dev/null and b/site/assets/img/portrait-natural-768.webp differ diff --git a/site/assets/img/studio-lens-1200.webp b/site/assets/img/studio-lens-1200.webp new file mode 100644 index 0000000..b19e4ad Binary files /dev/null and b/site/assets/img/studio-lens-1200.webp differ diff --git a/site/assets/img/studio-lens-1600.webp b/site/assets/img/studio-lens-1600.webp new file mode 100644 index 0000000..3efa8e5 Binary files /dev/null and b/site/assets/img/studio-lens-1600.webp differ diff --git a/site/assets/img/studio-lens-480.webp b/site/assets/img/studio-lens-480.webp new file mode 100644 index 0000000..ef867bd Binary files /dev/null and b/site/assets/img/studio-lens-480.webp differ diff --git a/site/assets/img/studio-lens-768.webp b/site/assets/img/studio-lens-768.webp new file mode 100644 index 0000000..c360cb0 Binary files /dev/null and b/site/assets/img/studio-lens-768.webp differ diff --git a/site/assets/img/studio-setup-1200.webp b/site/assets/img/studio-setup-1200.webp new file mode 100644 index 0000000..0e24a8f Binary files /dev/null and b/site/assets/img/studio-setup-1200.webp differ diff --git a/site/assets/img/studio-setup-1600.webp b/site/assets/img/studio-setup-1600.webp new file mode 100644 index 0000000..44e1422 Binary files /dev/null and b/site/assets/img/studio-setup-1600.webp differ diff --git a/site/assets/img/studio-setup-480.webp b/site/assets/img/studio-setup-480.webp new file mode 100644 index 0000000..a8c4a09 Binary files /dev/null and b/site/assets/img/studio-setup-480.webp differ diff --git a/site/assets/img/studio-setup-768.webp b/site/assets/img/studio-setup-768.webp new file mode 100644 index 0000000..fe711bf Binary files /dev/null and b/site/assets/img/studio-setup-768.webp differ diff --git a/site/assets/img/travel-blossom-1200.webp b/site/assets/img/travel-blossom-1200.webp new file mode 100644 index 0000000..ed6d4c7 Binary files /dev/null and b/site/assets/img/travel-blossom-1200.webp differ diff --git a/site/assets/img/travel-blossom-1600.webp b/site/assets/img/travel-blossom-1600.webp new file mode 100644 index 0000000..637da52 Binary files /dev/null and b/site/assets/img/travel-blossom-1600.webp differ diff --git a/site/assets/img/travel-blossom-2000.webp b/site/assets/img/travel-blossom-2000.webp new file mode 100644 index 0000000..2ba6959 Binary files /dev/null and b/site/assets/img/travel-blossom-2000.webp differ diff --git a/site/assets/img/travel-blossom-480.webp b/site/assets/img/travel-blossom-480.webp new file mode 100644 index 0000000..74a494c Binary files /dev/null and b/site/assets/img/travel-blossom-480.webp differ diff --git a/site/assets/img/travel-blossom-768.webp b/site/assets/img/travel-blossom-768.webp new file mode 100644 index 0000000..a2ea942 Binary files /dev/null and b/site/assets/img/travel-blossom-768.webp differ diff --git a/site/assets/img/travel-japan-1200.webp b/site/assets/img/travel-japan-1200.webp new file mode 100644 index 0000000..6f082a8 Binary files /dev/null and b/site/assets/img/travel-japan-1200.webp differ diff --git a/site/assets/img/travel-japan-1600.webp b/site/assets/img/travel-japan-1600.webp new file mode 100644 index 0000000..1e1d7f7 Binary files /dev/null and b/site/assets/img/travel-japan-1600.webp differ diff --git a/site/assets/img/travel-japan-2000.webp b/site/assets/img/travel-japan-2000.webp new file mode 100644 index 0000000..2542c7c Binary files /dev/null and b/site/assets/img/travel-japan-2000.webp differ diff --git a/site/assets/img/travel-japan-480.webp b/site/assets/img/travel-japan-480.webp new file mode 100644 index 0000000..516d625 Binary files /dev/null and b/site/assets/img/travel-japan-480.webp differ diff --git a/site/assets/img/travel-japan-768.webp b/site/assets/img/travel-japan-768.webp new file mode 100644 index 0000000..81b56a7 Binary files /dev/null and b/site/assets/img/travel-japan-768.webp differ diff --git a/site/assets/img/travel-norway-coast-1200.webp b/site/assets/img/travel-norway-coast-1200.webp new file mode 100644 index 0000000..67972a3 Binary files /dev/null and b/site/assets/img/travel-norway-coast-1200.webp differ diff --git a/site/assets/img/travel-norway-coast-1600.webp b/site/assets/img/travel-norway-coast-1600.webp new file mode 100644 index 0000000..68ad2e6 Binary files /dev/null and b/site/assets/img/travel-norway-coast-1600.webp differ diff --git a/site/assets/img/travel-norway-coast-2000.webp b/site/assets/img/travel-norway-coast-2000.webp new file mode 100644 index 0000000..55ea3ef Binary files /dev/null and b/site/assets/img/travel-norway-coast-2000.webp differ diff --git a/site/assets/img/travel-norway-coast-480.webp b/site/assets/img/travel-norway-coast-480.webp new file mode 100644 index 0000000..207f964 Binary files /dev/null and b/site/assets/img/travel-norway-coast-480.webp differ diff --git a/site/assets/img/travel-norway-coast-768.webp b/site/assets/img/travel-norway-coast-768.webp new file mode 100644 index 0000000..39f299f Binary files /dev/null and b/site/assets/img/travel-norway-coast-768.webp differ diff --git a/site/assets/img/travel-norway-lake-1200.webp b/site/assets/img/travel-norway-lake-1200.webp new file mode 100644 index 0000000..99000f5 Binary files /dev/null and b/site/assets/img/travel-norway-lake-1200.webp differ diff --git a/site/assets/img/travel-norway-lake-1600.webp b/site/assets/img/travel-norway-lake-1600.webp new file mode 100644 index 0000000..de8567a Binary files /dev/null and b/site/assets/img/travel-norway-lake-1600.webp differ diff --git a/site/assets/img/travel-norway-lake-2000.webp b/site/assets/img/travel-norway-lake-2000.webp new file mode 100644 index 0000000..1d94176 Binary files /dev/null and b/site/assets/img/travel-norway-lake-2000.webp differ diff --git a/site/assets/img/travel-norway-lake-480.webp b/site/assets/img/travel-norway-lake-480.webp new file mode 100644 index 0000000..8ac948d Binary files /dev/null and b/site/assets/img/travel-norway-lake-480.webp differ diff --git a/site/assets/img/travel-norway-lake-768.webp b/site/assets/img/travel-norway-lake-768.webp new file mode 100644 index 0000000..fc92905 Binary files /dev/null and b/site/assets/img/travel-norway-lake-768.webp differ diff --git a/site/assets/img/travel-norway-pano-1200.webp b/site/assets/img/travel-norway-pano-1200.webp new file mode 100644 index 0000000..3642454 Binary files /dev/null and b/site/assets/img/travel-norway-pano-1200.webp differ diff --git a/site/assets/img/travel-norway-pano-1600.webp b/site/assets/img/travel-norway-pano-1600.webp new file mode 100644 index 0000000..5aacdd7 Binary files /dev/null and b/site/assets/img/travel-norway-pano-1600.webp differ diff --git a/site/assets/img/travel-norway-pano-2000.webp b/site/assets/img/travel-norway-pano-2000.webp new file mode 100644 index 0000000..f646e6c Binary files /dev/null and b/site/assets/img/travel-norway-pano-2000.webp differ diff --git a/site/assets/img/travel-norway-pano-480.webp b/site/assets/img/travel-norway-pano-480.webp new file mode 100644 index 0000000..eec1929 Binary files /dev/null and b/site/assets/img/travel-norway-pano-480.webp differ diff --git a/site/assets/img/travel-norway-pano-768.webp b/site/assets/img/travel-norway-pano-768.webp new file mode 100644 index 0000000..1943d87 Binary files /dev/null and b/site/assets/img/travel-norway-pano-768.webp differ diff --git a/site/assets/img/travel-norway-valley-1200.webp b/site/assets/img/travel-norway-valley-1200.webp new file mode 100644 index 0000000..70fd940 Binary files /dev/null and b/site/assets/img/travel-norway-valley-1200.webp differ diff --git a/site/assets/img/travel-norway-valley-1600.webp b/site/assets/img/travel-norway-valley-1600.webp new file mode 100644 index 0000000..4fc98e3 Binary files /dev/null and b/site/assets/img/travel-norway-valley-1600.webp differ diff --git a/site/assets/img/travel-norway-valley-2000.webp b/site/assets/img/travel-norway-valley-2000.webp new file mode 100644 index 0000000..5535633 Binary files /dev/null and b/site/assets/img/travel-norway-valley-2000.webp differ diff --git a/site/assets/img/travel-norway-valley-480.webp b/site/assets/img/travel-norway-valley-480.webp new file mode 100644 index 0000000..c86e6f5 Binary files /dev/null and b/site/assets/img/travel-norway-valley-480.webp differ diff --git a/site/assets/img/travel-norway-valley-768.webp b/site/assets/img/travel-norway-valley-768.webp new file mode 100644 index 0000000..7cec70c Binary files /dev/null and b/site/assets/img/travel-norway-valley-768.webp differ diff --git a/site/assets/img/video-skogamor-poster-1200.webp b/site/assets/img/video-skogamor-poster-1200.webp new file mode 100644 index 0000000..018f567 Binary files /dev/null and b/site/assets/img/video-skogamor-poster-1200.webp differ diff --git a/site/assets/img/video-skogamor-poster-480.webp b/site/assets/img/video-skogamor-poster-480.webp new file mode 100644 index 0000000..c46e486 Binary files /dev/null and b/site/assets/img/video-skogamor-poster-480.webp differ diff --git a/site/assets/img/video-skogamor-poster-768.webp b/site/assets/img/video-skogamor-poster-768.webp new file mode 100644 index 0000000..27d82ba Binary files /dev/null and b/site/assets/img/video-skogamor-poster-768.webp differ diff --git a/site/assets/site.css b/site/assets/site.css new file mode 100644 index 0000000..3f090ff --- /dev/null +++ b/site/assets/site.css @@ -0,0 +1,386 @@ +/* desfoto.de - generated by scripts/build-site.py */ +:root{ + --paper:#F6F2EA; + --paper-2:#EEE7DB; + --paper-3:#E4DAC9; + --ink:#17140F; + --ink-80:#312B24; + --ink-70:#4E4740; + --ink-50:#7B736A; + --ink-35:#A79E93; + --line:rgba(23,20,15,.13); + --line-soft:rgba(23,20,15,.07); + --line-strong:rgba(23,20,15,.3); + --accent:#C0431C; + --accent-dark:#95330F; + --accent-soft:rgba(192,67,28,.12); + --teal:#16494A; + --radius:14px; + --radius-lg:22px; + --radius-pill:999px; + --shell:min(1220px,92vw); + --gap:clamp(1.1rem,2.2vw,2rem); + --section:clamp(3.6rem,7vw,7.5rem); + --ease:cubic-bezier(.22,.61,.36,1); + --shadow:0 24px 60px -30px rgba(23,20,15,.45); + --font-sans:"Manrope","Segoe UI",system-ui,-apple-system,sans-serif; + --font-display:"Fraunces","Iowan Old Style",Georgia,serif; +} +*,*::before,*::after{box-sizing:border-box} +html{-webkit-text-size-adjust:100%} +html:focus-within{scroll-behavior:smooth} +body{ + margin:0;background:var(--paper);color:var(--ink); + font-family:var(--font-sans);font-size:clamp(1rem,.35vw + .94rem,1.075rem); + line-height:1.62;font-weight:400; + -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility; + overflow-x:hidden; +} +img,svg,video{display:block;max-width:100%;height:auto} +a{color:inherit;text-decoration:none} +button{font:inherit;color:inherit} +h1,h2,h3,h4{font-family:var(--font-display);font-weight:600;line-height:1.04;letter-spacing:-.02em;margin:0} +h1{font-size:clamp(2.5rem,6.4vw,5.1rem)} +h2{font-size:clamp(1.9rem,3.9vw,3.1rem)} +h3{font-size:clamp(1.22rem,1.5vw,1.55rem);line-height:1.16} +p{margin:0} +ul,ol{margin:0;padding:0;list-style:none} +hr{border:0;border-top:1px solid var(--line);margin:0} +:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:4px} +::selection{background:var(--accent);color:#fff} +.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:#fff;padding:.7rem 1.1rem;z-index:99} +.skip:focus{left:.6rem;top:.6rem} + +.shell{width:var(--shell);margin-inline:auto} +.section{padding-block:var(--section)} +.section--tight{padding-block:clamp(2.4rem,4vw,4rem)} +.section--paper2{background:var(--paper-2)} +.section--ink{background:var(--ink);color:var(--paper)} +.section--line{border-top:1px solid var(--line)} +.stack>*+*{margin-top:var(--gap)} +.lead{font-size:clamp(1.08rem,1.5vw,1.32rem);line-height:1.55;color:var(--ink-80)} +.muted{color:var(--ink-50)} +.accent{color:var(--accent)} +.center{text-align:center} +.measure{max-width:64ch} +.measure-sm{max-width:48ch} +.balance{text-wrap:balance} + +.kicker{ + display:inline-flex;align-items:center;gap:.55rem; + font-size:.735rem;font-weight:600;letter-spacing:.22em;text-transform:uppercase; + color:var(--accent); +} +.kicker::before{content:"";width:1.9rem;height:1px;background:currentColor;opacity:.55} +.section--ink .kicker{color:#E6A184} +.eyebrow{ + font-size:.735rem;font-weight:600;letter-spacing:.22em;text-transform:uppercase; + color:var(--ink-35); +} +.section-head{display:flex;flex-wrap:wrap;gap:1.2rem 2.4rem;align-items:flex-end;justify-content:space-between} +.section-head__text{max-width:66ch} + +/* ---------- header ---------- */ +.masthead{ + position:sticky;top:0;z-index:50; + background:color-mix(in srgb,var(--paper) 88%,transparent); + backdrop-filter:saturate(1.4) blur(14px); + border-bottom:1px solid var(--line); +} +.masthead__inner{display:flex;align-items:center;gap:1rem;min-height:4.4rem;padding-block:.7rem} +.brand{display:inline-flex;align-items:center;gap:.62rem;font-family:var(--font-display);font-size:1.42rem;font-weight:600;letter-spacing:-.015em} +.brand__mark{width:1.72rem;height:1.72rem;flex:none} +.brand__mark .ring{stroke:var(--ink);fill:none;stroke-width:2.4} +.brand__mark .blade{stroke:var(--accent);stroke-width:2.6;stroke-linecap:round} +.brand small{display:block;font-family:var(--font-sans);font-size:.62rem;font-weight:600;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-50);line-height:1} +.brand__text{display:flex;flex-direction:column;gap:.22rem} + +.nav{margin-left:auto;display:flex;align-items:center;gap:.35rem} +.nav__link{ + position:relative;display:inline-block;padding:.55rem .72rem;border-radius:var(--radius-pill); + font-size:.9rem;font-weight:500;color:var(--ink-80);transition:color .2s var(--ease),background .2s var(--ease); +} +.nav__link::after{ + content:"";position:absolute;left:.72rem;right:.72rem;bottom:.34rem;height:1px; + background:var(--accent);transform:scaleX(0);transform-origin:left;transition:transform .28s var(--ease); +} +.nav__link:hover,.nav__link:focus-visible{color:var(--ink)} +.nav__link:hover::after,.nav__link:focus-visible::after{transform:scaleX(1)} +.nav__link[aria-current="page"]{color:var(--ink)} +.nav__link[aria-current="page"]::after{transform:scaleX(1)} +.nav__item{position:relative} +.nav__item>.nav__link{display:inline-flex;align-items:center;gap:.36rem} +.nav__caret{width:.6em;height:.6em;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;transition:transform .25s var(--ease)} +.nav__sub{ + position:absolute;top:calc(100% + .4rem);left:0;min-width:15.5rem; + background:var(--paper);border:1px solid var(--line);border-radius:var(--radius); + box-shadow:var(--shadow);padding:.5rem;display:grid;gap:.08rem; + opacity:0;visibility:hidden;transform:translateY(-.35rem);transition:all .22s var(--ease); +} +.nav__item:hover .nav__sub,.nav__item:focus-within .nav__sub{opacity:1;visibility:visible;transform:none} +.nav__item:hover .nav__caret,.nav__item:focus-within .nav__caret{transform:rotate(180deg)} +.nav__sub a{padding:.5rem .7rem;border-radius:9px;font-size:.875rem;color:var(--ink-80)} +.nav__sub a:hover{background:var(--paper-2);color:var(--ink)} +.nav__toggle{display:none;margin-left:auto;background:none;border:1px solid var(--line);border-radius:var(--radius-pill);padding:.5rem .95rem;font-size:.85rem;font-weight:600;cursor:pointer} +.nav__toggle svg{width:1rem;height:1rem;stroke:currentColor;stroke-width:2;fill:none} + +/* ---------- buttons ---------- */ +.btn{ + display:inline-flex;align-items:center;justify-content:center;gap:.5rem; + padding:.86rem 1.5rem;border-radius:var(--radius-pill);border:1px solid transparent; + font-size:.92rem;font-weight:600;letter-spacing:.01em;cursor:pointer; + transition:transform .2s var(--ease),background .2s var(--ease),color .2s var(--ease),border-color .2s var(--ease); +} +.btn:hover{transform:translateY(-2px)} +.btn--solid{background:var(--accent);color:#fff} +.btn--solid:hover{background:var(--accent-dark)} +.btn--ink{background:var(--ink);color:var(--paper)} +.btn--ink:hover{background:#000} +.btn--ghost{border-color:var(--line-strong);color:var(--ink)} +.btn--ghost:hover{border-color:var(--ink);background:var(--paper-2)} +.btn--light{background:var(--paper);color:var(--ink)} +.btn--light:hover{background:#fff} +.btn--outline-light{border-color:rgba(246,242,234,.42);color:var(--paper)} +.btn--outline-light:hover{border-color:var(--paper);background:rgba(246,242,234,.1)} +.btn svg{width:.95em;height:.95em;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} +.btn-row{display:flex;flex-wrap:wrap;gap:.7rem;align-items:center} + +.link-arrow{display:inline-flex;align-items:center;gap:.42rem;font-size:.9rem;font-weight:600;color:var(--ink);border-bottom:1px solid var(--line-strong);padding-bottom:.15rem;transition:gap .2s var(--ease),border-color .2s var(--ease)} +.link-arrow:hover{gap:.72rem;border-color:var(--accent);color:var(--accent-dark)} +.link-arrow svg{width:.85em;height:.85em;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} + +/* ---------- hero ---------- */ +.hero{position:relative;isolation:isolate;color:var(--paper);overflow:hidden} +.hero__media{position:absolute;inset:0;z-index:-2} +.hero__media img{width:100%;height:100%;object-fit:cover;object-position:50% 42%} +.hero::before{ + content:"";position:absolute;inset:0;z-index:-1; + background: + linear-gradient(180deg,rgba(14,12,10,.72) 0%,rgba(14,12,10,.34) 38%,rgba(14,12,10,.86) 100%), + linear-gradient(90deg,rgba(14,12,10,.7) 0%,rgba(14,12,10,.1) 65%); +} +.hero__inner{ + min-height:clamp(30rem,80vh,46rem); + display:flex;flex-direction:column;justify-content:flex-end; + padding-block:clamp(3rem,7vw,5.6rem) clamp(2.4rem,5vw,4.2rem); +} +.hero__kicker{color:#F0B79B} +.hero__title{max-width:20ch;margin-top:1.1rem;text-shadow:0 2px 30px rgba(0,0,0,.35);text-wrap:balance} +.hero__sub{max-width:52ch;margin-top:1.25rem;font-size:clamp(1.02rem,1.35vw,1.2rem);color:rgba(246,242,234,.9)} +.hero__actions{margin-top:2rem} +.hero__facts{ + margin-top:clamp(2.2rem,4vw,3.4rem);padding-top:1.4rem;border-top:1px solid rgba(246,242,234,.22); + display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); +} +.hero__fact{font-size:.85rem;color:rgba(246,242,234,.78)} +.hero__fact strong{display:block;font-family:var(--font-display);font-size:1.05rem;font-weight:600;color:var(--paper);letter-spacing:-.01em} + +/* ---------- generic media frame ---------- */ +.frame{position:relative;overflow:hidden;border-radius:var(--radius);background:var(--paper-3)} +.frame img{width:100%;height:100%;object-fit:cover;transition:transform .8s var(--ease)} +img.frame{width:100%;height:auto;object-fit:cover;transition:transform .8s var(--ease)} +.frame--hover:hover img,img.frame--hover:hover{transform:scale(1.035)} +.frame--16x10{aspect-ratio:16/10} +.frame--4x5{aspect-ratio:4/5} +.frame--3x2{aspect-ratio:3/2} +.frame--1x1{aspect-ratio:1/1} +.frame--21x9{aspect-ratio:21/9} + +/* ---------- statement ---------- */ +.statement{display:grid;gap:1.4rem 3.4rem;grid-template-columns:minmax(0,15rem) minmax(0,1fr)} +.statement__body{font-family:var(--font-display);font-size:clamp(1.35rem,2.5vw,2.05rem);line-height:1.28;letter-spacing:-.015em;font-weight:400;text-wrap:pretty} +.statement__body em{font-style:italic;color:var(--accent)} + +/* ---------- service cards ---------- */ +.grid{display:grid;gap:clamp(1.2rem,2.1vw,2rem)} +.grid--2{grid-template-columns:repeat(auto-fit,minmax(19rem,1fr))} +.grid--3{grid-template-columns:repeat(auto-fit,minmax(16.5rem,1fr))} +.grid--4{grid-template-columns:repeat(auto-fit,minmax(13.5rem,1fr))} +.card{display:flex;flex-direction:column;gap:1rem;height:100%} +.card__title{display:flex;align-items:baseline;gap:.6rem} +.card__num{font-size:.72rem;font-weight:600;letter-spacing:.16em;color:var(--accent)} +.card__text{color:var(--ink-70);font-size:.955rem} +.card__foot{margin-top:auto;padding-top:.4rem} + +/* ---------- gallery ---------- */ +.gallery{display:grid;gap:clamp(.8rem,1.4vw,1.2rem);grid-template-columns:repeat(6,1fr)} +.gallery figure{margin:0;position:relative} +.gallery figcaption{ + margin-top:.6rem;font-size:.8rem;letter-spacing:.02em;color:var(--ink-50); +} +.gallery .g-w3{grid-column:span 3} +.gallery .g-w2{grid-column:span 2} +.gallery .g-w4{grid-column:span 4} +.gallery .g-w6{grid-column:span 6} + +/* ---------- video facade ---------- */ +.video-shell{position:relative;border-radius:var(--radius-lg);overflow:hidden;background:#0B0A09;box-shadow:var(--shadow)} +.video-shell iframe{width:100%;aspect-ratio:16/9;border:0;display:block} +.video-facade{ + position:relative;display:block;width:100%;padding:0;border:0;background:#0B0A09;cursor:pointer; +} +.video-facade img{width:100%;aspect-ratio:16/9;object-fit:cover;opacity:.72;transition:transform .8s var(--ease),opacity .4s var(--ease)} +.video-facade:hover img{transform:scale(1.02);opacity:.85} +.video-facade__play{ + position:absolute;inset:0;display:grid;place-content:center;gap:.9rem;justify-items:center;color:#fff;text-align:center; +} +.video-facade__btn{ + width:4.6rem;height:4.6rem;border-radius:50%;background:rgba(246,242,234,.94);color:var(--ink); + display:grid;place-items:center;transition:transform .3s var(--ease),background .3s var(--ease); +} +.video-facade:hover .video-facade__btn{transform:scale(1.07);background:#fff} +.video-facade__btn svg{width:1.6rem;height:1.6rem;fill:currentColor;margin-left:.18rem} +.video-facade__label{font-size:.8rem;letter-spacing:.16em;text-transform:uppercase;text-shadow:0 1px 20px rgba(0,0,0,.6)} +.video-shell__meta{position:absolute;left:0;right:0;bottom:0;padding:1.1rem 1.3rem;background:linear-gradient(0deg,rgba(10,9,8,.88),transparent);color:#F2EDE4;font-size:.85rem;pointer-events:none} + +/* ---------- steps ---------- */ +.steps{counter-reset:step;display:grid;gap:clamp(1.4rem,2.4vw,2.2rem);grid-template-columns:repeat(auto-fit,minmax(15rem,1fr))} +.step{position:relative;padding-top:2.6rem;border-top:1px solid var(--line)} +.step::before{ + counter-increment:step;content:"0" counter(step); + position:absolute;top:.6rem;left:0;font-size:.75rem;font-weight:600;letter-spacing:.18em;color:var(--accent); +} +.step h3{margin-bottom:.5rem} +.step p{font-size:.94rem;color:var(--ink-70)} + +/* ---------- lists ---------- */ +.ticks{display:grid;gap:.62rem} +.ticks li{position:relative;padding-left:1.6rem;font-size:.965rem;color:var(--ink-80)} +.ticks li::before{ + content:"";position:absolute;left:0;top:.55em;width:.62rem;height:.62rem;border-radius:50%; + background:var(--accent-soft);border:1px solid var(--accent); +} +.plain-list{display:grid;gap:.5rem} +.plain-list a{border-bottom:1px solid var(--line);padding-bottom:.5rem;display:inline-flex;gap:.5rem;align-items:center} +.plain-list a:hover{color:var(--accent-dark);border-color:var(--accent)} + +.dl{display:grid;gap:1.6rem 2.4rem;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr))} +.dl div{display:grid;gap:.32rem} +.dl dt{font-size:.72rem;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-35)} +.dl dd{margin:0;font-size:.98rem} + +.note{ + border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:0 var(--radius) var(--radius) 0; + padding:1rem 1.2rem;font-size:.9rem;color:var(--ink-70);background:rgba(255,255,255,.4); +} + +/* ---------- facts strip ---------- */ +.facts{display:grid;gap:clamp(1.3rem,2.6vw,2.6rem);grid-template-columns:repeat(auto-fit,minmax(13rem,1fr))} +.fact h3{font-size:1.05rem;margin-bottom:.4rem} +.fact p{font-size:.9rem;color:var(--ink-70)} +.section--ink .fact p{color:rgba(246,242,234,.72)} + +/* ---------- contact ---------- */ +.contact-grid{display:grid;gap:clamp(1.6rem,3vw,3.2rem);grid-template-columns:minmax(0,1fr) minmax(0,1.05fr)} +.contact-card{ + border:1px solid var(--line);border-radius:var(--radius-lg);padding:clamp(1.4rem,2.4vw,2.2rem);background:rgba(255,255,255,.5); +} +.form{display:grid;gap:1rem} +.field{display:grid;gap:.4rem} +.field label{font-size:.76rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-50)} +.field input,.field textarea,.field select{ + width:100%;padding:.75rem .85rem;border:1px solid var(--line-strong);border-radius:10px; + background:var(--paper);color:var(--ink);font:inherit;font-size:.96rem; +} +.field textarea{min-height:8.5rem;resize:vertical} +.field input:focus,.field textarea:focus,.field select:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:var(--accent)} +.field-row{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr))} +.form__hint{font-size:.82rem;color:var(--ink-50)} +.form__status{font-size:.88rem;color:var(--teal);font-weight:600} +.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden} + +/* ---------- footer ---------- */ +.footer{background:var(--ink);color:rgba(246,242,234,.78);padding-block:clamp(2.6rem,5vw,4.4rem) 1.6rem;font-size:.92rem} +.footer a:hover{color:#fff} +.footer__grid{display:grid;gap:2rem;grid-template-columns:minmax(0,1.5fr) repeat(auto-fit,minmax(9rem,1fr))} +.footer__brand .brand{color:var(--paper)} +.footer__brand .brand__mark .ring{stroke:var(--paper)} +.footer__claim{margin-top:1rem;max-width:34ch;color:rgba(246,242,234,.66);font-size:.9rem} +.footer h2{font-family:var(--font-sans);font-size:.74rem;font-weight:600;letter-spacing:.2em;text-transform:uppercase;color:rgba(246,242,234,.5);margin-bottom:.9rem} +.footer__links{display:grid;gap:.5rem} +.footer__bottom{ + margin-top:clamp(2rem,4vw,3.2rem);padding-top:1.2rem;border-top:1px solid rgba(246,242,234,.16); + display:flex;flex-wrap:wrap;gap:.6rem 1.4rem;justify-content:space-between;font-size:.82rem;color:rgba(246,242,234,.58); +} +.footer__bottom nav{display:flex;gap:1.1rem;flex-wrap:wrap} + +/* ---------- page hero (sub pages) ---------- */ +.page-head{position:relative;isolation:isolate;overflow:hidden;background:var(--ink);color:var(--paper)} +.page-head__bg{position:absolute;inset:0;z-index:-2} +.page-head__bg img{width:100%;height:100%;object-fit:cover} +.page-head::before{ + content:"";position:absolute;inset:0;z-index:-1; + background:linear-gradient(115deg,rgba(12,11,9,.9) 0%,rgba(12,11,9,.65) 55%,rgba(12,11,9,.38) 100%); +} +.page-head__inner{padding-block:clamp(3.2rem,6vw,5.4rem) clamp(2.6rem,5vw,4.2rem);max-width:min(70ch,100%)} +.page-head h1{margin-top:1rem;font-size:clamp(2.1rem,4.6vw,3.7rem)} +.page-head__lead{margin-top:1.1rem;font-size:clamp(1rem,1.3vw,1.16rem);color:rgba(246,242,234,.88);max-width:58ch} +.page-head__actions{margin-top:1.8rem} +.breadcrumb{font-size:.78rem;letter-spacing:.14em;text-transform:uppercase;color:rgba(246,242,234,.6);margin-bottom:1.4rem;display:flex;gap:.5rem;flex-wrap:wrap} +.breadcrumb a:hover{color:#fff} + +/* ---------- prose (legal) ---------- */ +.prose{max-width:74ch} +.prose h2{font-size:clamp(1.35rem,2.2vw,1.85rem);margin-top:2.8rem} +.prose h3{font-size:1.1rem;margin-top:1.8rem;font-family:var(--font-sans);font-weight:600;letter-spacing:0} +.prose p,.prose li{color:var(--ink-80);font-size:.985rem} +.prose p+p{margin-top:.85rem} +.prose ul{margin:.8rem 0 0;display:grid;gap:.5rem} +.prose ul li{position:relative;padding-left:1.3rem} +.prose ul li::before{content:"";position:absolute;left:.1rem;top:.62em;width:.42rem;height:.42rem;border-radius:50%;background:var(--accent)} +.prose a{border-bottom:1px solid var(--line-strong)} +.prose a:hover{color:var(--accent-dark);border-color:var(--accent)} +.prose hr{margin-block:2.4rem} +.prose .note{margin-top:1.2rem} +.prose dl{margin-top:.8rem} +.prose dt{font-weight:600;margin-top:.9rem} +.prose dd{margin:0} + +/* ---------- 404 ---------- */ +.center-page{min-height:min(70vh,40rem);display:grid;place-items:center;text-align:center} +.center-page__inner{display:grid;gap:1.3rem;justify-items:center;max-width:46ch} + +/* ---------- motion ---------- */ +.reveal{opacity:0;transform:translateY(1.4rem);transition:opacity .8s var(--ease),transform .8s var(--ease)} +.reveal.is-in{opacity:1;transform:none} +@media (prefers-reduced-motion:reduce){ + .reveal{opacity:1;transform:none;transition:none} + html:focus-within{scroll-behavior:auto} + .btn:hover{transform:none} + .nav__link::after{transition:none} +} + +/* ---------- responsive ---------- */ +@media (max-width:62rem){ + .statement{grid-template-columns:1fr} + .contact-grid{grid-template-columns:1fr} + .gallery{grid-template-columns:repeat(4,1fr)} + .gallery .g-w3{grid-column:span 2} + .gallery .g-w4{grid-column:span 4} + .gallery .g-w2{grid-column:span 2} +} +@media (max-width:52rem){ + .nav{ + position:fixed;inset:4.4rem 0 auto;margin:0; + flex-direction:column;align-items:stretch;gap:.15rem; + background:var(--paper);border-bottom:1px solid var(--line); + padding:1rem max(4vw,1rem) 1.6rem; + max-height:calc(100vh - 4.4rem);overflow:auto; + transform:translateY(-1rem);opacity:0;visibility:hidden;transition:all .25s var(--ease); + box-shadow:var(--shadow); + } + .nav.is-open{transform:none;opacity:1;visibility:visible} + .nav__toggle{display:inline-flex;align-items:center;gap:.45rem} + .nav__link{padding:.7rem .2rem;font-size:1.02rem;border-bottom:1px solid var(--line-soft);border-radius:0} + .nav__link::after{display:none} + .nav__sub{position:static;opacity:1;visibility:visible;transform:none;box-shadow:none;border:0;padding:0 0 0 .9rem;min-width:0;background:none} + .nav__caret{display:none} + .nav .btn{margin-top:1rem;align-self:flex-start} + .hero__inner{min-height:clamp(26rem,72vh,34rem)} +} +@media (max-width:40rem){ + .gallery{grid-template-columns:repeat(2,1fr)} + .gallery .g-w3,.gallery .g-w4,.gallery .g-w6{grid-column:span 2} + .hero__facts{grid-template-columns:1fr 1fr} + .page-head__inner{max-width:none} +} diff --git a/site/assets/site.js b/site/assets/site.js new file mode 100644 index 0000000..6f8fc85 --- /dev/null +++ b/site/assets/site.js @@ -0,0 +1,88 @@ +/* desfoto.de - progressive enhancement only. */ +(function () { + 'use strict'; + + /* Mobile navigation */ + var toggle = document.querySelector('[data-nav-toggle]'); + var nav = document.getElementById('site-nav'); + if (toggle && nav) { + toggle.addEventListener('click', function () { + var open = nav.classList.toggle('is-open'); + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + nav.addEventListener('click', function (event) { + if (event.target.closest('a') && window.innerWidth < 832) { + nav.classList.remove('is-open'); + toggle.setAttribute('aria-expanded', 'false'); + } + }); + } + + /* Click-to-load YouTube (youtube-nocookie) */ + document.querySelectorAll('[data-video-id]').forEach(function (facade) { + facade.addEventListener('click', function () { + var id = facade.getAttribute('data-video-id'); + var iframe = document.createElement('iframe'); + iframe.setAttribute('src', 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + + '?autoplay=1&rel=0&modestbranding=1&playsinline=1'); + iframe.setAttribute('title', facade.getAttribute('aria-label') || 'Video'); + iframe.setAttribute('loading', 'eager'); + iframe.setAttribute('allow', 'accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture'); + iframe.setAttribute('allowfullscreen', ''); + iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + var shell = facade.parentElement; + shell.replaceChild(iframe, facade); + iframe.focus(); + }); + }); + + /* Contact form -> local mail draft, nothing is transmitted by this page */ + var form = document.querySelector('[data-contact-form]'); + if (form) { + var status = form.querySelector('[data-contact-status]'); + form.addEventListener('submit', function (event) { + event.preventDefault(); + var data = new FormData(form); + var name = (data.get('name') || '').toString().trim(); + var email = (data.get('email') || '').toString().trim(); + var topic = (data.get('topic') || '').toString().trim(); + var date = (data.get('date') || '').toString().trim(); + var message = (data.get('message') || '').toString().trim(); + if (!name || !email || !message) { + if (status) { + status.hidden = false; + status.textContent = 'Bitte Name, E-Mail und Nachricht ausfüllen.'; + status.style.color = '#95330F'; + } + return; + } + var subject = 'Anfrage über desfoto.de: ' + topic; + var body = 'Name: ' + name + '\nE-Mail: ' + email + '\nBereich: ' + topic + + (date ? '\nWunschtermin: ' + date : '') + '\n\n' + message + '\n'; + window.location.href = 'mailto:info@dennyschulz.de?subject=' + + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body); + if (status) { + status.hidden = false; + status.textContent = 'Dein Mailprogramm sollte sich mit dem fertigen Entwurf öffnen.'; + status.style.color = ''; + } + }); + } + + /* Soft reveal on scroll, disabled for reduced motion */ + var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + var items = document.querySelectorAll('.reveal'); + if (reduce || !('IntersectionObserver' in window)) { + items.forEach(function (item) { item.classList.add('is-in'); }); + } else { + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add('is-in'); + observer.unobserve(entry.target); + } + }); + }, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 }); + items.forEach(function (item) { observer.observe(item); }); + } +}()); diff --git a/site/businessfotografie/index.html b/site/businessfotografie/index.html new file mode 100644 index 0000000..bd3de0a --- /dev/null +++ b/site/businessfotografie/index.html @@ -0,0 +1,137 @@ + + + + + +Businessfotografie in Neumünster · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Mann im weißen Hemd vor dunklem Hintergrund, Softbox im Bild +
+
+ + Businessfotografie +

Bilder, die dein Unternehmen ernst nehmen

+

Portraits, Teams, Standorte und Produkte — abgestimmt auf die Flächen, auf denen sie später stehen: Website, LinkedIn, Kampagne, Stellenanzeige.

+ +
+
Typische Aufträge

Wofür Unternehmen mich buchen

Portraits & Profilbilder

Einzelpersonen vor ruhigem Hintergrund oder im eigenen Arbeitsumfeld. Mehrere Varianten, damit Website und Profil nicht dasselbe Bild zeigen.

Teams & Standort

Menschen bei der Arbeit, Räume, Details. Zeigt, wie es bei euch wirklich aussieht — ohne Stockfoto-Gefühl.

Produkt & Detail

Sauber ausgeleuchtete Produkte für Shop, Katalog und Anzeigen, auch mit Szenenbildern.

Recruiting & Arbeitgebermarke

Bildpakete für Stellenanzeigen und Karriereseiten, die Bewerberinnen und Bewerber vorher sehen lassen, was sie erwartet.

Presse & Kampagne

Bildstrecken für Pressemitteilungen, Anzeigen und Social-Media-Kampagnen, inklusive der passenden Formate.

Events & Messen

Dokumentarische Begleitung von Firmenfeiern, Tagungen und Messeauftritten.

Zusammenarbeit

Was ich von euch brauche

Ein kurzer Call oder eine E-Mail mit drei Angaben reicht für ein verbindliches Angebot.

  • Wofür die Bilder gebraucht werden (Website, Print, Social, Presse)
  • Wie viele Personen oder Produkte fotografiert werden
  • Ort und Wunschzeitraum — bei euch, im Studio oder draußen
  • Ob einheitliche Hintergründe oder zusätzliche Retusche gebraucht werden
Nutzungsrechte: Wir halten vorab fest, für welche Kanäle und wie lange die Bilder genutzt werden dürfen. Das schützt euch und mich — und ihr bekommt genau die Rechte, die ihr braucht.

Nach dem Shooting bekommt ihr eine Auswahlgalerie, in der ihr kommentieren und freigeben könnt. Erst danach geht es in die finale Bearbeitung.

Beispiele

Aus der Business- und Studiopraxis

Portrait eines Mannes vor dunklem HintergrundSitzendes Portrait im weichen StudiolichtKamerakoffer und Objektive vor einem StudiorackStudioequipment mit Logo von Denny Schulz Fotografie
+
+
+
+ Kontakt +

Bildpaket für euer Unternehmen?

+

Schreib kurz, wie viele Personen oder Produkte es sind und wofür die Bilder gebraucht werden. Du bekommst Ablauf, Aufwand und Preis.

+
+ +
+
+
+ + + + diff --git a/site/datenschutz/index.html b/site/datenschutz/index.html new file mode 100644 index 0000000..f944d5b --- /dev/null +++ b/site/datenschutz/index.html @@ -0,0 +1,240 @@ + + + + + +Datenschutzerklärung · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Stuhlreihen in einem festlich vorbereiteten Raum +
+
+ + Rechtliches +

Datenschutzerklärung

+

Informationen zur Verarbeitung personenbezogener Daten · Stand September 2026

+ +
+
+

Diese Datenschutzerklärung informiert dich darüber, welche personenbezogenen Daten beim Besuch + von desfoto.de und bei einer Kontaktaufnahme verarbeitet werden, zu welchem Zweck das geschieht und + welche Rechte du hast.

+ +

1. Verantwortlicher

+

Denny Schulz Fotografie
Inhaber: Denny Schulz
Flensburger Str. 16
24537 Neumünster, Deutschland

+

E-Mail: info@dennyschulz.de
+ Telefon: +49 175 4083133

+ +

2. Zuständige Aufsichtsbehörde

+

Unabhängiges Landeszentrum für Datenschutz Schleswig-Holstein
Holstenstraße 98
24103 Kiel
+ Web: www.datenschutzzentrum.de

+ +

3. Hosting und Server-Logdaten

+

Diese Website wird auf eigenen, selbst betriebenen und kontrollierten Servern innerhalb der + Europäischen Union bereitgestellt. Zum Ausliefern der Seiten ist technisch eine Verarbeitung der + IP-Adresse des anfragenden Geräts erforderlich.

+

Für diese Website werden keine Zugriffsprotokolle gespeichert. Es existiert + bewusst kein Access-Log, keine Reichweitenmessung und keine Statistiksoftware. Die IP-Adresse wird + ausschließlich flüchtig für die Dauer der Verbindung verarbeitet, um die Seite auszuliefern und + Angriffe abzuwehren; anschließend wird sie nicht gespeichert.

+

Auch ein technisches Fehlerprotokoll wird nicht geschrieben: Der Webserver verwirft + Fehlermeldungen zu Anfragen vollständig, statt sie in eine Protokolldatei zu schreiben. Es + entsteht daher keine Logdatei, in der eine IP-Adresse gespeichert werden könnte. + Rechtsgrundlage für die flüchtige Verarbeitung der IP-Adresse ist Art. 6 Abs. 1 lit. f DSGVO — + das berechtigte Interesse an einem sicheren und stabilen Betrieb der Website.

+ +

4. Kontaktaufnahme per E-Mail, Telefon oder Anfrageformular

+

Wenn du mich kontaktierst, verarbeite ich die von dir mitgeteilten Angaben (insbesondere Name, + E-Mail-Adresse, Telefonnummer, Anlass, Terminwunsch und Nachricht), um deine Anfrage zu beantworten + und — bei einem Auftrag — durchzuführen.

+

Das Anfrageformular auf dieser Website sendet keine Daten an einen Server. Es + setzt aus deinen Eingaben ausschließlich lokal in deinem Browser einen E-Mail-Entwurf in deinem + Mailprogramm zusammen. Erst wenn du diese E-Mail selbst versendest, verlassen die Daten dein Gerät. + Deine Eingaben werden dabei nicht gespeichert oder protokolliert.

+

Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b DSGVO (Anbahnung und Erfüllung eines Vertrags), + Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Beantwortung von Anfragen) sowie + Art. 6 Abs. 1 lit. a DSGVO, soweit du eine Einwilligung erteilt hast. Anfragen ohne anschließenden + Auftrag lösche ich, sobald der Vorgang abgeschlossen ist und keine gesetzlichen Aufbewahrungsgründe + entgegenstehen.

+ +

5. Cookies und lokale Speicherung

+

Diese Website setzt keine Cookies und verwendet keine Analyse-, Marketing- oder + Tracking-Technologien. Es werden auch keine Inhalte im lokalen Speicher deines Browsers abgelegt. + Ein Cookie-Banner ist deshalb nicht erforderlich.

+ +

6. Schriftarten und Bilder

+

Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden. + Beim Aufruf der Website werden keine Schriftarten oder Skripte von Google Fonts oder + vergleichbaren externen Servern geladen. Dasselbe gilt für die gezeigten Bilder: Sie werden + ausschließlich von dieser Website ausgeliefert.

+ +

7. YouTube-Videos (nur nach deinem Klick)

+

Auf einzelnen Seiten sind Videos über YouTube eingebunden. Die Einbindung erfolgt als + ausdrücklich gekennzeichnetes Vorschaubild. Erst wenn du das Vorschaubild anklickst, + wird das Video über die datenschutzfreundliche Domain youtube-nocookie.com geladen.

+

Ab dem Klick verarbeitet YouTube (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, + Irland) unter anderem deine IP-Adresse, Geräte- und Browserdaten sowie Angaben zur Nutzung des + Videos. Dabei kann eine Verarbeitung außerhalb der EU bzw. des EWR, insbesondere in den USA, + stattfinden. Google nennt hierzu in seinen Datenschutzinformationen die Grundlagen der Übermittlung, + etwa Standardvertragsklauseln. Auf die Verarbeitung durch YouTube habe ich keinen Einfluss. + Rechtsgrundlage für das Nachladen ist deine Einwilligung durch den Klick (Art. 6 Abs. 1 lit. a DSGVO) + sowie Art. 25 Abs. 1 TDDDG. Weitere Informationen findest du in der + Datenschutzerklärung von Google.

+ +

8. Externe Links

+

Diese Website enthält Links zu externen Websites. Beim Anklicken verlässt du mein Angebot. Dabei + werden an den jeweiligen Anbieter technisch erforderliche Zugriffsdaten wie deine IP-Adresse, + Datum und Uhrzeit des Aufrufs sowie gegebenenfalls der Referrer übermittelt. Für die Verarbeitung + personenbezogener Daten auf der verlinkten Website ist ausschließlich deren Betreiber verantwortlich.

+ +

9. Veröffentlichung von Bildern

+

Fotografien werden nur nach vorheriger Absprache zu Portfolio-, Referenz- oder Werbezwecken + veröffentlicht. Eine erteilte Einwilligung kann jederzeit mit Wirkung für die Zukunft widerrufen + werden. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung bleibt davon unberührt.

+ +

10. Empfänger und Auftragsverarbeiter

+

Eine Weitergabe personenbezogener Daten erfolgt nur, soweit dies zur Vertragserfüllung + erforderlich ist, eine gesetzliche Verpflichtung besteht oder eine Einwilligung vorliegt. Empfänger + können insbesondere Hosting- und Serverdienstleister, E-Mail-Dienstleister, Telekommunikationsanbieter, + Steuerberater sowie Finanzbehörden sein. Soweit Dienstleister Daten in meinem Auftrag verarbeiten, + geschieht dies auf Grundlage eines Vertrags zur Auftragsverarbeitung nach Art. 28 DSGVO, soweit + gesetzlich erforderlich.

+ +

11. Speicherdauer

+

Personenbezogene Daten werden nur so lange gespeichert, wie es für den jeweiligen Zweck oder zur + Erfüllung gesetzlicher Pflichten erforderlich ist. Vertrags-, Rechnungs- und steuerrelevante + Unterlagen werden entsprechend den gesetzlichen Aufbewahrungsfristen gespeichert und danach gelöscht. + Bilddaten können darüber hinaus aufbewahrt werden, soweit dies für spätere Nachbestellungen, + Datensicherung, Qualitätssicherung oder die Geltendmachung, Ausübung oder Verteidigung von + Rechtsansprüchen erforderlich ist.

+ +

12. Deine Rechte

+
    +
  • Recht auf Auskunft über die verarbeiteten personenbezogenen Daten
  • +
  • Recht auf Berichtigung unrichtiger Daten
  • +
  • Recht auf Löschung, soweit die gesetzlichen Voraussetzungen vorliegen
  • +
  • Recht auf Einschränkung der Verarbeitung
  • +
  • Recht auf Widerspruch gegen Verarbeitungen auf Grundlage von Art. 6 Abs. 1 lit. e oder lit. f DSGVO
  • +
  • Recht auf Datenübertragbarkeit, soweit die gesetzlichen Voraussetzungen vorliegen
  • +
  • Recht auf Widerruf einer erteilten Einwilligung mit Wirkung für die Zukunft
  • +
  • Recht auf Beschwerde bei einer Datenschutzaufsichtsbehörde
  • +
+

Für die Ausübung deiner Rechte genügt eine Nachricht an + info@dennyschulz.de.

+ +

13. Keine automatisierte Entscheidungsfindung

+

Eine automatisierte Entscheidungsfindung einschließlich Profiling im Sinne von Art. 22 DSGVO + findet nicht statt.

+ +

14. Aktualität dieser Erklärung

+

Diese Datenschutzerklärung wird angepasst, sobald sich die eingesetzten Techniken oder die + rechtlichen Rahmenbedingungen ändern. Es gilt die jeweils auf dieser Seite veröffentlichte Fassung + (Stand September 2026).

+ +
Kurzfassung: Diese Website setzt keine Cookies, sammelt keine Statistiken, speichert + keine Zugriffsprotokolle und lädt nichts von externen Servern — außer du klickst bewusst auf ein Video.
+
+ + + + diff --git a/site/familien-und-paare/index.html b/site/familien-und-paare/index.html new file mode 100644 index 0000000..527d4d6 --- /dev/null +++ b/site/familien-und-paare/index.html @@ -0,0 +1,137 @@ + + + + + +Familien- & Paarfotografie in Neumünster · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Frau mit Blumenstrauß lacht in die Kamera +
+
+ + Familien & Paare +

Wachsendes festhalten, ohne Pose

+

Familienshootings und Paarportraits, die nicht gestellt wirken. Zeit lassen, mitgehen und im richtigen Moment auslösen.

+ +
+
Anlässe

Für welche Momente mich Familien und Paare buchen

Familienshooting

Alle zusammen, ohne dass es nach Fototermin aussieht. Auch mit mehreren Generationen.

Paarshooting

Zwei Menschen, die sich nah sind. Vor der Hochzeit, zum Jahrestag oder einfach so.

Verlobung & Ja-Wort

Kleine Zeremonien, Standesamt, freie Trauung — begleitend oder als Portraitstrecke.

Jubiläum & Familientreffen

Wenn die ganze Verwandtschaft zusammenkommt, lohnt sich ein gemeinsames Bild.

Generationenbilder

Großeltern, Eltern, Enkel: Bilder, die in Jahren mehr wert sind als heute.

Kinderportraits

Kurz, spielerisch und geduldig — lieber zehn ehrliche Bilder als eine Stunde Stillhalten.

Ablauf

Entspannt statt steif

Wir treffen uns dort, wo ihr euch wohlfühlt — im Studio, im Park oder bei euch zuhause. Ich sage, wann es losgeht, und danach einfach laufen lassen.

Anfrage

Du schreibst kurz, worum es geht. Ich melde mich mit einer ehrlichen Einschätzung, einem Ablauf und einem Preis.

Planung

Wir klären Ort, Licht, Zeit und Bildidee. Bei größeren Produktionen gibt es vorab ein Moodboard oder ein Treatment.

Dreh

Am Termin übernehme ich Technik, Licht und Regie. Du musst nichts mitbringen außer Zeit und dich.

Lieferung

Du bekommst eine Auswahlgalerie, bearbeitete Dateien in Web- und Druckgröße und bei Video das passende Exportformat für deinen Kanal.

Beispiele

Paare und Menschen

Paar im Wald, PortraitPaar unter einem hellen Schleier, GegenlichtPaar geht Hand in Hand ueber einen WaldwegPaarportrait im GrünenGruppe feiert gemeinsam vor einem GebäudeGruppe posiert im Grünen
+
+
+
+ Kontakt +

Termin für dein Familienshooting?

+

Schreib mir, wer dabei ist, wo es stattfinden soll und ob du eher ruhige oder lebendige Bilder möchtest.

+
+ +
+
+
+ + + + diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..15dc8c5 --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/site/fotografie/index.html b/site/fotografie/index.html new file mode 100644 index 0000000..7af983e --- /dev/null +++ b/site/fotografie/index.html @@ -0,0 +1,137 @@ + + + + + +Fotografie · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Portrait im Profil mit Ohrring +
+
+ + Fotografie +

Menschen, Marken und Momente

+

Vier Schwerpunkte, ein Handwerk. Entscheide dich für den Bereich, der passt — oder schreib mir, wenn du zwischen zwei Bereichen stehst.

+ +
+
Überblick

Womit ich arbeite

Studiolicht in Neumünster, available light draußen, Reportage dort, wo es lebendig ist.

Editorial-Portrait vor schwarzem Hintergrund

Menschen

Portraits, Sedcards, Bewerbungsbilder und Familienaufnahmen. Wir arbeiten mit Licht, das zu dir passt — nicht mit dem Standardschema.

Sitzendes Portrait mit Kameratasche im Studio

Marken & Unternehmen

Team-, Standort- und Produktbilder, die auf Website, Kampagne und in Stellenanzeigen funktionieren.

Paar tanzt unter einem Kronleuchter

Anlässe

Feiern, Events, Vereinsleben und Premieren. Dokumentarisch begleitet, ohne den Ablauf auszubremsen.

Schwerpunkte

Die vier Bereiche im Detail

Mann im weißen Hemd vor dunklem Hintergrund, Softbox im Bild

01Businessfotografie

Portraits, Teams und Produkte für Website, LinkedIn, Kampagne und Presse — sauber ausgeleuchtet und sofort einsetzbar.

Model in dunklem Kleid in farbigem Licht

02Portrait & Model

Portraits, Sedcards, Editorial und Fashion. Im Studio in Neumünster oder an einem Ort, der zu dir und deiner Geschichte passt.

Frau mit Blumenstrauß lacht in die Kamera

03Familien & Paare

Familienshootings und Paarportraits, die nicht gestellt wirken. Zeit lassen, mitgehen, im richtigen Moment auslösen.

Lachendes Paar im natürlichen Licht

04Minishootings

Die kurze Session für alle, die schnell ein gutes Bild brauchen: klarer Ablauf, kleine Auswahl, kurze Lieferzeit.

Technik & Ausgabe

Was am Ende bei dir landet

Bilddaten in der Auflösung, die du wirklich brauchst — nicht ein Ordner voller RAW-Dateien.

  • Bearbeitete JPEG-Dateien in Web- und Druckgröße
  • Auswahlgalerie zum Sichten und Teilen
  • Retusche auf Wunsch, besprochen im Vorfeld
  • Nutzungsrechte schriftlich festgehalten
  • Studiolicht und mobile Blitzanlage im Einsatz
  • Available-light-Fotografie on location
  • Reportagestil bei Veranstaltungen
  • Lieferung per Downloadlink
+
+
+
+ Kontakt +

Lass uns dein Projekt besprechen.

+

Schreib kurz, worum es geht, wann es stattfinden soll und was du mit den Bildern vorhast. Du bekommst eine ehrliche Einschätzung und einen Preis.

+
+ +
+
+
+ + + + diff --git a/site/impressum/index.html b/site/impressum/index.html new file mode 100644 index 0000000..4059018 --- /dev/null +++ b/site/impressum/index.html @@ -0,0 +1,165 @@ + + + + + +Impressum · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Kamerakoffer und Objektive vor einem Studiorack +
+
+ + Rechtliches +

Impressum

+

Angaben gemäß § 5 DDG · Stand September 2026

+ +
+
+

Diensteanbieter

+

Denny Schulz Fotografie
+ Inhaber: Denny Schulz
+ Flensburger Str. 16
+ 24537 Neumünster
+ Deutschland

+ +

Kontakt

+

E-Mail: info@dennyschulz.de
+ Telefon: +49 175 4083133
+ Website: desfoto.de

+ +

Umsatzsteuer

+

Es wird gemäß § 19 UStG keine Umsatzsteuer berechnet.

+

Wirtschafts-Identifikationsnummer: DE462149560

+ +

Verantwortlich für Inhalte

+

Verantwortlich für journalistisch-redaktionelle Inhalte nach § 18 Abs. 2 MStV: + Denny Schulz, Anschrift wie oben.

+ +

Verbraucherstreitbeilegung

+

Ich bin nicht verpflichtet und nicht bereit, an Streitbeilegungsverfahren vor einer + Verbraucherschlichtungsstelle teilzunehmen. Die frühere EU-Plattform zur Online-Streitbeilegung + wurde zum 20.07.2025 eingestellt.

+ +

Marke und Angebot

+

desfoto ist der Markenauftritt von Denny Schulz Fotografie. Alle über diese + Website erreichbaren Leistungen werden von Denny Schulz Fotografie erbracht.

+ +

Urheberrecht und Bildrechte

+

Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt. + Sie dürfen ohne vorherige schriftliche Zustimmung nicht vervielfältigt, verbreitet, öffentlich + zugänglich gemacht oder bearbeitet werden — auch nicht für KI-Training oder Bildgenerierung. Eine + Nutzung durch Dritte ist ausschließlich im Rahmen der jeweils vereinbarten Nutzungsrechte zulässig.

+ +

Haftung für Inhalte und Links

+

Als Diensteanbieter bin ich für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen + verantwortlich. Für Inhalte externer Links ist ausschließlich der jeweilige Anbieter verantwortlich. + Zum Zeitpunkt der Verlinkung waren keine rechtswidrigen Inhalte erkennbar.

+ +
Datenschutz: Informationen zur Verarbeitung personenbezogener Daten auf dieser + Website findest du in der Datenschutzerklärung.
+
+ + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..7852603 --- /dev/null +++ b/site/index.html @@ -0,0 +1,180 @@ + + + + + +desfoto · Fotografie & Video aus Neumünster + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Hand an einer Kamera im Studio bei violettem Licht +
+
+ Fotografie und Video aus Neumünster +

Bilder und Filme, die nicht beliebig aussehen.

+

+ desfoto ist die fotografische und filmische Arbeit von Denny Schulz — für Unternehmen, + Agenturen, Models, Vereine und Menschen, die sich selbst zeigen wollen. +

+ +
+
Foto & VideoBeides aus einer Hand, damit Optik und Ton zusammenpassen.
+
Studio & on locationIn Neumünster und überall dort, wo dein Projekt spielt.
+
B2B & privatVon der Agenturserie bis zum Familienshooting.
+
Kurze WegeDirekter Kontakt, klare Absprachen, schnelle Lieferung.
+
+
+
Haltung
+

Kurz gesagt

+
+

Ich mache Bilder, die etwas erzählen und trotzdem funktionieren: auf der Website, + im Feed, im Bewerbungsportfolio, im Plakat. Kein Katalog von der Stange — wir klären kurz, + was du brauchst, und ich liefere genau das.

+
+
Leistungen

Was ich für dich mache

Sechs Bereiche, ein Ansprechpartner. Klick dich rein oder schreib direkt, wenn du unsicher bist, was zu deinem Vorhaben passt.

Mann im weißen Hemd vor dunklem Hintergrund, Softbox im Bild

01Businessfotografie

Portraits, Teams und Produkte für Website, LinkedIn, Kampagne und Presse — sauber ausgeleuchtet und sofort einsetzbar.

Model in dunklem Kleid in farbigem Licht

02Portrait & Model

Portraits, Sedcards, Editorial und Fashion. Im Studio in Neumünster oder an einem Ort, der zu dir und deiner Geschichte passt.

Frau mit Blumenstrauß lacht in die Kamera

03Familien & Paare

Familienshootings und Paarportraits, die nicht gestellt wirken. Zeit lassen, mitgehen, im richtigen Moment auslösen.

Lachendes Paar im natürlichen Licht

04Minishootings

Die kurze Session für alle, die schnell ein gutes Bild brauchen: klarer Ablauf, kleine Auswahl, kurze Lieferzeit.

Kameraobjektiv im Studiolicht

05Videoprojekte

Musikvideo, Imagefilm, Recruiting und Eventnachbericht — von der Idee über den Dreh bis zum fertigen Schnitt.

Menschen in Bewegung, bewusst unscharf aufgenommen

06Social-Media-Inhalte

Foto- und Videocontent im Format deiner Kanäle — wiederkehrend, planbar und über die Monate hinweg einheitlich.

Aus der Arbeit

Auszug aus Aufträgen und eigenen Projekten

Studioportraits, Businessbilder, Paare, Veranstaltungen und Reisen — die Auswahl zeigt die Bandbreite, nicht nur den schönsten Moment.

+
+
+
+ Video +

Musikvideo für Thjódrörir

+

+ Für das Musikvideo zu Skogamor habe ich Konzept, Dreh, Schnitt und + Farbkorrektur übernommen. Klick auf das Vorschaubild, dann wird das Video geladen. +

+
+ Mehr zu Video +
+
+ + +
+

Das Video wird erst nach deinem Klick von YouTube geladen (youtube-nocookie.com). Mehr dazu in der Datenschutzerklärung.

+
+
Ablauf

Vier Schritte, kein Rätselraten

Ob Minishooting oder Kampagne: Der Weg ist immer derselbe und du weißt jederzeit, was als Nächstes passiert.

Anfrage

Du schreibst kurz, worum es geht. Ich melde mich mit einer ehrlichen Einschätzung, einem Ablauf und einem Preis.

Planung

Wir klären Ort, Licht, Zeit und Bildidee. Bei größeren Produktionen gibt es vorab ein Moodboard oder ein Treatment.

Dreh

Am Termin übernehme ich Technik, Licht und Regie. Du musst nichts mitbringen außer Zeit und dich.

Lieferung

Du bekommst eine Auswahlgalerie, bearbeitete Dateien in Web- und Druckgröße und bei Video das passende Exportformat für deinen Kanal.

Warum desfoto

Was du erwarten kannst

Aus Neumünster

Zuhause zwischen Kiel und Hamburg — unterwegs in Schleswig-Holstein und darüber hinaus.

Foto und Video

Ein Ansprechpartner für Bild und Bewegtbild. Das spart Abstimmung und hält die Optik zusammen.

Klarer Ablauf

Umfang, Termin und Preis klären wir vor der Buchung. Keine Überraschungen auf der Rechnung.

Schnelle Lieferung

Kleine Aufträge bekommst du meist innerhalb weniger Tage, größere Projekte nach Absprache.

+
+
+
+ Kontakt +

Lass uns dein Projekt besprechen.

+

Schreib kurz, worum es geht, wann es stattfinden soll und was du mit den Bildern vorhast. Du bekommst eine ehrliche Einschätzung und einen Preis.

+
+ +
+
+
+ + + + diff --git a/site/kontakt/index.html b/site/kontakt/index.html new file mode 100644 index 0000000..cbfa437 --- /dev/null +++ b/site/kontakt/index.html @@ -0,0 +1,182 @@ + + + + + +Kontakt · desfoto Neumünster + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Stuhlreihen in einem festlich vorbereiteten Raum +
+
+ + Kontakt +

Erzähl mir, was du vorhast

+

Kurze Nachricht genügt: worum geht es, wann soll es stattfinden und was hast du mit den Bildern oder dem Video vor. Du bekommst eine ehrliche Einschätzung und einen Preis.

+ +
+

Direkter Draht

Am schnellsten geht es per E-Mail oder Telefon. Beides landet direkt bei mir, nicht in einem Ticketsystem.

Region
Neumünster · Schleswig-Holstein
Antwortzeit
Antwort in der Regel innerhalb von 24 Stunden
Termine vergebe ich nach Absprache — auch abends und am Wochenende, wenn es anders nicht passt.

Anfrage vorbereiten

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Direkt per E-Mail +
+ +

+ Das Formular sendet nichts an einen Server. Es baut aus deinen Angaben eine E-Mail in deinem + Mailprogramm zusammen. Deine Eingaben bleiben dafür ausschließlich in deinem Browser. + Alternativ erreichst du mich direkt unter info@dennyschulz.de + oder +49 175 4083133. +

+
Häufige Fragen

Kurz beantwortet

Was kostet ein Shooting?

Das hängt von Umfang, Ort und Nutzung ab. Nenn mir deinen Bedarf, dann bekommst du einen konkreten Preis — vor der Buchung, ohne Nachforderung.

Wie schnell bekomme ich die Bilder?

Kleine Aufträge liefere ich meist innerhalb weniger Tage. Bei umfangreichen Produktionen stimmen wir einen Zeitrahmen ab.

Wo finden die Termine statt?

Im Studio in Neumünster, bei dir vor Ort oder an einer Location, die wir gemeinsam auswählen.

Darf ich die Bilder veröffentlichen?

Ja, im vereinbarten Rahmen. Wir halten vorab fest, für welche Kanäle die Bilder verwendet werden dürfen.

Bekomme ich auch die RAW-Dateien?

In der Regel nicht. Du bekommst fertig bearbeitete Dateien in den Größen, die du brauchst — das ist die Grundlage meiner Arbeit.

Machst du auch Video und Foto zusammen?

Ja. Gerade bei Veranstaltungen und Kampagnen ist es von Vorteil, beides aus einer Hand zu bekommen.

+
+
+
+ Kontakt +

Lieber direkt telefonieren?

+

Kein Problem. Ruf einfach an — falls ich gerade im Termin bin, melde ich mich zurück.

+
+ +
+
+
+ + + + diff --git a/site/minishootings/index.html b/site/minishootings/index.html new file mode 100644 index 0000000..c3013e3 --- /dev/null +++ b/site/minishootings/index.html @@ -0,0 +1,137 @@ + + + + + +Minishootings in Neumünster · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Lachendes Paar im natürlichen Licht +
+
+ + Minishootings +

Kurze Session, sofort brauchbares Bild

+

Der Termin für alle, die schnell ein gutes Bild brauchen: klarer Ablauf, kleine Auswahl, kurze Lieferzeit. Meist in rund 20 bis 30 Minuten erledigt.

+ +
+
Gut geeignet für

Wann ein Minishooting reicht

Profilbild

Für LinkedIn, Bewerbung, Vereinsseite oder die Website.

Paar & Familie

Ein, zwei schöne Bilder zusammen — ohne großen Termin.

Social Media

Frisches Material für deine Kanäle, passend zum Format.

Verein & Mannschaft

Schnell alle Gesichter für Mannschaftsfotos und Anmeldungen.

Businesskurzportrait

Du brauchst nur ein Bild für die neue Stelle oder Präsentation.

Geschenkidee

Gutschein für ein gemeinsames Shooting — auf Anfrage.

So läuft es

Drei Schritte, ein Termin

Termin und Ort

Du wählst einen freien Slot im Studio Neumünster oder draußen. Kurze Info vorab reicht.

Die Session

Rund 20 bis 30 Minuten. Ich führe durch Licht und Position, du musst nichts vorbereiten.

Lieferung

Du bekommst eine Auswahl zum Ansehen und die bearbeiteten Dateien in Web- und Druckgröße.

Gut zu wissen

Was du vorher wissen solltest

  • Outfitwechsel ist möglich, wenn du etwas mitbringst
  • Studio- oder Außentermin — je nach Wunsch und Licht
  • Fester Preis, vorab geklärt, keine Nachforderung
  • Termine nach Absprache, auch kurzfristig
Wie viele Bilder du bekommst und was das Shooting kostet, hängt von Umfang und Nutzung ab. Sag mir kurz, was du brauchst — dann nenne ich dir den Preis für dein Paket, bevor du buchst.
Beispiele

Schnell und trotzdem gut

Lachendes Paar im natürlichen LichtMann im weißen Hemd vor dunklem Hintergrund, Softbox im BildModel im Sporttop vor schwarzem Hintergrund
+
+
+
+ Kontakt +

Kurz, günstig, fertig.

+

Schreib mir, wofür du das Bild brauchst und wann du Zeit hast. Du bekommst direkt einen Terminvorschlag.

+
+ +
+
+
+ + + + diff --git a/site/portrait-und-model/index.html b/site/portrait-und-model/index.html new file mode 100644 index 0000000..d91853f --- /dev/null +++ b/site/portrait-und-model/index.html @@ -0,0 +1,145 @@ + + + + + +Portrait & Model · desfoto Neumünster + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Model in dunklem Kleid in farbigem Licht +
+
+ + Portrait & Model +

Zeig dich so, wie du wirklich bist

+

Portraits, Sedcards, Editorial und Fashion. Im Studio in Neumünster oder an einem Ort, der zu dir und deiner Geschichte passt.

+ +
+
Formate

Was wir zusammen machen können

Portrait

Natürlich, klar oder dramatisch — je nachdem, wie du gesehen werden willst. Auch für Bewerbung und Profil.

Sedcard & Modelbook

Für neue Gesichter und erfahrene Models: ein Set, das Agenturen und Kundinnen einen Überblick gibt.

Editorial & Fashion

Konzept, Licht und Location als zusammenhängende Bildstrecke — mit oder ohne Team.

Testshooting / TFP

Gemeinsam Ideen ausprobieren, ohne Budget. Auf Anfrage, wenn Konzept und Termin passen.

Bewerbungs- & Businessportrait

Der Klassiker in sauber: keine Steifheit, aber ein Bild, das im Bewerbungsverfahren funktioniert.

Content für deine Kanäle

Bilder, die du danach über Wochen posten kannst — verschiedene Looks, ein Termin.

Ablauf

Von der Idee zur Auswahl

Du musst nicht wissen, wie du vor der Kamera stehen sollst. Genau dafür bin ich da.

Anfrage

Du schreibst kurz, worum es geht. Ich melde mich mit einer ehrlichen Einschätzung, einem Ablauf und einem Preis.

Planung

Wir klären Ort, Licht, Zeit und Bildidee. Bei größeren Produktionen gibt es vorab ein Moodboard oder ein Treatment.

Dreh

Am Termin übernehme ich Technik, Licht und Regie. Du musst nichts mitbringen außer Zeit und dich.

Lieferung

Du bekommst eine Auswahlgalerie, bearbeitete Dateien in Web- und Druckgröße und bei Video das passende Exportformat für deinen Kanal.

Studio & on location

Zwei Wege zum Bild

+

Im Studio Neumünster

+

Kontrolliertes Licht, ruhiger Hintergrund, Platz für Konzeptarbeit. + Gut bei Wind und Wetter und für alles, was eine klare, wiederholbare Optik braucht.

+
+

Draußen, wo du bist

+

Available light, Stadt, Wald, Wasser oder dein eigener Ort. + Lebendiger, weniger kontrolliert — und oft genau richtig für Portraits, die sich echt anfühlen.

+
Beispiele

Portraits und Editorial

Editorial-Portrait vor schwarzem HintergrundDetailaufnahme eines Rückens mit SporttopModel im Sporttop vor schwarzem HintergrundPortrait im Profil mit OhrringLachendes Paar im natürlichen LichtPaar in inniger Umarmung
+
+
+
+ Kontakt +

Lass uns dein Portrait machen.

+

Schreib mir, was du dir vorstellst und wofür die Bilder gedacht sind. Ich schlage dir Licht, Ort und Ablauf vor.

+
+ +
+
+
+ + + + diff --git a/site/projekte/index.html b/site/projekte/index.html new file mode 100644 index 0000000..2515a7b --- /dev/null +++ b/site/projekte/index.html @@ -0,0 +1,143 @@ + + + + + +Projekte · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Lichtspur aus einer langen Belichtung +
+
+ + Projekte +

Eigene Arbeiten und pro bono

+

Neben Auftragsarbeiten entstehen eigene Projekte. Manches davon startet als Idee im Kopf, manches als Anfrage von Menschen, die etwas Unterstützung brauchen.

+ +
+
Eigene Projekte

Arbeiten, die ich selbst angestoßen habe

Reisefotografie, Lichtmalerei und Musikvideo. Diese Projekte entstehen ohne Auftraggeber und zeigen, wie ich mir Bilder vorstelle.

Skogamor — Musikvideo

Eigenes Musikvideo für Thjódrörir mit Konzept, Dreh, Schnitt und Farbkorrektur. Zu sehen auf der Videoseite.

Norwegen

Reihe über Küsten, Seen und Berglandschaften. Landschaft als Gegenpol zur Portrait- und Auftragsarbeit.

Japan

Ruhige Bilder von Parks, Gärten und Architektur, entstanden auf Reisen statt auf Termin.

Lichtmalerei

Experimente mit langer Belichtung und bewegtem Licht — technische Spielwiese mit Ausstellungsqualität.

Bildstrecke

Aus eigenen Serien und Aufträgen

Pro bono

Arbeiten, die ich unterstütze

Manche Projekte sind wichtig, haben aber kein Budget. Wenn es passt, mache ich sie trotzdem.

+

Ich fotografiere und filme gelegentlich kostenlos für Initiativen, Vereine, + gemeinnützige Projekte und Menschen, die dabei Unterstützung brauchen — als Beitrag zu dem + Umfeld, in dem ich lebe.

+

Schreib mir ruhig, wenn du so ein Projekt hast. Erzähl kurz, worum es geht, + wann es stattfindet und was du dir an Bildern oder Video wünschst. Ich schaue, ob es zeitlich + und inhaltlich passt.

+
Einen Anspruch auf eine kostenlose Umsetzung gibt es nicht. Ich sage ehrlich zu, wenn ich es schaffe — oder ab, wenn nicht.

Pro-bono-Anfrage senden

Auftragsarbeiten

Und sonst?

Business, Portrait, Familie, Events und Video — alles, was in den Leistungen steht. Der beste Weg ist ein kurzer Blick in die Bereiche.

Businessfotografie

Portraits, Teams und Produkte für Unternehmen.

Portrait & Model

Portraits, Sedcards und Editorial.

Video

Musikvideo, Imagefilm und Eventnachbericht.

+
+
+
+ Kontakt +

Lass uns dein Projekt besprechen.

+

Schreib kurz, worum es geht, wann es stattfinden soll und was du mit den Bildern vorhast. Du bekommst eine ehrliche Einschätzung und einen Preis.

+
+ +
+
+
+ + + + diff --git a/site/robots.txt b/site/robots.txt new file mode 100644 index 0000000..636557b --- /dev/null +++ b/site/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://desfoto.de/sitemap.xml diff --git a/site/site.webmanifest b/site/site.webmanifest new file mode 100644 index 0000000..5ccde27 --- /dev/null +++ b/site/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "desfoto", + "short_name": "desfoto", + "description": "desfoto ist die Foto- und Videoproduktion von Denny Schulz aus Neumünster: Businessfotografie, Portraits, Model- und Familienaufnahmen, Minishootings, Videoprojekte und Social-Media-Inhalte.", + "start_url": "/", + "display": "standalone", + "background_color": "#F6F2EA", + "theme_color": "#17140F", + "icons": [ + { + "src": "/assets/img/favicon-32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/assets/img/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/site/sitemap.xml b/site/sitemap.xml new file mode 100644 index 0000000..72e950b --- /dev/null +++ b/site/sitemap.xml @@ -0,0 +1,16 @@ + + + https://desfoto.de/2026-09-19weekly1.0 + https://desfoto.de/fotografie/2026-09-19monthly0.9 + https://desfoto.de/businessfotografie/2026-09-19monthly0.9 + https://desfoto.de/portrait-und-model/2026-09-19monthly0.9 + https://desfoto.de/familien-und-paare/2026-09-19monthly0.9 + https://desfoto.de/minishootings/2026-09-19monthly0.9 + https://desfoto.de/video/2026-09-19monthly0.9 + https://desfoto.de/social-media/2026-09-19monthly0.9 + https://desfoto.de/projekte/2026-09-19monthly0.8 + https://desfoto.de/ueber/2026-09-19monthly0.8 + https://desfoto.de/kontakt/2026-09-19monthly0.8 + https://desfoto.de/impressum/2026-09-19monthly0.4 + https://desfoto.de/datenschutz/2026-09-19monthly0.4 + diff --git a/site/social-media/index.html b/site/social-media/index.html new file mode 100644 index 0000000..b3f672c --- /dev/null +++ b/site/social-media/index.html @@ -0,0 +1,137 @@ + + + + + +Social-Media-Inhalte aus Neumünster · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Menschen in Bewegung, bewusst unscharf aufgenommen +
+
+ + Social Media +

Content, der regelmäßig kommt

+

Foto- und Videomaterial im Format deiner Kanäle — wiederkehrend, planbar und über die Monate hinweg einheitlich.

+ +
+
Für wen

Wer damit arbeitet

Clubs & Veranstalter

Wiederkehrende Abende, Line-ups und Stimmung im Raum — als Story und als Feed-Beitrag.

Vereine & Sport

Spieltage, Training, Mannschaft und Ehrenamt, damit der Verein sichtbar bleibt.

Lokale Unternehmen

Team, Werkstatt, Laden, Produkt — der Alltag, der Vertrauen aufbaut.

Gastronomie & Hotels

Essen, Räume, Menschen und Anlässe, in konsistenter Optik.

Selbstständige & Creators

Kontinuierlich neues Material für Profil und Community.

Agenturen

Zulieferung von Foto- und Videomaterial nach Briefing und Formatvorgabe.

Formate

Was ich liefere

Foto-Sets

Aus einem Termin mehrere Motive für Wochen: Detail, Portrait, Szene, Atmosphäre.

Vertikale Clips

Kurze Videos im 9:16-Format für Reels, Shorts und TikTok.

Feed & Quadrat

4:5 und 1:1 zugeschnitten, damit nichts beschnitten wirkt.

Story-Serien

Kurzsequenzen, die einen Abend oder eine Aktion zusammenhängend erzählen.

Untertitel

Viele schauen ohne Ton — Untertitel sind eingeplant, nicht nachgerüstet.

Redaktionsrhythmus

Fester Turnus statt Aktionismus: ein Termin, verlässlich neue Inhalte.

Ablauf

Wiederkehrend statt einmalig

Kennenlernen

Kanäle, Zielgruppe, Tonalität und was bisher gut oder schlecht funktioniert hat.

Format festlegen

Wir legen fest, welche Formate und Längen ihr wirklich braucht.

Termine

Ein fester Rhythmus — monatlich, quartalsweise oder zu euren Anlässen.

Lieferung

Dateien benannt und sortiert, direkt einsatzbereit für die Planung.

Gut zu wissen

Kein Versprechen ohne Grundlage

  • Ich liefere Material — die Veröffentlichung bleibt bei euch
  • Formate und Umfang werden vorher festgelegt, nicht nachträglich
  • Bilder und Videos dürfen nur mit geklärten Rechten genutzt werden
  • Auf Wunsch einheitliche Bildsprache über mehrere Termine
Für Reichweite und Performance sind Redaktion und Community auf eurer Seite entscheidend. Ich sorge dafür, dass das Ausgangsmaterial stimmt — und dass es nach eurer Marke aussieht.
Einblicke

Material aus Terminen

Menschen in Bewegung, bewusst unscharf aufgenommenPaar tanzt unter einem KronleuchterFrau spricht mit Mikrofon bei einer Veranstaltung
+
+
+
+ Kontakt +

Contentplan für deine Kanäle?

+

Sag mir, welche Kanäle du bespielst und wie oft du etwas brauchst. Ich schlage dir einen Rhythmus und ein Paket vor.

+
+ +
+
+
+ + + + diff --git a/site/ueber/index.html b/site/ueber/index.html new file mode 100644 index 0000000..1ab4473 --- /dev/null +++ b/site/ueber/index.html @@ -0,0 +1,143 @@ + + + + + +Über desfoto · Denny Schulz, Neumünster + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Portrait eines Mannes vor dunklem Hintergrund +
+
+ + Über +

Fotograf und Videograf aus Neumünster

+

Ich bin Denny Schulz. Seit vielen Jahren fotografiere ich Menschen, Marken und Anlässe — und seit einiger Zeit genauso gern in Bewegung.

+ +
+
+

Arbeitsweise

+
+

Ich arbeite schnell, direkt und unaufgeregt. Du sagst mir, was du brauchst, ich sage dir, + was dafür nötig ist — und dann machen wir es. Ohne Umwege und ohne Fachchinesisch.

+
+
Was ich mache

Drei Dinge, richtig

Menschen fotografieren

Portrait, Business, Model, Familie und Paar. Die Arbeit an Gesichtern, Licht und Ausdruck ist mein Kern.

Bewegtbild produzieren

Von der Idee über den Dreh bis zu Schnitt, Farbkorrektur und Ton. Auch als Ergänzung zu einem Fototermin.

Inhalte für Kanäle liefern

Material, das ohne Umweg in Website, Feed oder Stellenanzeige passt — im richtigen Format und Look.

Werte

Wie ich arbeite

  • Ehrliche Einschätzung, auch wenn sie gegen einen Auftrag spricht
  • Klarer Umfang und Preis vor der Buchung
  • Kein Bild ohne Einverständnis veröffentlicht
  • Sorgfältiger Umgang mit deinen Daten und Bilddaten
  • Aufnahmen bilden die Person ab, wie sie ist — nicht wie ein Filter sie zeigt
  • Retusche bleibt dezent und wird vorher besprochen
  • Nachbestellungen und spätere Nutzung sind ausdrücklich möglich
  • Fragen sind jederzeit willkommen, auch während des Termins
Zuhause & unterwegs

Neumünster und darüber hinaus

Ich fotografiere im Studio in Neumünster und unterwegs in Schleswig-Holstein, Hamburg und Kiel. Für passende Projekte fahre ich auch weiter.

Mann im weißen Hemd vor dunklem Hintergrund, Softbox im BildStudioequipment mit Logo von Denny Schulz Fotografie
Kontakt

Direkt erreichen

Region
Neumünster · Schleswig-Holstein
Anfragen
Antwort in der Regel innerhalb von 24 Stunden
+
+
+
+ Kontakt +

Lass uns dein Projekt besprechen.

+

Schreib kurz, worum es geht, wann es stattfinden soll und was du mit den Bildern vorhast. Du bekommst eine ehrliche Einschätzung und einen Preis.

+
+ +
+
+
+ + + + diff --git a/site/video/index.html b/site/video/index.html new file mode 100644 index 0000000..57160de --- /dev/null +++ b/site/video/index.html @@ -0,0 +1,159 @@ + + + + + +Videoproduktion in Neumünster · desfoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Kameraobjektiv im Studiolicht +
+
+ + Videoprojekte +

Vom Treatment bis zur finalen Datei

+

Musikvideo, Imagefilm, Recruiting oder Eventnachbericht — ich übernehme Konzept, Dreh, Schnitt, Ton und Ausgabe in den Formaten, die dein Kanal braucht.

+ +
+
+
+
+
+ Referenz +

Thjódrörir – Skogamor (Official Video)

+

Eigenes Musikvideo für Thjódrörir: Konzept, Dreh, Schnitt und Farbkorrektur aus einer Hand.

+
+
+
+ +
Thjódrörir – Skogamor (Official Video) · Musikvideo für Thjódrörir
+
+

Das Video wird erst nach deinem Klick von YouTube geladen (youtube-nocookie.com). Mehr dazu in der Datenschutzerklärung.

+
+
Formate

Welche Videos ich produziere

Musikvideo

Bildsprache zur Musik: Konzept, Locations, Licht und Schnittrhythmus auf den Track abgestimmt.

Imagefilm

Wer ihr seid, was ihr macht, warum es sich lohnt. Mit O-Tönen, B-Roll und klarer Dramaturgie.

Recruiting-Video

Jobs und Team echt zeigen, statt mit Text zu beschreiben. Auch als kurze Stellen-Clips.

Eventnachbericht

Tagung, Vereinsjubiläum, Konzert oder Firmenfeier — als Zusammenschnitt für danach.

Interviews & Statements

Sauber ausgeleuchtet und vertont, als Ergänzung zu Fotoaufträgen oder Pressematerial.

Social-Media-Clips

Hochkant oder quadratisch zugeschnitten, mit Untertiteln und den richtigen Längen für die Feeds.

Produktionsweg

So entsteht ein Video

Idee & Treatment

Ziel, Zielgruppe, Tonalität und Referenzen — kurz schriftlich festgehalten.

Drehplanung

Locations, Ablauf, Licht, benötigte Personen und Tageszeiten.

Dreh

Kamera-, Licht- und Tontechnik bringe ich mit. Du kümmerst dich um Abläufe und Menschen.

Schnitt & Ausgabe

Schnitt, Farbkorrektur, Ton, Untertitel und Export in den gewünschten Formaten.

Zusammenarbeit

Was ich von dir brauche

  • Was das Video erreichen soll
  • Wo es laufen wird (Website, YouTube, Instagram, Messe, Recruiting)
  • Gewünschte Länge und Format
  • Wer mitwirkt und welche Orte zur Verfügung stehen
Musik sollte entweder von dir stammen oder entsprechend lizenziert sein. Bei der Musikauswahl und bei Fragen zu Rechten unterstütze ich dich.

Kleinere Projekte sind oft in wenigen Tagen umsetzbar. Bei größeren Produktionen planen wir Vorlauf ein — vor allem, wenn Location oder Menschen gebucht werden müssen.

Einblicke

Hinter der Kamera

+
+
+
+ Kontakt +

Videoidee im Kopf?

+

Erzähl mir kurz, was das Video zeigen soll und wo es läuft. Du bekommst eine Einschätzung zu Aufwand und Ablauf.

+
+ +
+
+
+ + + + diff --git a/src/content.py b/src/content.py new file mode 100644 index 0000000..bee82e8 --- /dev/null +++ b/src/content.py @@ -0,0 +1,250 @@ +"""Editorial content for desfoto.de. + +All facts here are either verified from the operator's own live legal pages +(Impressum/Datenschutz) or are neutral descriptions of the offered services. +No prices, testimonials, client logos or biographical dates are invented. +""" + +from __future__ import annotations + +SITE = { + "brand": "desfoto", + "domain": "desfoto.de", + "url": "https://desfoto.de", + "claim": "Fotografie & Video", + "tagline": "Fotografie und Video aus Neumünster", + "locale": "de_DE", + "language": "de", + "theme_color": "#17140F", + "default_description": ( + "desfoto ist die Foto- und Videoproduktion von Denny Schulz aus Neumünster: " + "Businessfotografie, Portraits, Model- und Familienaufnahmen, Minishootings, " + "Videoprojekte und Social-Media-Inhalte." + ), + "keywords": ( + "Fotograf Neumünster, Videograf Neumünster, Businessfotografie, Businessportrait, " + "Model-Shooting, Familienfotograf, Minishooting, Musikvideo, Social Media Inhalte, " + "Schleswig-Holstein" + ), +} + +CONTACT = { + "email": "info@dennyschulz.de", + "phone": "+49 175 4083133", + "phone_href": "+491754083133", + "region": "Neumünster · Schleswig-Holstein", + "response": "Antwort in der Regel innerhalb von 24 Stunden", +} + +# Verified operator data (from the operator's own live Impressum, § 5 DDG). +LEGAL = { + "business": "Denny Schulz Fotografie", + "owner": "Denny Schulz", + "street": "Flensburger Str. 16", + "city": "24537 Neumünster", + "country": "Deutschland", + "vat_note": "Es wird gemäß § 19 UStG keine Umsatzsteuer berechnet.", + "w_id": "DE462149560", + "authority": "Unabhängiges Landeszentrum für Datenschutz Schleswig-Holstein", + "authority_street": "Holstenstraße 98", + "authority_city": "24103 Kiel", + "authority_web": "https://www.datenschutzzentrum.de", + "imprint_updated": "September 2026", + "privacy_updated": "September 2026", +} + +VIDEO = { + "id": "NWWFTf7l8g0", + "title": "Thjódrörir – Skogamor (Official Video)", + "artist": "Thjódrörir", + "kind": "Musikvideo", + "poster": "video-skogamor-poster.jpg", + "description": ( + "Eigenes Musikvideo für Thjódrörir: Konzept, Dreh, Schnitt und Farbkorrektur " + "aus einer Hand." + ), +} + +NAV = [ + { + "label": "Fotografie", + "href": "/fotografie/", + "children": [ + ("Businessfotografie", "/businessfotografie/"), + ("Portrait & Model", "/portrait-und-model/"), + ("Familien & Paare", "/familien-und-paare/"), + ("Minishootings", "/minishootings/"), + ], + }, + {"label": "Video", "href": "/video/"}, + {"label": "Social Media", "href": "/social-media/"}, + {"label": "Projekte", "href": "/projekte/"}, + {"label": "Über", "href": "/ueber/"}, +] + +SERVICES = [ + { + "slug": "businessfotografie", + "href": "/businessfotografie/", + "title": "Businessfotografie", + "short": ( + "Portraits, Teams und Produkte für Website, LinkedIn, Kampagne und Presse — " + "sauber ausgeleuchtet und sofort einsetzbar." + ), + "image": "business-portrait-studio", + "alt_override": None, + "fit": "50% 30%", + }, + { + "slug": "portrait-und-model", + "href": "/portrait-und-model/", + "title": "Portrait & Model", + "short": ( + "Portraits, Sedcards, Editorial und Fashion. Im Studio in Neumünster " + "oder an einem Ort, der zu dir und deiner Geschichte passt." + ), + "image": "editorial-colour", + "fit": "50% 30%", + }, + { + "slug": "familien-und-paare", + "href": "/familien-und-paare/", + "title": "Familien & Paare", + "short": ( + "Familienshootings und Paarportraits, die nicht gestellt wirken. " + "Zeit lassen, mitgehen, im richtigen Moment auslösen." + ), + "image": "people-joy", + "fit": "50% 30%", + }, + { + "slug": "minishootings", + "href": "/minishootings/", + "title": "Minishootings", + "short": ( + "Die kurze Session für alle, die schnell ein gutes Bild brauchen: " + "klarer Ablauf, kleine Auswahl, kurze Lieferzeit." + ), + "image": "portrait-natural", + "fit": "50% 30%", + }, + { + "slug": "video", + "href": "/video/", + "title": "Videoprojekte", + "short": ( + "Musikvideo, Imagefilm, Recruiting und Eventnachbericht — von der Idee " + "über den Dreh bis zum fertigen Schnitt." + ), + "image": "studio-lens", + "fit": "50% 45%", + }, + { + "slug": "social-media", + "href": "/social-media/", + "title": "Social-Media-Inhalte", + "short": ( + "Foto- und Videocontent im Format deiner Kanäle — wiederkehrend, planbar " + "und über die Monate hinweg einheitlich." + ), + "image": "event-motion", + "fit": "50% 45%", + }, +] + +# Fotografie-Uebersicht +PHOTO_OVERVIEW = [ + { + "title": "Menschen", + "text": ( + "Portraits, Sedcards, Bewerbungsbilder und Familienaufnahmen. " + "Wir arbeiten mit Licht, das zu dir passt — nicht mit dem Standardschema." + ), + "image": "editorial-pose", + }, + { + "title": "Marken & Unternehmen", + "text": ( + "Team-, Standort- und Produktbilder, die auf Website, Kampagne und in " + "Stellenanzeigen funktionieren." + ), + "image": "business-portrait-sit", + }, + { + "title": "Anlässe", + "text": ( + "Feiern, Events, Vereinsleben und Premieren. Dokumentarisch begleitet, " + "ohne den Ablauf auszubremsen." + ), + "image": "event-dance", + }, +] + +WORK_GALLERY = [ + ("editorial-pose", "Model-Shooting im Studio", "g-w3", "4x5", "50% 28%"), + ("travel-norway-pano", "Norwegen — Panorama", "g-w3", "4x5", "50% 50%"), + ("people-forest", "Paarportrait im Wald", "g-w2", "1x1", "50% 26%"), + ("editorial-back", "Editorial — Detail", "g-w2", "1x1", "50% 40%"), + ("group-outdoor", "Feier im Freien", "g-w2", "1x1", "50% 36%"), + ("movement-light", "Lichtspur aus langer Belichtung", "g-w3", "3x2", "50% 50%"), + ("business-portrait-dark", "Businessportrait im Studiolicht", "g-w3", "3x2", "50% 30%"), +] + +PROCESS = [ + { + "title": "Anfrage", + "text": ( + "Du schreibst kurz, worum es geht. Ich melde mich mit einer ehrlichen " + "Einschätzung, einem Ablauf und einem Preis." + ), + }, + { + "title": "Planung", + "text": ( + "Wir klären Ort, Licht, Zeit und Bildidee. Bei größeren Produktionen " + "gibt es vorab ein Moodboard oder ein Treatment." + ), + }, + { + "title": "Dreh", + "text": ( + "Am Termin übernehme ich Technik, Licht und Regie. Du musst nichts " + "mitbringen außer Zeit und dich." + ), + }, + { + "title": "Lieferung", + "text": ( + "Du bekommst eine Auswahlgalerie, bearbeitete Dateien in Web- und " + "Druckgröße und bei Video das passende Exportformat für deinen Kanal." + ), + }, +] + +FACTS = [ + { + "title": "Aus Neumünster", + "text": "Zuhause zwischen Kiel und Hamburg — unterwegs in Schleswig-Holstein und darüber hinaus.", + }, + { + "title": "Foto und Video", + "text": "Ein Ansprechpartner für Bild und Bewegtbild. Das spart Abstimmung und hält die Optik zusammen.", + }, + { + "title": "Klarer Ablauf", + "text": "Umfang, Termin und Preis klären wir vor der Buchung. Keine Überraschungen auf der Rechnung.", + }, + { + "title": "Schnelle Lieferung", + "text": "Kleine Aufträge bekommst du meist innerhalb weniger Tage, größere Projekte nach Absprache.", + }, +] + +FOCUS_ITEMS = [ + ("Businessfotografie", "/businessfotografie/"), + ("Portrait & Model", "/portrait-und-model/"), + ("Familien & Paare", "/familien-und-paare/"), + ("Minishootings", "/minishootings/"), + ("Videoprojekte", "/video/"), + ("Social Media", "/social-media/"), +] diff --git a/src/images.json b/src/images.json new file mode 100644 index 0000000..1603a2c --- /dev/null +++ b/src/images.json @@ -0,0 +1,210 @@ +{ + "_comment": "Quellbilder aus dem eigenen Bilderpool (Orgatool-Volume business-suite_app_data/website). 'slug' wird zum Dateinamen der Auslieferungsderivate. 'alt' ist die redaktionelle Bildbeschreibung fuer Alternativtexte. 'raw' ist der Pfad im Pool.", + "pool_base": "https://orga.dennyschulz.de/api/website/media/files", + "images": [ + { + "slug": "hero-studio", + "raw": "Studio/1777027118997_1b1a51a6_dennyschulz_-07282.jpg", + "alt": "Hand an einer Kamera im Studio bei violettem Licht", + "focus": "50% 45%" + }, + { + "slug": "studio-lens", + "raw": "Studio/1777027118366_91e2a99d_dennyschulz_-07285.jpg", + "alt": "Kameraobjektiv im Studiolicht", + "focus": "50% 50%" + }, + { + "slug": "studio-setup", + "raw": "Studio/1777027120196_c501de64_dennyschulz_-05467.jpg", + "alt": "Studioequipment mit Logo von Denny Schulz Fotografie", + "focus": "50% 50%" + }, + { + "slug": "gear-cases", + "raw": "1778949186414_b1648d20_dennyschulz_-05400-2.jpg", + "alt": "Kamerakoffer und Objektive vor einem Studiorack", + "focus": "50% 50%" + }, + { + "slug": "business-portrait-studio", + "raw": "Studio/1777027114982_e7e775e6_dennyschulz_-05691.jpg", + "alt": "Mann im weißen Hemd vor dunklem Hintergrund, Softbox im Bild", + "focus": "50% 35%" + }, + { + "slug": "business-portrait-dark", + "raw": "1778941495302_d24d7afc_dennyschulz_-05646.jpg", + "alt": "Portrait eines Mannes vor dunklem Hintergrund", + "focus": "50% 35%" + }, + { + "slug": "business-portrait-sit", + "raw": "Studio/1777027115506_fba8055d_dennyschulz_-05649.jpg", + "alt": "Sitzendes Portrait mit Kameratasche im Studio", + "focus": "50% 40%" + }, + { + "slug": "business-portrait-stool", + "raw": "Studio/1777027117104_bb02b0b7_dennyschulz_-05646.jpg", + "alt": "Sitzendes Portrait im weichen Studiolicht", + "focus": "50% 40%" + }, + { + "slug": "editorial-back", + "raw": "1776342910702_0453b820_dennyschulz_-01677.jpg", + "alt": "Detailaufnahme eines Rückens mit Sporttop", + "focus": "50% 45%" + }, + { + "slug": "editorial-pose", + "raw": "1776342912592_e4e6f295_dennyschulz_-01645.jpg", + "alt": "Editorial-Portrait vor schwarzem Hintergrund", + "focus": "50% 35%" + }, + { + "slug": "editorial-earring", + "raw": "1776342914105_67b5bfad_dennyschulz_-01684.jpg", + "alt": "Portrait im Profil mit Ohrring", + "focus": "45% 35%" + }, + { + "slug": "editorial-black", + "raw": "1776342915877_c2112e30_dennyschulz_-02178.jpg", + "alt": "Model im Sporttop vor schwarzem Hintergrund", + "focus": "50% 35%" + }, + { + "slug": "editorial-colour", + "raw": "1776343219659_8daa3076_dennyschulz_-02946.jpg", + "alt": "Model in dunklem Kleid in farbigem Licht", + "focus": "50% 35%" + }, + { + "slug": "portrait-natural", + "raw": "1784293240857_deae4788_dennyschulz_-07255.jpg", + "alt": "Lachendes Paar im natürlichen Licht", + "focus": "50% 35%" + }, + { + "slug": "portrait-couple", + "raw": "1784293231735_a8ccbd3e_dennyschulz_-08620.jpg", + "alt": "Paarportrait im Grünen", + "focus": "50% 35%" + }, + { + "slug": "people-joy", + "raw": "1776343134779_e893c351_dennyschulz_-03745114107.jpg", + "alt": "Frau mit Blumenstrauß lacht in die Kamera", + "focus": "50% 35%" + }, + { + "slug": "people-kiss", + "raw": "1776343151721_e3f65ef1_dennyschulz_-04483.jpg", + "alt": "Paar in inniger Umarmung", + "focus": "50% 40%" + }, + { + "slug": "people-forest", + "raw": "Portfolio/hochzeit/1788686124131_7ce60a11_280826_dennyschulz_fav-18.jpg", + "alt": "Paar im Wald, Portrait", + "focus": "50% 30%" + }, + { + "slug": "people-veil", + "raw": "Portfolio/hochzeit/1788686144082_7e69d21e_280826_dennyschulz_fav-22.jpg", + "alt": "Paar unter einem hellen Schleier, Gegenlicht", + "focus": "50% 40%" + }, + { + "slug": "people-walk", + "raw": "Portfolio/hochzeit/1788347325902_9093b843_280826_feier-3.jpg", + "alt": "Paar geht Hand in Hand ueber einen Waldweg", + "focus": "50% 40%" + }, + { + "slug": "group-feier", + "raw": "1777033639756_1f5a9dc4_dennyschulz_-03904.jpg", + "alt": "Gruppe feiert gemeinsam vor einem Gebäude", + "focus": "50% 45%" + }, + { + "slug": "group-outdoor", + "raw": "1784293233172_7a8a3ed9_dennyschulz_-08435.jpg", + "alt": "Gruppe bei einer Feier im Freien", + "focus": "50% 40%" + }, + { + "slug": "group-posiert", + "raw": "1784293233955_033a5079_dennyschulz_-08456.jpg", + "alt": "Gruppe posiert im Grünen", + "focus": "50% 45%" + }, + { + "slug": "event-motion", + "raw": "1776670469051_dc2993ae_dennyschulz_-04663.jpg", + "alt": "Menschen in Bewegung, bewusst unscharf aufgenommen", + "focus": "50% 45%" + }, + { + "slug": "event-speaker", + "raw": "1784293225986_a37d21f4_dennyschulz_-07139.jpg", + "alt": "Frau spricht mit Mikrofon bei einer Veranstaltung", + "focus": "50% 35%" + }, + { + "slug": "event-venue", + "raw": "1788932455845_78e0851f_dennyschulz_-00451.jpg", + "alt": "Stuhlreihen in einem festlich vorbereiteten Raum", + "focus": "50% 40%" + }, + { + "slug": "event-dance", + "raw": "1784293226489_fa1b9420_dennyschulz_-09046.jpg", + "alt": "Paar tanzt unter einem Kronleuchter", + "focus": "50% 40%" + }, + { + "slug": "movement-light", + "raw": "1788850842323_a5ae410e_280826_shooting-69.jpg", + "alt": "Lichtspur aus einer langen Belichtung", + "focus": "50% 50%" + }, + { + "slug": "travel-japan", + "raw": "1776263908372_dennyschulz_-05756.jpg", + "alt": "Torii-Tor in einem moosgrünen Garten in Japan", + "focus": "45% 45%" + }, + { + "slug": "travel-norway-lake", + "raw": "1776264398432_dennyschulz_-1044.jpg", + "alt": "Spiegelung einer Hütte auf einem See in Norwegen", + "focus": "50% 50%" + }, + { + "slug": "travel-norway-coast", + "raw": "1776332816743_c0361ec0_dennyschulz.jpg", + "alt": "Felsküste an einem Fjord in Norwegen", + "focus": "50% 50%" + }, + { + "slug": "travel-norway-valley", + "raw": "1776333775618_94cc71b8_dennyschulz_--2.jpeg", + "alt": "Flusstal zwischen Bergen im Morgennebel", + "focus": "50% 50%" + }, + { + "slug": "travel-norway-pano", + "raw": "1776346804976_5ab84574_dennyschulz_pano2.jpg", + "alt": "Panorama einer Berglandschaft in Norwegen", + "focus": "50% 50%" + }, + { + "slug": "travel-blossom", + "raw": "1776344687313_d34fd8f9_dennyschulz_-05183.jpg", + "alt": "Kirschblüte mit einem Eichhörnchen", + "focus": "50% 40%" + } + ] +} diff --git a/src/layout.py b/src/layout.py new file mode 100644 index 0000000..d6f2c4b --- /dev/null +++ b/src/layout.py @@ -0,0 +1,355 @@ +"""HTML shell, navigation, media helpers and page furniture.""" + +from __future__ import annotations + +import html +import json + +from content import CONTACT, LEGAL, NAV, SITE + +# Filled by scripts/build-site.py before rendering: slug -> derivatives metadata. +IMAGE_TABLE: dict[str, dict] = {} + +ICONS = { + "arrow": '', + "arrow_up_right": '', + "menu": '', + "external": '', +} + +MARK_SVG = """ + + + + + + """ + + +def e(value: object) -> str: + return html.escape(str(value), quote=True) + + +def picture( + slug: str, + sizes: str, + *, + css_class: str = "frame", + extra_class: str = "", + loading: str = "lazy", + priority: bool = False, + alt: str | None = None, + fit: str | None = None, + decoding: str = "async", +) -> str: + """Responsive for a processed pool image.""" + meta = IMAGE_TABLE.get(slug) + if meta is None: + raise KeyError(f"unknown image slug: {slug}") + srcset = ", ".join(f"{meta['base']}-{w}.webp {w}w" for w in meta["widths"]) + largest = meta["widths"][-1] + fallback = f"{meta['base']}-{min(1200, largest)}.webp" + cls = " ".join(part for part in [css_class, "frame--hover" if not priority else "", extra_class] if part) + attrs = [ + f'class="{e(cls)}"', + f'src="{e(fallback)}"', + f'srcset="{e(srcset)}"', + f'sizes="{e(sizes)}"', + f'width="{meta["width"]}"', + f'height="{meta["height"]}"', + f'decoding="{e(decoding)}"', + f'loading="{e(loading)}"', + f'alt="{e(alt or meta["alt"])}"', + f'style="object-position:{e(fit or meta["focus"])}"', + ] + if priority: + attrs.append('fetchpriority="high"') + return "" + + +def preload(slug: str, sizes: str) -> str: + meta = IMAGE_TABLE[slug] + srcset = ", ".join(f"{meta['base']}-{w}.webp {w}w" for w in meta["widths"]) + return ( + f'' + ) + + +def nav_html(current: str) -> str: + parts = [] + for item in NAV: + children = item.get("children") or [] + is_current = current == item["href"] or any(current == href for _, href in children) + current_attr = ' aria-current="page"' if is_current else "" + if children: + sub_parts = [] + for label, href in children: + sub_current = ' aria-current="page"' if current == href else "" + sub_parts.append(f'{e(label)}') + sub_items = "".join(sub_parts) + parts.append( + '" + ) + else: + parts.append( + f'{e(item["label"])}' + ) + parts.append('Projekt anfragen') + return "".join(parts) + + +def head( + *, + title: str, + description: str, + path: str, + og_image: str = "og-desfoto.jpg", + og_type: str = "website", + json_ld: list[dict] | None = None, + preload_html: str = "", + robots: str = "index,follow,max-image-preview:large", + indexable: bool = True, +) -> str: + canonical = f"{SITE['url']}{path}" + canonical_html = f'\n' if indexable else "" + ld = "" + if json_ld: + ld = "\n".join( + '" + for block in json_ld + ) + return f""" + + + + +{e(title)} + + + + +{canonical_html} + + + + + + + + + + + + + + + + + + +{preload_html} +{ld} + + + +
+
+ + {MARK_SVG} + desfoto{e(SITE['claim'])} + + + +
+
+
""" + + +def footer_html() -> str: + return f"""
+ + + + +""" + + +def page( + *, + title: str, + description: str, + path: str, + body: str, + og_image: str = "og-desfoto.jpg", + og_type: str = "website", + json_ld: list[dict] | None = None, + preload_html: str = "", + robots: str = "index,follow,max-image-preview:large", + indexable: bool = True, +) -> str: + return ( + head( + title=title, + description=description, + path=path, + og_image=og_image, + og_type=og_type, + json_ld=json_ld, + preload_html=preload_html, + robots=robots, + indexable=indexable, + ) + + body + + footer_html() + ) + + +def page_head( + *, + kicker: str, + title: str, + lead: str, + image: str, + breadcrumb: tuple[str, str] | None = None, + actions: str = "", + fit: str | None = None, +) -> str: + crumb = "" + if breadcrumb: + crumb = ( + f'' + ) + actions_html = f'
{actions}
' if actions else "" + return f"""
+
+ {picture(image, "100vw", css_class="", priority=True, loading="eager", alt="", fit=fit)} +
+
+ {crumb} + {e(kicker)} +

{e(title)}

+

{lead}

+ {actions_html} +
+
""" + + +def section( + *, + kicker: str = "", + title: str = "", + lead: str = "", + content: str = "", + variant: str = "", + tight: bool = False, + anchor: str = "", + title_id: str = "", +) -> str: + classes = ["section"] + if tight: + classes.append("section--tight") + if variant: + classes.append(f"section--{variant}") + head_bits = [] + if kicker: + head_bits.append(f'{e(kicker)}') + if title: + id_attr = f' id="{e(title_id)}"' if title_id else "" + head_bits.append(f'

{e(title)}

') + if lead: + head_bits.append(f'

{lead}

') + head_html = "" + if head_bits: + head_html = '
' + "".join(head_bits) + "
" + anchor_attr = f' id="{e(anchor)}"' if anchor else "" + return ( + f'
' + + head_html + + content + + "
" + ) + + +def cta_band( + *, + title: str = "Lass uns dein Projekt besprechen.", + text: str = "Schreib kurz, worum es geht, wann es stattfinden soll und was du mit den Bildern vorhast. Du bekommst eine ehrliche Einschätzung und einen Preis.", + primary: tuple[str, str] = ("Projekt anfragen", "/kontakt/"), + secondary: tuple[str, str] | None = ("Arbeiten ansehen", "/projekte/"), +) -> str: + secondary_html = ( + f'{e(secondary[0])}' + if secondary + else "" + ) + return f"""
+
+
+
+ Kontakt +

{e(title)}

+

{e(text)}

+
+
+ {e(primary[0])} + {secondary_html} +
+
+
+
""" diff --git a/src/pages.py b/src/pages.py new file mode 100644 index 0000000..38d132a --- /dev/null +++ b/src/pages.py @@ -0,0 +1,1559 @@ +"""Page bodies for desfoto.de. + +Each function returns the inner HTML of
. Titles, descriptions and +canonical paths are returned by the ROUTES table at the bottom of the module. +""" + +from __future__ import annotations + +from content import ( + CONTACT, + FACTS, + LEGAL, + PHOTO_OVERVIEW, + PROCESS, + SERVICES, + SITE, + VIDEO, + WORK_GALLERY, +) +from layout import ICONS, cta_band, e, page, page_head, picture, preload, section + +# ---------------------------------------------------------------- helper bits + + +def service_cards(slugs: list[str] | None = None) -> str: + cards = [] + chosen = [s for s in SERVICES if slugs is None or s["slug"] in slugs] + for index, service in enumerate(chosen, start=1): + cards.append( + '
' + f'
{picture(service["image"], "(min-width: 62rem) 30vw, (min-width: 40rem) 45vw, 92vw", css_class="frame frame--16x10", fit=service.get("fit"))}
' + f'

{index:02d}{e(service["title"])}

' + f'

{e(service["short"])}

' + f'' + "
" + ) + return '
' + "".join(cards) + "
" + + +def work_gallery() -> str: + figures = [] + for slug, caption, width_class, ratio, fit in WORK_GALLERY: + figures.append( + f'
' + + picture( + slug, + "(min-width: 62rem) 45vw, 92vw", + css_class=f"frame frame--{ratio}", + fit=fit, + alt=caption, + ) + + f"
{e(caption)}
" + ) + return '" + + +def process_steps() -> str: + steps = "".join( + f'

{e(step["title"])}

{e(step["text"])}

' + for step in PROCESS + ) + return f'
{steps}
' + + +def facts_grid() -> str: + facts = "".join( + f'

{e(fact["title"])}

{e(fact["text"])}

' for fact in FACTS + ) + return f'
{facts}
' + + +def video_block(*, dark: bool = True, with_meta: bool = True) -> str: + meta = ( + '
' + f'{e(VIDEO["title"])} · {e(VIDEO["kind"])} für {e(VIDEO["artist"])}' + "
" + if with_meta + else "" + ) + return f"""
+ + {meta} +
+

Das Video wird erst nach deinem Klick von YouTube geladen (youtube-nocookie.com). Mehr dazu in der Datenschutzerklärung.

""" + + +def ticks(items: list[str]) -> str: + return '
    ' + "".join(f"
  • {item}
  • " for item in items) + "
" + + +def two_col(*, left: str, right: str, ratio: str = "minmax(0,1fr) minmax(0,1fr)") -> str: + return f'
{left}{right}
' + + +def contact_form() -> str: + return f"""
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Direkt per E-Mail +
+ +

+ Das Formular sendet nichts an einen Server. Es baut aus deinen Angaben eine E-Mail in deinem + Mailprogramm zusammen. Deine Eingaben bleiben dafür ausschließlich in deinem Browser. + Alternativ erreichst du mich direkt unter {e(CONTACT['email'])} + oder {e(CONTACT['phone'])}. +

+
""" + + +def breadcrumb_ld(name: str, path: str) -> dict: + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": [ + {"@type": "ListItem", "position": 1, "name": "Start", "item": SITE["url"] + "/"}, + {"@type": "ListItem", "position": 2, "name": name, "item": SITE["url"] + path}, + ], + } + + +def service_ld(name: str, path: str, description: str) -> dict: + return { + "@context": "https://schema.org", + "@type": "Service", + "name": name, + "serviceType": name, + "description": description, + "url": SITE["url"] + path, + "provider": { + "@type": "ProfessionalService", + "name": SITE["brand"], + "legalName": LEGAL["business"], + "url": SITE["url"], + "email": CONTACT["email"], + "telephone": CONTACT["phone"], + "address": { + "@type": "PostalAddress", + "streetAddress": LEGAL["street"], + "postalCode": "24537", + "addressLocality": "Neumünster", + "addressCountry": "DE", + }, + }, + "areaServed": ["Neumünster", "Schleswig-Holstein", "Hamburg", "Kiel"], + } + + +# --------------------------------------------------------------------- pages + + +def home() -> str: + hero = f"""
+
+ {picture('hero-studio', '100vw', css_class='', priority=True, loading='eager', fit='50% 45%', alt='Hand an einer Kamera im Studio bei violettem Licht')} +
+
+ {e(SITE['tagline'])} +

Bilder und Filme, die nicht beliebig aussehen.

+

+ desfoto ist die fotografische und filmische Arbeit von Denny Schulz — für Unternehmen, + Agenturen, Models, Vereine und Menschen, die sich selbst zeigen wollen. +

+ +
+
Foto & VideoBeides aus einer Hand, damit Optik und Ton zusammenpassen.
+
Studio & on locationIn Neumünster und überall dort, wo dein Projekt spielt.
+
B2B & privatVon der Agenturserie bis zum Familienshooting.
+
Kurze WegeDirekter Kontakt, klare Absprachen, schnelle Lieferung.
+
+
+
""" + + statement = section( + tight=True, + kicker="Haltung", + content="""
+

Kurz gesagt

+
+

Ich mache Bilder, die etwas erzählen und trotzdem funktionieren: auf der Website, + im Feed, im Bewerbungsportfolio, im Plakat. Kein Katalog von der Stange — wir klären kurz, + was du brauchst, und ich liefere genau das.

+
+
""", + ) + + services = section( + kicker="Leistungen", + title="Was ich für dich mache", + lead="Sechs Bereiche, ein Ansprechpartner. Klick dich rein oder schreib direkt, wenn du unsicher bist, was zu deinem Vorhaben passt.", + content=service_cards(), + ) + + works = section( + variant="paper2", + kicker="Aus der Arbeit", + title="Auszug aus Aufträgen und eigenen Projekten", + lead="Studioportraits, Businessbilder, Paare, Veranstaltungen und Reisen — die Auswahl zeigt die Bandbreite, nicht nur den schönsten Moment.", + content=work_gallery() + + '', + ) + + video_sec = f"""
+
+
+
+ Video +

Musikvideo für Thjódrörir

+

+ Für das Musikvideo zu Skogamor habe ich Konzept, Dreh, Schnitt und + Farbkorrektur übernommen. Klick auf das Vorschaubild, dann wird das Video geladen. +

+
+ Mehr zu Video {ICONS['arrow']} +
+
{video_block(with_meta=False)}
+
+
""" + + process = section( + kicker="Ablauf", + title="Vier Schritte, kein Rätselraten", + lead="Ob Minishooting oder Kampagne: Der Weg ist immer derselbe und du weißt jederzeit, was als Nächstes passiert.", + content=process_steps(), + ) + + facts = section( + variant="paper2", + kicker="Warum desfoto", + title="Was du erwarten kannst", + content=facts_grid(), + ) + + return page( + title="desfoto · Fotografie & Video aus Neumünster", + description=SITE["default_description"], + path="/", + preload_html=preload("hero-studio", "100vw"), + json_ld=[ + { + "@context": "https://schema.org", + "@type": "ProfessionalService", + "name": SITE["brand"], + "alternateName": LEGAL["business"], + "description": SITE["default_description"], + "url": SITE["url"] + "/", + "image": SITE["url"] + "/assets/img/og-desfoto.jpg", + "email": CONTACT["email"], + "telephone": CONTACT["phone"], + "address": { + "@type": "PostalAddress", + "streetAddress": LEGAL["street"], + "postalCode": "24537", + "addressLocality": "Neumünster", + "addressCountry": "DE", + }, + "founder": {"@type": "Person", "name": LEGAL["owner"]}, + "areaServed": ["Neumünster", "Schleswig-Holstein", "Hamburg", "Kiel"], + "knowsAbout": [s["title"] for s in SERVICES], + } + ], + body=hero + statement + services + works + video_sec + process + facts + cta_band(), + ) + + +def fotografie() -> str: + path = "/fotografie/" + overview = "".join( + f'
' + + picture(item["image"], "(min-width: 62rem) 30vw, 92vw", css_class="frame frame--4x5", fit="50% 30%") + + f'

{e(item["title"])}

{e(item["text"])}

' + for item in PHOTO_OVERVIEW + ) + body = ( + page_head( + kicker="Fotografie", + title="Menschen, Marken und Momente", + lead="Vier Schwerpunkte, ein Handwerk. Entscheide dich für den Bereich, der passt — oder schreib mir, wenn du zwischen zwei Bereichen stehst.", + image="editorial-earring", + breadcrumb=("Fotografie", path), + fit="45% 30%", + actions='Projekt anfragen' + 'Arbeiten ansehen', + ) + + section( + kicker="Überblick", + title="Womit ich arbeite", + lead="Studiolicht in Neumünster, available light draußen, Reportage dort, wo es lebendig ist.", + content=f'
{overview}
', + ) + + section( + variant="paper2", + kicker="Schwerpunkte", + title="Die vier Bereiche im Detail", + content=service_cards(["businessfotografie", "portrait-und-model", "familien-und-paare", "minishootings"]), + tight=True, + ) + + section( + kicker="Technik & Ausgabe", + title="Was am Ende bei dir landet", + lead="Bilddaten in der Auflösung, die du wirklich brauchst — nicht ein Ordner voller RAW-Dateien.", + content=two_col( + left=ticks( + [ + "Bearbeitete JPEG-Dateien in Web- und Druckgröße", + "Auswahlgalerie zum Sichten und Teilen", + "Retusche auf Wunsch, besprochen im Vorfeld", + "Nutzungsrechte schriftlich festgehalten", + ] + ), + right=ticks( + [ + "Studiolicht und mobile Blitzanlage im Einsatz", + "Available-light-Fotografie on location", + "Reportagestil bei Veranstaltungen", + "Lieferung per Downloadlink", + ] + ), + ), + ) + + cta_band() + ) + return page( + title="Fotografie · desfoto", + description="Fotografie aus Neumünster: Business, Portrait & Model, Familien & Paare, Minishootings. Studiolicht und available light, ein Ansprechpartner.", + path=path, + json_ld=[service_ld("Fotografie", path, "Fotografie für Unternehmen, Models, Familien und Paare."), breadcrumb_ld("Fotografie", path)], + body=body, + ) + + +def businessfotografie() -> str: + path = "/businessfotografie/" + body = ( + page_head( + kicker="Businessfotografie", + title="Bilder, die dein Unternehmen ernst nehmen", + lead="Portraits, Teams, Standorte und Produkte — abgestimmt auf die Flächen, auf denen sie später stehen: Website, LinkedIn, Kampagne, Stellenanzeige.", + image="business-portrait-studio", + breadcrumb=("Businessfotografie", path), + fit="50% 30%", + actions='Termin anfragen', + ) + + section( + kicker="Typische Aufträge", + title="Wofür Unternehmen mich buchen", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ( + "Portraits & Profilbilder", + "Einzelpersonen vor ruhigem Hintergrund oder im eigenen Arbeitsumfeld. Mehrere Varianten, damit Website und Profil nicht dasselbe Bild zeigen.", + ), + ( + "Teams & Standort", + "Menschen bei der Arbeit, Räume, Details. Zeigt, wie es bei euch wirklich aussieht — ohne Stockfoto-Gefühl.", + ), + ( + "Produkt & Detail", + "Sauber ausgeleuchtete Produkte für Shop, Katalog und Anzeigen, auch mit Szenenbildern.", + ), + ( + "Recruiting & Arbeitgebermarke", + "Bildpakete für Stellenanzeigen und Karriereseiten, die Bewerberinnen und Bewerber vorher sehen lassen, was sie erwartet.", + ), + ( + "Presse & Kampagne", + "Bildstrecken für Pressemitteilungen, Anzeigen und Social-Media-Kampagnen, inklusive der passenden Formate.", + ), + ( + "Events & Messen", + "Dokumentarische Begleitung von Firmenfeiern, Tagungen und Messeauftritten.", + ), + ] + ) + + "
", + ) + + section( + variant="paper2", + kicker="Zusammenarbeit", + title="Was ich von euch brauche", + lead="Ein kurzer Call oder eine E-Mail mit drei Angaben reicht für ein verbindliches Angebot.", + content=two_col( + left=ticks( + [ + "Wofür die Bilder gebraucht werden (Website, Print, Social, Presse)", + "Wie viele Personen oder Produkte fotografiert werden", + "Ort und Wunschzeitraum — bei euch, im Studio oder draußen", + "Ob einheitliche Hintergründe oder zusätzliche Retusche gebraucht werden", + ] + ), + right='
Nutzungsrechte: Wir halten vorab fest, für welche Kanäle und wie lange die Bilder genutzt werden dürfen. Das schützt euch und mich — und ihr bekommt genau die Rechte, die ihr braucht.
' + '

Nach dem Shooting bekommt ihr eine Auswahlgalerie, in der ihr kommentieren und freigeben könnt. Erst danach geht es in die finale Bearbeitung.

', + ), + ) + + section( + kicker="Beispiele", + title="Aus der Business- und Studiopraxis", + content='
' + + picture("business-portrait-dark", "(min-width: 62rem) 45vw, 92vw", css_class="frame frame--4x5", fit="50% 28%") + + picture("business-portrait-stool", "(min-width: 62rem) 45vw, 92vw", css_class="frame frame--4x5", fit="50% 35%") + + picture("gear-cases", "(min-width: 62rem) 45vw, 92vw", css_class="frame frame--3x2") + + picture("studio-setup", "(min-width: 62rem) 45vw, 92vw", css_class="frame frame--3x2") + + "
", + ) + + cta_band( + title="Bildpaket für euer Unternehmen?", + text="Schreib kurz, wie viele Personen oder Produkte es sind und wofür die Bilder gebraucht werden. Du bekommst Ablauf, Aufwand und Preis.", + ) + ) + return page( + title="Businessfotografie in Neumünster · desfoto", + description="Businessfotografie aus Neumünster: Portraits, Team- und Standortbilder, Produktfotografie, Recruiting-Bilder und Eventbegleitung für Unternehmen und Agenturen.", + path=path, + json_ld=[ + service_ld("Businessfotografie", path, "Portraits, Team-, Standort- und Produktbilder für Unternehmen."), + breadcrumb_ld("Businessfotografie", path), + ], + body=body, + ) + + +def portrait_und_model() -> str: + path = "/portrait-und-model/" + body = ( + page_head( + kicker="Portrait & Model", + title="Zeig dich so, wie du wirklich bist", + lead="Portraits, Sedcards, Editorial und Fashion. Im Studio in Neumünster oder an einem Ort, der zu dir und deiner Geschichte passt.", + image="editorial-colour", + breadcrumb=("Portrait & Model", path), + fit="50% 28%", + actions='Shooting anfragen' + 'Kurz mal reinschnuppern', + ) + + section( + kicker="Formate", + title="Was wir zusammen machen können", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ( + "Portrait", + "Natürlich, klar oder dramatisch — je nachdem, wie du gesehen werden willst. Auch für Bewerbung und Profil.", + ), + ( + "Sedcard & Modelbook", + "Für neue Gesichter und erfahrene Models: ein Set, das Agenturen und Kundinnen einen Überblick gibt.", + ), + ( + "Editorial & Fashion", + "Konzept, Licht und Location als zusammenhängende Bildstrecke — mit oder ohne Team.", + ), + ( + "Testshooting / TFP", + "Gemeinsam Ideen ausprobieren, ohne Budget. Auf Anfrage, wenn Konzept und Termin passen.", + ), + ( + "Bewerbungs- & Businessportrait", + "Der Klassiker in sauber: keine Steifheit, aber ein Bild, das im Bewerbungsverfahren funktioniert.", + ), + ( + "Content für deine Kanäle", + "Bilder, die du danach über Wochen posten kannst — verschiedene Looks, ein Termin.", + ), + ] + ) + + "
", + ) + + section( + variant="paper2", + kicker="Ablauf", + title="Von der Idee zur Auswahl", + lead="Du musst nicht wissen, wie du vor der Kamera stehen sollst. Genau dafür bin ich da.", + content=process_steps(), + ) + + section( + kicker="Studio & on location", + title="Zwei Wege zum Bild", + content=two_col( + left="""
+

Im Studio Neumünster

+

Kontrolliertes Licht, ruhiger Hintergrund, Platz für Konzeptarbeit. + Gut bei Wind und Wetter und für alles, was eine klare, wiederholbare Optik braucht.

+
""", + right="""
+

Draußen, wo du bist

+

Available light, Stadt, Wald, Wasser oder dein eigener Ort. + Lebendiger, weniger kontrolliert — und oft genau richtig für Portraits, die sich echt anfühlen.

+
""", + ), + ) + + section( + variant="paper2", + kicker="Beispiele", + title="Portraits und Editorial", + content='
' + + "".join( + picture(slug, "(min-width: 62rem) 30vw, 92vw", css_class="frame frame--4x5", fit=fit) + for slug, fit in [ + ("editorial-pose", "50% 28%"), + ("editorial-back", "50% 40%"), + ("editorial-black", "50% 28%"), + ("editorial-earring", "45% 28%"), + ("portrait-natural", "50% 30%"), + ("people-kiss", "50% 32%"), + ] + ) + + "
", + ) + + cta_band( + title="Lass uns dein Portrait machen.", + text="Schreib mir, was du dir vorstellst und wofür die Bilder gedacht sind. Ich schlage dir Licht, Ort und Ablauf vor.", + secondary=("Minishootings ansehen", "/minishootings/"), + ) + ) + return page( + title="Portrait & Model · desfoto Neumünster", + description="Portrait-, Model- und Editorialfotografie aus Neumünster: Sedcards, Fashion, Businessportraits und Testshootings im Studio oder on location.", + path=path, + json_ld=[ + service_ld("Portrait- und Model-Fotografie", path, "Portraits, Sedcards, Editorial und Fashion im Studio oder on location."), + breadcrumb_ld("Portrait & Model", path), + ], + body=body, + ) + + +def familien_und_paare() -> str: + path = "/familien-und-paare/" + body = ( + page_head( + kicker="Familien & Paare", + title="Wachsendes festhalten, ohne Pose", + lead="Familienshootings und Paarportraits, die nicht gestellt wirken. Zeit lassen, mitgehen und im richtigen Moment auslösen.", + image="people-joy", + breadcrumb=("Familien & Paare", path), + fit="50% 30%", + actions='Termin anfragen', + ) + + section( + kicker="Anlässe", + title="Für welche Momente mich Familien und Paare buchen", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ( + "Familienshooting", + "Alle zusammen, ohne dass es nach Fototermin aussieht. Auch mit mehreren Generationen.", + ), + ( + "Paarshooting", + "Zwei Menschen, die sich nah sind. Vor der Hochzeit, zum Jahrestag oder einfach so.", + ), + ( + "Verlobung & Ja-Wort", + "Kleine Zeremonien, Standesamt, freie Trauung — begleitend oder als Portraitstrecke.", + ), + ( + "Jubiläum & Familientreffen", + "Wenn die ganze Verwandtschaft zusammenkommt, lohnt sich ein gemeinsames Bild.", + ), + ( + "Generationenbilder", + "Großeltern, Eltern, Enkel: Bilder, die in Jahren mehr wert sind als heute.", + ), + ( + "Kinderportraits", + "Kurz, spielerisch und geduldig — lieber zehn ehrliche Bilder als eine Stunde Stillhalten.", + ), + ] + ) + + "
", + ) + + section( + variant="paper2", + kicker="Ablauf", + title="Entspannt statt steif", + lead="Wir treffen uns dort, wo ihr euch wohlfühlt — im Studio, im Park oder bei euch zuhause. Ich sage, wann es losgeht, und danach einfach laufen lassen.", + content=process_steps(), + ) + + section( + kicker="Beispiele", + title="Paare und Menschen", + content='
' + + "".join( + picture(slug, "(min-width: 62rem) 30vw, 92vw", css_class="frame frame--4x5", fit=fit) + for slug, fit in [ + ("people-forest", "50% 24%"), + ("people-veil", "50% 32%"), + ("people-walk", "50% 32%"), + ("portrait-couple", "50% 30%"), + ("group-feier", "50% 36%"), + ("group-posiert", "50% 38%"), + ] + ) + + "
", + ) + + cta_band( + title="Termin für dein Familienshooting?", + text="Schreib mir, wer dabei ist, wo es stattfinden soll und ob du eher ruhige oder lebendige Bilder möchtest.", + secondary=("Minishootings ansehen", "/minishootings/"), + ) + ) + return page( + title="Familien- & Paarfotografie in Neumünster · desfoto", + description="Familien- und Paarshootings aus Neumünster: natürliche Bilder ohne Pose, im Studio, in der Natur oder bei euch zuhause.", + path=path, + json_ld=[ + service_ld("Familien- und Paarfotografie", path, "Familienshootings und Paarportraits ohne gestellte Posen."), + breadcrumb_ld("Familien & Paare", path), + ], + body=body, + ) + + +def minishootings() -> str: + path = "/minishootings/" + body = ( + page_head( + kicker="Minishootings", + title="Kurze Session, sofort brauchbares Bild", + lead="Der Termin für alle, die schnell ein gutes Bild brauchen: klarer Ablauf, kleine Auswahl, kurze Lieferzeit. Meist in rund 20 bis 30 Minuten erledigt.", + image="portrait-natural", + breadcrumb=("Minishootings", path), + fit="50% 30%", + actions='Minishooting buchen', + ) + + section( + kicker="Gut geeignet für", + title="Wann ein Minishooting reicht", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ("Profilbild", "Für LinkedIn, Bewerbung, Vereinsseite oder die Website."), + ("Paar & Familie", "Ein, zwei schöne Bilder zusammen — ohne großen Termin."), + ("Social Media", "Frisches Material für deine Kanäle, passend zum Format."), + ("Verein & Mannschaft", "Schnell alle Gesichter für Mannschaftsfotos und Anmeldungen."), + ("Businesskurzportrait", "Du brauchst nur ein Bild für die neue Stelle oder Präsentation."), + ("Geschenkidee", "Gutschein für ein gemeinsames Shooting — auf Anfrage."), + ] + ) + + "
", + ) + + section( + variant="paper2", + kicker="So läuft es", + title="Drei Schritte, ein Termin", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ( + "Termin und Ort", + "Du wählst einen freien Slot im Studio Neumünster oder draußen. Kurze Info vorab reicht.", + ), + ( + "Die Session", + "Rund 20 bis 30 Minuten. Ich führe durch Licht und Position, du musst nichts vorbereiten.", + ), + ( + "Lieferung", + "Du bekommst eine Auswahl zum Ansehen und die bearbeiteten Dateien in Web- und Druckgröße.", + ), + ] + ) + + "
", + ) + + section( + kicker="Gut zu wissen", + title="Was du vorher wissen solltest", + content=two_col( + left=ticks( + [ + "Outfitwechsel ist möglich, wenn du etwas mitbringst", + "Studio- oder Außentermin — je nach Wunsch und Licht", + "Fester Preis, vorab geklärt, keine Nachforderung", + "Termine nach Absprache, auch kurzfristig", + ] + ), + right='
Wie viele Bilder du bekommst und was das Shooting kostet, hängt von Umfang und Nutzung ab. Sag mir kurz, was du brauchst — dann nenne ich dir den Preis für dein Paket, bevor du buchst.
', + ), + ) + + section( + variant="paper2", + kicker="Beispiele", + title="Schnell und trotzdem gut", + content='
' + + "".join( + picture(slug, "(min-width: 62rem) 30vw, 92vw", css_class="frame frame--4x5", fit=fit) + for slug, fit in [ + ("portrait-natural", "50% 28%"), + ("business-portrait-studio", "50% 28%"), + ("editorial-black", "50% 28%"), + ] + ) + + "
", + ) + + cta_band( + title="Kurz, günstig, fertig.", + text="Schreib mir, wofür du das Bild brauchst und wann du Zeit hast. Du bekommst direkt einen Terminvorschlag.", + secondary=("Kontakt ansehen", "/kontakt/"), + ) + ) + return page( + title="Minishootings in Neumünster · desfoto", + description="Minishootings in Neumünster: kurze Fotosession mit festem Preis, schnelle Lieferung und Bildern in Web- und Druckgröße.", + path=path, + json_ld=[ + service_ld("Minishootings", path, "Kurze Fotosessions mit festem Preis und schneller Lieferung."), + breadcrumb_ld("Minishootings", path), + ], + body=body, + ) + + +def video() -> str: + path = "/video/" + body = ( + page_head( + kicker="Videoprojekte", + title="Vom Treatment bis zur finalen Datei", + lead="Musikvideo, Imagefilm, Recruiting oder Eventnachbericht — ich übernehme Konzept, Dreh, Schnitt, Ton und Ausgabe in den Formaten, die dein Kanal braucht.", + image="studio-lens", + breadcrumb=("Video", path), + fit="50% 45%", + actions='Videoprojekt anfragen', + ) + + f"""
+
+
+
+ Referenz +

{e(VIDEO['title'])}

+

{e(VIDEO['description'])}

+
+
+
{video_block()}
+
+
""" + + section( + kicker="Formate", + title="Welche Videos ich produziere", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ( + "Musikvideo", + "Bildsprache zur Musik: Konzept, Locations, Licht und Schnittrhythmus auf den Track abgestimmt.", + ), + ( + "Imagefilm", + "Wer ihr seid, was ihr macht, warum es sich lohnt. Mit O-Tönen, B-Roll und klarer Dramaturgie.", + ), + ( + "Recruiting-Video", + "Jobs und Team echt zeigen, statt mit Text zu beschreiben. Auch als kurze Stellen-Clips.", + ), + ( + "Eventnachbericht", + "Tagung, Vereinsjubiläum, Konzert oder Firmenfeier — als Zusammenschnitt für danach.", + ), + ( + "Interviews & Statements", + "Sauber ausgeleuchtet und vertont, als Ergänzung zu Fotoaufträgen oder Pressematerial.", + ), + ( + "Social-Media-Clips", + "Hochkant oder quadratisch zugeschnitten, mit Untertiteln und den richtigen Längen für die Feeds.", + ), + ] + ) + + "
", + ) + + section( + variant="paper2", + kicker="Produktionsweg", + title="So entsteht ein Video", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ("Idee & Treatment", "Ziel, Zielgruppe, Tonalität und Referenzen — kurz schriftlich festgehalten."), + ("Drehplanung", "Locations, Ablauf, Licht, benötigte Personen und Tageszeiten."), + ("Dreh", "Kamera-, Licht- und Tontechnik bringe ich mit. Du kümmerst dich um Abläufe und Menschen."), + ("Schnitt & Ausgabe", "Schnitt, Farbkorrektur, Ton, Untertitel und Export in den gewünschten Formaten."), + ] + ) + + "
", + ) + + section( + kicker="Zusammenarbeit", + title="Was ich von dir brauche", + content=two_col( + left=ticks( + [ + "Was das Video erreichen soll", + "Wo es laufen wird (Website, YouTube, Instagram, Messe, Recruiting)", + "Gewünschte Länge und Format", + "Wer mitwirkt und welche Orte zur Verfügung stehen", + ] + ), + right='
Musik sollte entweder von dir stammen oder entsprechend lizenziert sein. Bei der Musikauswahl und bei Fragen zu Rechten unterstütze ich dich.
' + '

Kleinere Projekte sind oft in wenigen Tagen umsetzbar. Bei größeren Produktionen planen wir Vorlauf ein — vor allem, wenn Location oder Menschen gebucht werden müssen.

', + ), + ) + + section( + variant="paper2", + kicker="Einblicke", + title="Hinter der Kamera", + content='", + ) + + cta_band( + title="Videoidee im Kopf?", + text="Erzähl mir kurz, was das Video zeigen soll und wo es läuft. Du bekommst eine Einschätzung zu Aufwand und Ablauf.", + secondary=("Social-Media-Inhalte", "/social-media/"), + ) + ) + return page( + title="Videoproduktion in Neumünster · desfoto", + description="Videoproduktion aus Neumünster: Musikvideo, Imagefilm, Recruiting, Eventnachbericht und Social-Media-Clips — von Konzept bis Schnitt.", + path=path, + json_ld=[ + service_ld("Videoproduktion", path, "Musikvideo, Imagefilm, Recruiting-Video, Eventnachbericht und Social-Media-Clips."), + breadcrumb_ld("Video", path), + ], + body=body, + ) + + +def social_media() -> str: + path = "/social-media/" + body = ( + page_head( + kicker="Social Media", + title="Content, der regelmäßig kommt", + lead="Foto- und Videomaterial im Format deiner Kanäle — wiederkehrend, planbar und über die Monate hinweg einheitlich.", + image="event-motion", + breadcrumb=("Social Media", path), + fit="50% 42%", + actions='Content anfragen', + ) + + section( + kicker="Für wen", + title="Wer damit arbeitet", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ("Clubs & Veranstalter", "Wiederkehrende Abende, Line-ups und Stimmung im Raum — als Story und als Feed-Beitrag."), + ("Vereine & Sport", "Spieltage, Training, Mannschaft und Ehrenamt, damit der Verein sichtbar bleibt."), + ("Lokale Unternehmen", "Team, Werkstatt, Laden, Produkt — der Alltag, der Vertrauen aufbaut."), + ("Gastronomie & Hotels", "Essen, Räume, Menschen und Anlässe, in konsistenter Optik."), + ("Selbstständige & Creators", "Kontinuierlich neues Material für Profil und Community."), + ("Agenturen", "Zulieferung von Foto- und Videomaterial nach Briefing und Formatvorgabe."), + ] + ) + + "
", + ) + + section( + variant="paper2", + kicker="Formate", + title="Was ich liefere", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ("Foto-Sets", "Aus einem Termin mehrere Motive für Wochen: Detail, Portrait, Szene, Atmosphäre."), + ("Vertikale Clips", "Kurze Videos im 9:16-Format für Reels, Shorts und TikTok."), + ("Feed & Quadrat", "4:5 und 1:1 zugeschnitten, damit nichts beschnitten wirkt."), + ("Story-Serien", "Kurzsequenzen, die einen Abend oder eine Aktion zusammenhängend erzählen."), + ("Untertitel", "Viele schauen ohne Ton — Untertitel sind eingeplant, nicht nachgerüstet."), + ("Redaktionsrhythmus", "Fester Turnus statt Aktionismus: ein Termin, verlässlich neue Inhalte."), + ] + ) + + "
", + ) + + section( + kicker="Ablauf", + title="Wiederkehrend statt einmalig", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ("Kennenlernen", "Kanäle, Zielgruppe, Tonalität und was bisher gut oder schlecht funktioniert hat."), + ("Format festlegen", "Wir legen fest, welche Formate und Längen ihr wirklich braucht."), + ("Termine", "Ein fester Rhythmus — monatlich, quartalsweise oder zu euren Anlässen."), + ("Lieferung", "Dateien benannt und sortiert, direkt einsatzbereit für die Planung."), + ] + ) + + "
", + ) + + section( + variant="paper2", + kicker="Gut zu wissen", + title="Kein Versprechen ohne Grundlage", + content=two_col( + left=ticks( + [ + "Ich liefere Material — die Veröffentlichung bleibt bei euch", + "Formate und Umfang werden vorher festgelegt, nicht nachträglich", + "Bilder und Videos dürfen nur mit geklärten Rechten genutzt werden", + "Auf Wunsch einheitliche Bildsprache über mehrere Termine", + ] + ), + right='
Für Reichweite und Performance sind Redaktion und Community auf eurer Seite entscheidend. Ich sorge dafür, dass das Ausgangsmaterial stimmt — und dass es nach eurer Marke aussieht.
', + ), + ) + + section( + kicker="Einblicke", + title="Material aus Terminen", + content='
' + + "".join( + picture(slug, "(min-width: 62rem) 30vw, 92vw", css_class="frame frame--4x5", fit=fit) + for slug, fit in [ + ("event-motion", "50% 45%"), + ("event-dance", "50% 36%"), + ("event-speaker", "50% 30%"), + ] + ) + + "
", + ) + + cta_band( + title="Contentplan für deine Kanäle?", + text="Sag mir, welche Kanäle du bespielst und wie oft du etwas brauchst. Ich schlage dir einen Rhythmus und ein Paket vor.", + secondary=("Videoprojekte", "/video/"), + ) + ) + return page( + title="Social-Media-Inhalte aus Neumünster · desfoto", + description="Social-Media-Foto- und Videocontent aus Neumünster: Reels, Storys, Feed-Formate und wiederkehrende Shootings für Clubs, Vereine und Unternehmen.", + path=path, + json_ld=[ + service_ld("Social-Media-Content", path, "Foto- und Videocontent für Reels, Storys und Feed-Formate."), + breadcrumb_ld("Social Media", path), + ], + body=body, + ) + + +def projekte() -> str: + path = "/projekte/" + gallery = ( + '" + ) + body = ( + page_head( + kicker="Projekte", + title="Eigene Arbeiten und pro bono", + lead="Neben Auftragsarbeiten entstehen eigene Projekte. Manches davon startet als Idee im Kopf, manches als Anfrage von Menschen, die etwas Unterstützung brauchen.", + image="movement-light", + breadcrumb=("Projekte", path), + fit="50% 50%", + ) + + section( + kicker="Eigene Projekte", + title="Arbeiten, die ich selbst angestoßen habe", + lead="Reisefotografie, Lichtmalerei und Musikvideo. Diese Projekte entstehen ohne Auftraggeber und zeigen, wie ich mir Bilder vorstelle.", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ( + "Skogamor — Musikvideo", + "Eigenes Musikvideo für Thjódrörir mit Konzept, Dreh, Schnitt und Farbkorrektur. Zu sehen auf der Videoseite.", + ), + ( + "Norwegen", + "Reihe über Küsten, Seen und Berglandschaften. Landschaft als Gegenpol zur Portrait- und Auftragsarbeit.", + ), + ( + "Japan", + "Ruhige Bilder von Parks, Gärten und Architektur, entstanden auf Reisen statt auf Termin.", + ), + ( + "Lichtmalerei", + "Experimente mit langer Belichtung und bewegtem Licht — technische Spielwiese mit Ausstellungsqualität.", + ), + ] + ) + + "
" + + '", + ) + + section(variant="paper2", kicker="Bildstrecke", title="Aus eigenen Serien und Aufträgen", content=gallery) + + section( + kicker="Pro bono", + title="Arbeiten, die ich unterstütze", + lead="Manche Projekte sind wichtig, haben aber kein Budget. Wenn es passt, mache ich sie trotzdem.", + content=two_col( + left="""
+

Ich fotografiere und filme gelegentlich kostenlos für Initiativen, Vereine, + gemeinnützige Projekte und Menschen, die dabei Unterstützung brauchen — als Beitrag zu dem + Umfeld, in dem ich lebe.

+

Schreib mir ruhig, wenn du so ein Projekt hast. Erzähl kurz, worum es geht, + wann es stattfindet und was du dir an Bildern oder Video wünschst. Ich schaue, ob es zeitlich + und inhaltlich passt.

+
""", + right='
Einen Anspruch auf eine kostenlose Umsetzung gibt es nicht. ' + "Ich sage ehrlich zu, wenn ich es schaffe — oder ab, wenn nicht.
" + '

Pro-bono-Anfrage senden

', + ), + ) + + section( + variant="paper2", + kicker="Auftragsarbeiten", + title="Und sonst?", + lead="Business, Portrait, Familie, Events und Video — alles, was in den Leistungen steht. Der beste Weg ist ein kurzer Blick in die Bereiche.", + content='
' + + "".join( + f'' + for t, d, h in [ + ("Businessfotografie", "Portraits, Teams und Produkte für Unternehmen.", "/businessfotografie/"), + ("Portrait & Model", "Portraits, Sedcards und Editorial.", "/portrait-und-model/"), + ("Video", "Musikvideo, Imagefilm und Eventnachbericht.", "/video/"), + ] + ) + + "
", + ) + + cta_band() + ) + return page( + title="Projekte · desfoto", + description="Eigene Projekte und pro-bono-Arbeiten von desfoto: Reisefotografie, Lichtmalerei, Musikvideo und Unterstützung für Initiativen ohne Budget.", + path=path, + json_ld=[breadcrumb_ld("Projekte", path)], + body=body, + ) + + +def ueber() -> str: + path = "/ueber/" + body = ( + page_head( + kicker="Über", + title="Fotograf und Videograf aus Neumünster", + lead="Ich bin Denny Schulz. Seit vielen Jahren fotografiere ich Menschen, Marken und Anlässe — und seit einiger Zeit genauso gern in Bewegung.", + image="business-portrait-dark", + breadcrumb=("Über", path), + fit="50% 25%", + actions='Kennst du noch nicht? Schreib mir', + ) + + section( + content="""
+

Arbeitsweise

+
+

Ich arbeite schnell, direkt und unaufgeregt. Du sagst mir, was du brauchst, ich sage dir, + was dafür nötig ist — und dann machen wir es. Ohne Umwege und ohne Fachchinesisch.

+
+
""", + ) + + section( + variant="paper2", + kicker="Was ich mache", + title="Drei Dinge, richtig", + content='
' + + "".join( + f'

{e(t)}

{e(d)}

' + for t, d in [ + ( + "Menschen fotografieren", + "Portrait, Business, Model, Familie und Paar. Die Arbeit an Gesichtern, Licht und Ausdruck ist mein Kern.", + ), + ( + "Bewegtbild produzieren", + "Von der Idee über den Dreh bis zu Schnitt, Farbkorrektur und Ton. Auch als Ergänzung zu einem Fototermin.", + ), + ( + "Inhalte für Kanäle liefern", + "Material, das ohne Umweg in Website, Feed oder Stellenanzeige passt — im richtigen Format und Look.", + ), + ] + ) + + "
", + ) + + section( + kicker="Werte", + title="Wie ich arbeite", + content=two_col( + left=ticks( + [ + "Ehrliche Einschätzung, auch wenn sie gegen einen Auftrag spricht", + "Klarer Umfang und Preis vor der Buchung", + "Kein Bild ohne Einverständnis veröffentlicht", + "Sorgfältiger Umgang mit deinen Daten und Bilddaten", + ] + ), + right=ticks( + [ + "Aufnahmen bilden die Person ab, wie sie ist — nicht wie ein Filter sie zeigt", + "Retusche bleibt dezent und wird vorher besprochen", + "Nachbestellungen und spätere Nutzung sind ausdrücklich möglich", + "Fragen sind jederzeit willkommen, auch während des Termins", + ] + ), + ), + ) + + section( + variant="paper2", + kicker="Zuhause & unterwegs", + title="Neumünster und darüber hinaus", + lead="Ich fotografiere im Studio in Neumünster und unterwegs in Schleswig-Holstein, Hamburg und Kiel. Für passende Projekte fahre ich auch weiter.", + content='
' + + picture("business-portrait-studio", "(min-width: 62rem) 45vw, 92vw", css_class="frame frame--4x5", fit="50% 28%") + + picture("studio-setup", "(min-width: 62rem) 45vw, 92vw", css_class="frame frame--4x5", fit="50% 45%") + + "
", + ) + + section( + kicker="Kontakt", + title="Direkt erreichen", + content='
' + + f'' + + f'' + + f'
Region
{e(CONTACT["region"])}
' + + f'
Anfragen
{e(CONTACT["response"])}
' + + "
" + + '', + ) + + cta_band() + ) + return page( + title="Über desfoto · Denny Schulz, Neumünster", + description="desfoto ist die Foto- und Videoproduktion von Denny Schulz aus Neumünster. Arbeitsweise, Werte und Kontaktmöglichkeiten.", + path=path, + json_ld=[ + breadcrumb_ld("Über", path), + { + "@context": "https://schema.org", + "@type": "Person", + "name": LEGAL["owner"], + "jobTitle": "Fotograf und Videograf", + "url": SITE["url"] + path, + "worksFor": {"@type": "Organization", "name": LEGAL["business"], "url": SITE["url"]}, + "address": { + "@type": "PostalAddress", + "streetAddress": LEGAL["street"], + "postalCode": "24537", + "addressLocality": "Neumünster", + "addressCountry": "DE", + }, + }, + ], + body=body, + ) + + +def kontakt() -> str: + path = "/kontakt/" + body = ( + page_head( + kicker="Kontakt", + title="Erzähl mir, was du vorhast", + lead="Kurze Nachricht genügt: worum geht es, wann soll es stattfinden und was hast du mit den Bildern oder dem Video vor. Du bekommst eine ehrliche Einschätzung und einen Preis.", + image="event-venue", + breadcrumb=("Kontakt", path), + fit="50% 45%", + ) + + section( + content='
' + + '
' + + '

Direkter Draht

' + + f'

Am schnellsten geht es per E-Mail oder Telefon. Beides landet direkt bei mir, nicht in einem Ticketsystem.

' + + '
' + + f'' + + f'' + + f'
Region
{e(CONTACT["region"])}
' + + f'
Antwortzeit
{e(CONTACT["response"])}
' + + "
" + + '
Termine vergebe ich nach Absprache — auch abends und am Wochenende, wenn es anders nicht passt.
' + + "
" + + f'

Anfrage vorbereiten

{contact_form()}
' + + "
", + ) + + section( + variant="paper2", + kicker="Häufige Fragen", + title="Kurz beantwortet", + content='
' + + "".join( + f'

{e(q)}

{e(a)}

' + for q, a in [ + ( + "Was kostet ein Shooting?", + "Das hängt von Umfang, Ort und Nutzung ab. Nenn mir deinen Bedarf, dann bekommst du einen konkreten Preis — vor der Buchung, ohne Nachforderung.", + ), + ( + "Wie schnell bekomme ich die Bilder?", + "Kleine Aufträge liefere ich meist innerhalb weniger Tage. Bei umfangreichen Produktionen stimmen wir einen Zeitrahmen ab.", + ), + ( + "Wo finden die Termine statt?", + "Im Studio in Neumünster, bei dir vor Ort oder an einer Location, die wir gemeinsam auswählen.", + ), + ( + "Darf ich die Bilder veröffentlichen?", + "Ja, im vereinbarten Rahmen. Wir halten vorab fest, für welche Kanäle die Bilder verwendet werden dürfen.", + ), + ( + "Bekomme ich auch die RAW-Dateien?", + "In der Regel nicht. Du bekommst fertig bearbeitete Dateien in den Größen, die du brauchst — das ist die Grundlage meiner Arbeit.", + ), + ( + "Machst du auch Video und Foto zusammen?", + "Ja. Gerade bei Veranstaltungen und Kampagnen ist es von Vorteil, beides aus einer Hand zu bekommen.", + ), + ] + ) + + "
", + ) + + cta_band( + title="Lieber direkt telefonieren?", + text="Kein Problem. Ruf einfach an — falls ich gerade im Termin bin, melde ich mich zurück.", + primary=("Anrufen", "tel:+491754083133"), + secondary=("E-Mail schreiben", "mailto:info@dennyschulz.de"), + ) + ) + return page( + title="Kontakt · desfoto Neumünster", + description="Kontakt zu desfoto: Anfragen für Fotografie, Video und Social-Media-Inhalte aus Neumünster. E-Mail, Telefon und vorbereitete Anfrage.", + path=path, + json_ld=[ + breadcrumb_ld("Kontakt", path), + { + "@context": "https://schema.org", + "@type": "ContactPage", + "url": SITE["url"] + path, + "name": "Kontakt · desfoto", + }, + ], + body=body, + ) + + +def _legal_paragraphs(items: list[str]) -> str: + return "".join(f"

{item}

" for item in items) + + +def impressum() -> str: + path = "/impressum/" + body = page_head( + kicker="Rechtliches", + title="Impressum", + lead=f"Angaben gemäß § 5 DDG · Stand {LEGAL['imprint_updated']}", + image="gear-cases", + breadcrumb=("Impressum", path), + fit="50% 50%", + ) + section( + content=f"""
+

Diensteanbieter

+

{e(LEGAL['business'])}
+ Inhaber: {e(LEGAL['owner'])}
+ {e(LEGAL['street'])}
+ {e(LEGAL['city'])}
+ {e(LEGAL['country'])}

+ +

Kontakt

+

E-Mail: {e(CONTACT['email'])}
+ Telefon: {e(CONTACT['phone'])}
+ Website: desfoto.de

+ +

Umsatzsteuer

+

{e(LEGAL['vat_note'])}

+

Wirtschafts-Identifikationsnummer: {e(LEGAL['w_id'])}

+ +

Verantwortlich für Inhalte

+

Verantwortlich für journalistisch-redaktionelle Inhalte nach § 18 Abs. 2 MStV: + {e(LEGAL['owner'])}, Anschrift wie oben.

+ +

Verbraucherstreitbeilegung

+

Ich bin nicht verpflichtet und nicht bereit, an Streitbeilegungsverfahren vor einer + Verbraucherschlichtungsstelle teilzunehmen. Die frühere EU-Plattform zur Online-Streitbeilegung + wurde zum 20.07.2025 eingestellt.

+ +

Marke und Angebot

+

desfoto ist der Markenauftritt von {e(LEGAL['business'])}. Alle über diese + Website erreichbaren Leistungen werden von {e(LEGAL['business'])} erbracht.

+ +

Urheberrecht und Bildrechte

+

Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt. + Sie dürfen ohne vorherige schriftliche Zustimmung nicht vervielfältigt, verbreitet, öffentlich + zugänglich gemacht oder bearbeitet werden — auch nicht für KI-Training oder Bildgenerierung. Eine + Nutzung durch Dritte ist ausschließlich im Rahmen der jeweils vereinbarten Nutzungsrechte zulässig.

+ +

Haftung für Inhalte und Links

+

Als Diensteanbieter bin ich für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen + verantwortlich. Für Inhalte externer Links ist ausschließlich der jeweilige Anbieter verantwortlich. + Zum Zeitpunkt der Verlinkung waren keine rechtswidrigen Inhalte erkennbar.

+ +
Datenschutz: Informationen zur Verarbeitung personenbezogener Daten auf dieser + Website findest du in der Datenschutzerklärung.
+
""" + ) + return page( + title="Impressum · desfoto", + description="Impressum und Anbieterkennzeichnung gemäß § 5 DDG für desfoto.de, den Markenauftritt von Denny Schulz Fotografie aus Neumünster.", + path=path, + json_ld=[breadcrumb_ld("Impressum", path)], + body=body, + ) + + +def datenschutz() -> str: + path = "/datenschutz/" + body = page_head( + kicker="Rechtliches", + title="Datenschutzerklärung", + lead=f"Informationen zur Verarbeitung personenbezogener Daten · Stand {LEGAL['privacy_updated']}", + image="event-venue", + breadcrumb=("Datenschutz", path), + fit="50% 60%", + ) + section( + content=f"""
+

Diese Datenschutzerklärung informiert dich darüber, welche personenbezogenen Daten beim Besuch + von desfoto.de und bei einer Kontaktaufnahme verarbeitet werden, zu welchem Zweck das geschieht und + welche Rechte du hast.

+ +

1. Verantwortlicher

+

{e(LEGAL['business'])}
Inhaber: {e(LEGAL['owner'])}
{e(LEGAL['street'])}
{e(LEGAL['city'])}, {e(LEGAL['country'])}

+

E-Mail: {e(CONTACT['email'])}
+ Telefon: {e(CONTACT['phone'])}

+ +

2. Zuständige Aufsichtsbehörde

+

{e(LEGAL['authority'])}
{e(LEGAL['authority_street'])}
{e(LEGAL['authority_city'])}
+ Web: {e(LEGAL['authority_web'].replace('https://', ''))}

+ +

3. Hosting und Server-Logdaten

+

Diese Website wird auf eigenen, selbst betriebenen und kontrollierten Servern innerhalb der + Europäischen Union bereitgestellt. Zum Ausliefern der Seiten ist technisch eine Verarbeitung der + IP-Adresse des anfragenden Geräts erforderlich.

+

Für diese Website werden keine Zugriffsprotokolle gespeichert. Es existiert + bewusst kein Access-Log, keine Reichweitenmessung und keine Statistiksoftware. Die IP-Adresse wird + ausschließlich flüchtig für die Dauer der Verbindung verarbeitet, um die Seite auszuliefern und + Angriffe abzuwehren; anschließend wird sie nicht gespeichert.

+

Auch ein technisches Fehlerprotokoll wird nicht geschrieben: Der Webserver verwirft + Fehlermeldungen zu Anfragen vollständig, statt sie in eine Protokolldatei zu schreiben. Es + entsteht daher keine Logdatei, in der eine IP-Adresse gespeichert werden könnte. + Rechtsgrundlage für die flüchtige Verarbeitung der IP-Adresse ist Art. 6 Abs. 1 lit. f DSGVO — + das berechtigte Interesse an einem sicheren und stabilen Betrieb der Website.

+ +

4. Kontaktaufnahme per E-Mail, Telefon oder Anfrageformular

+

Wenn du mich kontaktierst, verarbeite ich die von dir mitgeteilten Angaben (insbesondere Name, + E-Mail-Adresse, Telefonnummer, Anlass, Terminwunsch und Nachricht), um deine Anfrage zu beantworten + und — bei einem Auftrag — durchzuführen.

+

Das Anfrageformular auf dieser Website sendet keine Daten an einen Server. Es + setzt aus deinen Eingaben ausschließlich lokal in deinem Browser einen E-Mail-Entwurf in deinem + Mailprogramm zusammen. Erst wenn du diese E-Mail selbst versendest, verlassen die Daten dein Gerät. + Deine Eingaben werden dabei nicht gespeichert oder protokolliert.

+

Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b DSGVO (Anbahnung und Erfüllung eines Vertrags), + Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Beantwortung von Anfragen) sowie + Art. 6 Abs. 1 lit. a DSGVO, soweit du eine Einwilligung erteilt hast. Anfragen ohne anschließenden + Auftrag lösche ich, sobald der Vorgang abgeschlossen ist und keine gesetzlichen Aufbewahrungsgründe + entgegenstehen.

+ +

5. Cookies und lokale Speicherung

+

Diese Website setzt keine Cookies und verwendet keine Analyse-, Marketing- oder + Tracking-Technologien. Es werden auch keine Inhalte im lokalen Speicher deines Browsers abgelegt. + Ein Cookie-Banner ist deshalb nicht erforderlich.

+ +

6. Schriftarten und Bilder

+

Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden. + Beim Aufruf der Website werden keine Schriftarten oder Skripte von Google Fonts oder + vergleichbaren externen Servern geladen. Dasselbe gilt für die gezeigten Bilder: Sie werden + ausschließlich von dieser Website ausgeliefert.

+ +

7. YouTube-Videos (nur nach deinem Klick)

+

Auf einzelnen Seiten sind Videos über YouTube eingebunden. Die Einbindung erfolgt als + ausdrücklich gekennzeichnetes Vorschaubild. Erst wenn du das Vorschaubild anklickst, + wird das Video über die datenschutzfreundliche Domain youtube-nocookie.com geladen.

+

Ab dem Klick verarbeitet YouTube (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, + Irland) unter anderem deine IP-Adresse, Geräte- und Browserdaten sowie Angaben zur Nutzung des + Videos. Dabei kann eine Verarbeitung außerhalb der EU bzw. des EWR, insbesondere in den USA, + stattfinden. Google nennt hierzu in seinen Datenschutzinformationen die Grundlagen der Übermittlung, + etwa Standardvertragsklauseln. Auf die Verarbeitung durch YouTube habe ich keinen Einfluss. + Rechtsgrundlage für das Nachladen ist deine Einwilligung durch den Klick (Art. 6 Abs. 1 lit. a DSGVO) + sowie Art. 25 Abs. 1 TDDDG. Weitere Informationen findest du in der + Datenschutzerklärung von Google.

+ +

8. Externe Links

+

Diese Website enthält Links zu externen Websites. Beim Anklicken verlässt du mein Angebot. Dabei + werden an den jeweiligen Anbieter technisch erforderliche Zugriffsdaten wie deine IP-Adresse, + Datum und Uhrzeit des Aufrufs sowie gegebenenfalls der Referrer übermittelt. Für die Verarbeitung + personenbezogener Daten auf der verlinkten Website ist ausschließlich deren Betreiber verantwortlich.

+ +

9. Veröffentlichung von Bildern

+

Fotografien werden nur nach vorheriger Absprache zu Portfolio-, Referenz- oder Werbezwecken + veröffentlicht. Eine erteilte Einwilligung kann jederzeit mit Wirkung für die Zukunft widerrufen + werden. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung bleibt davon unberührt.

+ +

10. Empfänger und Auftragsverarbeiter

+

Eine Weitergabe personenbezogener Daten erfolgt nur, soweit dies zur Vertragserfüllung + erforderlich ist, eine gesetzliche Verpflichtung besteht oder eine Einwilligung vorliegt. Empfänger + können insbesondere Hosting- und Serverdienstleister, E-Mail-Dienstleister, Telekommunikationsanbieter, + Steuerberater sowie Finanzbehörden sein. Soweit Dienstleister Daten in meinem Auftrag verarbeiten, + geschieht dies auf Grundlage eines Vertrags zur Auftragsverarbeitung nach Art. 28 DSGVO, soweit + gesetzlich erforderlich.

+ +

11. Speicherdauer

+

Personenbezogene Daten werden nur so lange gespeichert, wie es für den jeweiligen Zweck oder zur + Erfüllung gesetzlicher Pflichten erforderlich ist. Vertrags-, Rechnungs- und steuerrelevante + Unterlagen werden entsprechend den gesetzlichen Aufbewahrungsfristen gespeichert und danach gelöscht. + Bilddaten können darüber hinaus aufbewahrt werden, soweit dies für spätere Nachbestellungen, + Datensicherung, Qualitätssicherung oder die Geltendmachung, Ausübung oder Verteidigung von + Rechtsansprüchen erforderlich ist.

+ +

12. Deine Rechte

+
    +
  • Recht auf Auskunft über die verarbeiteten personenbezogenen Daten
  • +
  • Recht auf Berichtigung unrichtiger Daten
  • +
  • Recht auf Löschung, soweit die gesetzlichen Voraussetzungen vorliegen
  • +
  • Recht auf Einschränkung der Verarbeitung
  • +
  • Recht auf Widerspruch gegen Verarbeitungen auf Grundlage von Art. 6 Abs. 1 lit. e oder lit. f DSGVO
  • +
  • Recht auf Datenübertragbarkeit, soweit die gesetzlichen Voraussetzungen vorliegen
  • +
  • Recht auf Widerruf einer erteilten Einwilligung mit Wirkung für die Zukunft
  • +
  • Recht auf Beschwerde bei einer Datenschutzaufsichtsbehörde
  • +
+

Für die Ausübung deiner Rechte genügt eine Nachricht an + {e(CONTACT['email'])}.

+ +

13. Keine automatisierte Entscheidungsfindung

+

Eine automatisierte Entscheidungsfindung einschließlich Profiling im Sinne von Art. 22 DSGVO + findet nicht statt.

+ +

14. Aktualität dieser Erklärung

+

Diese Datenschutzerklärung wird angepasst, sobald sich die eingesetzten Techniken oder die + rechtlichen Rahmenbedingungen ändern. Es gilt die jeweils auf dieser Seite veröffentlichte Fassung + (Stand {e(LEGAL['privacy_updated'])}).

+ +
Kurzfassung: Diese Website setzt keine Cookies, sammelt keine Statistiken, speichert + keine Zugriffsprotokolle und lädt nichts von externen Servern — außer du klickst bewusst auf ein Video.
+
""" + ) + return page( + title="Datenschutzerklärung · desfoto", + description="Datenschutzerklärung für desfoto.de: keine Cookies, keine Analyse, keine Zugriffsprotokolle, lokal ausgelieferte Schriften und YouTube erst nach Klick.", + path=path, + json_ld=[breadcrumb_ld("Datenschutz", path)], + body=body, + ) + + +def not_found() -> str: + body = """
+
+
+ Fehler 404 +

Diese Seite gibt es nicht.

+

Vielleicht hat sich die Adresse geändert oder es ist ein Tippfehler passiert. + Hier geht es zurück zu den Bildern.

+ +
+
+
""" + return page( + title="Seite nicht gefunden · desfoto", + description="Die angeforderte Seite existiert nicht. Zurück zur Startseite von desfoto.", + path="/404.html", + body=body, + robots="noindex,follow", + indexable=False, + ) + + +ROUTES = [ + ("/", "index.html", home, None, "1.0"), + ("/fotografie/", "fotografie/index.html", fotografie, None, "0.9"), + ("/businessfotografie/", "businessfotografie/index.html", businessfotografie, None, "0.9"), + ("/portrait-und-model/", "portrait-und-model/index.html", portrait_und_model, None, "0.9"), + ("/familien-und-paare/", "familien-und-paare/index.html", familien_und_paare, None, "0.9"), + ("/minishootings/", "minishootings/index.html", minishootings, None, "0.9"), + ("/video/", "video/index.html", video, None, "0.9"), + ("/social-media/", "social-media/index.html", social_media, None, "0.9"), + ("/projekte/", "projekte/index.html", projekte, None, "0.8"), + ("/ueber/", "ueber/index.html", ueber, None, "0.8"), + ("/kontakt/", "kontakt/index.html", kontakt, None, "0.8"), + ("/impressum/", "impressum/index.html", impressum, None, "0.4"), + ("/datenschutz/", "datenschutz/index.html", datenschutz, None, "0.4"), +] diff --git a/src/theme.py b/src/theme.py new file mode 100644 index 0000000..205c979 --- /dev/null +++ b/src/theme.py @@ -0,0 +1,396 @@ +"""Design tokens and the single stylesheet for desfoto.de. + +The visual language follows the same photographic, warm paper-toned family as +the photographer's other site, but with a distinctly more modern, direct and +personal edge: high-contrast editorial type, hairline structure, generous +whitespace and one confident rust accent instead of gold. +""" + +TOKENS = """ + --paper:#F6F2EA; + --paper-2:#EEE7DB; + --paper-3:#E4DAC9; + --ink:#17140F; + --ink-80:#312B24; + --ink-70:#4E4740; + --ink-50:#7B736A; + --ink-35:#A79E93; + --line:rgba(23,20,15,.13); + --line-soft:rgba(23,20,15,.07); + --line-strong:rgba(23,20,15,.3); + --accent:#C0431C; + --accent-dark:#95330F; + --accent-soft:rgba(192,67,28,.12); + --teal:#16494A; + --radius:14px; + --radius-lg:22px; + --radius-pill:999px; + --shell:min(1220px,92vw); + --gap:clamp(1.1rem,2.2vw,2rem); + --section:clamp(3.6rem,7vw,7.5rem); + --ease:cubic-bezier(.22,.61,.36,1); + --shadow:0 24px 60px -30px rgba(23,20,15,.45); + --font-sans:"Manrope","Segoe UI",system-ui,-apple-system,sans-serif; + --font-display:"Fraunces","Iowan Old Style",Georgia,serif; +""" + +CSS = """ +*,*::before,*::after{box-sizing:border-box} +html{-webkit-text-size-adjust:100%} +html:focus-within{scroll-behavior:smooth} +body{ + margin:0;background:var(--paper);color:var(--ink); + font-family:var(--font-sans);font-size:clamp(1rem,.35vw + .94rem,1.075rem); + line-height:1.62;font-weight:400; + -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility; + overflow-x:hidden; +} +img,svg,video{display:block;max-width:100%;height:auto} +a{color:inherit;text-decoration:none} +button{font:inherit;color:inherit} +h1,h2,h3,h4{font-family:var(--font-display);font-weight:600;line-height:1.04;letter-spacing:-.02em;margin:0} +h1{font-size:clamp(2.5rem,6.4vw,5.1rem)} +h2{font-size:clamp(1.9rem,3.9vw,3.1rem)} +h3{font-size:clamp(1.22rem,1.5vw,1.55rem);line-height:1.16} +p{margin:0} +ul,ol{margin:0;padding:0;list-style:none} +hr{border:0;border-top:1px solid var(--line);margin:0} +:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:4px} +::selection{background:var(--accent);color:#fff} +.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:#fff;padding:.7rem 1.1rem;z-index:99} +.skip:focus{left:.6rem;top:.6rem} + +.shell{width:var(--shell);margin-inline:auto} +.section{padding-block:var(--section)} +.section--tight{padding-block:clamp(2.4rem,4vw,4rem)} +.section--paper2{background:var(--paper-2)} +.section--ink{background:var(--ink);color:var(--paper)} +.section--line{border-top:1px solid var(--line)} +.stack>*+*{margin-top:var(--gap)} +.lead{font-size:clamp(1.08rem,1.5vw,1.32rem);line-height:1.55;color:var(--ink-80)} +.muted{color:var(--ink-50)} +.accent{color:var(--accent)} +.center{text-align:center} +.measure{max-width:64ch} +.measure-sm{max-width:48ch} +.balance{text-wrap:balance} + +.kicker{ + display:inline-flex;align-items:center;gap:.55rem; + font-size:.735rem;font-weight:600;letter-spacing:.22em;text-transform:uppercase; + color:var(--accent); +} +.kicker::before{content:"";width:1.9rem;height:1px;background:currentColor;opacity:.55} +.section--ink .kicker{color:#E6A184} +.eyebrow{ + font-size:.735rem;font-weight:600;letter-spacing:.22em;text-transform:uppercase; + color:var(--ink-35); +} +.section-head{display:flex;flex-wrap:wrap;gap:1.2rem 2.4rem;align-items:flex-end;justify-content:space-between} +.section-head__text{max-width:66ch} + +/* ---------- header ---------- */ +.masthead{ + position:sticky;top:0;z-index:50; + background:color-mix(in srgb,var(--paper) 88%,transparent); + backdrop-filter:saturate(1.4) blur(14px); + border-bottom:1px solid var(--line); +} +.masthead__inner{display:flex;align-items:center;gap:1rem;min-height:4.4rem;padding-block:.7rem} +.brand{display:inline-flex;align-items:center;gap:.62rem;font-family:var(--font-display);font-size:1.42rem;font-weight:600;letter-spacing:-.015em} +.brand__mark{width:1.72rem;height:1.72rem;flex:none} +.brand__mark .ring{stroke:var(--ink);fill:none;stroke-width:2.4} +.brand__mark .blade{stroke:var(--accent);stroke-width:2.6;stroke-linecap:round} +.brand small{display:block;font-family:var(--font-sans);font-size:.62rem;font-weight:600;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-50);line-height:1} +.brand__text{display:flex;flex-direction:column;gap:.22rem} + +.nav{margin-left:auto;display:flex;align-items:center;gap:.35rem} +.nav__link{ + position:relative;display:inline-block;padding:.55rem .72rem;border-radius:var(--radius-pill); + font-size:.9rem;font-weight:500;color:var(--ink-80);transition:color .2s var(--ease),background .2s var(--ease); +} +.nav__link::after{ + content:"";position:absolute;left:.72rem;right:.72rem;bottom:.34rem;height:1px; + background:var(--accent);transform:scaleX(0);transform-origin:left;transition:transform .28s var(--ease); +} +.nav__link:hover,.nav__link:focus-visible{color:var(--ink)} +.nav__link:hover::after,.nav__link:focus-visible::after{transform:scaleX(1)} +.nav__link[aria-current="page"]{color:var(--ink)} +.nav__link[aria-current="page"]::after{transform:scaleX(1)} +.nav__item{position:relative} +.nav__item>.nav__link{display:inline-flex;align-items:center;gap:.36rem} +.nav__caret{width:.6em;height:.6em;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;transition:transform .25s var(--ease)} +.nav__sub{ + position:absolute;top:calc(100% + .4rem);left:0;min-width:15.5rem; + background:var(--paper);border:1px solid var(--line);border-radius:var(--radius); + box-shadow:var(--shadow);padding:.5rem;display:grid;gap:.08rem; + opacity:0;visibility:hidden;transform:translateY(-.35rem);transition:all .22s var(--ease); +} +.nav__item:hover .nav__sub,.nav__item:focus-within .nav__sub{opacity:1;visibility:visible;transform:none} +.nav__item:hover .nav__caret,.nav__item:focus-within .nav__caret{transform:rotate(180deg)} +.nav__sub a{padding:.5rem .7rem;border-radius:9px;font-size:.875rem;color:var(--ink-80)} +.nav__sub a:hover{background:var(--paper-2);color:var(--ink)} +.nav__toggle{display:none;margin-left:auto;background:none;border:1px solid var(--line);border-radius:var(--radius-pill);padding:.5rem .95rem;font-size:.85rem;font-weight:600;cursor:pointer} +.nav__toggle svg{width:1rem;height:1rem;stroke:currentColor;stroke-width:2;fill:none} + +/* ---------- buttons ---------- */ +.btn{ + display:inline-flex;align-items:center;justify-content:center;gap:.5rem; + padding:.86rem 1.5rem;border-radius:var(--radius-pill);border:1px solid transparent; + font-size:.92rem;font-weight:600;letter-spacing:.01em;cursor:pointer; + transition:transform .2s var(--ease),background .2s var(--ease),color .2s var(--ease),border-color .2s var(--ease); +} +.btn:hover{transform:translateY(-2px)} +.btn--solid{background:var(--accent);color:#fff} +.btn--solid:hover{background:var(--accent-dark)} +.btn--ink{background:var(--ink);color:var(--paper)} +.btn--ink:hover{background:#000} +.btn--ghost{border-color:var(--line-strong);color:var(--ink)} +.btn--ghost:hover{border-color:var(--ink);background:var(--paper-2)} +.btn--light{background:var(--paper);color:var(--ink)} +.btn--light:hover{background:#fff} +.btn--outline-light{border-color:rgba(246,242,234,.42);color:var(--paper)} +.btn--outline-light:hover{border-color:var(--paper);background:rgba(246,242,234,.1)} +.btn svg{width:.95em;height:.95em;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} +.btn-row{display:flex;flex-wrap:wrap;gap:.7rem;align-items:center} + +.link-arrow{display:inline-flex;align-items:center;gap:.42rem;font-size:.9rem;font-weight:600;color:var(--ink);border-bottom:1px solid var(--line-strong);padding-bottom:.15rem;transition:gap .2s var(--ease),border-color .2s var(--ease)} +.link-arrow:hover{gap:.72rem;border-color:var(--accent);color:var(--accent-dark)} +.link-arrow svg{width:.85em;height:.85em;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} + +/* ---------- hero ---------- */ +.hero{position:relative;isolation:isolate;color:var(--paper);overflow:hidden} +.hero__media{position:absolute;inset:0;z-index:-2} +.hero__media img{width:100%;height:100%;object-fit:cover;object-position:50% 42%} +.hero::before{ + content:"";position:absolute;inset:0;z-index:-1; + background: + linear-gradient(180deg,rgba(14,12,10,.72) 0%,rgba(14,12,10,.34) 38%,rgba(14,12,10,.86) 100%), + linear-gradient(90deg,rgba(14,12,10,.7) 0%,rgba(14,12,10,.1) 65%); +} +.hero__inner{ + min-height:clamp(30rem,80vh,46rem); + display:flex;flex-direction:column;justify-content:flex-end; + padding-block:clamp(3rem,7vw,5.6rem) clamp(2.4rem,5vw,4.2rem); +} +.hero__kicker{color:#F0B79B} +.hero__title{max-width:20ch;margin-top:1.1rem;text-shadow:0 2px 30px rgba(0,0,0,.35);text-wrap:balance} +.hero__sub{max-width:52ch;margin-top:1.25rem;font-size:clamp(1.02rem,1.35vw,1.2rem);color:rgba(246,242,234,.9)} +.hero__actions{margin-top:2rem} +.hero__facts{ + margin-top:clamp(2.2rem,4vw,3.4rem);padding-top:1.4rem;border-top:1px solid rgba(246,242,234,.22); + display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); +} +.hero__fact{font-size:.85rem;color:rgba(246,242,234,.78)} +.hero__fact strong{display:block;font-family:var(--font-display);font-size:1.05rem;font-weight:600;color:var(--paper);letter-spacing:-.01em} + +/* ---------- generic media frame ---------- */ +.frame{position:relative;overflow:hidden;border-radius:var(--radius);background:var(--paper-3)} +.frame img{width:100%;height:100%;object-fit:cover;transition:transform .8s var(--ease)} +img.frame{width:100%;height:auto;object-fit:cover;transition:transform .8s var(--ease)} +.frame--hover:hover img,img.frame--hover:hover{transform:scale(1.035)} +.frame--16x10{aspect-ratio:16/10} +.frame--4x5{aspect-ratio:4/5} +.frame--3x2{aspect-ratio:3/2} +.frame--1x1{aspect-ratio:1/1} +.frame--21x9{aspect-ratio:21/9} + +/* ---------- statement ---------- */ +.statement{display:grid;gap:1.4rem 3.4rem;grid-template-columns:minmax(0,15rem) minmax(0,1fr)} +.statement__body{font-family:var(--font-display);font-size:clamp(1.35rem,2.5vw,2.05rem);line-height:1.28;letter-spacing:-.015em;font-weight:400;text-wrap:pretty} +.statement__body em{font-style:italic;color:var(--accent)} + +/* ---------- service cards ---------- */ +.grid{display:grid;gap:clamp(1.2rem,2.1vw,2rem)} +.grid--2{grid-template-columns:repeat(auto-fit,minmax(19rem,1fr))} +.grid--3{grid-template-columns:repeat(auto-fit,minmax(16.5rem,1fr))} +.grid--4{grid-template-columns:repeat(auto-fit,minmax(13.5rem,1fr))} +.card{display:flex;flex-direction:column;gap:1rem;height:100%} +.card__title{display:flex;align-items:baseline;gap:.6rem} +.card__num{font-size:.72rem;font-weight:600;letter-spacing:.16em;color:var(--accent)} +.card__text{color:var(--ink-70);font-size:.955rem} +.card__foot{margin-top:auto;padding-top:.4rem} + +/* ---------- gallery ---------- */ +.gallery{display:grid;gap:clamp(.8rem,1.4vw,1.2rem);grid-template-columns:repeat(6,1fr)} +.gallery figure{margin:0;position:relative} +.gallery figcaption{ + margin-top:.6rem;font-size:.8rem;letter-spacing:.02em;color:var(--ink-50); +} +.gallery .g-w3{grid-column:span 3} +.gallery .g-w2{grid-column:span 2} +.gallery .g-w4{grid-column:span 4} +.gallery .g-w6{grid-column:span 6} + +/* ---------- video facade ---------- */ +.video-shell{position:relative;border-radius:var(--radius-lg);overflow:hidden;background:#0B0A09;box-shadow:var(--shadow)} +.video-shell iframe{width:100%;aspect-ratio:16/9;border:0;display:block} +.video-facade{ + position:relative;display:block;width:100%;padding:0;border:0;background:#0B0A09;cursor:pointer; +} +.video-facade img{width:100%;aspect-ratio:16/9;object-fit:cover;opacity:.72;transition:transform .8s var(--ease),opacity .4s var(--ease)} +.video-facade:hover img{transform:scale(1.02);opacity:.85} +.video-facade__play{ + position:absolute;inset:0;display:grid;place-content:center;gap:.9rem;justify-items:center;color:#fff;text-align:center; +} +.video-facade__btn{ + width:4.6rem;height:4.6rem;border-radius:50%;background:rgba(246,242,234,.94);color:var(--ink); + display:grid;place-items:center;transition:transform .3s var(--ease),background .3s var(--ease); +} +.video-facade:hover .video-facade__btn{transform:scale(1.07);background:#fff} +.video-facade__btn svg{width:1.6rem;height:1.6rem;fill:currentColor;margin-left:.18rem} +.video-facade__label{font-size:.8rem;letter-spacing:.16em;text-transform:uppercase;text-shadow:0 1px 20px rgba(0,0,0,.6)} +.video-shell__meta{position:absolute;left:0;right:0;bottom:0;padding:1.1rem 1.3rem;background:linear-gradient(0deg,rgba(10,9,8,.88),transparent);color:#F2EDE4;font-size:.85rem;pointer-events:none} + +/* ---------- steps ---------- */ +.steps{counter-reset:step;display:grid;gap:clamp(1.4rem,2.4vw,2.2rem);grid-template-columns:repeat(auto-fit,minmax(15rem,1fr))} +.step{position:relative;padding-top:2.6rem;border-top:1px solid var(--line)} +.step::before{ + counter-increment:step;content:"0" counter(step); + position:absolute;top:.6rem;left:0;font-size:.75rem;font-weight:600;letter-spacing:.18em;color:var(--accent); +} +.step h3{margin-bottom:.5rem} +.step p{font-size:.94rem;color:var(--ink-70)} + +/* ---------- lists ---------- */ +.ticks{display:grid;gap:.62rem} +.ticks li{position:relative;padding-left:1.6rem;font-size:.965rem;color:var(--ink-80)} +.ticks li::before{ + content:"";position:absolute;left:0;top:.55em;width:.62rem;height:.62rem;border-radius:50%; + background:var(--accent-soft);border:1px solid var(--accent); +} +.plain-list{display:grid;gap:.5rem} +.plain-list a{border-bottom:1px solid var(--line);padding-bottom:.5rem;display:inline-flex;gap:.5rem;align-items:center} +.plain-list a:hover{color:var(--accent-dark);border-color:var(--accent)} + +.dl{display:grid;gap:1.6rem 2.4rem;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr))} +.dl div{display:grid;gap:.32rem} +.dl dt{font-size:.72rem;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-35)} +.dl dd{margin:0;font-size:.98rem} + +.note{ + border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:0 var(--radius) var(--radius) 0; + padding:1rem 1.2rem;font-size:.9rem;color:var(--ink-70);background:rgba(255,255,255,.4); +} + +/* ---------- facts strip ---------- */ +.facts{display:grid;gap:clamp(1.3rem,2.6vw,2.6rem);grid-template-columns:repeat(auto-fit,minmax(13rem,1fr))} +.fact h3{font-size:1.05rem;margin-bottom:.4rem} +.fact p{font-size:.9rem;color:var(--ink-70)} +.section--ink .fact p{color:rgba(246,242,234,.72)} + +/* ---------- contact ---------- */ +.contact-grid{display:grid;gap:clamp(1.6rem,3vw,3.2rem);grid-template-columns:minmax(0,1fr) minmax(0,1.05fr)} +.contact-card{ + border:1px solid var(--line);border-radius:var(--radius-lg);padding:clamp(1.4rem,2.4vw,2.2rem);background:rgba(255,255,255,.5); +} +.form{display:grid;gap:1rem} +.field{display:grid;gap:.4rem} +.field label{font-size:.76rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-50)} +.field input,.field textarea,.field select{ + width:100%;padding:.75rem .85rem;border:1px solid var(--line-strong);border-radius:10px; + background:var(--paper);color:var(--ink);font:inherit;font-size:.96rem; +} +.field textarea{min-height:8.5rem;resize:vertical} +.field input:focus,.field textarea:focus,.field select:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:var(--accent)} +.field-row{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr))} +.form__hint{font-size:.82rem;color:var(--ink-50)} +.form__status{font-size:.88rem;color:var(--teal);font-weight:600} +.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden} + +/* ---------- footer ---------- */ +.footer{background:var(--ink);color:rgba(246,242,234,.78);padding-block:clamp(2.6rem,5vw,4.4rem) 1.6rem;font-size:.92rem} +.footer a:hover{color:#fff} +.footer__grid{display:grid;gap:2rem;grid-template-columns:minmax(0,1.5fr) repeat(auto-fit,minmax(9rem,1fr))} +.footer__brand .brand{color:var(--paper)} +.footer__brand .brand__mark .ring{stroke:var(--paper)} +.footer__claim{margin-top:1rem;max-width:34ch;color:rgba(246,242,234,.66);font-size:.9rem} +.footer h2{font-family:var(--font-sans);font-size:.74rem;font-weight:600;letter-spacing:.2em;text-transform:uppercase;color:rgba(246,242,234,.5);margin-bottom:.9rem} +.footer__links{display:grid;gap:.5rem} +.footer__bottom{ + margin-top:clamp(2rem,4vw,3.2rem);padding-top:1.2rem;border-top:1px solid rgba(246,242,234,.16); + display:flex;flex-wrap:wrap;gap:.6rem 1.4rem;justify-content:space-between;font-size:.82rem;color:rgba(246,242,234,.58); +} +.footer__bottom nav{display:flex;gap:1.1rem;flex-wrap:wrap} + +/* ---------- page hero (sub pages) ---------- */ +.page-head{position:relative;isolation:isolate;overflow:hidden;background:var(--ink);color:var(--paper)} +.page-head__bg{position:absolute;inset:0;z-index:-2} +.page-head__bg img{width:100%;height:100%;object-fit:cover} +.page-head::before{ + content:"";position:absolute;inset:0;z-index:-1; + background:linear-gradient(115deg,rgba(12,11,9,.9) 0%,rgba(12,11,9,.65) 55%,rgba(12,11,9,.38) 100%); +} +.page-head__inner{padding-block:clamp(3.2rem,6vw,5.4rem) clamp(2.6rem,5vw,4.2rem);max-width:min(70ch,100%)} +.page-head h1{margin-top:1rem;font-size:clamp(2.1rem,4.6vw,3.7rem)} +.page-head__lead{margin-top:1.1rem;font-size:clamp(1rem,1.3vw,1.16rem);color:rgba(246,242,234,.88);max-width:58ch} +.page-head__actions{margin-top:1.8rem} +.breadcrumb{font-size:.78rem;letter-spacing:.14em;text-transform:uppercase;color:rgba(246,242,234,.6);margin-bottom:1.4rem;display:flex;gap:.5rem;flex-wrap:wrap} +.breadcrumb a:hover{color:#fff} + +/* ---------- prose (legal) ---------- */ +.prose{max-width:74ch} +.prose h2{font-size:clamp(1.35rem,2.2vw,1.85rem);margin-top:2.8rem} +.prose h3{font-size:1.1rem;margin-top:1.8rem;font-family:var(--font-sans);font-weight:600;letter-spacing:0} +.prose p,.prose li{color:var(--ink-80);font-size:.985rem} +.prose p+p{margin-top:.85rem} +.prose ul{margin:.8rem 0 0;display:grid;gap:.5rem} +.prose ul li{position:relative;padding-left:1.3rem} +.prose ul li::before{content:"";position:absolute;left:.1rem;top:.62em;width:.42rem;height:.42rem;border-radius:50%;background:var(--accent)} +.prose a{border-bottom:1px solid var(--line-strong)} +.prose a:hover{color:var(--accent-dark);border-color:var(--accent)} +.prose hr{margin-block:2.4rem} +.prose .note{margin-top:1.2rem} +.prose dl{margin-top:.8rem} +.prose dt{font-weight:600;margin-top:.9rem} +.prose dd{margin:0} + +/* ---------- 404 ---------- */ +.center-page{min-height:min(70vh,40rem);display:grid;place-items:center;text-align:center} +.center-page__inner{display:grid;gap:1.3rem;justify-items:center;max-width:46ch} + +/* ---------- motion ---------- */ +.reveal{opacity:0;transform:translateY(1.4rem);transition:opacity .8s var(--ease),transform .8s var(--ease)} +.reveal.is-in{opacity:1;transform:none} +@media (prefers-reduced-motion:reduce){ + .reveal{opacity:1;transform:none;transition:none} + html:focus-within{scroll-behavior:auto} + .btn:hover{transform:none} + .nav__link::after{transition:none} +} + +/* ---------- responsive ---------- */ +@media (max-width:62rem){ + .statement{grid-template-columns:1fr} + .contact-grid{grid-template-columns:1fr} + .gallery{grid-template-columns:repeat(4,1fr)} + .gallery .g-w3{grid-column:span 2} + .gallery .g-w4{grid-column:span 4} + .gallery .g-w2{grid-column:span 2} +} +@media (max-width:52rem){ + .nav{ + position:fixed;inset:4.4rem 0 auto;margin:0; + flex-direction:column;align-items:stretch;gap:.15rem; + background:var(--paper);border-bottom:1px solid var(--line); + padding:1rem max(4vw,1rem) 1.6rem; + max-height:calc(100vh - 4.4rem);overflow:auto; + transform:translateY(-1rem);opacity:0;visibility:hidden;transition:all .25s var(--ease); + box-shadow:var(--shadow); + } + .nav.is-open{transform:none;opacity:1;visibility:visible} + .nav__toggle{display:inline-flex;align-items:center;gap:.45rem} + .nav__link{padding:.7rem .2rem;font-size:1.02rem;border-bottom:1px solid var(--line-soft);border-radius:0} + .nav__link::after{display:none} + .nav__sub{position:static;opacity:1;visibility:visible;transform:none;box-shadow:none;border:0;padding:0 0 0 .9rem;min-width:0;background:none} + .nav__caret{display:none} + .nav .btn{margin-top:1rem;align-self:flex-start} + .hero__inner{min-height:clamp(26rem,72vh,34rem)} +} +@media (max-width:40rem){ + .gallery{grid-template-columns:repeat(2,1fr)} + .gallery .g-w3,.gallery .g-w4,.gallery .g-w6{grid-column:span 2} + .hero__facts{grid-template-columns:1fr 1fr} + .page-head__inner{max-width:none} +} +""" diff --git a/tests/test_site.py b/tests/test_site.py new file mode 100644 index 0000000..bd88364 --- /dev/null +++ b/tests/test_site.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Deterministic structural tests for the committed desfoto.de build in site/. + +Run after `python3 scripts/build-site.py`: + + python3 -m unittest discover -s tests -v + +The tests only read generated files, so they are fast and side-effect free. +""" + +from __future__ import annotations + +import json +import re +import sys +import unittest +from html import unescape +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SITE = ROOT / "site" +SRC = ROOT / "src" +sys.path.insert(0, str(SRC)) + +import pages # noqa: E402 +from content import CONTACT, LEGAL, SITE as META # noqa: E402 + +ROUTE_PATHS = [route[0] for route in pages.ROUTES] +HTML_FILES = sorted(SITE.rglob("*.html")) + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def visible_text(html_text: str) -> str: + body = re.search(r"", html_text, re.S) + chunk = body.group(0) if body else html_text + chunk = re.sub(r"<(script|style)\b.*?", " ", chunk, flags=re.S) + return unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", chunk))).strip() + + +class BuildLayout(unittest.TestCase): + def test_every_route_is_built(self): + for route in pages.ROUTES: + _path, rel = route[0], route[1] + with self.subTest(route=route[0]): + self.assertTrue((SITE / rel).is_file(), f"missing {rel}") + + def test_no_stray_html_files(self): + expected = {route[1] for route in pages.ROUTES} | {"404.html"} + actual = {str(f.relative_to(SITE)) for f in HTML_FILES} + self.assertEqual(expected, actual) + + def test_site_files_are_world_readable(self): + """The nginx worker is not root: 0600 build output would 403 in production.""" + for path in SITE.rglob("*"): + if path.is_dir(): + continue + with self.subTest(asset=str(path.relative_to(SITE))): + self.assertTrue(path.stat().st_mode & 0o044, f"{path} is not world readable") + + def test_meta_files_exist(self): + for rel in ( + "sitemap.xml", + "robots.txt", + "site.webmanifest", + "favicon.svg", + ".well-known/security.txt", + "assets/site.css", + "assets/site.js", + "assets/fonts.css", + ): + with self.subTest(rel=rel): + self.assertTrue((SITE / rel).is_file(), f"missing {rel}") + + +class HeadTags(unittest.TestCase): + def test_titles_descriptions_and_canonicals(self): + for path in ROUTE_PATHS: + rel = dict((r[0], r[1]) for r in pages.ROUTES)[path] + with self.subTest(route=path): + text = read(SITE / rel) + self.assertIn('', text) + title = re.search(r"([^<]+)", text) + self.assertIsNotNone(title) + self.assertTrue(title.group(1).strip()) + desc = re.search(r'', text) + self.assertIn('', text) + self.assertNotIn('rel="canonical"', text) + + def test_single_h1_per_page(self): + for path in ROUTE_PATHS: + rel = dict((r[0], r[1]) for r in pages.ROUTES)[path] + with self.subTest(route=path): + self.assertEqual(read(SITE / rel).count("(.*?)', + read(SITE / rel), + re.S, + ): + with self.subTest(route=path): + json.loads(block) + + +class ImagesAndAssets(unittest.TestCase): + def test_every_img_has_alt(self): + for file in HTML_FILES: + for tag in re.findall(r"]*>", read(file)): + with self.subTest(file=file.name): + self.assertIn('alt="', tag) + + def test_wordpress_style_external_assets_absent(self): + """No privacy page promise may be broken: nothing is loaded cross-origin.""" + allowed = {"https://www.youtube-nocookie.com"} + for file in HTML_FILES + [SITE / "assets" / "site.css", SITE / "assets" / "fonts.css"]: + text = read(file) + candidates = list(re.findall(r'(?:src|srcset|imagesrcset|poster)="([^"]+)"', text)) + candidates += list(re.findall(r"url\(([^)]*)\)", text)) + for match in candidates: + for url in re.findall(r"(https?://[^\s,)'\"]+)", match): + with self.subTest(file=file.name, url=url): + self.assertIn(url.split("/embed")[0], allowed | {META["url"]}) + + def test_referenced_images_exist(self): + for file in HTML_FILES: + text = read(file) + for candidate in set(re.findall(r"/assets/img/([A-Za-z0-9._-]+)", text)): + with self.subTest(file=file.name, asset=candidate): + self.assertTrue((SITE / "assets" / "img" / candidate).is_file()) + + def test_no_hero_image_without_dimensions(self): + for file in HTML_FILES: + for tag in re.findall(r"]*>", read(file)): + with self.subTest(file=file.name): + self.assertIn("width=", tag) + self.assertIn("height=", tag) + + +class PrivacyAndLegal(unittest.TestCase): + def test_no_cookies_analytics_or_third_party_scripts(self): + for file in HTML_FILES: + text = read(file).lower() + with self.subTest(file=file.name): + for forbidden in ("google-analytics", "googletagmanager", "gtag(", "document.cookie", "fonts.googleapis"): + self.assertNotIn(forbidden, text) + + def test_nginx_writes_no_access_log(self): + conf = read(ROOT / "nginx.conf") + self.assertIn("access_log off;", conf) + self.assertIn("error_log /dev/null crit;", conf) + self.assertNotIn("/var/log/nginx", conf) + + def test_nginx_sets_the_documented_security_headers(self): + conf = read(ROOT / "nginx.conf") + for header in ( + "X-Content-Type-Options", + "Referrer-Policy", + "X-Frame-Options", + "Permissions-Policy", + "Strict-Transport-Security", + "Content-Security-Policy", + ): + with self.subTest(header=header): + self.assertIn(f"add_header {header}", conf) + self.assertIn("default-src 'self'", conf) + self.assertIn("frame-src https://www.youtube-nocookie.com", conf) + self.assertIn("form-action 'self' mailto:", conf) + + def test_styles_and_scripts_revalidate(self): + """site.css/site.js are not content-hashed: they must be revalidated.""" + conf = read(ROOT / "nginx.conf") + cache_rule = re.search(r'~\^/assets/site\\\.\(css\|js\)\$\s+"([^"]+)"', conf) + self.assertIsNotNone(cache_rule) + self.assertIn("max-age=0", cache_rule.group(1)) + self.assertIn("must-revalidate", cache_rule.group(1)) + + def test_index_html_redirect_handles_query_strings(self): + conf = read(ROOT / "nginx.conf") + self.assertIn(r"^/(.*/)?index\.html(\?.*)?$", conf) + + def test_impressum_has_required_provider_data(self): + text = visible_text(read(SITE / "impressum" / "index.html")) + for value in ( + LEGAL["owner"], + LEGAL["street"], + LEGAL["city"], + CONTACT["email"], + CONTACT["phone"], + "§ 5 DDG", + "§ 19 UStG", + LEGAL["w_id"], + "§ 18 Abs. 2 MStV", + ): + with self.subTest(value=value): + self.assertIn(value, text) + + def test_datenschutz_covers_the_actual_data_flows(self): + text = visible_text(read(SITE / "datenschutz" / "index.html")) + for needle in ( + "keine Zugriffsprotokolle", + "keine Cookies", + "youtube-nocookie.com", + "Art. 6 Abs. 1 lit. f DSGVO", + LEGAL["authority"], + "Art. 22 DSGVO", + ): + with self.subTest(needle=needle): + self.assertIn(needle, text) + + def test_datenschutz_matches_the_log_free_server_configuration(self): + """The page must describe `error_log /dev/null`, not a retention period.""" + text = visible_text(read(SITE / "datenschutz" / "index.html")) + self.assertIn("Fehlerprotokoll wird nicht geschrieben", text) + self.assertNotIn("14 Tagen gelöscht", text) + conf = read(ROOT / "nginx.conf") + self.assertIn("error_log /dev/null crit;", conf) + + def test_site_javascript_is_local_and_stateless(self): + js = read(SITE / "assets" / "site.js") + for forbidden in ( + "document.cookie", + "localStorage", + "sessionStorage", + "indexedDB", + "navigator.sendBeacon", + "XMLHttpRequest", + "fetch(", + "WebSocket", + ): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, js) + + def test_contact_form_sends_nothing_by_default(self): + text = read(SITE / "kontakt" / "index.html") + self.assertIn("data-contact-form", text) + self.assertIn('action="mailto:info@dennyschulz.de"', text) + self.assertNotIn('method="post" action="/', text) + self.assertIn("sendet nichts an einen Server", visible_text(text)) + + def test_https_links_and_no_plain_http(self): + for file in HTML_FILES: + with self.subTest(file=file.name): + self.assertNotIn("http://desfoto.de", read(file)) + + +class Content(unittest.TestCase): + def test_all_requested_services_are_reachable(self): + home = read(SITE / "index.html") + for href in ( + "/businessfotografie/", + "/familien-und-paare/", + "/minishootings/", + "/projekte/", + "/video/", + "/social-media/", + ): + with self.subTest(href=href): + self.assertIn(f'href="{href}"', home) + + def test_pro_bono_work_is_offered(self): + projekte = read(SITE / "projekte" / "index.html") + self.assertIn("Pro bono", projekte) + self.assertIn("Pro-bono-Anfrage senden", projekte) + kontakt = read(SITE / "kontakt" / "index.html") + self.assertIn("Pro-bono-Anfrage", kontakt) + + def test_music_video_is_embedded_and_only_after_a_click(self): + text = read(SITE / "video" / "index.html") + self.assertIn("NWWFTf7l8g0", text) + self.assertIn("youtube-nocookie.com", read(SITE / "assets" / "site.js")) + self.assertNotIn("youtube.com/embed", text) + + def test_no_placeholder_or_unfinished_text(self): + markers = ("lorem ipsum", "todo", "tbd", "xxx", "platzhalter", "coming soon") + for file in HTML_FILES: + lower = read(file).lower() + for marker in markers: + with self.subTest(file=file.name, marker=marker): + self.assertNotIn(marker, lower) + + def test_footer_on_every_page(self): + for file in HTML_FILES: + text = read(file) + with self.subTest(file=file.name): + self.assertIn('href="/impressum/"', text) + self.assertIn('href="/datenschutz/"', text) + self.assertIn(CONTACT["email"], text) + + def test_sitemap_matches_routes(self): + sitemap = read(SITE / "sitemap.xml") + for path in ROUTE_PATHS: + with self.subTest(route=path): + self.assertIn(f"{META['url']}{path}", sitemap) + self.assertNotIn("404", sitemap) + + def test_robots_allows_crawling(self): + self.assertIn("User-agent: *", read(SITE / "robots.txt")) + self.assertIn(f"Sitemap: {META['url']}/sitemap.xml", read(SITE / "robots.txt")) + + +class Release(unittest.TestCase): + def test_deploy_removes_only_targeted_desfoto_labels(self): + deploy = read(ROOT / ".ocauto" / "deploy") + self.assertNotIn('"desfoto" not in line', deploy) + self.assertNotIn("sudo python3", deploy) + self.assertIn("traefik\\..*desfoto", deploy) + self.assertIn("stack-backup", deploy) + + def test_deploy_validates_before_replacing_the_shared_stack(self): + deploy = read(ROOT / ".ocauto" / "deploy") + self.assertIn('config --quiet', deploy) + self.assertIn('mktemp "$STACK_DIR/', deploy) + self.assertIn('sudo mv "$stack_tmp" "$stack_file"', deploy) + + def test_deploy_hooks_are_executable(self): + import os + + for name in ("qa", "deploy", "verify"): + with self.subTest(hook=name): + path = ROOT / ".ocauto" / name + self.assertTrue(path.is_file(), f"missing .ocauto/{name}") + self.assertTrue(os.access(path, os.X_OK), f".ocauto/{name} is not executable") + + +class Fonts(unittest.TestCase): + def test_fonts_are_self_hosted(self): + css = read(SITE / "assets" / "fonts.css") + self.assertIn("@font-face", css) + self.assertIn("font-display:swap", css) + urls = re.findall(r"url\('([^']+)'\)", css) + self.assertTrue(urls) + for url in urls: + with self.subTest(url=url): + self.assertTrue(url.startswith("/assets/fonts/")) + self.assertTrue((SITE / url.lstrip("/")).is_file()) + + def test_licences_shipped(self): + for name in ("font-fraunces-OFL.txt", "font-manrope-OFL.txt"): + with self.subTest(name=name): + self.assertTrue((ROOT / "docs" / name).is_file()) + + +if __name__ == "__main__": + unittest.main(verbosity=2)