Files
bookhoard/templates/annotations_banner.templ
John O'Keefe 89665fb2bc 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.
2026-10-15 19:33:18 -04:00

224 lines
9.2 KiB
Templ

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, Highlights &amp; Bookmarks</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 annotations
</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, highlights or bookmarks 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 relative">
<!-- Note indicator: a quiet stroke glyph in the card's
upper-right corner (never left of text) — every
note row, and highlights with an attached note. -->
<svg
x-show="row.kind === 'bookmark'"
class="absolute top-2 right-2 pointer-events-none reader-icon"
style="color: var(--text-secondary);"
width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"
><path d="M5 3h10v14l-5-3.5L5 17z"></path></svg>
<svg
x-show="row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)"
class="absolute top-2 right-2 pointer-events-none reader-icon"
style="color: var(--text-secondary);"
width="13" height="13" viewBox="0 0 20 20" aria-hidden="true"
><path d="M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5"></path></svg>
<button
type="button"
class="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover"
:class="(row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)) ? 'pr-8' : ''"
@click="row.expanded = !row.expanded"
>
<span
class="w-1.5 self-stretch rounded-full shrink-0"
:style="{ backgroundColor: row.kind === 'highlight' ? row.color : 'transparent' }"
></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 · edit (notes + attached highlight notes) · delete
(location notes) — objective 8a -->
<div class="flex items-center justify-between px-4 pb-3 pt-1">
<a
x-show="row.loc"
: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 class="flex items-center gap-1">
<button
type="button"
@click="startBannerEdit(row)"
class="text-xs px-2 py-1 rounded-lg hover:bg-surface-hover"
style="color: var(--text-secondary);"
x-text="row.kind === 'note' ? 'Edit' : (row.hasNote ? 'Edit note' : 'Add note')"
></button>
<button
type="button"
@click="row.kind === 'note' ? deleteBannerNote(row) : row.kind === 'bookmark' ? deleteBannerBookmark(row) : deleteBannerHighlight(row)"
class="text-xs px-2 py-1 rounded-lg hover:bg-surface-hover"
style="color: var(--status-danger);"
>✕ Delete</button>
</div>
</div>
<!-- Expanded rich body (markdown pipeline). Highlight
rows split at the highlight→note boundary: the
selection (bordered in the row color), a divider,
then the note markdown — the break reads on its
own, no label needed. -->
<div x-show="row.expanded" class="px-4 pb-4">
<p
x-show="row.kind === 'highlight'"
class="text-sm"
:style="row.kind === 'highlight' ? 'border-left: 3px solid ' + row.color + '; padding-left: .75rem; color: var(--text-secondary); margin: 0 0 ' + (row.hasNote ? '.75rem' : '0') + ';' : 'color: var(--text-secondary);'"
x-text="row.richSelection"
></p>
<div
x-show="row.kind === 'highlight' && row.hasNote"
class="border-t mt-3 pt-3"
style="border-color: var(--border);"
></div>
<div class="note-rich text-sm" x-html="row.richNote"></div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Note edit dialog (objective 8a): raw markdown source; for
highlight rows the dialog edits the note ATTACHED to the
highlight (never the highlight itself) -->
<div
id="banner-note-dialog"
x-cloak
x-show="bannerEditOpen"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background: rgba(0,0,0,.6);"
>
<div class="max-w-md w-full card p-6">
<h4 class="text-lg font-semibold mb-1" style="color: var(--text-primary);" x-text="bannerEditRow && bannerEditRow.kind === 'highlight' ? (bannerEditRow.hasNote ? 'Edit highlight note' : 'Add note to highlight') : 'Edit note'"></h4>
<p class="text-xs mb-3" style="color: var(--text-secondary);">Markdown supported</p>
<textarea
x-model="bannerEditText"
rows="6"
class="input w-full text-sm"
></textarea>
<div class="flex gap-2 mt-4">
<button
type="button"
@click="saveBannerNote()"
:disabled="!String(bannerEditText || '').trim()"
class="flex-1 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>Save</button>
<button
type="button"
@click="bannerEditOpen = false"
class="flex-1 py-2 text-sm border rounded hover:bg-surface-hover"
style="border-color: var(--border); color: var(--text-secondary);"
>Cancel</button>
</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>
}