map: clickable address + status chip + Bewerben/Ablehnen in Leaflet popups
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
7f7cbb5b1f
commit
931e0bb8b7
4 changed files with 106 additions and 21 deletions
15
web/app.py
15
web/app.py
|
|
@ -412,13 +412,28 @@ def _wohnungen_context(user) -> dict:
|
||||||
f = item["row"]
|
f = item["row"]
|
||||||
if f["lat"] is None or f["lng"] is None:
|
if f["lat"] is None or f["lng"] is None:
|
||||||
continue
|
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({
|
map_points.append({
|
||||||
|
"id": f["id"],
|
||||||
"lat": f["lat"], "lng": f["lng"],
|
"lat": f["lat"], "lng": f["lng"],
|
||||||
"address": f["address"] or f["link"],
|
"address": f["address"] or f["link"],
|
||||||
"link": f["link"],
|
"link": f["link"],
|
||||||
"rent": f["total_rent"],
|
"rent": f["total_rent"],
|
||||||
"rooms": f["rooms"],
|
"rooms": f["rooms"],
|
||||||
"size": f["size"],
|
"size": f["size"],
|
||||||
|
"status": status,
|
||||||
|
"can_apply": allowed and not already_applied,
|
||||||
|
"is_running": is_running,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
"flats": flats_view,
|
"flats": flats_view,
|
||||||
|
|
|
||||||
|
|
@ -19,28 +19,84 @@ const BERLIN_ZOOM = 11;
|
||||||
|
|
||||||
function readMapData() {
|
function readMapData() {
|
||||||
const script = document.getElementById("flats-map-data");
|
const script = document.getElementById("flats-map-data");
|
||||||
if (!script) return [];
|
if (!script) return { csrf: "", flats: [] };
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(script.textContent || "[]");
|
const flats = JSON.parse(script.textContent || "[]");
|
||||||
return Array.isArray(data) ? data : [];
|
return {
|
||||||
|
csrf: script.dataset.csrf || "",
|
||||||
|
flats: Array.isArray(flats) ? flats : [],
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [];
|
return { csrf: "", flats: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fingerprintOf(data) {
|
function fingerprintOf(data) {
|
||||||
return 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("|");
|
.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 = `<div class="map-popup">` +
|
||||||
|
`<a class="map-popup-title" href="${link}" target="_blank" rel="noopener">${addr}</a>`;
|
||||||
|
if (meta) html += `<div class="map-popup-meta">${meta}</div>`;
|
||||||
|
if (f.status) {
|
||||||
|
html += `<div class="map-popup-status">` +
|
||||||
|
`<span class="chip chip-${escHtml(f.status.chip)}">${escHtml(f.status.label)}</span>` +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
html += `<div class="map-popup-actions">`;
|
||||||
|
if (f.can_apply) {
|
||||||
|
const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`;
|
||||||
|
html +=
|
||||||
|
`<form hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
|
||||||
|
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
|
||||||
|
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
|
||||||
|
`<button class="btn btn-primary text-xs" type="submit"` +
|
||||||
|
(f.is_running ? " disabled" : "") +
|
||||||
|
` hx-confirm="${escHtml(confirm)}">` +
|
||||||
|
(f.is_running ? "läuft…" : "Bewerben") +
|
||||||
|
`</button>` +
|
||||||
|
`</form>`;
|
||||||
|
}
|
||||||
|
html +=
|
||||||
|
`<form hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
|
||||||
|
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
|
||||||
|
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
|
||||||
|
`<button class="btn btn-ghost text-xs" type="submit"` +
|
||||||
|
` hx-confirm="Ablehnen und aus der Liste entfernen?">Ablehnen</button>` +
|
||||||
|
`</form>`;
|
||||||
|
html += `</div></div>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkers(payload) {
|
||||||
if (!mapInstance) return;
|
if (!mapInstance) return;
|
||||||
const fp = fingerprintOf(data);
|
const { csrf, flats } = payload;
|
||||||
|
const fp = fingerprintOf(flats);
|
||||||
if (fp === currentFingerprint) return;
|
if (fp === currentFingerprint) return;
|
||||||
currentFingerprint = fp;
|
currentFingerprint = fp;
|
||||||
const geo = data.filter((f) => typeof f.lat === "number" && typeof f.lng === "number").length;
|
const geo = flats.filter((f) => typeof f.lat === "number" && typeof f.lng === "number").length;
|
||||||
console.log(`[lazyflat.map] rendering ${geo}/${data.length} markers`);
|
console.log(`[lazyflat.map] rendering ${geo}/${flats.length} markers`);
|
||||||
|
|
||||||
if (markerLayer) {
|
if (markerLayer) {
|
||||||
markerLayer.clearLayers();
|
markerLayer.clearLayers();
|
||||||
|
|
@ -48,18 +104,11 @@ function renderMarkers(data) {
|
||||||
markerLayer = L.layerGroup().addTo(mapInstance);
|
markerLayer = L.layerGroup().addTo(mapInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
data.forEach((f) => {
|
flats.forEach((f) => {
|
||||||
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
|
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
|
||||||
const rent = f.rent ? Math.round(f.rent) + " €" : "";
|
L.marker([f.lat, f.lng])
|
||||||
const rooms = f.rooms ? f.rooms + " Zi" : "";
|
.addTo(markerLayer)
|
||||||
const size = f.size ? Math.round(f.size) + " m²" : "";
|
.bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true });
|
||||||
const meta = [rooms, size, rent].filter(Boolean).join(" · ");
|
|
||||||
const safeAddr = (f.address || "").replace(/</g, "<");
|
|
||||||
const safeLink = (f.link || "#").replace(/"/g, """);
|
|
||||||
L.marker([f.lat, f.lng]).addTo(markerLayer).bindPopup(
|
|
||||||
`<b>${safeAddr}</b>` + (meta ? `<br>${meta}` : "") +
|
|
||||||
`<br><a href="${safeLink}" target="_blank" rel="noopener">Zur Anzeige →</a>`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +129,16 @@ function buildMap(el) {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
}).addTo(mapInstance);
|
}).addTo(mapInstance);
|
||||||
markerLayer = L.layerGroup().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() {
|
function ensureMap() {
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
<div id="flats-map" hx-preserve="true"></div>
|
<div id="flats-map" hx-preserve="true"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script id="flats-map-data" type="application/json">{{ map_points | tojson }}</script>
|
<script id="flats-map-data" type="application/json" data-csrf="{{ csrf }}">{{ map_points | tojson }}</script>
|
||||||
|
|
||||||
<!-- Liste -->
|
<!-- Liste -->
|
||||||
<section class="view-list card">
|
<section class="view-list card">
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,17 @@
|
||||||
body:has(#v_map:checked) .view-list { display: none; }
|
body:has(#v_map:checked) .view-list { display: none; }
|
||||||
body:has(#v_map:checked) .view-map { display: block; }
|
body:has(#v_map:checked) .view-map { display: block; }
|
||||||
#flats-map { height: 520px; border-radius: 10px; }
|
#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 {
|
.brand-dot {
|
||||||
width: 2rem; height: 2rem; border-radius: 10px;
|
width: 2rem; height: 2rem; border-radius: 10px;
|
||||||
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);
|
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue