feat(ui): green map pin for applied flats, hide map reject after apply, lightbox image viewer

Map: replace Leaflet's default marker with a divIcon SVG pin coloured
per state — green when the user has already successfully applied
(status.chip === "ok"), brand-blue otherwise. Same condition also hides
the action buttons in the popup, matching the list view, which already
hid both Bewerben and Ablehnen on success — so the only remaining
action on an applied flat is opening the original ad link.

Image gallery: clicks now open a global lightbox modal instead of a new
tab. The viewer fits each image into the viewport via max-width/height
+ object-fit: contain (uniform sizing regardless of source aspect),
shows × top-right, prev/next arrows on the sides, ←/→/Esc keyboard
nav, and click-on-backdrop to close. Prev arrow is hidden on the first
image and next on the last. Tile changes from <a target="_blank"> to
<button> since the new-tab fallback is no longer wanted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-23 12:37:15 +02:00
parent fe43a402d8
commit 787f848aba
5 changed files with 173 additions and 21 deletions

View file

@ -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); }

View file

@ -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));
});
})();

View file

@ -64,6 +64,10 @@ function popupHtml(f, csrf) {
`<span class="chip chip-${escHtml(f.status.chip)}">${escHtml(f.status.label)}</span>` +
`</div>`;
}
// 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 += `<div class="map-popup-actions">`;
if (f.can_apply) {
const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`;
@ -85,10 +89,31 @@ function popupHtml(f, csrf) {
`<button class="btn btn-ghost text-xs" type="submit"` +
` hx-confirm="Ablehnen und aus der Liste entfernen?">Ablehnen</button>` +
`</form>`;
html += `</div></div>`;
html += `</div>`;
}
html += `</div>`;
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 =
`<svg width="28" height="38" viewBox="0 0 28 38" xmlns="http://www.w3.org/2000/svg">` +
`<path d="M14 1C7 1 1 6.6 1 13.4c0 9.5 13 23.6 13 23.6s13-14.1 13-23.6C27 6.6 21 1 14 1z" ` +
`fill="${color}" stroke="rgba(0,0,0,0.25)" stroke-width="1"/>` +
`<circle cx="14" cy="13.5" r="4.5" fill="white"/>` +
`</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 });
});

View file

@ -10,9 +10,9 @@
<div class="px-4 py-4 space-y-3">
<div class="flat-gallery">
{% for src in image_urls %}
<a class="flat-gallery-tile" href="{{ src }}" target="_blank" rel="noopener">
<button type="button" class="flat-gallery-tile" data-full-src="{{ src }}" aria-label="Foto {{ loop.index }} öffnen">
<img src="{{ src }}" loading="lazy" alt="Foto {{ loop.index }}">
</a>
</button>
{% endfor %}
</div>
<div class="text-xs">

View file

@ -18,5 +18,20 @@
</head>
<body class="min-h-screen">
{% block body %}{% endblock %}
{# Image lightbox — global so any flat-gallery on any page reuses the same modal. #}
<div id="lazyflat-lightbox" class="lightbox hidden" aria-hidden="true" role="dialog" aria-label="Bildansicht">
<button class="lightbox-close" type="button" aria-label="Schließen" data-lightbox-close>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
<button class="lightbox-prev" type="button" aria-label="Vorheriges Bild" data-lightbox-prev>
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="lightbox-next" type="button" aria-label="Nächstes Bild" data-lightbox-next>
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<img class="lightbox-image" alt="">
<div class="lightbox-counter"></div>
</div>
</body>
</html>