diff --git a/web/static/app.css b/web/static/app.css index a438e02..e5b4296 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -128,11 +128,48 @@ body:has(#v_map:checked) .view-map { display: block; } gap: 8px; } .flat-gallery-tile { aspect-ratio: 4 / 3; overflow: hidden; border-radius: 8px; border: 1px solid var(--border); - background: #f0f5fa; display: block; } + background: #f0f5fa; display: block; + padding: 0; cursor: zoom-in; } .flat-gallery-tile img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .3s; } .flat-gallery-tile:hover img { transform: scale(1.04); } +/* Image lightbox — full-viewport overlay with a centered, uniformly sized + image. Prev arrow is hidden on the first image; next arrow on the last. */ +.lightbox { position: fixed; inset: 0; z-index: 1000; + background: rgba(8, 18, 32, .92); + display: flex; align-items: center; justify-content: center; + animation: lightbox-fade .15s ease-out; } +.lightbox.hidden { display: none; } +.lightbox-image { max-width: 92vw; max-height: 88vh; object-fit: contain; + border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,.5); + background: #0c1726; } +.lightbox button { background: rgba(255,255,255,.08); color: #fff; border: 0; + border-radius: 9999px; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + transition: background .15s, transform .05s; padding: 0; } +.lightbox button:hover { background: rgba(255,255,255,.22); } +.lightbox button:active { transform: scale(.96); } +.lightbox-close { position: absolute; top: 1.25rem; right: 1.25rem; + width: 2.5rem; height: 2.5rem; } +.lightbox-prev, .lightbox-next { position: absolute; top: 50%; + transform: translateY(-50%); + width: 3rem; height: 3rem; } +.lightbox-prev:active, .lightbox-next:active { transform: translateY(-50%) scale(.96); } +.lightbox-prev { left: 1.25rem; } +.lightbox-next { right: 1.25rem; } +.lightbox-prev[hidden], .lightbox-next[hidden] { display: none; } +.lightbox-counter { position: absolute; bottom: 1.25rem; left: 50%; + transform: translateX(-50%); color: rgba(255,255,255,.7); + font-size: .85rem; font-variant-numeric: tabular-nums; + letter-spacing: .02em; pointer-events: none; } +body.lightbox-open { overflow: hidden; } +@keyframes lightbox-fade { from { opacity: 0 } to { opacity: 1 } } + +/* Map pin — divIcon default class has a white box; clear it so the SVG sits clean. */ +.lazyflat-pin { background: transparent; border: 0; } +.lazyflat-pin svg { display: block; filter: drop-shadow(0 1px 2px rgba(0,0,0,.25)); } + /* Leaflet popup — match site visual */ .leaflet-popup-content-wrapper { border-radius: 12px; box-shadow: 0 6px 20px rgba(16,37,63,.15); } .leaflet-popup-content { margin: 12px 14px; min-width: 220px; color: var(--text); } diff --git a/web/static/app.js b/web/static/app.js index fb88a94..b5e58c0 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -128,3 +128,76 @@ function openDeepLinkedFlat() { } document.addEventListener("DOMContentLoaded", openDeepLinkedFlat); + +// Image lightbox — single global modal in base.html, opened by clicking any +// .flat-gallery-tile. Click handler is delegated so it survives HTMX swaps. +(function () { + const overlay = document.getElementById("lazyflat-lightbox"); + if (!overlay) return; + const imgEl = overlay.querySelector(".lightbox-image"); + const counterEl = overlay.querySelector(".lightbox-counter"); + const prevBtn = overlay.querySelector("[data-lightbox-prev]"); + const nextBtn = overlay.querySelector("[data-lightbox-next]"); + const closeBtn = overlay.querySelector("[data-lightbox-close]"); + let urls = []; + let idx = 0; + + function render() { + imgEl.src = urls[idx]; + imgEl.alt = `Foto ${idx + 1} von ${urls.length}`; + counterEl.textContent = urls.length > 1 ? `${idx + 1} / ${urls.length}` : ""; + prevBtn.hidden = idx <= 0; + nextBtn.hidden = idx >= urls.length - 1; + } + + function open(list, startIdx) { + if (!list.length) return; + urls = list; + idx = Math.max(0, Math.min(startIdx | 0, urls.length - 1)); + overlay.classList.remove("hidden"); + overlay.setAttribute("aria-hidden", "false"); + document.body.classList.add("lightbox-open"); + render(); + } + + function close() { + overlay.classList.add("hidden"); + overlay.setAttribute("aria-hidden", "true"); + document.body.classList.remove("lightbox-open"); + imgEl.removeAttribute("src"); + urls = []; + } + + function step(delta) { + const next = idx + delta; + if (next < 0 || next >= urls.length) return; + idx = next; + render(); + } + + prevBtn.addEventListener("click", () => step(-1)); + nextBtn.addEventListener("click", () => step(1)); + closeBtn.addEventListener("click", close); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) close(); + }); + document.addEventListener("keydown", (ev) => { + if (overlay.classList.contains("hidden")) return; + if (ev.key === "Escape") close(); + else if (ev.key === "ArrowLeft") step(-1); + else if (ev.key === "ArrowRight") step(1); + }); + + document.addEventListener("click", (ev) => { + const tile = ev.target.closest(".flat-gallery-tile"); + if (!tile) return; + const gallery = tile.closest(".flat-gallery"); + if (!gallery) return; + ev.preventDefault(); + const tiles = Array.from(gallery.querySelectorAll(".flat-gallery-tile")); + const list = tiles + .map((t) => t.dataset.fullSrc || (t.querySelector("img") || {}).src || "") + .filter(Boolean); + open(list, tiles.indexOf(tile)); + }); +})(); diff --git a/web/static/map.js b/web/static/map.js index eac18a3..0e66c4b 100644 --- a/web/static/map.js +++ b/web/static/map.js @@ -64,31 +64,56 @@ function popupHtml(f, csrf) { `${escHtml(f.status.label)}` + ``; } - html += `