lazyflat/web/static/app.js
EiSiMo c379bc989f debug(lightbox): log overlay rect, computed style, arrow handler fires
Tile-click logs from the user's session reveal that the modal opens
(display becomes flex) but every "arrow click" actually lands on a
gallery thumbnail behind the modal — closest(.flat-gallery-tile)
finds a button with target=img. So either the overlay isn't covering
the viewport (positioning fails) or pointer events leak through.

Add log lines to settle it:
- open() now dumps computed display/position/zIndex/inset/pointer-events
  + getBoundingClientRect() and the viewport size, so we can see
  whether the overlay box actually spans the screen.
- Logs the prev/next button rects too — tells us where the arrows
  sit and whether they overlap the gallery.
- Each of prevBtn/nextBtn/closeBtn/overlay click handlers logs when
  it actually fires — confirms whether arrow handlers are reached at
  all when the user clicks them.
- step() logs entry, delta, idx and out-of-range exits.

All logs still tagged [lazyflat.lightbox] / DEBUG(lightbox): for grep
+ removal once fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:06:32 +02:00

277 lines
11 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";
// DEBUG(lightbox): confirm fetched partial actually contains <button class="flat-gallery-tile">
const tileCount = pane.querySelectorAll(".flat-gallery-tile").length;
const aTagCount = pane.querySelectorAll("a.flat-gallery-tile").length;
console.log("[lazyflat.lightbox] partial loaded flat=%s tiles=%d (a-tags=%d, html-snippet=%o)",
flatId, tileCount, aTagCount, html.slice(0, 200));
})
.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 () {
// DEBUG(lightbox): start-up trace, remove once the viewer is confirmed working.
console.log("[lazyflat.lightbox] IIFE start");
const overlay = document.getElementById("lazyflat-lightbox");
console.log("[lazyflat.lightbox] overlay element:", overlay);
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]");
console.log("[lazyflat.lightbox] children:",
{ imgEl: !!imgEl, counterEl: !!counterEl, prevBtn: !!prevBtn,
nextBtn: !!nextBtn, closeBtn: !!closeBtn });
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) {
console.log("[lazyflat.lightbox] open() called", { count: list && list.length, startIdx, list });
if (!list.length) {
console.warn("[lazyflat.lightbox] open() bailed — empty url list");
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();
// DEBUG(lightbox): dump everything we'd want to know if clicks pass through.
const cs = getComputedStyle(overlay);
const rect = overlay.getBoundingClientRect();
console.log("[lazyflat.lightbox] open() done", {
display: cs.display, position: cs.position, zIndex: cs.zIndex,
top: cs.top, left: cs.left, right: cs.right, bottom: cs.bottom,
pointerEvents: cs.pointerEvents,
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
viewport: { w: window.innerWidth, h: window.innerHeight, scrollY: window.scrollY },
src: imgEl.src,
});
const prevRect = prevBtn.getBoundingClientRect();
const nextRect = nextBtn.getBoundingClientRect();
console.log("[lazyflat.lightbox] arrow rects", {
prev: { hidden: prevBtn.hidden, x: prevRect.x, y: prevRect.y, w: prevRect.width, h: prevRect.height },
next: { hidden: nextBtn.hidden, x: nextRect.x, y: nextRect.y, w: nextRect.width, h: nextRect.height },
});
}
function close() {
overlay.style.display = "none";
overlay.setAttribute("aria-hidden", "true");
document.body.classList.remove("lightbox-open");
imgEl.removeAttribute("src");
urls = [];
}
function step(delta) {
console.log("[lazyflat.lightbox] step()", { delta, from: idx, max: urls.length - 1 });
const next = idx + delta;
if (next < 0 || next >= urls.length) {
console.log("[lazyflat.lightbox] step() out of range — ignored");
return;
}
idx = next;
render();
}
prevBtn.addEventListener("click", (ev) => {
console.log("[lazyflat.lightbox] prevBtn click handler fired", { target: ev.target });
step(-1);
});
nextBtn.addEventListener("click", (ev) => {
console.log("[lazyflat.lightbox] nextBtn click handler fired", { target: ev.target });
step(1);
});
closeBtn.addEventListener("click", (ev) => {
console.log("[lazyflat.lightbox] closeBtn click handler fired");
close();
});
overlay.addEventListener("click", (ev) => {
console.log("[lazyflat.lightbox] overlay click — target===overlay?", ev.target === overlay,
"target=", ev.target);
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) {
// DEBUG(lightbox): only log clicks somewhere INSIDE a gallery so we don't spam.
if (ev.target.closest && ev.target.closest(".flat-gallery")) {
console.log("[lazyflat.lightbox] click inside gallery but no .flat-gallery-tile ancestor",
{ target: ev.target, tagName: ev.target.tagName });
}
return;
}
console.log("[lazyflat.lightbox] tile clicked", { tile, target: ev.target,
tagName: tile.tagName, fullSrc: tile.dataset.fullSrc });
const gallery = tile.closest(".flat-gallery");
if (!gallery) {
console.warn("[lazyflat.lightbox] tile has no .flat-gallery ancestor");
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);
console.log("[lazyflat.lightbox] gallery has", tiles.length, "tiles,", list.length, "valid urls");
open(list, tiles.indexOf(tile));
});
console.log("[lazyflat.lightbox] click delegate attached");
})();
// DEBUG(lightbox): catch any uncaught error so we know if app.js stopped
// running before the IIFE attached its handlers.
window.addEventListener("error", (ev) => {
console.error("[lazyflat.lightbox] window error:", ev.message, ev.filename + ":" + ev.lineno);
});