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 = `
`;
+ 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%);