feat(web): book-detail annotations banner — ?loc= reader jumps, single/zip markdown export

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
2026-10-04 14:30:56 -04:00
parent 4b5e193d9f
commit bd5c599d95
10 changed files with 1044 additions and 483 deletions
+1
View File
@@ -16,6 +16,7 @@
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"dompurify": "^3.4.16",
"fflate": "^0.8.3",
"highlight.js": "^11.11.1",
"htmx.org": "^2.0.8",
"lunr": "^2.3.9",
+138
View File
@@ -0,0 +1,138 @@
package templates
import "bookhoard/internal/handlers"
// AnnotationsBanner is the book-detail slide-out listing every note and
// highlight on the book (objective 7). Rows show a friendly human location
// label (never a raw cfi:epubcfi(…) string), expand to the full rich markup
// (markdown pipeline identical to the reader's), and offer a per-row jump
// that opens the reader at the annotation's location via ?loc=. Web-only
// export lives at the top: single .md file, or one file per item zipped.
// Formatting is IDENTICAL in both modes:
// location note: "# <friendly label>" header + note source as body
// highlight: "# <highlight text>" header + note source as body
// (no note → header only)
templ AnnotationsBanner(book handlers.MediaDetail) {
<div
id="annotations-banner"
x-cloak
x-show="annotationsBannerOpen"
x-transition:enter="transition-transform duration-200 ease-out"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform duration-150 ease-in"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="fixed right-0 top-0 bottom-0 w-full max-w-md z-50 shadow-2xl overflow-y-auto"
style="background: var(--bg-primary); border-left: 1px solid var(--border);"
role="dialog"
aria-label="Annotations"
>
<div class="sticky top-0 px-4 py-3 flex items-center justify-between" style="background: var(--bg-primary); border-bottom: 1px solid var(--border);">
<h3 class="text-lg font-semibold" style="color: var(--text-primary);">Notes &amp; Highlights</h3>
<button
type="button"
@click="hideAnnotationsBanner()"
class="p-2 rounded-lg hover:bg-surface-hover"
style="color: var(--text-secondary);"
aria-label="Close annotations"
>
@Icon("close", "w-5 h-5")
</button>
</div>
<div class="px-4 py-3">
<button
type="button"
id="export-annotations-btn"
:disabled="annotationsBannerLoading || bannerRows.length === 0"
@click="showExportDialog()"
class="w-full py-2 mb-3 text-sm font-medium rounded-lg border transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
style="border-color: var(--border); color: var(--text-primary);"
>
Export all notes and highlights
</button>
<p x-show="annotationsBannerLoading" class="text-sm py-4" style="color: var(--text-secondary);">Loading…</p>
<template x-if="!annotationsBannerLoading && bannerRows.length === 0">
<p class="text-sm py-4" style="color: var(--text-secondary);">
No notes or highlights on this book yet.
</p>
</template>
<!-- Notes first, then highlights — stable ledger order -->
<div class="space-y-2">
<template x-for="row in bannerRows" :key="row.key">
<div class="card p-0 overflow-hidden">
<button
type="button"
class="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover"
@click="row.expanded = !row.expanded"
>
<span
class="w-1.5 self-stretch rounded-full shrink-0"
:style="{ backgroundColor: row.color }"
></span>
<span class="flex-1 min-w-0">
<span
class="block text-sm"
x-text="row.preview"
></span>
<span class="block text-xs mt-0.5" style="color: var(--text-secondary);" x-text="row.label"></span>
</span>
</button>
<!-- Jump opens the reader at the annotation's location -->
<div class="flex items-center justify-between px-4 pb-3 pt-1">
<a
:href="'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(row.loc)"
class="text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors"
style="border-color: var(--border); color: var(--accent);"
>
Open reader here ↗
</a>
</div>
<!-- Expanded rich body (markdown pipeline) -->
<div x-show="row.expanded" class="px-4 pb-4" x-html="row.rich"></div>
</div>
</template>
</div>
</div>
</div>
<!-- Export format dialog -->
<div
id="annotations-export-dialog"
x-cloak
x-show="annotationsExportOpen"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background: rgba(0,0,0,.6);"
>
<div class="max-w-sm w-full card p-6">
<h4 class="text-lg font-semibold mb-1" style="color: var(--text-primary);">Export annotations</h4>
<p class="text-sm mb-4" style="color: var(--text-secondary);">Notes and highlights, as markdown files.</p>
<div class="space-y-2">
<button
type="button"
@click="exportAnnotations('single')"
class="w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors"
style="border-color: var(--border); color: var(--text-primary);"
>
Single file — everything in one .md
</button>
<button
type="button"
@click="exportAnnotations('multi')"
class="w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors"
style="border-color: var(--border); color: var(--text-primary);"
>
One file per item — zipped
</button>
</div>
<button
type="button"
@click="hideExportDialog()"
class="w-full py-2 mt-3 text-sm rounded-lg hover:bg-surface-hover"
style="color: var(--text-secondary);"
>
Cancel
</button>
</div>
</div>
}
+61
View File
@@ -0,0 +1,61 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "bookhoard/internal/handlers"
// AnnotationsBanner is the book-detail slide-out listing every note and
// highlight on the book (objective 7). Rows show a friendly human location
// label (never a raw cfi:epubcfi(…) string), expand to the full rich markup
// (markdown pipeline identical to the reader's), and offer a per-row jump
// that opens the reader at the annotation's location via ?loc=. Web-only
// export lives at the top: single .md file, or one file per item zipped.
// Formatting is IDENTICAL in both modes:
//
// location note: "# <friendly label>" header + note source as body
// highlight: "# <highlight text>" header + note source as body
// (no note → header only)
func AnnotationsBanner(book handlers.MediaDetail) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"annotations-banner\" x-cloak x-show=\"annotationsBannerOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"fixed right-0 top-0 bottom-0 w-full max-w-md z-50 shadow-2xl overflow-y-auto\" style=\"background: var(--bg-primary); border-left: 1px solid var(--border);\" role=\"dialog\" aria-label=\"Annotations\"><div class=\"sticky top-0 px-4 py-3 flex items-center justify-between\" style=\"background: var(--bg-primary); border-bottom: 1px solid var(--border);\"><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary);\">Notes &amp; Highlights</h3><button type=\"button\" @click=\"hideAnnotationsBanner()\" class=\"p-2 rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close annotations\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "w-5 h-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</button></div><div class=\"px-4 py-3\"><button type=\"button\" id=\"export-annotations-btn\" :disabled=\"annotationsBannerLoading || bannerRows.length === 0\" @click=\"showExportDialog()\" class=\"w-full py-2 mb-3 text-sm font-medium rounded-lg border transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed\" style=\"border-color: var(--border); color: var(--text-primary);\">Export all notes and highlights</button><p x-show=\"annotationsBannerLoading\" class=\"text-sm py-4\" style=\"color: var(--text-secondary);\">Loading…</p><template x-if=\"!annotationsBannerLoading && bannerRows.length === 0\"><p class=\"text-sm py-4\" style=\"color: var(--text-secondary);\">No notes or highlights on this book yet.</p></template><!-- Notes first, then highlights — stable ledger order --><div class=\"space-y-2\"><template x-for=\"row in bannerRows\" :key=\"row.key\"><div class=\"card p-0 overflow-hidden\"><button type=\"button\" class=\"w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover\" @click=\"row.expanded = !row.expanded\"><span class=\"w-1.5 self-stretch rounded-full shrink-0\" :style=\"{ backgroundColor: row.color }\"></span> <span class=\"flex-1 min-w-0\"><span class=\"block text-sm\" x-text=\"row.preview\"></span> <span class=\"block text-xs mt-0.5\" style=\"color: var(--text-secondary);\" x-text=\"row.label\"></span></span></button><!-- Jump opens the reader at the annotation's location --><div class=\"flex items-center justify-between px-4 pb-3 pt-1\"><a :href=\"'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(row.loc)\" class=\"text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors\" style=\"border-color: var(--border); color: var(--accent);\">Open reader here ↗</a></div><!-- Expanded rich body (markdown pipeline) --><div x-show=\"row.expanded\" class=\"px-4 pb-4\" x-html=\"row.rich\"></div></div></template></div></div></div><!-- Export format dialog --><div id=\"annotations-export-dialog\" x-cloak x-show=\"annotationsExportOpen\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background: rgba(0,0,0,.6);\"><div class=\"max-w-sm w-full card p-6\"><h4 class=\"text-lg font-semibold mb-1\" style=\"color: var(--text-primary);\">Export annotations</h4><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary);\">Notes and highlights, as markdown files.</p><div class=\"space-y-2\"><button type=\"button\" @click=\"exportAnnotations('single')\" class=\"w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors\" style=\"border-color: var(--border); color: var(--text-primary);\">Single file — everything in one .md</button> <button type=\"button\" @click=\"exportAnnotations('multi')\" class=\"w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors\" style=\"border-color: var(--border); color: var(--text-primary);\">One file per item — zipped</button></div><button type=\"button\" @click=\"hideExportDialog()\" class=\"w-full py-2 mt-3 text-sm rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">Cancel</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+8
View File
@@ -107,6 +107,13 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
}
</button>
<button @click="toggleAnnotationsBanner()" class="btn btn-secondary px-5 py-2.5">
@Icon("bookmark", "h-4 w-4")
<span>Annotations</span>
if book.NotesCount + book.HighlightsCount > 0 {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
}
</button>
if user.Role == "admin" {
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4")
@@ -434,6 +441,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</div>
@ProgressSyncModal(user, book)
@NotesHighlightsModal(user, book)
@AnnotationsBanner(book)
if user.Role == "admin" {
@MetadataEditorModal(book)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+197
View File
@@ -0,0 +1,197 @@
// Book-detail annotations banner (objective 7): every note and highlight
// on the book, friendly location labels (never a raw cfi:epubcfi(…) string
// in UI or export headers), per-row reader jumps via ?loc=, and the
// WEB-ONLY export (single .md or one-file-per-item zip). Item formatting
// is identical in both modes:
// location note: "# <friendly label>" header + note source as body
// highlight: "# <highlight text>" header + note source as body
// (no note → header only)
// Rows expand to the rich markup through the same markdown pipeline the
// reader uses (renderNoteHtml — sanitized at render time).
import { renderNoteHtml, stripNote } from "./markdown";
interface AnnotationRow {
/** stable key for x-for */
key: string;
/** note | highlight */
kind: "note" | "highlight";
/** human location label (Section N / Page N / NN% / source words) */
label: string;
/** compact preview line: stripped markdown (note) or capped selection */
preview: string;
/** rich body for the expanded view (pre-sanitized) */
rich: string;
/** the ?loc= handoff value for the jump */
loc: string;
/** row accent color (swatch) */
color: string;
expanded: boolean;
}
interface AnnotationSource {
row: AnnotationRow;
/** full markdown body under the export header ("" when none) */
source: string;
/** highlight selection text (the export header for highlight rows) */
highlightText: string;
}
/** Friendly human location label; mirrors the app's positionHint(). */
function friendlyAnnotationLabel(position: string | null | undefined, fallbackText: string): string {
const raw = (position ?? "").trim();
if (raw) {
// PDF/comic anchors: "page:N"
if (raw.startsWith("page:")) {
const n = parseInt(raw.slice(5), 10);
if (Number.isFinite(n) && n > 0) return `Page ${n}`;
}
// Reflowable location notes: "cfi:epubcfi(...)"
const cfi = raw.startsWith("cfi:") ? raw.slice(4).trim() : raw;
const m = /epubcfi\(\s*\/6\/(\d+)/.exec(cfi);
if (m) {
// The spine step counts 1-based element pairs; /6/N!/ → section N/2.
const spine = Math.floor(parseInt(m[1], 10) / 2) - 1;
if (spine >= 0) return `Section ${spine + 1}`;
}
}
// Spec fallback: the text's first few words (never a raw locator).
const words = stripNote(fallbackText || "").replace(/\s+/g, " ").trim();
if (words) {
const cut = words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words;
return cut;
}
return "Location";
}
/** PDF highlights store their page inside the epubcfi_start column as a
* JSON descriptor {"v":1,"page":N,"rects":[…]} — parse it off. */
function pdfEnvelopePage(cfi: string): number | null {
if (cfi.startsWith("{")) {
try {
const a = JSON.parse(cfi);
if (a && typeof a.page === "number") return a.page;
} catch {
/* not ours */
}
}
return null;
}
function buildAnnotationRows(
notes: any[],
highlights: any[],
): { rows: AnnotationRow[]; sources: AnnotationSource[] } {
const rows: AnnotationRow[] = [];
const sources: AnnotationSource[] = [];
for (const n of notes) {
const content = n.content ?? "";
const label = friendlyAnnotationLabel(n.position, content);
const loc = n.position ?? "";
const row: AnnotationRow = {
key: `note-${n.id}`,
kind: "note",
label,
preview: stripNote(content) || "(empty note)",
rich: renderNoteHtml(content),
loc,
color: "var(--accent)",
expanded: false,
};
rows.push(row);
sources.push({
row,
source: content,
highlightText: "",
});
}
for (const h of highlights) {
const selection = (h.selection_text ?? "").trim();
const note = (h.note_text ?? "").trim();
const color = h.color || "#ffff00";
const envelopePage = pdfEnvelopePage(h.epubcfi_start ?? "");
const cfi = (h.epubcfi_start ?? "").trim();
const pct = typeof h.percentage_start === "number" ? h.percentage_start : null;
let label = "Location";
let loc = "";
if (envelopePage != null) {
label = `Page ${envelopePage + 1}`;
// ?loc= speaks 1-based pages
loc = `page:${envelopePage + 1}`;
} else if (cfi || pct != null) {
label = friendlyAnnotationLabel(cfi || null, selection);
}
if (envelopePage == null && cfi.startsWith("epubcfi(")) {
loc = `cfi:${cfi}`;
}
if (!loc && pct != null) {
label = `${Math.round(pct * 100)}%`;
}
const capped = selection.length > 80 ? selection.slice(0, 80) + "…" : selection;
// Expanded body: the full highlight text followed by its rich note.
const rich = note
? `<p style="color: var(--text-secondary); margin: 0 0 .5rem;">${escapeHtmlForRich(selection)}</p>` + renderNoteHtml(note)
: `<p style="color: var(--text-secondary);">${escapeHtmlForRich(selection) || "…"}</p>`;
const row: AnnotationRow = {
key: `hl-${h.id}`,
kind: "highlight",
label,
preview: capped || "(empty highlight)",
rich,
loc,
color,
expanded: false,
};
rows.push(row);
sources.push({ row, source: note, highlightText: selection });
}
return { rows, sources };
}
function escapeHtmlForRich(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** The export item text — identical in single and multi modes. */
function annotationItemMarkdown(source: AnnotationSource): string {
if (source.row.kind === "highlight") {
const body = source.source ? "\n\n" + source.source.trim() : "";
return `# ${source.highlightText || "(highlight)"}${body}\n`;
}
const body = source.source.trim();
return `# ${source.row.label}\n${body ? "\n" + body : ""}\n`;
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
function bannerBookTitle(): string {
const h1 = document.querySelector("h1")?.textContent?.trim();
const clean = (h1 || "").replace(/[\\/:*?"<>|]/g, "").slice(0, 80);
return clean || "book";
}
export {
buildAnnotationRows,
annotationItemMarkdown,
triggerDownload,
bannerBookTitle,
type AnnotationRow,
type AnnotationSource,
};
+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`, {
+33 -3
View File
@@ -934,12 +934,20 @@ document.addEventListener("alpine:init", () => {
this.handleKeydown(ev),
);
// Reading position comes from the database, fetched fresh at open
// (the rendered page carries no snapshot of it).
const saved = await this.fetchSavedLocation();
if (this.isFixedLayout && saved.page != null && saved.page > 0) {
// (the rendered page carries no snapshot of it) — UNLESS an
// annotations-banner jump handed one in via ?loc= (objective 7:
// the jump outranks the stored progress for this open only).
const savedLoc = await this.fetchSavedLocation();
const jump = this.consumeLocJump();
const saved = jump ?? savedLoc;
if (this.isFixedLayout && jump?.page != null) {
await this.view.init({ lastLocation: jump.page - 1 });
} else if (this.isFixedLayout && saved.page != null && saved.page > 0) {
// Fixed-layout & comics: a page index is the exact, universal locator.
// A bare number navigates directly to the section index in foliate.
await this.view.init({ lastLocation: saved.page - 1 })
} else if (jump?.cfi) {
await this.view.init({ lastLocation: jump.cfi })
} else if (saved.cfi) {
await this.view.init({ lastLocation: saved.cfi })
} else if (saved.percentage != null && saved.percentage > 0) {
@@ -2122,6 +2130,28 @@ document.addEventListener("alpine:init", () => {
return {};
}
},
/** ?loc= handoff from the book-detail annotations banner (objective 7):
* `cfi:<epubcfi>` or `page:<1-based>`; outranks the stored progress for
* this open only. Parsed once; consumed + stripped from the URL so a
* reload (or device sync / back navigation) restores normally. */
consumeLocJump(): { cfi?: string; page?: number } | null {
const params = new URLSearchParams(window.location.search);
const raw = params.get("loc");
if (!raw) return null;
params.delete("loc");
const qs = params.toString();
const base = window.location.pathname;
window.history.replaceState({}, "", base + (qs ? `?${qs}` : ""));
const value = decodeURIComponent(raw);
if (value.startsWith("cfi:")) {
return { cfi: value.slice(4) };
}
if (value.startsWith("page:")) {
const n = parseInt(value.slice(5), 10);
if (Number.isFinite(n) && n > 0) return { page: n };
}
return {};
},
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
// Only an actual change from the last stored position writes
// progress: displaying a restored position must never overwrite a
+1 -1
View File
File diff suppressed because one or more lines are too long