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:
1 parent
2c714dcb60
commit
89665fb2bc
19 files changed
+273
-92
No files matched your search
+45
-5
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user