264cf21960
The strip is the head of the same updated_at DESC reading list the All tab renders, and app.html put it on every tab: on a small library it repeated the list immediately below it while eating ~230px of the first phone screen, and on Archived and Finished it showed reading series above the opposite bucket. buildListView now fills Recent only for tab=all with more reading series than the strip holds. Because the strip lives outside the htmx-swapped #list, an in-page tab switch cannot re-render it, so applyFilter also decides its visibility from the active tab; setActiveTab pokes it after a swap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
5.7 KiB
JavaScript
156 lines
5.7 KiB
JavaScript
// 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 strip is All-only and never filtered. It lives outside #list, so an
|
|
// htmx tab switch does not re-render it and this is what takes it down;
|
|
// leaving it up during a search would also put non-matching covers above
|
|
// an empty list.
|
|
var recent = document.querySelector(".recent");
|
|
if (recent) {
|
|
recent.hidden = needle !== "" || !document.querySelector('.tabs a.active[href="/?tab=all"]');
|
|
}
|
|
|
|
// 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];
|
|
togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
|
|
}
|
|
|
|
// 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;
|
|
clearTimeout(slot._hideTimer);
|
|
slot._hideTimer = setTimeout(function () {
|
|
slot.hidden = true;
|
|
}, linkHref ? 30000 : 5000);
|
|
}
|
|
|
|
// 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.");
|
|
});
|
|
})();
|