map view (Leaflet + OSM), iOS switches, Alarm → Benachrichtigungen
* flats: new lat/lng columns (migration v3); alert geocodes every new flat through googlemaps and ships coords in the payload * web: CSP extended for unpkg (leaflet.css) + tile.openstreetmap.org * Wohnungen tab: Liste/Karte view toggle (segmented, CSS-only via :has(), selection persisted in localStorage). Karte shows passende flats as Pins on an OSM tile map; Popup per Pin mit Adresse, Zimmer/m²/€ und Link * Top-strip toggles are now proper iOS-style toggle switches (single rounded knob sliding in a pill, red when on), no descriptive subtitle * Alarm-Karte verlinkt jetzt auf /einstellungen/benachrichtigungen (Filter-Karte bleibt /einstellungen/filter) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d9468f6814
commit
376551213a
8 changed files with 239 additions and 83 deletions
23
web/app.py
23
web/app.py
|
|
@ -132,8 +132,9 @@ async def security_headers(request: Request, call_next):
|
|||
"Content-Security-Policy",
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
|
||||
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "
|
||||
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||
"style-src 'self' https://cdn.tailwindcss.com https://unpkg.com 'unsafe-inline'; "
|
||||
"img-src 'self' data: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
|
||||
"connect-src 'self'; frame-ancestors 'none';"
|
||||
)
|
||||
return resp
|
||||
|
||||
|
|
@ -394,8 +395,22 @@ def _wohnungen_context(user) -> dict:
|
|||
allowed, reason = _manual_apply_allowed()
|
||||
alert_label, alert_chip = _alert_status(filters_row, notif_row)
|
||||
has_running = _has_running_application(uid)
|
||||
map_points = []
|
||||
for item in flats_view:
|
||||
f = item["row"]
|
||||
if f["lat"] is None or f["lng"] is None:
|
||||
continue
|
||||
map_points.append({
|
||||
"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"],
|
||||
})
|
||||
return {
|
||||
"flats": flats_view,
|
||||
"map_points": map_points,
|
||||
"alert_label": alert_label,
|
||||
"alert_chip": alert_chip,
|
||||
"filter_summary": _filter_summary(filters_row),
|
||||
|
|
@ -462,7 +477,7 @@ async def action_save_filters(
|
|||
@app.post("/actions/auto-apply")
|
||||
async def action_auto_apply(
|
||||
request: Request,
|
||||
value: str = Form(...),
|
||||
value: str = Form(default="off"),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
|
|
@ -834,7 +849,7 @@ async def action_password(
|
|||
@app.post("/actions/submit-forms")
|
||||
async def action_submit_forms(
|
||||
request: Request,
|
||||
value: str = Form(...),
|
||||
value: str = Form(default="off"),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
|
|
|
|||
10
web/db.py
10
web/db.py
|
|
@ -180,6 +180,11 @@ MIGRATIONS: list[str] = [
|
|||
value TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
# 0003: lat/lng for map view
|
||||
"""
|
||||
ALTER TABLE flats ADD COLUMN lat REAL;
|
||||
ALTER TABLE flats ADD COLUMN lng REAL;
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -388,8 +393,8 @@ def upsert_flat(payload: dict) -> bool:
|
|||
"""INSERT INTO flats(
|
||||
id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs,
|
||||
connectivity_morning_time, connectivity_night_time, address_link_gmaps,
|
||||
payload_json, discovered_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
payload_json, discovered_at, lat, lng
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
flat_id, payload.get("link", ""), payload.get("address", ""),
|
||||
payload.get("rooms"), payload.get("size"), payload.get("total_rent"),
|
||||
|
|
@ -399,6 +404,7 @@ def upsert_flat(payload: dict) -> bool:
|
|||
payload.get("address_link_gmaps"),
|
||||
json.dumps(payload, default=str),
|
||||
now_iso(),
|
||||
payload.get("lat"), payload.get("lng"),
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
|
|
|||
94
web/static/map.js
Normal file
94
web/static/map.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// lazyflat — Leaflet flat map
|
||||
// A single Leaflet map instance; re-initialised after every HTMX swap of
|
||||
// the Wohnungen body. Also flushes size when the view toggle flips from
|
||||
// list to map (Leaflet needs invalidateSize on a hidden-then-shown map).
|
||||
|
||||
let mapInstance = null;
|
||||
const BERLIN_CENTER = [52.52, 13.405];
|
||||
const BERLIN_ZOOM = 11;
|
||||
|
||||
function initFlatsMap() {
|
||||
const el = document.getElementById("flats-map");
|
||||
if (!el || typeof L === "undefined") return;
|
||||
if (mapInstance) {
|
||||
try { mapInstance.remove(); } catch (e) {}
|
||||
mapInstance = null;
|
||||
}
|
||||
mapInstance = L.map(el).setView(BERLIN_CENTER, BERLIN_ZOOM);
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap",
|
||||
maxZoom: 18,
|
||||
}).addTo(mapInstance);
|
||||
|
||||
let data = [];
|
||||
try {
|
||||
data = JSON.parse(el.dataset.flats || "[]");
|
||||
} catch (e) {
|
||||
console.warn("flats-map: bad JSON in data-flats", e);
|
||||
}
|
||||
|
||||
const bounds = [];
|
||||
data.forEach((f) => {
|
||||
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
|
||||
const m = L.marker([f.lat, f.lng]).addTo(mapInstance);
|
||||
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(/</g, "<");
|
||||
const safeLink = (f.link || "#").replace(/"/g, """);
|
||||
m.bindPopup(
|
||||
`<b>${safeAddr}</b>` +
|
||||
(meta ? `<br>${meta}` : "") +
|
||||
`<br><a href="${safeLink}" target="_blank" rel="noopener">Zur Anzeige →</a>`,
|
||||
);
|
||||
bounds.push([f.lat, f.lng]);
|
||||
});
|
||||
if (bounds.length === 1) {
|
||||
mapInstance.setView(bounds[0], 14);
|
||||
} else if (bounds.length > 1) {
|
||||
mapInstance.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
}
|
||||
|
||||
function flushMapSize() {
|
||||
if (mapInstance) {
|
||||
setTimeout(() => mapInstance.invalidateSize(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function wireViewToggle() {
|
||||
document.querySelectorAll('input[name="view_mode"]').forEach((r) => {
|
||||
if (r.dataset.wired === "1") return;
|
||||
r.dataset.wired = "1";
|
||||
r.addEventListener("change", (e) => {
|
||||
try {
|
||||
localStorage.setItem("lazyflat_view_mode", e.target.value);
|
||||
} catch (err) {}
|
||||
flushMapSize();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function restoreView() {
|
||||
let stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem("lazyflat_view_mode");
|
||||
} catch (err) {}
|
||||
if (!stored) return;
|
||||
const el = document.querySelector(
|
||||
`input[name="view_mode"][value="${stored}"]`,
|
||||
);
|
||||
if (el && !el.checked) {
|
||||
el.checked = true;
|
||||
flushMapSize();
|
||||
}
|
||||
}
|
||||
|
||||
function onReady() {
|
||||
initFlatsMap();
|
||||
wireViewToggle();
|
||||
restoreView();
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", onReady);
|
||||
document.body && document.body.addEventListener("htmx:afterSwap", onReady);
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<!-- Reihe 1: Info-Kacheln Alarm + Filter -->
|
||||
<section class="grid grid-cols-2 gap-3">
|
||||
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
||||
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/benachrichtigungen">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
|
||||
|
|
@ -21,68 +21,36 @@
|
|||
|
||||
<!-- Reihe 2: Schalter Automatisch bewerben + Final absenden -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<!-- Automatisch bewerben -->
|
||||
<form class="card p-4 flex items-center justify-between gap-3">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Automatisch bewerben</div>
|
||||
<div class="text-xs text-slate-500">bei Match ohne Nachfrage bewerben</div>
|
||||
</div>
|
||||
<div class="toggle warn">
|
||||
<label>
|
||||
<input type="radio" name="value" value="off"
|
||||
hx-post="/actions/auto-apply"
|
||||
hx-trigger="change"
|
||||
hx-include="closest form"
|
||||
hx-target="#wohnungen-body"
|
||||
hx-swap="outerHTML"
|
||||
{% if not auto_apply_enabled %}checked{% endif %}>
|
||||
Aus
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="value" value="on"
|
||||
hx-post="/actions/auto-apply"
|
||||
hx-trigger="change"
|
||||
hx-include="closest form"
|
||||
hx-target="#wohnungen-body"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."
|
||||
{% if auto_apply_enabled %}checked{% endif %}>
|
||||
An
|
||||
</label>
|
||||
</div>
|
||||
<div class="text-sm font-medium">Automatisch bewerben</div>
|
||||
<label class="switch warn">
|
||||
<input type="checkbox" name="value" value="on"
|
||||
hx-post="/actions/auto-apply"
|
||||
hx-trigger="change"
|
||||
hx-include="closest form"
|
||||
hx-target="#wohnungen-body"
|
||||
hx-swap="outerHTML"
|
||||
{% if not auto_apply_enabled %}hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."{% endif %}
|
||||
{% if auto_apply_enabled %}checked{% endif %}>
|
||||
<span class="switch-visual"></span>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<!-- Final absenden (inverse of submit_forms: on=real, off=trocken) -->
|
||||
<form class="card p-4 flex items-center justify-between gap-3">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Final absenden</div>
|
||||
<div class="text-xs text-slate-500">aus = Formular ausfüllen, nicht abschicken</div>
|
||||
</div>
|
||||
<div class="toggle warn">
|
||||
<label>
|
||||
<input type="radio" name="value" value="off"
|
||||
hx-post="/actions/submit-forms"
|
||||
hx-trigger="change"
|
||||
hx-include="closest form"
|
||||
hx-target="#wohnungen-body"
|
||||
hx-swap="outerHTML"
|
||||
{% if not submit_forms %}checked{% endif %}>
|
||||
Aus
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="value" value="on"
|
||||
hx-post="/actions/submit-forms"
|
||||
hx-trigger="change"
|
||||
hx-include="closest form"
|
||||
hx-target="#wohnungen-body"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Final absenden einschalten? Formulare werden dann WIRKLICH abgeschickt!"
|
||||
{% if submit_forms %}checked{% endif %}>
|
||||
An
|
||||
</label>
|
||||
</div>
|
||||
<div class="text-sm font-medium">Final absenden</div>
|
||||
<label class="switch warn">
|
||||
<input type="checkbox" name="value" value="on"
|
||||
hx-post="/actions/submit-forms"
|
||||
hx-trigger="change"
|
||||
hx-include="closest form"
|
||||
hx-target="#wohnungen-body"
|
||||
hx-swap="outerHTML"
|
||||
{% if not submit_forms %}hx-confirm="Final absenden einschalten? Formulare werden dann WIRKLICH abgeschickt!"{% endif %}
|
||||
{% if submit_forms %}checked{% endif %}>
|
||||
<span class="switch-visual"></span>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
|
@ -107,17 +75,42 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Liste passender Wohnungen -->
|
||||
<section class="card">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap">
|
||||
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
|
||||
<div class="text-xs text-slate-500 flex gap-3 items-center">
|
||||
<span>{{ flats|length }} gefunden</span>
|
||||
{% if next_scrape_utc %}
|
||||
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
||||
{% endif %}
|
||||
<!-- Header + View-Toggle (Liste/Karte) -->
|
||||
<section class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-500">
|
||||
<span>{{ flats|length }} gefunden</span>
|
||||
{% if next_scrape_utc %}
|
||||
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
||||
{% endif %}
|
||||
<div class="view-toggle ml-2">
|
||||
<label>
|
||||
<input type="radio" name="view_mode" id="v_list" value="list" checked>
|
||||
Liste
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="view_mode" id="v_map" value="map">
|
||||
Karte
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Karte -->
|
||||
<section class="view-map">
|
||||
<div class="card p-3">
|
||||
<div id="flats-map" data-flats='{{ map_points | tojson }}'></div>
|
||||
{% if not map_points %}
|
||||
<p class="mt-3 text-xs text-slate-500">
|
||||
Keine Koordinaten für passende Wohnungen vorhanden —
|
||||
entweder sind noch keine neuen Flats geocoded worden oder die Filter lassen noch nichts durch.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Liste -->
|
||||
<section class="view-list card">
|
||||
<div class="divide-y divide-soft">
|
||||
{% for item in flats %}
|
||||
{% set f = item.row %}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@
|
|||
<title>{% block title %}lazyflat{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
<script src="/static/map.js" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
|
||||
|
|
@ -47,17 +52,36 @@
|
|||
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
|
||||
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
|
||||
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
|
||||
/* Segmented toggle (An/Aus Kippschalter) */
|
||||
.toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 999px;
|
||||
overflow: hidden; background: var(--surface); font-size: 0.85rem; font-weight: 500; }
|
||||
.toggle label { padding: 0.45rem 1.1rem; cursor: pointer; user-select: none;
|
||||
color: var(--muted); transition: background .15s, color .15s; }
|
||||
.toggle label + label { border-left: 1px solid var(--border); }
|
||||
.toggle label input[type="radio"] { position: absolute; opacity: 0; pointer-events: none;
|
||||
width: 0; height: 0; }
|
||||
.toggle label:hover { color: var(--text); background: var(--ghost); }
|
||||
.toggle label:has(input:checked) { background: var(--primary); color: #fff; }
|
||||
.toggle.warn label:has(input[value="on"]:checked) { background: var(--danger); }
|
||||
/* iOS-style toggle switch */
|
||||
.switch { position: relative; display: inline-block; width: 46px; height: 26px;
|
||||
flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.switch-visual { position: absolute; cursor: pointer; inset: 0;
|
||||
background: #cfd9e6; border-radius: 999px;
|
||||
transition: background .2s; }
|
||||
.switch-visual::before { content: ""; position: absolute; width: 20px; height: 20px;
|
||||
left: 3px; top: 3px; background: #fff; border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(16,37,63,0.25);
|
||||
transition: transform .2s; }
|
||||
.switch input:checked + .switch-visual { background: var(--primary); }
|
||||
.switch input:checked + .switch-visual::before { transform: translateX(20px); }
|
||||
.switch.warn input:checked + .switch-visual { background: var(--danger); }
|
||||
.switch input:focus-visible + .switch-visual { box-shadow: 0 0 0 3px rgba(47,138,224,.25); }
|
||||
|
||||
/* View toggle (Liste / Karte) — segmented pill, CSS-only via :has() */
|
||||
.view-toggle { display: inline-flex; border: 1px solid var(--border);
|
||||
border-radius: 999px; overflow: hidden; background: var(--surface);
|
||||
font-size: 0.85rem; font-weight: 500; }
|
||||
.view-toggle label { padding: 0.35rem 0.95rem; cursor: pointer; user-select: none;
|
||||
color: var(--muted); transition: background .15s, color .15s; }
|
||||
.view-toggle input { position: absolute; opacity: 0; pointer-events: none;
|
||||
width: 0; height: 0; }
|
||||
.view-toggle label:hover { color: var(--text); background: var(--ghost); }
|
||||
.view-toggle label:has(input:checked) { background: var(--primary); color: #fff; }
|
||||
.view-map { display: none; }
|
||||
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; }
|
||||
.brand-dot {
|
||||
width: 2rem; height: 2rem; border-radius: 10px;
|
||||
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue