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

+51 -2
View File
@@ -13,8 +13,8 @@ import { renderNoteHtml, stripNote } from "./markdown";
interface AnnotationRow {
/** stable key for x-for */
key: string;
/** note | highlight */
kind: "note" | "highlight";
/** note | highlight | bookmark */
kind: "note" | "highlight" | "bookmark";
/** human location label (Section N / Page N / NN% / source words) */
label: string;
/** compact preview line: stripped markdown (note) or capped selection */
@@ -83,9 +83,32 @@ function pdfEnvelopePage(cfi: string): number | null {
return null;
}
/** Bookmark position labels: audio:<s> renders H:MM:SS; page:N /
* comic:page:N renders Page N; chapter:N renders Chapter N (0-based in
* storage); else the bookmark title's own words. */
function bookmarkLabel(position: string | null | undefined, title: string): string {
const raw = (position ?? "").trim();
if (raw.startsWith("audio:")) {
const sec = parseInt(raw.slice(6), 10);
if (Number.isFinite(sec) && sec >= 0) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const sec2 = sec % 60;
const two = (n: number) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${two(m)}:${two(sec2)}` : `${m}:${two(sec2)}`;
}
}
const pageM = /^(?:comic:)?page:(\d+)$/.exec(raw);
if (pageM) return `Page ${pageM[1]}`;
const chapM = /^chapter:(\d+)$/.exec(raw);
if (chapM) return `Chapter ${Number(chapM[1]) + 1}`;
return title || "Bookmark";
}
function buildAnnotationRows(
notes: any[],
highlights: any[],
bookmarks: any[] = [],
): { rows: AnnotationRow[]; sources: AnnotationSource[] } {
const rows: AnnotationRow[] = [];
const sources: AnnotationSource[] = [];
@@ -161,6 +184,32 @@ function buildAnnotationRows(
sources.push({ row, source: note, highlightText: selection });
}
for (const b of bookmarks) {
if (b.deleted) continue;
const notesrc = String(b.notes ?? "");
const title = String(b.title ?? "");
const label = bookmarkLabel(b.position, title);
const loc = String(b.position ?? "");
const row: AnnotationRow = {
key: `bookmark-${b.id}`,
kind: "bookmark",
label,
preview: title || label,
richSelection: "",
richNote: renderNoteHtml(notesrc),
loc,
color: "var(--accent)",
hasNote: notesrc.trim().length > 0,
expanded: false,
};
rows.push(row);
sources.push({
row,
source: notesrc,
highlightText: title || label,
});
}
return { rows, sources };
}
+8 -1
View File
@@ -217,13 +217,20 @@ Alpine.data("audioReader", () => ({
const p = await progress.json();
const chapter = Number(p.chapter ?? 0);
const offset = Number(p.audio_offset_seconds ?? 0);
if (Number.isFinite(p.percentage) && this.bookDuration > 0) {
// Banner/deep-link jumps (?loc=audio:<seconds>) win over the
// stored position: the user explicitly asked for this spot.
const locParam = new URLSearchParams(window.location.search).get("loc") || "";
if (locParam.startsWith("audio:")) {
const at = Number(locParam.slice("audio:".length));
if (Number.isFinite(at)) this.seekTo(at);
} else if (Number.isFinite(p.percentage) && this.bookDuration > 0) {
const at = Number(p.percentage) * this.bookDuration;
this.seekTo(at);
} else if (chapter > 0 && this.chapters[chapter]?.start != null) {
this.seekTo(this.chapters[chapter].start + offset);
}
this.currentChapterIdx = Math.max(0, chapter);
this.tickChapter();
}
} catch {
// Offline start: still playable; sync resumes when reachable.
+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();
+16 -7
View File
@@ -302,11 +302,20 @@ const MANGA_ICON =
'<path d="M4 4h16v13a3 3 0 0 1-3 3H4z"></path>' +
'<path d="M4 4v16M10 4h10v8"></path>' +
"</svg>";
// Media-type identifier for mixed grids: '' for books (unbadged default)
function badgeIconFor(formatGroup: string): string {
if (formatGroup === "audiobook") return HEADPHONES_ICON;
if (formatGroup === "comic") return COMIC_ICON;
if (formatGroup === "manga") return MANGA_ICON;
// Media-type identifier for mixed grids, from the server-resolved badge
// (format_group + library type — comic vs manga is a LIBRARY distinction):
// '' for books (unbadged default)
function badgeIconFor(badge: string): string {
if (badge === "audiobook") return HEADPHONES_ICON;
if (badge === "comic") return COMIC_ICON;
if (badge === "manga") return MANGA_ICON;
return "";
}
function badgeTitleFor(badge: string): string {
if (badge === "audiobook") return "Audiobook";
if (badge === "comic") return "Comic";
if (badge === "manga") return "Manga";
return "";
}
@@ -328,7 +337,7 @@ function renderBookCard(book: BookInfo): string {
? "Listen"
: "Read";
const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON;
const badgeIcon = badgeIconFor(book.format_group || "");
const badgeIcon = badgeIconFor(book.badge || "");
return `
<div class="flex-shrink-0 w-36 sm:w-40 snap-start" data-media-item-id="${book.media_item_id}">
@@ -340,7 +349,7 @@ function renderBookCard(book: BookInfo): string {
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
${badgeIcon ? `<span class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm" style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--text-primary); backdrop-filter: blur(3px);" title="${isAudio ? "Audiobook" : book.format_group === "comic" ? "Comic" : "Manga"}">${badgeIcon}</span>` : ""}
${badgeIcon ? `<span class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm" style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--text-primary); backdrop-filter: blur(3px);" title="${badgeTitleFor(book.badge || "")}">${badgeIcon}</span>` : ""}
</div>
<div class="book-card-meta">
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title="${book.title}">
+1
View File
@@ -103,6 +103,7 @@ interface BookInfo {
cover_image_path: string;
has_conflict: boolean;
format_group: string;
badge: string;
}
// Dashboard type definitions