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
@@ -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
@@ -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
@@ -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
@@ -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}">
|
||||
|
||||
Vendored
+1
@@ -103,6 +103,7 @@ interface BookInfo {
|
||||
cover_image_path: string;
|
||||
has_conflict: boolean;
|
||||
format_group: string;
|
||||
badge: string;
|
||||
}
|
||||
|
||||
// Dashboard type definitions
|
||||
|
||||
Reference in new issue
Block a user