feat(web): book-detail annotations banner — ?loc= reader jumps, single/zip markdown export
Release / build-and-push (push) Successful in 3m9s

Objective 7 (web half). The 'Annotations' button slides a right-hand
banner (annotations_banner.templ + annotations-banner.ts) fed by the
existing per-book GET endpoints: friendly labels (Section N parsed from
the CFI spine / Page N from the PDF envelope / NN% + first-words
fallbacks), capped highlight previews, rich x-html expansion through the
objective-6 markdown pipeline, and per-row 'Open reader here' jumps
riding /readers/:uuid?loc= (consumeLocJump: jump outranks stored
progress for that open; URL stripped before sync can echo it). WEB-ONLY
export: single .md or one-file-per-item zip via fflate — item format
identical both modes ('# friendly label' / '# selection text' headers,
raw markdown bodies, no raw CFIs anywhere).

Verified live in Brave: 12 restored highlights + a markdown note
labeled/expanded correctly; the jump navigated, landed on the right
chapter and consumed ?loc=; export produced the 13-item .md (friendly
headers only) and a valid 13-entry zip. Book-detail's existing
recently-deleted modal stays untouched (banner is additive).
This commit is contained in:
John O'Keefe committed 2026-10-04 14:30:56 -04:00
1 parent 4b5e193d9f
commit 0b83db8fce
10 files changed
+1044 -483

No files matched your search

+91
View File
@@ -1,6 +1,15 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
import { strToU8, zipSync } from "fflate";
import {
buildAnnotationRows,
annotationItemMarkdown,
triggerDownload,
bannerBookTitle,
type AnnotationRow,
type AnnotationSource,
} from "./annotations-banner";
function getMediaId(): string {
const parts = window.location.pathname.split("/");
@@ -157,6 +166,17 @@ Alpine.data("bookDetail", () => {
showTagDropdown: false,
highlightedTagIndex: -1,
// ----- annotations banner (objective 7) -----
annotationsBannerOpen: false,
annotationsBannerLoading: false,
annotationsExportOpen: false,
bannerMediaId: getMediaId(),
bannerRows: [] as AnnotationRow[],
bannerLoaded: false,
bannerError: "",
// raw source kept for the export builder (not exposed to the template)
bannerSources: [] as { row: AnnotationRow; source: string; highlightText: string }[],
showReaderPlaceholder,
showMetadataEditor,
hideMetadataEditor,
@@ -165,6 +185,77 @@ Alpine.data("bookDetail", () => {
hideProgressSyncModal,
hideNotesModal,
async toggleAnnotationsBanner() {
this.annotationsBannerOpen = !this.annotationsBannerOpen;
if (this.annotationsBannerOpen && !this.bannerLoaded) {
await this.fetchBannerAnnotations();
}
},
hideAnnotationsBanner(): void {
this.annotationsBannerOpen = false;
this.hideExportDialog();
},
async fetchBannerAnnotations() {
const mediaId = getMediaId();
this.annotationsBannerLoading = true;
try {
const [noteResp, hlResp] = await Promise.all([
fetch(`/api/media-items/${mediaId}/notes`, {
headers: { Authorization: getAuthHeader() },
}),
fetch(`/api/media-items/${mediaId}/highlights`, {
headers: { Authorization: getAuthHeader() },
}),
]);
const notes = noteResp.ok ? await noteResp.json() : [];
const highlights = hlResp.ok ? await hlResp.json() : [];
const built = buildAnnotationRows(notes, highlights);
this.bannerRows = built.rows;
this.bannerSources = built.sources;
this.bannerLoaded = true;
} catch (e) {
this.bannerError = String(e);
showToast("Could not load annotations", "error");
} finally {
this.annotationsBannerLoading = false;
}
},
showExportDialog(): void {
if (this.bannerRows.length === 0) return;
this.annotationsExportOpen = true;
},
hideExportDialog(): void {
this.annotationsExportOpen = false;
},
async exportAnnotations(mode: "single" | "multi") {
this.hideExportDialog();
if (this.bannerRows.length === 0) return;
const items = this.bannerSources.map(annotationItemMarkdown);
const title = bannerBookTitle();
try {
if (mode === "single") {
const blob = new Blob([items.join("\n")], {
type: "text/markdown",
});
triggerDownload(blob, `${title}-annotations.md`);
} else {
// One file per item: 1.md … N.md, zipped client-side.
const files: Record<string, Uint8Array> = {};
items.forEach((text, i) => {
files[`${i + 1}.md`] = strToU8(text);
});
const zipped = zipSync(files);
triggerDownload(
new Blob([zipped], { type: "application/zip" }),
`${title}-annotations.zip`,
);
}
showToast("Annotations exported", "success");
} catch (_e) {
showToast("Export failed", "error");
}
},
async resolveConflict(conflictId: string, winner: string) {
try {
const resp = await fetch(`/api/conflicts/${conflictId}/resolve`, {