Confirm-gate lifecycle actions, cluster the action strip
Fixes the 2026-07-30 impeccable critique of the web UI. Archive and finish now open their own .confirm-row disclosure, the pattern delete already used, so no single tap moves a series out of the list. Restore stays instant — it is the reversal. The two reversible confirms wear grey; remove keeps the ember wash. The six action cells cluster by consequence: navigate (play) | organize (favourite, chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells sit on a recessed ground, separated by a hairline on a phone and by a gap on desktop, so the 4th-slot icon swapping between .box and .restore no longer breaks position memory. htmx:responseError branches on status: 401 offers a login link, 400 surfaces the server's own reason, everything else keeps the retry copy. Busy bar and inline error move to --mute, leaving --ember exclusive to the new-chapter signal. Also: aria-live on the confirm rows, max on the chapter input, cover width and row gap as tokens instead of a hardcoded 94px indent, and mobile covers 30% larger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+51
-20
@@ -25,61 +25,92 @@ function setActiveTab(el) {
|
||||
});
|
||||
}
|
||||
|
||||
// The chapter-edit form and the delete confirm row are the two 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.
|
||||
// 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 form = document.getElementById("chapter-form-" + key);
|
||||
var confirm = document.getElementById("confirm-row-" + key);
|
||||
if (form) form.hidden = true;
|
||||
if (confirm) confirm.hidden = true;
|
||||
var card = document.getElementById("card-" + key);
|
||||
if (card) {
|
||||
card.querySelectorAll(".actions .open").forEach(function (b) {
|
||||
b.classList.remove("open");
|
||||
});
|
||||
}
|
||||
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 + key);
|
||||
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);
|
||||
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-", ".actions .pencil");
|
||||
var form = togglePanel(key, "chapter-form-" + key, ".actions .pencil");
|
||||
if (form && !form.hidden) form.querySelector("input").focus();
|
||||
}
|
||||
|
||||
function toggleConfirmRow(key) {
|
||||
togglePanel(key, "confirm-row-", ".actions .remove");
|
||||
// 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) {
|
||||
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;
|
||||
}, 5000);
|
||||
}, 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) {
|
||||
|
||||
Reference in New Issue
Block a user