- notifications: round sqm_price to whole € in Telegram match messages
(was emitting raw float like "12.345614 €/m²").
- wohnungen: remove the admin-only "Bilder nachladen (N)" button. It
flickered into view whenever a freshly-scraped flat was still in
pending state, which was effectively random from the user's point of
view, and the manual backfill it triggered isn't needed anymore — new
flats are auto-enriched at scrape time. Also drops the dead helpers
it was the sole caller of: enrichment.kick_backfill,
enrichment._backfill_runner, db.flats_needing_enrichment,
db.enrichment_counts.
- lightbox: the modal didn't appear because Tailwind's Play CDN injects
its own .hidden { display: none } rule at runtime, which kept fighting
our class toggle. Switch the show/hide to inline style.display so no
external stylesheet can mask it. Single-class .lightbox now only owns
the layout — the initial-hidden state is on the element via
style="display:none".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
7.5 KiB
JavaScript
210 lines
7.5 KiB
JavaScript
// lazyflat — live time helpers.
|
|
// [data-rel-utc="<iso>"] → "vor 3 min" (relative-past, updated every 5 s)
|
|
// [data-counter-up-utc="<iso>"] → "vor X s" counting up each second (used for
|
|
// "aktualisiert vor X s"; resets when the
|
|
// server ships a newer timestamp in the swap)
|
|
|
|
function fmtRelative(iso) {
|
|
const ts = Date.parse(iso);
|
|
if (!iso || Number.isNaN(ts)) return "—";
|
|
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
if (diff < 5) return "gerade eben";
|
|
if (diff < 60) return `vor ${diff} s`;
|
|
if (diff < 3600) return `vor ${Math.floor(diff / 60)} min`;
|
|
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} h`;
|
|
return `vor ${Math.floor(diff / 86400)} Tagen`;
|
|
}
|
|
|
|
function fmtCountUp(iso) {
|
|
const ts = Date.parse(iso);
|
|
if (!iso || Number.isNaN(ts)) return "—";
|
|
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
return `vor ${diff} s`;
|
|
}
|
|
|
|
function updateRelativeTimes() {
|
|
document.querySelectorAll("[data-rel-utc]").forEach((el) => {
|
|
el.textContent = fmtRelative(el.dataset.relUtc);
|
|
});
|
|
}
|
|
|
|
function updateCounterUps() {
|
|
document.querySelectorAll("[data-counter-up-utc]").forEach((el) => {
|
|
el.textContent = fmtCountUp(el.dataset.counterUpUtc);
|
|
});
|
|
}
|
|
|
|
// After the HTMX swap rebuilds the list, the open-chevron class is gone even
|
|
// though the corresponding .flat-detail pane is preserved. Re-sync by looking
|
|
// at pane visibility.
|
|
function syncFlatExpandState() {
|
|
document.querySelectorAll(".flat-detail").forEach((pane) => {
|
|
const row = pane.closest(".flat-row");
|
|
if (!row) return;
|
|
const btn = row.querySelector(".flat-expand-btn");
|
|
if (!btn) return;
|
|
const open = pane.dataset.loaded === "1" && pane.style.display !== "none";
|
|
btn.classList.toggle("open", open);
|
|
});
|
|
}
|
|
|
|
function tick() {
|
|
updateRelativeTimes();
|
|
updateCounterUps();
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", tick);
|
|
document.addEventListener("DOMContentLoaded", syncFlatExpandState);
|
|
if (document.body) {
|
|
document.body.addEventListener("htmx:afterSwap", tick);
|
|
document.body.addEventListener("htmx:afterSwap", syncFlatExpandState);
|
|
}
|
|
setInterval(updateCounterUps, 1000);
|
|
setInterval(updateRelativeTimes, 5000);
|
|
|
|
// Flat detail expand — lazily fetches /partials/wohnung/<id> into the sibling
|
|
// .flat-detail container on first open, toggles visibility on subsequent clicks.
|
|
// Event delegation survives HTMX swaps without re-binding on each poll.
|
|
document.addEventListener("click", (ev) => {
|
|
const btn = ev.target.closest(".flat-expand-btn");
|
|
if (!btn) return;
|
|
const row = btn.closest(".flat-row");
|
|
if (!row) return;
|
|
const pane = row.querySelector(".flat-detail");
|
|
if (!pane) return;
|
|
|
|
if (btn.classList.contains("open")) {
|
|
pane.style.display = "none";
|
|
btn.classList.remove("open");
|
|
return;
|
|
}
|
|
btn.classList.add("open");
|
|
pane.style.display = "block";
|
|
if (pane.dataset.loaded) return;
|
|
|
|
pane.innerHTML = '<div class="px-4 py-5 text-sm text-slate-500">lädt…</div>';
|
|
const flatId = btn.dataset.flatId || "";
|
|
fetch("/partials/wohnung/" + encodeURIComponent(flatId),
|
|
{ headers: { "HX-Request": "true" } })
|
|
.then((r) => r.text())
|
|
.then((html) => {
|
|
pane.innerHTML = html;
|
|
pane.dataset.loaded = "1";
|
|
})
|
|
.catch(() => {
|
|
pane.innerHTML = '<div class="px-4 py-5 text-sm text-slate-500">Detail konnte nicht geladen werden.</div>';
|
|
});
|
|
});
|
|
|
|
// Deep-link landing: ?flat=<id> on first load expands + highlights that row.
|
|
// Used from Telegram match notifications so "Zur lazyflat Seite" jumps
|
|
// straight to the relevant card.
|
|
function openDeepLinkedFlat() {
|
|
const params = new URLSearchParams(location.search);
|
|
const targetId = params.get("flat");
|
|
if (!targetId) return;
|
|
let found = null;
|
|
for (const btn of document.querySelectorAll(".flat-expand-btn")) {
|
|
if (btn.dataset.flatId === targetId) { found = btn; break; }
|
|
}
|
|
if (!found) {
|
|
// Flat not in the visible list (filter/age/rejected). Drop the param so
|
|
// a reload doesn't keep failing silently.
|
|
params.delete("flat");
|
|
const qs = params.toString();
|
|
history.replaceState(null, "", location.pathname + (qs ? "?" + qs : "") + location.hash);
|
|
return;
|
|
}
|
|
const row = found.closest(".flat-row");
|
|
if (!found.classList.contains("open")) found.click();
|
|
if (row) {
|
|
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
row.classList.add("flat-highlight");
|
|
setTimeout(() => row.classList.remove("flat-highlight"), 3000);
|
|
}
|
|
params.delete("flat");
|
|
const qs = params.toString();
|
|
history.replaceState(null, "", location.pathname + (qs ? "?" + qs : "") + location.hash);
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", openDeepLinkedFlat);
|
|
|
|
// Image lightbox — single global modal in base.html, opened by clicking any
|
|
// .flat-gallery-tile. Click handler is delegated so it survives HTMX swaps.
|
|
// Visibility is driven by inline style.display rather than a `hidden` class
|
|
// because Tailwind's CDN injects its own `.hidden { display: none }` rule
|
|
// at runtime, which conflicted with our class toggle and kept the modal
|
|
// invisible after open().
|
|
(function () {
|
|
const overlay = document.getElementById("lazyflat-lightbox");
|
|
if (!overlay) {
|
|
console.warn("[lazyflat.lightbox] #lazyflat-lightbox not in DOM; viewer disabled");
|
|
return;
|
|
}
|
|
const imgEl = overlay.querySelector(".lightbox-image");
|
|
const counterEl = overlay.querySelector(".lightbox-counter");
|
|
const prevBtn = overlay.querySelector("[data-lightbox-prev]");
|
|
const nextBtn = overlay.querySelector("[data-lightbox-next]");
|
|
const closeBtn = overlay.querySelector("[data-lightbox-close]");
|
|
let urls = [];
|
|
let idx = 0;
|
|
|
|
function render() {
|
|
imgEl.src = urls[idx];
|
|
imgEl.alt = `Foto ${idx + 1} von ${urls.length}`;
|
|
counterEl.textContent = urls.length > 1 ? `${idx + 1} / ${urls.length}` : "";
|
|
prevBtn.hidden = idx <= 0;
|
|
nextBtn.hidden = idx >= urls.length - 1;
|
|
}
|
|
|
|
function open(list, startIdx) {
|
|
if (!list.length) return;
|
|
urls = list;
|
|
idx = Math.max(0, Math.min(startIdx | 0, urls.length - 1));
|
|
overlay.style.display = "flex";
|
|
overlay.setAttribute("aria-hidden", "false");
|
|
document.body.classList.add("lightbox-open");
|
|
render();
|
|
}
|
|
|
|
function close() {
|
|
overlay.style.display = "none";
|
|
overlay.setAttribute("aria-hidden", "true");
|
|
document.body.classList.remove("lightbox-open");
|
|
imgEl.removeAttribute("src");
|
|
urls = [];
|
|
}
|
|
|
|
function step(delta) {
|
|
const next = idx + delta;
|
|
if (next < 0 || next >= urls.length) return;
|
|
idx = next;
|
|
render();
|
|
}
|
|
|
|
prevBtn.addEventListener("click", () => step(-1));
|
|
nextBtn.addEventListener("click", () => step(1));
|
|
closeBtn.addEventListener("click", close);
|
|
overlay.addEventListener("click", (ev) => {
|
|
if (ev.target === overlay) close();
|
|
});
|
|
document.addEventListener("keydown", (ev) => {
|
|
if (overlay.style.display === "none") return;
|
|
if (ev.key === "Escape") close();
|
|
else if (ev.key === "ArrowLeft") step(-1);
|
|
else if (ev.key === "ArrowRight") step(1);
|
|
});
|
|
|
|
document.addEventListener("click", (ev) => {
|
|
const tile = ev.target.closest(".flat-gallery-tile");
|
|
if (!tile) return;
|
|
const gallery = tile.closest(".flat-gallery");
|
|
if (!gallery) return;
|
|
ev.preventDefault();
|
|
const tiles = Array.from(gallery.querySelectorAll(".flat-gallery-tile"));
|
|
const list = tiles
|
|
.map((t) => t.dataset.fullSrc || (t.querySelector("img") || {}).src || "")
|
|
.filter(Boolean);
|
|
open(list, tiles.indexOf(tile));
|
|
});
|
|
})();
|