feat(web): book-detail annotations banner — ?loc= reader jumps, single/zip markdown export
Release / build-and-push (push) Successful in 3m9s
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:
@@ -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 & 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>
|
||||
}
|
||||
@@ -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 & 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+509
-474
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user