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 };
}