feat(annotations + player): bookmark rows in the banner, stacked volume panel, badge parity for comics/manga

Annotations banner: audio/text bookmarks join notes and highlights —
third row kind (bookmark glyph indicator, H:MM:SS / Page N / Chapter N
friendly labels), edit-note/delete actions branch by kind, and the
banner's export now covers all annotations. The archived ledger
(ListDeletedAnnotationsForBook) already tombstoned bookmarks equally
and Restore/Purge take annotation_type=bookmark — verified live:
delete → restore brings the row back with its note intact.

Volume: the dragger sits BELOW the mute icon in the panel (stacked
flyout shape) with the horizontal slider from the last round.

Badges: the real fix for comics/manga — format_group only knows
'comic_archive' (manga_type is unreliable across sources), so the
comic/manga split is resolved from the LIBRARY type server-side.
handlers.MediaBadgeKind + BookInfo.Badge at every construction site
(dashboard SSR + sections API, collections, series, and the bookshelf
search grid — which was also missing FormatGroup entirely, so no card
ever badged there). Both renderers (templ + dashboard.ts) switch on
the resolved badge. Verified: All-Books grid renders 2 Comic + 3
Manga badges, dashboard the audiobook one.

Infra: header.templ main.js src gains ?v=<stamp> — browsers keyed an
intermediate bundle by URL and served it across rebuilds (the stale
'Add note' the user saw); bump the stamp when JS stale-shadows.
This commit is contained in:
John O'Keefe committed 2026-10-15 19:33:18 -04:00
1 parent 2c714dcb60
commit 89665fb2bc
19 files changed
+273 -92

No files matched your search

+45 -5
View File
@@ -230,29 +230,37 @@ Alpine.data("bookDetail", () => {
const mediaId = getMediaId();
this.annotationsBannerLoading = true;
try {
const [noteResp, hlResp] = await Promise.all([
const [noteResp, hlResp, bmResp] = await Promise.all([
fetch(`/api/media-items/${mediaId}/notes`, {
headers: { Authorization: getAuthHeader() },
}),
fetch(`/api/media-items/${mediaId}/highlights`, {
headers: { Authorization: getAuthHeader() },
}),
fetch(`/api/media-items/${mediaId}/bookmarks`, {
headers: { Authorization: getAuthHeader() },
}),
]);
// A partial failure must not read as "no annotations": a failed
// endpoint used to be published as an empty list, silently wiping
// that kind from the banner while the other kind stayed (found
// auditing the app's fetch-wipe fix, bookhoard-app 07bbb4c).
// Keep the previous rows; the next banner open retries.
if (!noteResp.ok || !hlResp.ok) {
if (!noteResp.ok || !hlResp.ok || !bmResp.ok) {
console.warn(
`banner annotation load failed ` +
`(notes ${noteResp.status}, highlights ${hlResp.status}) — keeping previous`,
`(notes ${noteResp.status}, highlights ${hlResp.status}, bookmarks ${bmResp.status}) — keeping previous`,
);
return;
}
const notes = await noteResp.json();
const highlights = await hlResp.json();
const built = buildAnnotationRows(notes, highlights);
const bookmarks = await bmResp.json();
const built = buildAnnotationRows(
notes,
highlights,
Array.isArray(bookmarks) ? bookmarks : [],
);
this.bannerRows = built.rows;
this.bannerSources = built.sources;
this.bannerLoaded = true;
@@ -282,7 +290,22 @@ Alpine.data("bookDetail", () => {
if (!row || (!content && row.kind === "note")) return;
const mediaId = getMediaId();
try {
if (row.kind === "note") {
if (row.kind === "bookmark") {
// Full-replace PUT: title + position ride along from the row's
// stored values; the note is the editable part.
const bmId = row.key.slice("bookmark-".length);
const src = this.bannerSources.find(s => s.row.key === row.key);
const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
method: "PUT",
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({
title: String(src?.highlightText || "Bookmark"),
notes: content,
position: row.loc || "",
}),
});
if (!resp.ok) throw new Error(String(resp.status));
} else if (row.kind === "note") {
const noteId = row.key.slice("note-".length);
// The stored position rides along: the server's PUT hard-sets the
// position column.
@@ -352,6 +375,23 @@ Alpine.data("bookDetail", () => {
showToast("Could not delete the note", "error");
}
},
async deleteBannerBookmark(row: AnnotationRow) {
if (row.kind !== "bookmark") return;
const mediaId = getMediaId();
const bmId = row.key.slice("bookmark-".length);
try {
const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) throw new Error(String(resp.status));
// Tombstoned server-side: restorable from the Deleted modal.
showToast("Bookmark deleted — restorable from Deleted", "success");
await this.fetchBannerAnnotations();
} catch (_e) {
showToast("Could not delete the bookmark", "error");
}
},
async deleteBannerHighlight(row: AnnotationRow) {
if (row.kind !== "highlight") return;
const mediaId = getMediaId();