Logs from the user's last session showed that after walking through all images and trying to go back from the last one, a backdrop click fired (target===overlay) and closed the modal — even though the user believed they clicked the prev arrow. Two reinforcing causes: 1. The image (.lightbox-image) is a sibling AFTER the buttons in the DOM with no z-index, so paint order put the image on top of the absolute-positioned arrows. Where the image's max-width/height box overlapped the arrows, clicks landed on the image instead of the arrow, and clicks in the gap between image and arrow hit the overlay backdrop. 2. Even when an arrow handler did fire, the click bubbled up to the overlay's click handler. While target===overlay was false in that path, the next click sometimes did land on the backdrop, and the close button had the same exposure. Fix: - Stack the controls above the image: image gets z-index:1, every .lightbox button gets z-index:2. - stopPropagation on prev/next/close button clicks AND on the image click — guarantees they can never bubble into the overlay's backdrop-close handler. Backdrop close still works on actual backdrop clicks. - Bump button background to rgba(0,0,0,.55) (was .08 white on dark) so the arrows are clearly visible against the image. Also strip the [lazyflat.lightbox] DEBUG(lightbox) tracer logs and the window.error catch-all — original symptom is fixed and the existing flow is confirmed working in user's logs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
7.8 KiB
JavaScript
212 lines
7.8 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) 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();
|
|
}
|
|
|
|
// stopPropagation on every control so a click on a button can never bubble
|
|
// up to the overlay's backdrop-close handler. Earlier symptom: clicking
|
|
// a control next to the image edge sometimes registered as a backdrop
|
|
// close because the click was hitting the gap between image and button.
|
|
prevBtn.addEventListener("click", (ev) => { ev.stopPropagation(); step(-1); });
|
|
nextBtn.addEventListener("click", (ev) => { ev.stopPropagation(); step(1); });
|
|
closeBtn.addEventListener("click", (ev) => { ev.stopPropagation(); close(); });
|
|
imgEl.addEventListener("click", (ev) => ev.stopPropagation());
|
|
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));
|
|
});
|
|
})();
|