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
|
|
@ -28,6 +28,7 @@ class Flat:
|
||||||
self.id = self.link # we could use data.get('id', None) but link is easier to debug
|
self.id = self.link # we could use data.get('id', None) but link is easier to debug
|
||||||
self.gmaps = maps.Maps()
|
self.gmaps = maps.Maps()
|
||||||
self._connectivity = None
|
self._connectivity = None
|
||||||
|
self._coords = None
|
||||||
self.address_link_gmaps = f"https://www.google.com/maps/search/?api=1&query={quote(self.address)}"
|
self.address_link_gmaps = f"https://www.google.com/maps/search/?api=1&query={quote(self.address)}"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
@ -59,6 +60,12 @@ class Flat:
|
||||||
self._connectivity = self.gmaps.calculate_score(self.address)
|
self._connectivity = self.gmaps.calculate_score(self.address)
|
||||||
return self._connectivity
|
return self._connectivity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def coords(self):
|
||||||
|
if self._coords is None:
|
||||||
|
self._coords = self.gmaps.geocode(self.address) or (None, None)
|
||||||
|
return self._coords
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_address(self):
|
def display_address(self):
|
||||||
if ',' in self.address:
|
if ',' in self.address:
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class FlatAlerter:
|
||||||
|
|
||||||
def _flat_payload(self, flat: Flat) -> dict:
|
def _flat_payload(self, flat: Flat) -> dict:
|
||||||
c = flat.connectivity
|
c = flat.connectivity
|
||||||
|
lat, lng = flat.coords
|
||||||
return {
|
return {
|
||||||
"id": flat.id,
|
"id": flat.id,
|
||||||
"link": flat.link,
|
"link": flat.link,
|
||||||
|
|
@ -53,6 +54,8 @@ class FlatAlerter:
|
||||||
"energy_value": flat.energy_value,
|
"energy_value": flat.energy_value,
|
||||||
"energy_certificate": flat.energy_certificate,
|
"energy_certificate": flat.energy_certificate,
|
||||||
"address_link_gmaps": flat.address_link_gmaps,
|
"address_link_gmaps": flat.address_link_gmaps,
|
||||||
|
"lat": lat,
|
||||||
|
"lng": lng,
|
||||||
"connectivity": {
|
"connectivity": {
|
||||||
"morning_time": c.get("morning_time", 0),
|
"morning_time": c.get("morning_time", 0),
|
||||||
"morning_transfers": c.get("morning_transfers", 0),
|
"morning_transfers": c.get("morning_transfers", 0),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,20 @@ class Maps:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.gmaps = googlemaps.Client(key=GMAPS_API_KEY)
|
self.gmaps = googlemaps.Client(key=GMAPS_API_KEY)
|
||||||
|
|
||||||
|
def geocode(self, address):
|
||||||
|
"""Return (lat, lng) or None for a Berlin address string."""
|
||||||
|
if not address:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
res = self.gmaps.geocode(f"{address}, Berlin, Germany")
|
||||||
|
if not res:
|
||||||
|
return None
|
||||||
|
loc = res[0]["geometry"]["location"]
|
||||||
|
return (float(loc["lat"]), float(loc["lng"]))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("geocode failed for %r: %s", address, e)
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_next_weekday(self, date, weekday):
|
def _get_next_weekday(self, date, weekday):
|
||||||
days_ahead = weekday - date.weekday()
|
days_ahead = weekday - date.weekday()
|
||||||
if days_ahead <= 0:
|
if days_ahead <= 0:
|
||||||
|
|
|
||||||
23
web/app.py
23
web/app.py
|
|
@ -132,8 +132,9 @@ async def security_headers(request: Request, call_next):
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
|
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
|
||||||
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "
|
"style-src 'self' https://cdn.tailwindcss.com https://unpkg.com 'unsafe-inline'; "
|
||||||
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
"img-src 'self' data: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
|
||||||
|
"connect-src 'self'; frame-ancestors 'none';"
|
||||||
)
|
)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
@ -394,8 +395,22 @@ def _wohnungen_context(user) -> dict:
|
||||||
allowed, reason = _manual_apply_allowed()
|
allowed, reason = _manual_apply_allowed()
|
||||||
alert_label, alert_chip = _alert_status(filters_row, notif_row)
|
alert_label, alert_chip = _alert_status(filters_row, notif_row)
|
||||||
has_running = _has_running_application(uid)
|
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 {
|
return {
|
||||||
"flats": flats_view,
|
"flats": flats_view,
|
||||||
|
"map_points": map_points,
|
||||||
"alert_label": alert_label,
|
"alert_label": alert_label,
|
||||||
"alert_chip": alert_chip,
|
"alert_chip": alert_chip,
|
||||||
"filter_summary": _filter_summary(filters_row),
|
"filter_summary": _filter_summary(filters_row),
|
||||||
|
|
@ -462,7 +477,7 @@ async def action_save_filters(
|
||||||
@app.post("/actions/auto-apply")
|
@app.post("/actions/auto-apply")
|
||||||
async def action_auto_apply(
|
async def action_auto_apply(
|
||||||
request: Request,
|
request: Request,
|
||||||
value: str = Form(...),
|
value: str = Form(default="off"),
|
||||||
csrf: str = Form(...),
|
csrf: str = Form(...),
|
||||||
user=Depends(require_user),
|
user=Depends(require_user),
|
||||||
):
|
):
|
||||||
|
|
@ -834,7 +849,7 @@ async def action_password(
|
||||||
@app.post("/actions/submit-forms")
|
@app.post("/actions/submit-forms")
|
||||||
async def action_submit_forms(
|
async def action_submit_forms(
|
||||||
request: Request,
|
request: Request,
|
||||||
value: str = Form(...),
|
value: str = Form(default="off"),
|
||||||
csrf: str = Form(...),
|
csrf: str = Form(...),
|
||||||
user=Depends(require_user),
|
user=Depends(require_user),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
10
web/db.py
10
web/db.py
|
|
@ -180,6 +180,11 @@ MIGRATIONS: list[str] = [
|
||||||
value TEXT NOT NULL
|
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(
|
"""INSERT INTO flats(
|
||||||
id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs,
|
id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs,
|
||||||
connectivity_morning_time, connectivity_night_time, address_link_gmaps,
|
connectivity_morning_time, connectivity_night_time, address_link_gmaps,
|
||||||
payload_json, discovered_at
|
payload_json, discovered_at, lat, lng
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
flat_id, payload.get("link", ""), payload.get("address", ""),
|
flat_id, payload.get("link", ""), payload.get("address", ""),
|
||||||
payload.get("rooms"), payload.get("size"), payload.get("total_rent"),
|
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"),
|
payload.get("address_link_gmaps"),
|
||||||
json.dumps(payload, default=str),
|
json.dumps(payload, default=str),
|
||||||
now_iso(),
|
now_iso(),
|
||||||
|
payload.get("lat"), payload.get("lng"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return True
|
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 -->
|
<!-- Reihe 1: Info-Kacheln Alarm + Filter -->
|
||||||
<section class="grid grid-cols-2 gap-3">
|
<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="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
|
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
|
||||||
|
|
@ -21,68 +21,36 @@
|
||||||
|
|
||||||
<!-- Reihe 2: Schalter Automatisch bewerben + Final absenden -->
|
<!-- Reihe 2: Schalter Automatisch bewerben + Final absenden -->
|
||||||
<section class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<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">
|
<form class="card p-4 flex items-center justify-between gap-3">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="text-sm font-medium">Automatisch bewerben</div>
|
||||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Automatisch bewerben</div>
|
<label class="switch warn">
|
||||||
<div class="text-xs text-slate-500">bei Match ohne Nachfrage bewerben</div>
|
<input type="checkbox" name="value" value="on"
|
||||||
</div>
|
hx-post="/actions/auto-apply"
|
||||||
<div class="toggle warn">
|
hx-trigger="change"
|
||||||
<label>
|
hx-include="closest form"
|
||||||
<input type="radio" name="value" value="off"
|
hx-target="#wohnungen-body"
|
||||||
hx-post="/actions/auto-apply"
|
hx-swap="outerHTML"
|
||||||
hx-trigger="change"
|
{% if not auto_apply_enabled %}hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."{% endif %}
|
||||||
hx-include="closest form"
|
{% if auto_apply_enabled %}checked{% endif %}>
|
||||||
hx-target="#wohnungen-body"
|
<span class="switch-visual"></span>
|
||||||
hx-swap="outerHTML"
|
</label>
|
||||||
{% 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>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Final absenden (inverse of submit_forms: on=real, off=trocken) -->
|
|
||||||
<form class="card p-4 flex items-center justify-between gap-3">
|
<form class="card p-4 flex items-center justify-between gap-3">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="text-sm font-medium">Final absenden</div>
|
||||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Final absenden</div>
|
<label class="switch warn">
|
||||||
<div class="text-xs text-slate-500">aus = Formular ausfüllen, nicht abschicken</div>
|
<input type="checkbox" name="value" value="on"
|
||||||
</div>
|
hx-post="/actions/submit-forms"
|
||||||
<div class="toggle warn">
|
hx-trigger="change"
|
||||||
<label>
|
hx-include="closest form"
|
||||||
<input type="radio" name="value" value="off"
|
hx-target="#wohnungen-body"
|
||||||
hx-post="/actions/submit-forms"
|
hx-swap="outerHTML"
|
||||||
hx-trigger="change"
|
{% if not submit_forms %}hx-confirm="Final absenden einschalten? Formulare werden dann WIRKLICH abgeschickt!"{% endif %}
|
||||||
hx-include="closest form"
|
{% if submit_forms %}checked{% endif %}>
|
||||||
hx-target="#wohnungen-body"
|
<span class="switch-visual"></span>
|
||||||
hx-swap="outerHTML"
|
</label>
|
||||||
{% 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>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -107,17 +75,42 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Liste passender Wohnungen -->
|
<!-- Header + View-Toggle (Liste/Karte) -->
|
||||||
<section class="card">
|
<section class="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<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>
|
||||||
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
|
<div class="flex items-center gap-3 text-xs text-slate-500">
|
||||||
<div class="text-xs text-slate-500 flex gap-3 items-center">
|
<span>{{ flats|length }} gefunden</span>
|
||||||
<span>{{ flats|length }} gefunden</span>
|
{% if next_scrape_utc %}
|
||||||
{% if next_scrape_utc %}
|
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
||||||
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
{% endif %}
|
||||||
{% 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>
|
||||||
</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">
|
<div class="divide-y divide-soft">
|
||||||
{% for item in flats %}
|
{% for item in flats %}
|
||||||
{% set f = item.row %}
|
{% set f = item.row %}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@
|
||||||
<title>{% block title %}lazyflat{% endblock %}</title>
|
<title>{% block title %}lazyflat{% endblock %}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.3"></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/app.js" defer></script>
|
||||||
|
<script src="/static/map.js" defer></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
|
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
|
||||||
|
|
@ -47,17 +52,36 @@
|
||||||
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
|
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
|
||||||
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
|
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
|
||||||
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
|
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
|
||||||
/* Segmented toggle (An/Aus Kippschalter) */
|
/* iOS-style toggle switch */
|
||||||
.toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 999px;
|
.switch { position: relative; display: inline-block; width: 46px; height: 26px;
|
||||||
overflow: hidden; background: var(--surface); font-size: 0.85rem; font-weight: 500; }
|
flex-shrink: 0; }
|
||||||
.toggle label { padding: 0.45rem 1.1rem; cursor: pointer; user-select: none;
|
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||||
color: var(--muted); transition: background .15s, color .15s; }
|
.switch-visual { position: absolute; cursor: pointer; inset: 0;
|
||||||
.toggle label + label { border-left: 1px solid var(--border); }
|
background: #cfd9e6; border-radius: 999px;
|
||||||
.toggle label input[type="radio"] { position: absolute; opacity: 0; pointer-events: none;
|
transition: background .2s; }
|
||||||
width: 0; height: 0; }
|
.switch-visual::before { content: ""; position: absolute; width: 20px; height: 20px;
|
||||||
.toggle label:hover { color: var(--text); background: var(--ghost); }
|
left: 3px; top: 3px; background: #fff; border-radius: 50%;
|
||||||
.toggle label:has(input:checked) { background: var(--primary); color: #fff; }
|
box-shadow: 0 1px 3px rgba(16,37,63,0.25);
|
||||||
.toggle.warn label:has(input[value="on"]:checked) { background: var(--danger); }
|
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 {
|
.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