From 931e0bb8b738b836c1097c808a5652f53d855bf9 Mon Sep 17 00:00:00 2001 From: EiSiMo Date: Tue, 21 Apr 2026 14:01:11 +0200 Subject: [PATCH] map: clickable address + status chip + Bewerben/Ablehnen in Leaflet popups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map_points payload now carries flat id, per-user status, can_apply, is_running - Popup titles link to the listing; status chip mirrors the list (beworben / läuft… / fehlgeschlagen); Bewerben + Ablehnen submit via the same HTMX endpoints as the list, re-swapping #wohnungen-body - csrf token rides on the script[data-csrf] sibling of #flats-map - popupopen → htmx.process(popupEl) so hx-* on freshly injected DOM binds - site-style .map-popup-* CSS hooked into Leaflet's popup wrapper Co-Authored-By: Claude Opus 4.7 (1M context) --- web/app.py | 15 +++++ web/static/map.js | 99 ++++++++++++++++++++++++------ web/templates/_wohnungen_body.html | 2 +- web/templates/base.html | 11 ++++ 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/web/app.py b/web/app.py index d784992..c36da2b 100644 --- a/web/app.py +++ b/web/app.py @@ -412,13 +412,28 @@ def _wohnungen_context(user) -> dict: f = item["row"] if f["lat"] is None or f["lng"] is None: continue + last = item["last"] + is_running = bool(last and last["finished_at"] is None) + already_applied = bool(last and last["success"] == 1) + if is_running: + status = {"label": "läuft…", "chip": "warn"} + elif already_applied: + status = {"label": "beworben", "chip": "ok"} + elif last and last["success"] == 0: + status = {"label": "fehlgeschlagen", "chip": "bad"} + else: + status = None map_points.append({ + "id": f["id"], "lat": f["lat"], "lng": f["lng"], "address": f["address"] or f["link"], "link": f["link"], "rent": f["total_rent"], "rooms": f["rooms"], "size": f["size"], + "status": status, + "can_apply": allowed and not already_applied, + "is_running": is_running, }) return { "flats": flats_view, diff --git a/web/static/map.js b/web/static/map.js index 6d0108f..eac18a3 100644 --- a/web/static/map.js +++ b/web/static/map.js @@ -19,28 +19,84 @@ const BERLIN_ZOOM = 11; function readMapData() { const script = document.getElementById("flats-map-data"); - if (!script) return []; + if (!script) return { csrf: "", flats: [] }; try { - const data = JSON.parse(script.textContent || "[]"); - return Array.isArray(data) ? data : []; + const flats = JSON.parse(script.textContent || "[]"); + return { + csrf: script.dataset.csrf || "", + flats: Array.isArray(flats) ? flats : [], + }; } catch (e) { - return []; + return { csrf: "", flats: [] }; } } function fingerprintOf(data) { return data - .map((f) => `${f.lat},${f.lng},${f.address || ""},${f.rent || ""}`) + .map((f) => + [f.id, f.lat, f.lng, (f.status && f.status.label) || "", + f.can_apply ? 1 : 0, f.is_running ? 1 : 0].join(","), + ) .join("|"); } -function renderMarkers(data) { +function escHtml(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); +} + +function popupHtml(f, csrf) { + const addrText = f.address || f.link || "—"; + const addr = escHtml(addrText); + const link = escHtml(f.link || "#"); + const metaParts = []; + if (f.rooms) metaParts.push(escHtml(f.rooms) + " Zi"); + if (f.size) metaParts.push(Math.round(f.size) + " m²"); + if (f.rent) metaParts.push(Math.round(f.rent) + " €"); + const meta = metaParts.join(" · "); + + let html = `
` + + `${addr}`; + if (meta) html += `
${meta}
`; + if (f.status) { + html += `
` + + `${escHtml(f.status.label)}` + + `
`; + } + html += `
`; + if (f.can_apply) { + const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`; + html += + `
` + + `` + + `` + + `` + + `
`; + } + html += + `
` + + `` + + `` + + `` + + `
`; + html += `
`; + return html; +} + +function renderMarkers(payload) { if (!mapInstance) return; - const fp = fingerprintOf(data); + const { csrf, flats } = payload; + const fp = fingerprintOf(flats); if (fp === currentFingerprint) return; currentFingerprint = fp; - const geo = data.filter((f) => typeof f.lat === "number" && typeof f.lng === "number").length; - console.log(`[lazyflat.map] rendering ${geo}/${data.length} markers`); + const geo = flats.filter((f) => typeof f.lat === "number" && typeof f.lng === "number").length; + console.log(`[lazyflat.map] rendering ${geo}/${flats.length} markers`); if (markerLayer) { markerLayer.clearLayers(); @@ -48,18 +104,11 @@ function renderMarkers(data) { markerLayer = L.layerGroup().addTo(mapInstance); } - data.forEach((f) => { + flats.forEach((f) => { if (typeof f.lat !== "number" || typeof f.lng !== "number") return; - const rent = f.rent ? Math.round(f.rent) + " €" : ""; - const rooms = f.rooms ? f.rooms + " Zi" : ""; - const size = f.size ? Math.round(f.size) + " m²" : ""; - const meta = [rooms, size, rent].filter(Boolean).join(" · "); - const safeAddr = (f.address || "").replace(/${safeAddr}` + (meta ? `
${meta}` : "") + - `
Zur Anzeige →`, - ); + L.marker([f.lat, f.lng]) + .addTo(markerLayer) + .bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true }); }); } @@ -80,6 +129,16 @@ function buildMap(el) { maxZoom: 19, }).addTo(mapInstance); markerLayer = L.layerGroup().addTo(mapInstance); + + // Leaflet injects popup HTML directly into the DOM — HTMX hasn't scanned it, + // so the hx-* attributes on Bewerben/Ablehnen wouldn't bind. Poke htmx.process + // at the popup element each time one opens. + mapInstance.on("popupopen", (e) => { + const el = e.popup && e.popup.getElement(); + if (el && window.htmx) { + window.htmx.process(el); + } + }); } function ensureMap() { diff --git a/web/templates/_wohnungen_body.html b/web/templates/_wohnungen_body.html index 339fa7c..128f213 100644 --- a/web/templates/_wohnungen_body.html +++ b/web/templates/_wohnungen_body.html @@ -102,7 +102,7 @@
- +
diff --git a/web/templates/base.html b/web/templates/base.html index 14cee1f..0e56f05 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -82,6 +82,17 @@ body:has(#v_map:checked) .view-list { display: none; } body:has(#v_map:checked) .view-map { display: block; } #flats-map { height: 520px; border-radius: 10px; } + + /* 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); } + .map-popup-title { font-weight: 600; font-size: 13px; display: inline-block; color: var(--primary); } + .map-popup-title:hover { text-decoration: underline; } + .map-popup-meta { color: var(--muted); font-size: 12px; margin-top: 2px; } + .map-popup-status { margin-top: 8px; } + .map-popup-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; } + .map-popup-actions .btn { padding: 0.35rem 0.7rem; font-size: 12px; } + .map-popup-actions form { margin: 0; } .brand-dot { width: 2rem; height: 2rem; border-radius: 10px; background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);