Compare commits

9 Commits

Author SHA1 Message Date
desfoto automation
5e7fa9be05 fix: recover old GTIN service worker caches 2026-09-25 20:08:25 +02:00
desfoto automation
de6de3c6c4 fix: cache canonical GTIN page without redirects 2026-09-25 20:02:16 +02:00
desfoto automation
8be63309d3 fix: use GTIN-local legal favicons 2026-09-25 19:51:41 +02:00
desfoto automation
89b8ca2283 fix: isolate GTIN tool and legal pages 2026-09-25 19:47:28 +02:00
desfoto automation
160d72f144 feat: publish unlisted GTIN tool 2026-09-25 19:01:51 +02:00
desfoto automation
68613788b8 feat: reposition desfoto as an independent brand with four Arbeitsbereiche 2026-09-19 19:14:42 +02:00
desfoto automation
96247da074 fix: keep the footer brand column readable on narrow viewports 2026-09-19 18:11:54 +02:00
desfoto automation
8ffeea6731 feat: expand portfolios, drop wedding crowd photos, offer studio on site
- 28 more of the operator's own public images (42 -> 67), alts from his captions
- remove group-feier, group-outdoor, group-posiert (wedding guests visible)
- studio section now offers Studio Neumuenster and Mobiles Studio
- deploy: --force-recreate on release and rollback, plus in-container content probe
- verify: DESFOTO_BASE dry-run, redirect, asset and privacy checks
- tests: 57 OK (3 new content guards, 2 deploy/verify regression guards)
2026-09-19 17:37:49 +02:00
desfoto automation
447b909d05 feat: portfolio-led redesign with Shootings umbrella, original logo and motion layer 2026-09-19 17:11:25 +02:00
347 changed files with 9028 additions and 969 deletions

View File

@@ -39,7 +39,10 @@ if [ "$release_sha" = "rollback" ]; then
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'
sudo -n -u denny bash -c \"cd '$live_dir' && docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans\"
# --force-recreate for the same reason as in the forward path: the site
# tree inode changes, so a merely running container would keep the deleted
# directory mounted and serve an empty root.
sudo -n -u denny bash -c \"cd '$live_dir' && docker compose -f compose.yml -f compose.vps.yml up -d --force-recreate --remove-orphans\"
else
echo '[deploy] initial-state snapshot: removing the desfoto stack again'
if sudo test -d '$live_dir'; then
@@ -98,12 +101,19 @@ 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. The live directory belongs to denny and the SSH
# login cannot traverse /home/denny, so the directory change and the compose
# command run together in one shell under the owning identity.
sudo -n -u denny bash -c "cd '$LIVE_DIR' && docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans"
# 3. Start or update the stack. --force-recreate is deliberate: step 2 replaces
# the site directory (new inode) and rewrites nginx.conf, and a container that
# is merely "running" keeps the bind mount to the deleted directory and then
# serves an empty document root (every request 404) while Compose reports no
# change. Recreating costs a moment of 502s behind Traefik and guarantees that
# a fresh container binds the new tree and reads the new config. The live
# directory belongs to denny and the SSH login cannot traverse /home/denny, so
# the directory change and the compose command run together in one shell under
# the owning identity.
sudo -n -u denny bash -c "cd '$LIVE_DIR' && docker compose -f compose.yml -f compose.vps.yml up -d --force-recreate --remove-orphans"
# 4. Wait for the container health check.
# 4. Wait for the container health check. The healthcheck requests "/" from
# inside the container, so an empty document root cannot report healthy.
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
@@ -117,6 +127,15 @@ if [ "$status" != "healthy" ]; then
exit 1
fi
# 4b. Content check, not process check: a stale bind mount served 404 for every
# path while Compose still reported the container healthy. Ask the container
# itself for the homepage and fail the deploy if it is not served.
if ! sudo docker exec desfoto-web-1 wget -q --spider http://127.0.0.1/; then
echo "[deploy] ERROR: desfoto-web-1 does not serve the release at / (empty mount?)" >&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.

View File

@@ -12,7 +12,13 @@ cd "$(dirname "$0")/.."
root="$(pwd)"
release_sha="${1:-unknown}"
remote="${DESFOTO_REMOTE:-prod-main}"
base="https://desfoto.de"
# DESFOTO_BASE lets the hook be dry-run against a local container
# (DESFOTO_BASE=http://127.0.0.1:18430 .ocauto/verify unknown); the
# domain-specific sections (deployed tree, canonical host, neighbours, TLS)
# only run against the real production origin.
base="${DESFOTO_BASE:-https://desfoto.de}"
live=0
if [ "$base" = "https://desfoto.de" ]; then live=1; fi
failures=0
pass() { printf ' ok %s\n' "$1"; }
@@ -44,8 +50,29 @@ check_header() {
fi
}
check_redirect() {
local url="$1" want="$2" got target
got="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$url" || echo 000)"
target="$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 "$url" || true)"
if [ "$got" = "301" ] && [ "$target" = "$want" ]; then
pass "$url -> 301 $want"
else
fail "$url -> $got ${target:-<no Location>} (want 301 $want)"
fi
}
check_not_contains() {
local url="$1" needle="$2" body
body="$(curl -sS --max-time 30 "$url" || true)"
if printf '%s' "$body" | grep -qF -- "$needle"; then
fail "$url still contains '$needle'"
else
pass "$url no longer contains '$needle'"
fi
}
printf '\n== release identity\n'
if [ "$release_sha" != "unknown" ] && git -C "$root" rev-parse --verify --quiet "$release_sha" >/dev/null; then
if [ "$live" = 1 ] && [ "$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)"
@@ -63,12 +90,29 @@ if [ "$release_sha" != "unknown" ] && git -C "$root" rev-parse --verify --quiet
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
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
/minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/ \
/gtin/ /gtin/impressum/ /gtin/datenschutz/ /gtin/aktualisieren/; do
check_status "$base$route" 200
done
check_redirect "$base/gtin" "$base/gtin/"
check_redirect "$base/gtin/impressum" "$base/gtin/impressum/"
check_redirect "$base/gtin/datenschutz" "$base/gtin/datenschutz/"
check_redirect "$base/gtin/aktualisieren" "$base/gtin/aktualisieren/"
# The retired photo URL must keep working: it is the umbrella page now.
check_redirect "$base/shootings" "$base/fotografie/"
check_redirect "$base/shootings/" "$base/fotografie/"
check_redirect "$base/shootings.html" "$base/fotografie/"
check_redirect "$base/fotografie.html" "$base/fotografie/"
# Familien & Paare was renamed: the remaining family work stays with desfoto.
check_redirect "$base/familien-und-paare" "$base/familie/"
check_redirect "$base/familien-und-paare/" "$base/familie/"
check_redirect "$base/familien-und-paare.html" "$base/familie/"
printf '\n== canonical host and redirects\n'
if [ "$live" = 1 ]; then
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
@@ -81,15 +125,51 @@ case "$http_target" in
https://desfoto.de/*) pass "http://desfoto.de/ -> $http_target" ;;
*) fail "http://desfoto.de/ redirects to '$http_target'" ;;
esac
fi
printf '\n== content\n'
check_contains "$base/" "Bilder und Filme, die nicht beliebig aussehen"
check_contains "$base/" 'href="/fotografie/"'
check_contains "$base/" 'href="/musik-und-buehne/"'
check_contains "$base/" 'href="/video/"'
check_contains "$base/" "Mobiles Studio"
check_contains "$base/" "Studio Neumünster"
check_contains "$base/" "desfoto ist ein Angebot von Denny Schulz, Neumünster."
check_not_contains "$base/" 'href="/shootings'
check_not_contains "$base/" "Paarshooting"
check_contains "$base/fotografie/" "Autarke Blitzanlage"
check_contains "$base/fotografie/" "Minishootings"
check_contains "$base/familie/" "Familienshooting"
check_contains "$base/musik-und-buehne/" "Thjódrörir"
check_contains "$base/musik-und-buehne/" "Sagenbringer"
check_contains "$base/impressum/" "§ 5 DDG"
check_contains "$base/impressum/" "DE462149560"
check_contains "$base/datenschutz/" "keine Zugriffsprotokolle"
check_not_contains "$base/datenschutz/" "GTIN"
check_not_contains "$base/impressum/" "GTIN"
check_contains "$base/gtin/" 'name="robots" content="noindex, nofollow"'
check_not_contains "$base/" 'href="/gtin'
check_not_contains "$base/sitemap.xml" "/gtin/"
check_contains "$base/gtin/" 'href="/gtin/impressum/"'
check_contains "$base/gtin/" 'href="/gtin/datenschutz/"'
check_contains "$base/gtin/impressum/" "§ 5 DDG"
check_contains "$base/gtin/impressum/" "DE462149560"
check_contains "$base/gtin/datenschutz/" "Cache API"
check_contains "$base/gtin/datenschutz/" "nicht an einen Server übertragen"
check_contains "$base/gtin/datenschutz/" 'name="robots" content="noindex, nofollow"'
check_contains "$base/gtin/impressum/" 'name="robots" content="noindex, nofollow"'
check_contains "$base/gtin/aktualisieren/" 'name="robots" content="noindex, nofollow"'
check_status "$base/gtin/js/aktualisieren.js" 200
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
/minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ \
/kontakt/ /impressum/ /datenschutz/; do
check_not_contains "$base$route" 'href="/gtin'
done
check_contains "$base/datenschutz/" "youtube-nocookie.com"
check_contains "$base/video/" "NWWFTf7l8g0"
check_contains "$base/sitemap.xml" "<loc>https://desfoto.de/video/</loc>"
check_contains "$base/" "https://www.dennyschulz.de/"
check_contains "$base/" "https://dennyapp.de/"
check_contains "$base/sitemap.xml" "<loc>https://desfoto.de/fotografie/</loc>"
check_not_contains "$base/sitemap.xml" "<loc>https://desfoto.de/shootings/</loc>"
printf '\n== error handling and metadata\n'
check_status "$base/diese-seite-gibt-es-nicht/" 404
@@ -98,11 +178,44 @@ 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/logo-160.png" 200
check_status "$base/assets/img/favicon-32.png" 200
check_status "$base/assets/img/og-fotografie.jpg" 200
check_status "$base/assets/site.css" 200
check_status "$base/gtin/css/app.css" 200
check_status "$base/gtin/js/app.js" 200
check_status "$base/gtin/sw.js" 200
check_status "$base/gtin/manifest.webmanifest" 200
check_status "$base/gtin/health" 404
manifest_type="$(curl -sSI --max-time 30 "$base/gtin/manifest.webmanifest" | tr -d '\r' | grep -i '^content-type:' || true)"
if printf '%s' "$manifest_type" | grep -qi 'application/manifest+json'; then
pass "$base/gtin/manifest.webmanifest uses the Web App Manifest MIME type"
else
fail "$base/gtin/manifest.webmanifest has wrong Content-Type: ${manifest_type:-<missing>}"
fi
check_status "$base/assets/fonts/fraunces-latin.woff2" 200
check_status "$base/assets/img/og-desfoto.jpg" 200
# The portfolio expansion ships the operator's own public work...
check_status "$base/assets/img/music-dreadlocks-1200.webp" 200
check_status "$base/assets/img/business-hotel-window-1200.webp" 200
check_status "$base/assets/img/free-portrait-light-1200.webp" 200
check_status "$base/assets/img/editorial-leap-1200.webp" 200
# ...and the wedding pictures showing the guests stay removed.
check_status "$base/assets/img/group-feier-1200.webp" 404
check_status "$base/assets/img/group-outdoor-1200.webp" 404
check_status "$base/assets/img/group-posiert-1200.webp" 404
# Couple imagery and the studio shot with the old business card ship nowhere.
check_status "$base/assets/img/couple-park-1200.webp" 404
check_status "$base/assets/img/portrait-couple-1200.webp" 404
check_status "$base/assets/img/studio-setup-1200.webp" 404
# Brautpaar-Bilder sind komplett aus dem Bestand: Fotografie- und Eventbilder
# mit Paaren gehören zu dennyschulz.de, nicht zu desfoto.
check_status "$base/assets/img/portrait-natural-1200.webp" 404
check_status "$base/assets/img/event-motion-1200.webp" 404
check_status "$base/assets/img/event-dance-1200.webp" 404
check_contains "$base/minishootings/" "free-modern-portrait-1200.webp"
check_contains "$base/social-media/" "event-speaker-1200.webp"
check_contains "$base/projekte/" "music-dreadlocks-1200.webp"
check_contains "$base/familie/" "free-portrait-light-1200.webp"
printf '\n== privacy promises and headers\n'
check_header "$base/" "Strict-Transport-Security"
@@ -116,12 +229,14 @@ else
fi
printf '\n== neighbouring sites untouched\n'
if [ "$live" = 1 ]; then
check_status "https://www.dennyschulz.de/" 200
check_contains "https://www.dennyschulz.de/impressum" "Denny Schulz"
check_status "https://dennyapp.de/" 200
fi
printf '\n== TLS\n'
if command -v openssl >/dev/null 2>&1; then
if [ "$live" = 1 ] && 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"

View File

@@ -7,7 +7,23 @@ 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.
`dennyschulz.de` is a **separate** site: nothing here may modify it, proxy it or
hotlink its assets. Linking out to it from the footer is an explicit operator
request and is the only permitted reference (see "Editing rules").
## Structure
- `/` — hero, the four **Arbeitsbereiche** (Fotografie, Video, Musik & Bühne, Projekte),
the bookable services ("Was du bei mir buchen kannst"), the portfolio gallery
("Was ich außerdem mache"), music video, mobiles Studio, process, facts.
- `/fotografie/` — the umbrella for all photographic work: Businessfotografie,
Portrait & Model, Familie, Minishootings, plus the mobiles Studio.
`/shootings`, `/shootings/` and `/shootings.html` are 301-redirected here by
`nginx.conf`, and the old `/fotografie.html` is canonicalised to `/fotografie/`.
- `/familie/` — family photography (no couples: Paare and Engagement stay on
`dennyschulz.de`). `/familien-und-paare`, `/familien-und-paare/` and
`/familien-und-paare.html` are 301-redirected here.
- `/musik-und-buehne/` — concerts, band portraits and the Thjódrörir music video.
- `/video/`, `/social-media/`, `/projekte/` (music + free work + pro bono), `/ueber/`.
## Commands
- `python3 scripts/fetch-assets.py` — download pool images, Google Fonts subsets and
@@ -27,20 +43,65 @@ and nginx serves `site/` from a container behind the shared VPS Traefik instance
- 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`).
The portfolio must reflect the operator's real work (musicians, live shows, free
and artistic series, business shoots) — never invent trips, locations or jobs
(an earlier build captioned an image as a "Japanreise"; that was invented and must
not return).
- 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`.
`"source": "legacy"` marks images fetched from the operator's own site
(`dennyschulz.de/legacy/<file>`) — they are written into `assets-src/images/` and
never hotlinked at runtime. `alt` texts must come from the operator's own captions.
- The operator's original logo lives in `assets-src/brand/logo.png`; the build derives
`logo-160.png`, the favicons, the apple-touch icon and `favicon.svg` from it. Never
redraw or guess a replacement mark.
- The footer's "Netzwerk" column links to `https://www.dennyschulz.de/` and
`https://dennyapp.de/` because the operator asked for it; these are the only
external links allowed and they must keep `target="_blank" rel="noopener"`.
Nothing else may reference `dennyschulz.de` (no assets, no fetches, no rewriting).
- Animations are progressive enhancement: they live in CSS plus the guarded
`site.js` block, every page carries the `<noscript>` reveal fallback, and the
`prefers-reduced-motion` block must stay the **last** rule in `src/theme.py` so it
wins over the motion definitions.
- Never hide the element you observe: Chromium folds a target's own `clip-path` into
the `IntersectionObserver` geometry, so a closed mask never reports as visible and
the content stays hidden forever. `.reveal--mask` therefore clips its children.
- Never nest `<a>` inside `<a>` (or any other invalid nesting). The parser closes the
outer anchor and the surrounding grid falls apart — that is what happened to the
two homepage pillars the redesign replaced. Cards with their own links use the
stretched-link pattern (`.pillar__go::after`, `.link-arrow`) or keep the link in
the card foot; `tests/test_site.py` guards this for every page.
- **Brand separation:** `desfoto` is the brand, `Denny Schulz Fotografie` is only the
legal entity. The legal name may appear in the Impressum, the Datenschutz page and
the JSON-LD `legalName` — nowhere else. The footer carries exactly one ownership
sentence (`SITE["ownership"]`). Couples, engagements and weddings are advertised on
`dennyschulz.de` only; desfoto shows single-person portraits, family and business
work, and no couple imagery may be added back to `src/images.json`.
- `/fotografie/` is the umbrella term for the photographic work ("Shooting" is not a
section label any more). Renaming a route means updating `pages.ROUTES`,
`nginx.conf` (301 from the old URL) and `scripts/build-site.py:ROUTE_OG`;
the build prunes the retired page from `site/` automatically.
- 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;
- no cookies, analytics or tracking. The standalone `/gtin/` tool is the only
`localStorage`/Cache API exception: it keeps its last 20 generated entries in
the browser and caches only its own offline app shell; it sends no tool data
to a server. Its `/gtin/impressum/` and `/gtin/datenschutz/` pages are
standalone, unlisted legal pages. The tool stays unlisted: no main-site
navigation or page links, no sitemap entry, and `noindex,nofollow`; only the
GTIN area links to its own legal pages, which never link back to the main site.
The unlisted `/gtin/aktualisieren/` page repairs old redirected app caches;
- `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;
- the GTIN service worker is scoped to `/gtin/` and may not intercept other site
routes or delete caches owned by another application;
- 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
@@ -55,3 +116,10 @@ and removes the obsolete `desfoto.de → dennyschulz.de` labels from `/srv/stack
`.ocauto/verify <sha>` 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.
The deploy replaces `site/` (new inode) and rewrites `nginx.conf`, so it runs
`docker compose up -d --force-recreate`: a container that is merely "running" keeps
the deleted directory bind-mounted and then serves an empty document root — every
path 404, while Compose reports no change and the stale health status still says
`healthy`. The recreate is followed by an explicit in-container request for `/`, and
`tests/test_site.py` guards the flag. Never remove either check.

View File

@@ -41,9 +41,14 @@ 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.
Startseite · Fotografie · Businessfotografie · Portrait & Model · Familie · Minishootings ·
Video · Social Media · Projekte · Über · Kontakt · Impressum · Datenschutz — plus eine eigene
404-Seite.
Der GTIN-Generator unter `/gtin/` ist ein separates, öffentlich direkt aufrufbares Werkzeug.
Er bleibt absichtlich unverlinkt, fehlt in der Sitemap und trägt `noindex,nofollow`; nur das
Werkzeug selbst verlinkt auf seine eigenständigen Rechtstexte unter `/gtin/impressum/` und
`/gtin/datenschutz/`. Diese Seiten verwenden keine Navigation oder Rechtstexte des Hauptauftritts.
## Technische Entscheidungen
@@ -59,6 +64,15 @@ Datenschutz — plus eine eigene 404-Seite.
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.
- **GTIN-Generator.** Der Generator unter `/gtin/` rechnet vollständig im Browser,
ohne Konto, externe API oder Serverübertragung. Er speichert bis zu 20 zuletzt
erzeugte Einträge lokal im Browser; der Offline-Appcache enthält nur die
Anwendungsdateien. Der Service Worker speichert die kanonische Startadresse
`/gtin/` ohne Weiterleitung; jede Änderung am Werkzeug erhält eine neue
Cache-Version. Die eigenständige Datenschutzerklärung liegt unter
`/gtin/datenschutz/`; die Rechtstexte sind nicht mit denen des Hauptauftritts verknüpft.
Der unverlinkte Pfad `/gtin/aktualisieren/` entfernt eine fehlerhafte
Startantwort aus älteren Browser-Caches und öffnet das Werkzeug danach erneut.
## Herkunft der Inhalte

View File

@@ -64,6 +64,30 @@ server {
location = /impressum.html { return 301 /impressum/; }
location = /datenschutz.html { return 301 /datenschutz/; }
location = /kontakt.html { return 301 /kontakt/; }
location = /gtin { return 301 /gtin/; }
location = /gtin/impressum { return 301 /gtin/impressum/; }
location = /gtin/datenschutz { return 301 /gtin/datenschutz/; }
location = /gtin/aktualisieren { return 301 /gtin/aktualisieren/; }
location = /gtin/manifest.webmanifest {
default_type application/manifest+json;
try_files $uri =404;
}
# /fotografie.html used to redirect to /shootings/; it resolves to its own
# canonical page now instead of turning into a 404.
location = /fotografie.html { return 301 /fotografie/; }
# Fotografie is the umbrella term for the photographic work; the old
# /shootings/ URL stays reachable and points to the new page.
# (/fotografie/index.html is already canonicalised to /fotografie/ above.)
location = /shootings { return 301 /fotografie/; }
location = /shootings/ { return 301 /fotografie/; }
location = /shootings.html { return 301 /fotografie/; }
# Familie keeps the photographic range that stays with desfoto; the old
# combined Familien-und-Paare URL points here.
location = /familien-und-paare { return 301 /familie/; }
location = /familien-und-paare/ { return 301 /familie/; }
location = /familien-und-paare.html { return 301 /familie/; }
location ^~ /.well-known/ {
allow all;

View File

@@ -12,7 +12,10 @@ Steps
from __future__ import annotations
import base64
import datetime as dt
import hashlib
import io
import json
import shutil
import sys
@@ -27,6 +30,7 @@ SITE = ROOT / "site"
IMG_OUT = SITE / "assets" / "img"
FONT_OUT = SITE / "assets" / "fonts"
TTF = ASSETS / "fonts-ttf"
LOGO_SRC = ASSETS / "brand" / "logo.png"
sys.path.insert(0, str(SRC))
@@ -35,8 +39,14 @@ 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)
WIDTHS = (480, 768, 1200, 1600)
WEBP_QUALITY = 84
UNLISTED_HTML = [
"gtin/index.html",
"gtin/impressum/index.html",
"gtin/datenschutz/index.html",
"gtin/aktualisieren/index.html",
]
INK = (23, 20, 15)
PAPER = (246, 242, 234)
@@ -44,16 +54,17 @@ 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"),
"/": ("music-vocal-blue", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."),
"/fotografie/": ("business-portrait-studio", "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"),
"/familie/": ("free-portrait-light", "og-familie.jpg", "Familienfotografie"),
"/minishootings/": ("free-modern-portrait", "og-minishootings.jpg", "Minishootings"),
"/video/": ("music-guitar-silhouette", "og-video.jpg", "Videoprojekte"),
"/musik-und-buehne/": ("music-vocal-blue", "og-musik-und-buehne.jpg", "Musik & Bühne"),
"/social-media/": ("event-speaker", "og-social-media.jpg", "Social-Media-Inhalte"),
"/projekte/": ("free-mountain-view", "og-projekte.jpg", "Eigene Projekte"),
"/ueber/": ("gear-cases", "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"),
@@ -159,59 +170,38 @@ 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 load_logo() -> Image.Image:
"""The operator's own logo (as published on dennyschulz.de)."""
if not LOGO_SRC.exists():
raise SystemExit(f"missing brand asset {LOGO_SRC} - run scripts/fetch-assets.py")
return Image.open(LOGO_SRC).convert("RGBA")
def build_brand_assets(table: dict[str, dict]) -> None:
logo = load_logo()
# 1:1 copy of the operator's original logo for the header/footer mark.
logo.save(IMG_OUT / "logo-160.png", "PNG", optimize=True)
logo.resize((180, 180), Image.Resampling.LANCZOS).save(
IMG_OUT / "apple-touch-icon.png", "PNG", optimize=True
)
logo.resize((32, 32), Image.Resampling.LANCZOS).save(
IMG_OUT / "favicon-32.png", "PNG", optimize=True
)
logo.resize((16, 16), Image.Resampling.LANCZOS).save(
IMG_OUT / "favicon-16.png", "PNG", optimize=True
)
# Self-contained SVG favicon; the operator's own site uses the same trick.
buffer = io.BytesIO()
logo.save(buffer, "PNG", optimize=True)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
(SITE / "favicon.svg").write_text(
"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="desfoto">
<rect width="64" height="64" rx="14" fill="#17140F"/>
<circle cx="32" cy="32" r="17" fill="none" stroke="#F6F2EA" stroke-width="3.4"/>
<circle cx="32" cy="32" r="6.6" fill="none" stroke="#F6F2EA" stroke-width="2.6"/>
<g stroke="#C0431C" stroke-width="3.6" stroke-linecap="round">
<path d="M32 15V6"/><path d="M46.7 40.5 54.4 45"/><path d="M17.3 40.5 9.6 45"/>
</g>
</svg>
""",
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-label="desfoto">\n'
f' <image width="160" height="160" href="data:image/png;base64,{encoded}"/>\n'
"</svg>\n",
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")
print(" [brand] logo-160.png, favicon.svg, apple-touch-icon.png, favicon-32.png")
og_done = set()
for slug, filename, headline in ROUTE_OG.values():
@@ -219,6 +209,11 @@ def build_brand_assets(table: dict[str, dict]) -> None:
continue
og_done.add(filename)
build_og(table, slug, filename, headline)
# A renamed route would otherwise leave its old card behind forever.
for stale in sorted(IMG_OUT.glob("og-*.jpg")):
if stale.name not in og_done:
stale.unlink()
print(f" [brand] removed stale {stale.name}")
def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) -> None:
@@ -249,8 +244,10 @@ def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) ->
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))
mark = load_logo().resize((102, 102), Image.Resampling.LANCZOS)
source.paste(mark, (76, 62), mark)
d.text((198, 72), "desfoto", font=brand_font, fill=PAPER)
d.text((202, 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:
@@ -288,16 +285,40 @@ def build_html(table: dict[str, dict]) -> list[tuple[str, str]]:
target.write_text(html, encoding="utf-8")
written.append((path, rel))
print(f" [html] {rel}")
shutil.rmtree(SITE / "gtin", ignore_errors=True)
shutil.copytree(SRC / "gtin", SITE / "gtin")
version_gtin_service_worker()
print(" [html] gtin/ (unlisted browser tool and standalone legal pages)")
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 version_gtin_service_worker() -> None:
"""Give each GTIN source release its own offline cache."""
digest = hashlib.sha256()
source_root = SRC / "gtin"
for path in sorted(source_root.rglob("*")):
if not path.is_file():
continue
digest.update(path.relative_to(source_root).as_posix().encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
worker = SITE / "gtin" / "sw.js"
script = worker.read_text(encoding="utf-8")
marker = "__BUILD_VERSION__"
if script.count(marker) != 1:
raise RuntimeError("GTIN service worker cache version marker is missing or duplicated")
worker.write_text(script.replace(marker, digest.hexdigest()[:12]), encoding="utf-8")
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:
for path, _rel, _factory, _extra, priority in pages.ROUTES:
urls.append(
f" <url><loc>{SITE_META['url']}{path}</loc><lastmod>{today}</lastmod>"
f"<changefreq>{'weekly' if priority == '1.0' else 'monthly'}</changefreq>"
@@ -421,22 +442,108 @@ def build_script() -> None:
});
}
/* Soft reveal on scroll, disabled for reduced motion */
/* Motion layer: reveals, scroll progress, parallax and pointer spotlights.
Everything degrades to a static page and is disabled for reduced motion. */
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* Anything inside [data-stagger] reveals one item after another. */
Array.prototype.forEach.call(document.querySelectorAll('[data-stagger]'), function (group) {
var step = parseFloat(group.getAttribute('data-stagger')) || 0.08;
Array.prototype.forEach.call(group.children, function (child, index) {
if (!child.classList.contains('reveal')) {
child.classList.add('reveal');
}
child.style.setProperty('--d', (index * step).toFixed(2) + 's');
});
});
var items = document.querySelectorAll('.reveal');
var revealAll = function () {
Array.prototype.forEach.call(items, function (item) { item.classList.add('is-in'); });
};
if (reduce || !('IntersectionObserver' in window)) {
items.forEach(function (item) { item.classList.add('is-in'); });
revealAll();
} else {
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-in');
observer.unobserve(entry.target);
try {
var fired = 0;
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
fired = 1;
entry.target.classList.add('is-in');
observer.unobserve(entry.target);
}
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.06 });
Array.prototype.forEach.call(items, function (item) { observer.observe(item); });
/* Safety net: if the browser never reports a box that is on screen, drop the
effect entirely instead of leaving content hidden. */
window.setTimeout(function () {
if (fired) { return; }
var onScreen = false;
Array.prototype.forEach.call(items, function (item) {
var box = item.getBoundingClientRect();
if (box.top < window.innerHeight && box.bottom > 0) { onScreen = true; }
});
if (onScreen) {
observer.disconnect();
revealAll();
}
});
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 });
items.forEach(function (item) { observer.observe(item); });
}, 1600);
} catch (error) {
revealAll();
}
}
if (reduce) {
return;
}
var progress = document.querySelector('[data-progress]');
var parallax = Array.prototype.slice.call(document.querySelectorAll('[data-parallax]'));
var ticking = false;
function onScroll() {
var doc = document.documentElement;
var max = doc.scrollHeight - window.innerHeight;
var ratio = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
if (progress) {
progress.style.setProperty('--scroll', ratio.toFixed(4));
}
if (parallax.length) {
var view = window.innerHeight;
parallax.forEach(function (element) {
var rect = element.getBoundingClientRect();
if (rect.bottom < -240 || rect.top > view + 240) {
return;
}
var speed = parseFloat(element.getAttribute('data-parallax')) || 0.12;
var offset = (rect.top + rect.height / 2 - view / 2) * speed;
element.style.setProperty('--py', offset.toFixed(1) + 'px');
});
}
ticking = false;
}
function requestScroll() {
if (!ticking) {
ticking = true;
window.requestAnimationFrame(onScroll);
}
}
window.addEventListener('scroll', requestScroll, { passive: true });
window.addEventListener('resize', requestScroll);
onScroll();
/* Pointer-following highlight on the media cards. */
Array.prototype.forEach.call(document.querySelectorAll('.spot'), function (card) {
card.addEventListener('pointermove', function (event) {
var rect = card.getBoundingClientRect();
card.style.setProperty('--mx', ((event.clientX - rect.left) / rect.width * 100).toFixed(1) + '%');
card.style.setProperty('--my', ((event.clientY - rect.top) / rect.height * 100).toFixed(1) + '%');
});
});
}());
""",
encoding="utf-8",
@@ -451,6 +558,7 @@ def link_check(routes: list[tuple[str, str]]) -> list[str]:
problems: list[str] = []
built = {f"/{rel}".replace("/index.html", "/") for _p, rel in routes}
built.update(f"/{rel}".replace("/index.html", "/") for rel in UNLISTED_HTML)
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"]:
@@ -467,6 +575,8 @@ def link_check(routes: list[tuple[str, str]]) -> list[str]:
if target.startswith("/assets/img/") or target.startswith("/assets/fonts/"):
if (SITE / target.lstrip("/")).exists():
continue
if target.startswith("/gtin/") and (SITE / target.lstrip("/")).exists():
continue
if target == "/assets/fonts/fonts.css":
continue
problems.append(f"{file.relative_to(SITE)} -> {target}")
@@ -488,6 +598,28 @@ def link_check(routes: list[tuple[str, str]]) -> list[str]:
return problems
def prune_stale_pages(routes: list[tuple]) -> None:
"""Remove built pages that no longer belong to the route table.
The build writes into the existing tree, so a renamed route would otherwise
leave its old HTML (and its retired image srcsets) behind and the link check
would keep failing on files nobody serves any more.
"""
expected = (
{SITE / route[1] for route in routes}
| {SITE / rel for rel in UNLISTED_HTML}
| {SITE / "404.html"}
)
for html in sorted(SITE.rglob("*.html")):
if html in expected:
continue
print(f" [pages] removed stale {html.relative_to(SITE)}")
html.unlink()
parent = html.parent
if parent != SITE and not any(parent.iterdir()):
parent.rmdir()
def normalize_permissions() -> None:
"""Make the build output readable for the serving nginx worker.
@@ -512,6 +644,7 @@ def main() -> int:
build_brand_assets(table)
print("== pages ==")
routes = build_html(table)
prune_stale_pages(routes)
build_meta_files(routes)
build_script()
print("== checks ==")

View File

@@ -7,6 +7,10 @@ Sources
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.
* Own portfolio files of the existing site (www.dennyschulz.de/legacy/<file>).
These carry the concert, band and free-work photographs that are missing from
the pool; they are fetched as-is and downscaled by the build. Also used for
the operator's original logo (www.dennyschulz.de/logo.png).
* 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".
@@ -26,10 +30,13 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "assets-src"
IMG_OUT = SRC / "images"
BRAND_OUT = SRC / "brand"
FONT_OUT = SRC / "fonts"
TTF_OUT = SRC / "fonts-ttf"
DOCS = ROOT / "docs"
LOGO_URL = "https://www.dennyschulz.de/logo.png"
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).
@@ -81,22 +88,26 @@ def try_fetch(url: str, *, ua: str = UA) -> bytes | None:
def fetch_images() -> None:
manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8"))
base = manifest["pool_base"].rstrip("/")
legacy_base = manifest["legacy_base"].rstrip("/")
suffixes = (".webp", ".src", ".jpg", ".jpeg", ".png")
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():
if any((IMG_OUT / f"{slug}{suffix}").exists() for suffix in suffixes):
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)
if entry.get("source") == "legacy":
data = fetch(f"{legacy_base}/{rel}")
(IMG_OUT / f"{slug}.jpg").write_bytes(data)
else:
target.write_bytes(data)
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}")
(IMG_OUT / f"{slug}.src").write_bytes(data)
else:
(IMG_OUT / f"{slug}.webp").write_bytes(data)
ok += 1
print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)")
print(f"images: {ok} fetched, {skipped} already present")
@@ -110,6 +121,18 @@ def fetch_images() -> None:
print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)")
def fetch_brand() -> None:
"""The operator's original logo (used for the brand mark and all icons)."""
BRAND_OUT.mkdir(parents=True, exist_ok=True)
target = BRAND_OUT / "logo.png"
if target.exists():
print(f"brand: logo already present ({target.stat().st_size / 1024:.0f} KiB)")
return
data = fetch(LOGO_URL)
target.write_bytes(data)
print(f" [ok] logo ({len(data) / 1024:.0f} KiB)")
def fetch_fonts() -> None:
FONT_OUT.mkdir(parents=True, exist_ok=True)
for family, spec in FAMILIES.items():
@@ -160,8 +183,10 @@ def fetch_fonts() -> None:
def main() -> int:
for path in (IMG_OUT, FONT_OUT, DOCS):
for path in (IMG_OUT, BRAND_OUT, FONT_OUT, DOCS):
path.mkdir(parents=True, exist_ok=True)
print("== brand ==")
fetch_brand()
print("== images ==")
fetch_images()
print("== fonts ==")

View File

@@ -1,4 +1,4 @@
Contact: mailto:info@dennyschulz.de
Expires: 2027-09-19T00:00:00.000Z
Expires: 2027-09-25T00:00:00.000Z
Preferred-Languages: de, en
Canonical: https://desfoto.de/.well-known/security.txt

View File

@@ -27,6 +27,7 @@
<link rel="manifest" href="/site.webmanifest">
<link rel="stylesheet" href="/assets/fonts.css">
<link rel="stylesheet" href="/assets/site.css">
<noscript><style>.reveal,.reveal--mask{opacity:1;transform:none;clip-path:none}.reveal--mask>*{clip-path:none}.hero__title .w>span,.hero__sub,.hero__actions,.hero__facts{opacity:1;transform:none}</style></noscript>
</head>
@@ -35,20 +36,15 @@
<header class="masthead">
<div class="shell masthead__inner">
<a class="brand" href="/" aria-label="desfoto Startseite">
<svg class="brand__mark" viewBox="0 0 40 40" role="img" aria-label="desfoto Bildmarke">
<circle class="ring" cx="20" cy="20" r="13"/>
<circle class="ring" cx="20" cy="20" r="5.2" stroke-width="2"/>
<path class="blade" d="M20 7 20 1.6"/>
<path class="blade" d="M31.3 26.5 36 29.2"/>
<path class="blade" d="M8.7 26.5 4 29.2"/>
</svg>
<span class="brand__mark"><img src="/assets/img/logo-160.png" width="160" height="160" alt="" decoding="async"></span>
<span class="brand__text"><span>desfoto</span><small>Fotografie &amp; Video</small></span>
</a>
<button class="nav__toggle" type="button" aria-expanded="false" aria-controls="site-nav" data-nav-toggle>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg> Menü
</button>
<nav class="nav" id="site-nav" aria-label="Hauptnavigation"><div class="nav__item"><a class="nav__link" href="/fotografie/">Fotografie<svg class="nav__caret" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6l5 5 5-5"/></svg></a><div class="nav__sub"><a href="/businessfotografie/">Businessfotografie</a><a href="/portrait-und-model/">Portrait &amp; Model</a><a href="/familien-und-paare/">Familien &amp; Paare</a><a href="/minishootings/">Minishootings</a></div></div><a class="nav__link" href="/video/">Video</a><a class="nav__link" href="/social-media/">Social Media</a><a class="nav__link" href="/projekte/">Projekte</a><a class="nav__link" href="/ueber/">Über</a><a class="btn btn--solid" href="/kontakt/">Projekt anfragen</a></nav>
<nav class="nav" id="site-nav" aria-label="Hauptnavigation"><div class="nav__item"><a class="nav__link" href="/fotografie/">Fotografie<svg class="nav__caret" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6l5 5 5-5"/></svg></a><div class="nav__sub"><a href="/businessfotografie/">Businessfotografie</a><a href="/portrait-und-model/">Portrait &amp; Model</a><a href="/familie/">Familie</a><a href="/minishootings/">Minishootings</a></div></div><a class="nav__link" href="/video/">Video</a><a class="nav__link" href="/musik-und-buehne/">Musik &amp; Bühne</a><a class="nav__link" href="/projekte/">Projekte</a><a class="nav__link" href="/ueber/">Über</a><a class="btn btn--solid" href="/kontakt/">Projekt anfragen</a></nav>
</div>
<span class="progress" aria-hidden="true"><span class="progress__bar" data-progress></span></span>
</header>
<main id="main"><section class="section">
<div class="shell center-page">
@@ -70,31 +66,32 @@
<div class="footer__grid">
<div class="footer__brand">
<span class="brand">
<svg class="brand__mark" viewBox="0 0 40 40" role="img" aria-label="desfoto Bildmarke">
<circle class="ring" cx="20" cy="20" r="13"/>
<circle class="ring" cx="20" cy="20" r="5.2" stroke-width="2"/>
<path class="blade" d="M20 7 20 1.6"/>
<path class="blade" d="M31.3 26.5 36 29.2"/>
<path class="blade" d="M8.7 26.5 4 29.2"/>
</svg>
<span class="brand__mark"><img src="/assets/img/logo-160.png" width="160" height="160" alt="" decoding="async"></span>
<span class="brand__text"><span>desfoto</span><small>Fotografie &amp; Video</small></span>
</span>
<p class="footer__claim">Fotografie und Video aus Neumünster. Ein Angebot von Denny Schulz Fotografie.</p>
<p class="footer__claim">desfoto ist ein Angebot von Denny Schulz, Neumünster.</p>
</div>
<div>
<h2>Fotografie</h2>
<div class="footer__links">
<a href="/fotografie/">Überblick Fotografie</a>
<a href="/businessfotografie/">Businessfotografie</a>
<a href="/portrait-und-model/">Portrait &amp; Model</a>
<a href="/familien-und-paare/">Familien &amp; Paare</a>
<a href="/familie/">Familie</a>
<a href="/minishootings/">Minishootings</a>
</div>
</div>
<div>
<h2>Weitere Leistungen</h2>
<h2>Video &amp; Bühne</h2>
<div class="footer__links">
<a href="/video/">Videoprojekte</a>
<a href="/musik-und-buehne/">Musik &amp; Bühne</a>
<a href="/social-media/">Social Media</a>
</div>
</div>
<div>
<h2>Mehr</h2>
<div class="footer__links">
<a href="/projekte/">Projekte</a>
<a href="/ueber/">Über desfoto</a>
</div>
@@ -108,6 +105,10 @@
<span class="muted">Neumünster · Schleswig-Holstein</span>
</div>
</div>
<div>
<h2>Netzwerk</h2>
<div class="footer__links"><a class="footer__ext" href="https://www.dennyschulz.de/" target="_blank" rel="noopener">dennyschulz.de <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-7 7M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4"/></svg></a><small>Hochzeiten und Paare — die Arbeit unter eigenem Namen, mit eigenem Schwerpunkt.</small><a class="footer__ext" href="https://dennyapp.de/" target="_blank" rel="noopener">dennyapp.de <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-7 7M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4"/></svg></a><small>Websites und Web-Apps aus Neumünster — Design, Entwicklung und eigene Produkte.</small></div>
</div>
</div>
<div class="footer__bottom">
<nav aria-label="Rechtliches">
@@ -115,7 +116,7 @@
<a href="/datenschutz/">Datenschutz</a>
<a href="/kontakt/">Kontakt</a>
</nav>
<span>© Denny Schulz · Denny Schulz Fotografie · Alle Rechte an den gezeigten Bildern und Filmen vorbehalten.</span>
<span>© Denny Schulz · Alle Rechte an den gezeigten Bildern und Filmen vorbehalten.</span>
</div>
</div>
</footer>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 B

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Some files were not shown because too many files have changed in this diff Show More