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 += `
`; - if (f.can_apply) { - const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`; + // Once the user has successfully applied, both action buttons disappear — + // matches the list view, which hides them on success too. + const applied = !!(f.status && f.status.chip === "ok"); + if (!applied) { + html += `
`; + if (f.can_apply) { + const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`; + html += + `
` + + `` + + `` + + `` + + `
`; + } html += - `
` + + `` + `` + `` + - `` + + `` + `
`; + html += `
`; } - html += - `
` + - `` + - `` + - `` + - `
`; - html += `
`; + html += ``; return html; } +// Custom pin icon — divIcon with inline SVG so we can color it per-state +// without shipping marker images. Successful apply → green; everything else +// → brand blue. Sized + anchored to roughly match Leaflet's default pin. +function pinIcon(color) { + const svg = + `` + + `` + + `` + + ``; + return L.divIcon({ + className: "lazyflat-pin", + html: svg, + iconSize: [28, 38], + iconAnchor: [14, 38], + popupAnchor: [0, -34], + }); +} + function renderMarkers(payload) { if (!mapInstance) return; const { csrf, flats } = payload; @@ -106,7 +131,9 @@ function renderMarkers(payload) { flats.forEach((f) => { if (typeof f.lat !== "number" || typeof f.lng !== "number") return; - L.marker([f.lat, f.lng]) + const applied = !!(f.status && f.status.chip === "ok"); + const color = applied ? "#1f8a4a" : "#2f8ae0"; + L.marker([f.lat, f.lng], { icon: pinIcon(color) }) .addTo(markerLayer) .bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true }); }); diff --git a/web/templates/_wohnung_detail.html b/web/templates/_wohnung_detail.html index e3ea633..d6611b4 100644 --- a/web/templates/_wohnung_detail.html +++ b/web/templates/_wohnung_detail.html @@ -10,9 +10,9 @@
diff --git a/web/templates/base.html b/web/templates/base.html index 6e395a9..339b5f6 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -18,5 +18,20 @@ {% block body %}{% endblock %} + +{# Image lightbox — global so any flat-gallery on any page reuses the same modal. #} +