// Title search runs entirely in the browser: the full list is already in the // DOM, so filtering it needs no request. (function () { function applyFilter() { var box = document.getElementById("search"); if (!box) return; var query = box.value.trim(); var needle = query.toLowerCase(); var cards = document.querySelectorAll(".card"); var visible = 0; cards.forEach(function (card) { var title = (card.dataset.title || "").toLowerCase(); card.hidden = needle !== "" && title.indexOf(needle) === -1; if (!card.hidden) visible++; }); // The server decides what the strip holds — it ships empty for every tab // but All, and out of band after every mutation. All this has to do is keep // it down while a filter is active, since the strip is never filtered and // would otherwise put non-matching covers above an empty list. var recent = document.querySelector(".recent"); if (recent) { recent.hidden = needle !== "" || !recent.querySelector(".recent-card"); } // An empty bucket already explains itself server-side; this only speaks // when the filter is what emptied the screen. var none = document.getElementById("no-match"); if (none) { none.hidden = !(needle !== "" && cards.length > 0 && visible === 0); if (!none.hidden) none.querySelector(".no-match-q").textContent = query; } } document.addEventListener("input", function (e) { if (e.target && e.target.id === "search") applyFilter(); }); document.addEventListener("click", function (e) { if (!e.target || !e.target.classList.contains("clear-search")) return; var box = document.getElementById("search"); box.value = ""; applyFilter(); box.focus(); }); // htmx replaces the list on a tab switch, so re-apply to the new cards. document.body.addEventListener("htmx:afterSwap", applyFilter); document.addEventListener("mangabm:refilter", applyFilter); })(); function setActiveTab(el) { el.parentElement.querySelectorAll("a").forEach(function (t) { var on = t === el; t.classList.toggle("active", on); if (on) t.setAttribute("aria-current", "page"); else t.removeAttribute("aria-current"); }); // The strip is outside the swapped region, so its visibility is re-decided // here rather than by the server that just answered. document.dispatchEvent(new Event("mangabm:refilter")); } // The chapter-edit form and the archive/finish/remove confirm rows are the // per-card disclosure panels; only one makes sense open at a time. The button // that owns an open panel carries .open, which is how the strip shows which // cell the panel belongs to. function closeCardPanels(key) { var card = document.getElementById("card-" + key); if (!card) return; card.querySelectorAll(".chapter-form, .confirm-row").forEach(function (p) { p.hidden = true; }); card.querySelectorAll(".actions .open").forEach(function (b) { b.classList.remove("open"); b.setAttribute("aria-expanded", "false"); }); } function togglePanel(key, panelId, buttonSelector) { var panel = document.getElementById(panelId); if (!panel) return null; var opening = panel.hidden; closeCardPanels(key); panel.hidden = !opening; var card = document.getElementById("card-" + key); var button = card && card.querySelector(buttonSelector); if (button) { button.classList.toggle("open", opening); button.setAttribute("aria-expanded", opening ? "true" : "false"); } return panel; } function toggleChapterForm(key) { var form = togglePanel(key, "chapter-form-" + key, ".actions .pencil"); if (form && !form.hidden) form.querySelector("input").focus(); } // kind is "archive" | "finish" | "remove" — the panel id and the owning action // cell share it. function toggleConfirmRow(key, kind) { var cls = { archive: ".box", finish: ".finish", remove: ".remove" }[kind]; var row = togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls); // Focus the answer rather than trusting aria-live on a container that merely // unhides: it makes the announcement deterministic, keeps tab order inside // the confirm instead of running on into the next card, and means the row // cannot be opened and scrolled past unnoticed. // The reversible rows open on their affirmative; remove opens on Cancel. // Focusing the first button in DOM order would hand the irreversible action // a pre-armed Enter, which is the opposite of what a confirm gate is for. if (row && !row.hidden) { row.querySelector(row.classList.contains("calm") ? "button" : "button + button").focus(); } } // Esc closes whichever panel this card has open and hands focus back to the // cell that owns it — otherwise the only way out is finding that exact cell // again. document.addEventListener("keydown", function (e) { if (e.key !== "Escape" || !e.target.closest) return; var card = e.target.closest(".card"); var owner = card && card.querySelector(".actions .open"); if (!owner) return; closeCardPanels(card.id.replace(/^card-/, "")); owner.focus(); }); // A failed favourite/chapter/delete request leaves the card in place (htmx // does not swap on a non-2xx response) but otherwise gives no sign anything // went wrong. Surface it inline instead of leaving the tap looking ignored. (function () { function showError(elt, message, linkHref, linkText) { var card = elt.closest(".card"); var slot = card && card.querySelector(".error-inline"); if (!slot) return; slot.textContent = message; if (linkHref) { var a = document.createElement("a"); a.href = linkHref; a.textContent = linkText; a.className = "error-link"; slot.append(" ", a); } slot.hidden = false; // No self-destruct timer: this reader gets interrupted mid-tap, and a // notice that expires after 5s leaves a failed write with no trace at all // — the star is back off and nothing says why. The notice stays until the // next request from this card clears it (or a successful one swaps the // whole card away). slot.scrollIntoView({ block: "nearest", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", }); } document.body.addEventListener("htmx:beforeRequest", function (e) { var card = e.detail.elt.closest(".card"); var slot = card && card.querySelector(".error-inline"); if (slot) slot.hidden = true; }); // The handlers answer a bad value with http.Error, i.e. a short plain-text // line — worth showing verbatim. Anything long or HTML-ish is an error page, // not a reason, so fall back to the generic copy. function reasonFrom(xhr) { var body = (xhr.responseText || "").trim(); if (!body || body.length > 120 || body.indexOf("<") === 0) return ""; return body.charAt(0).toUpperCase() + body.slice(1); } document.body.addEventListener("htmx:responseError", function (e) { var xhr = e.detail.xhr; if (xhr.status === 401) { showError(e.detail.elt, "Session expired — nothing was saved.", "/login", "Log in again"); return; } if (xhr.status === 400) { var reason = reasonFrom(xhr); showError(e.detail.elt, reason ? reason + "." : "That value wasn't accepted — check it and try again."); return; } showError(e.detail.elt, "Couldn't save — try again."); }); document.body.addEventListener("htmx:sendError", function (e) { showError(e.detail.elt, "No connection — try again."); }); })();