feat(web): note editing at the reader slider and book banner; Deleted rename

Objective 8a (web half). Reader notes-drawer rows gain an inline editor
(mirroring the bookmark-rename pattern) that PUTs the new content — the
stored position rides along because the server's note PUT hard-sets the
position column (a probe note proved an omitted position NULLs the
anchor). Book-detail banner rows gain Edit/Delete for location notes
and Edit-note/Add-note for the note ATTACHED to a highlight (full-replace
highlight PUT; empty content clears the note; the highlight itself is
never edited from the banner). The edit dialog is centered with the
markdown caption.

The book-detail 'Notes' button becomes 'Deleted' (badge =
deleted-annotation count) and the modal title becomes 'Deleted
annotations' — that modal only ever showed removed annotations.

Verified live in Brave: drawer inline edit round-trips content and
position; banner note edit, highlight-note edit, and delete (tombstone
appears in the Deleted modal); rename renders with the count badge.
This commit is contained in:
John O'Keefe committed 2026-10-05 11:36:14 -04:00
1 parent 0b83db8fce
commit 5275f2ba38
11 files changed
+257 -30

No files matched your search

+54 -1
View File
@@ -78,15 +78,33 @@ templ AnnotationsBanner(book handlers.MediaDetail) {
<span class="block text-xs mt-0.5" style="color: var(--text-secondary);" x-text="row.label"></span> <span class="block text-xs mt-0.5" style="color: var(--text-secondary);" x-text="row.label"></span>
</span> </span>
</button> </button>
<!-- Jump opens the reader at the annotation's location --> <!-- Jump · edit (notes + attached highlight notes) · delete
(location notes) — objective 8a -->
<div class="flex items-center justify-between px-4 pb-3 pt-1"> <div class="flex items-center justify-between px-4 pb-3 pt-1">
<a <a
x-show="row.loc"
:href="'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(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" class="text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors"
style="border-color: var(--border); color: var(--accent);" style="border-color: var(--border); color: var(--accent);"
> >
Open reader here ↗ Open reader here ↗
</a> </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 === 'highlight' ? (row.hasNote ? 'Edit note' : 'Add note') : 'Edit'"
></button>
<button
type="button"
x-show="row.kind === 'note'"
@click="deleteBannerNote(row)"
class="text-xs px-2 py-1 rounded-lg hover:bg-surface-hover"
style="color: var(--status-danger);"
>✕ Delete</button>
</div>
</div> </div>
<!-- Expanded rich body (markdown pipeline) --> <!-- Expanded rich body (markdown pipeline) -->
<div x-show="row.expanded" class="px-4 pb-4" x-html="row.rich"></div> <div x-show="row.expanded" class="px-4 pb-4" x-html="row.rich"></div>
@@ -96,6 +114,41 @@ templ AnnotationsBanner(book handlers.MediaDetail) {
</div> </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 --> <!-- Export format dialog -->
<div <div
id="annotations-export-dialog" id="annotations-export-dialog"
+1 -1
View File
@@ -50,7 +50,7 @@ func AnnotationsBanner(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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>") 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 · edit (notes + attached highlight notes) · delete\n\t\t\t\t\t\t (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 === 'highlight' ? (row.hasNote ? 'Edit note' : 'Add note') : 'Edit'\"></button> <button type=\"button\" x-show=\"row.kind === 'note'\" @click=\"deleteBannerNote(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) --><div x-show=\"row.expanded\" class=\"px-4 pb-4\" x-html=\"row.rich\"></div></div></template></div></div></div><!-- Note edit dialog (objective 8a): raw markdown source; for\n\t highlight rows the dialog edits the note ATTACHED to the\n\t 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=\"hideExpLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+4 -4
View File
@@ -101,10 +101,10 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</button> </button>
} }
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5"> <button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4") @Icon("trash", "h-4 w-4")
<span>Notes</span> <span>Deleted</span>
if book.NotesCount + book.HighlightsCount > 0 { if len(book.DeletedAnnotations) > 0 {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span> <span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
} }
</button> </button>
<button @click="toggleAnnotationsBanner()" class="btn btn-secondary px-5 py-2.5"> <button @click="toggleAnnotationsBanner()" class="btn btn-secondary px-5 py-2.5">
+2 -2
View File
@@ -144,8 +144,8 @@ templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
@Icon("edit", "h-5 w-5") @Icon("edit", "h-5 w-5")
</span> </span>
<div> <div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Notes &amp; Highlights</h2> <h2 class="text-xl font-bold" style="color: var(--text-primary)">Deleted annotations</h2>
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p> <p class="text-sm" style="color: var(--text-secondary);">{ book.Title } — restore or purge</p>
</div> </div>
</div> </div>
<button <button
+2 -2
View File
@@ -364,7 +364,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Notes &amp; Highlights</h2><p class=\"text-sm\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Deleted annotations</h2><p class=\"text-sm\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -377,7 +377,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</p></div></div><button @click=\"hideNotesModal()\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " — restore or purge</p></div></div><button @click=\"hideNotesModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+5 -5
View File
@@ -315,23 +315,23 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Notes</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Deleted</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.NotesCount+book.HighlightsCount > 0 { if len(book.DeletedAnnotations) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"badge ml-1\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"badge ml-1\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.NotesCount + book.HighlightsCount) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 107, Col: 144} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 107, Col: 134}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+46 -14
View File
@@ -676,20 +676,52 @@ templ ReaderAnnotationsDrawer() {
+ Add Note + Add Note
</button> </button>
<div class="space-y-2"> <div class="space-y-2">
<template x-for="note in noteItems" :key="note.id"> <template x-for="note in noteItems" :key="note.id">
<div class="reader-hl-row group"> <div class="reader-hl-row group">
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700"> <template x-if="!note.editOpen">
<span class="block text-sm truncate reader-note-rich" x-html="note.contentHtml"></span> <div class="flex">
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span> <a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
</a> <span class="block text-sm truncate reader-note-rich" x-html="note.contentHtml"></span>
<button <span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
@click="deleteNoteById(note.id)" </a>
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity" <button
title="Delete note" @click="startNoteEdit(note)"
aria-label="Delete note" class="p-2 rounded hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
> title="Edit note"
✕ aria-label="Edit note"
</button> >
✎
</button>
<button
@click="deleteNoteById(note.id)"
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete note"
aria-label="Delete note"
>
✕
</button>
</div>
</template>
<!-- Inline edit: mirrors the bookmark-rename pattern -->
<div class="w-full py-2" x-show="note.editOpen" @click.stop>
<textarea
x-model="note.editText"
rows="3"
class="reader-note-input"
></textarea>
<div class="flex gap-2 mt-1">
<button
@click="saveNoteEdit(note)"
class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!String(note.editText || '').trim()"
>Save</button>
<button
@click="cancelNoteEdit(note)"
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
style="border-color: var(--border);"
>Cancel</button>
</div>
</div>
</div> </div>
</template> </template>
<template x-if="noteItems.length === 0"> <template x-if="noteItems.length === 0">
+1 -1
View File
@@ -302,7 +302,7 @@ func ReaderAnnotationsDrawer() templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"reader-tabs\"><button :class=\"annotationsTab === 'highlights' ? 'active' : ''\" @click=\"annotationsTab = 'highlights'\">Highlights <span class=\"reader-tab-count\" x-text=\"highlightItems.length\">0</span></button> <button :class=\"annotationsTab === 'notes' ? 'active' : ''\" @click=\"annotationsTab = 'notes'\">Notes <span class=\"reader-tab-count\" x-text=\"noteItems.length\">0</span></button> <button :class=\"annotationsTab === 'bookmarks' ? 'active' : ''\" @click=\"annotationsTab = 'bookmarks'\">Bookmarks <span class=\"reader-tab-count\" x-text=\"bookmarkItems.length\">0</span></button></div><div class=\"reader-drawer-body\"><!-- Highlights --><div x-show=\"annotationsTab === 'highlights'\" class=\"space-y-2\"><template x-for=\"hl in highlightItems\" :key=\"hl.id\"><div class=\"reader-hl-row group\"><a href=\"#\" @click.prevent=\"goToHighlight(hl)\" class=\"flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700\"><span class=\"block text-sm truncate\" :style=\"'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'\" x-text=\"hl.text\"></span> <span class=\"block text-xs mt-0.5 truncate pl-2\" style=\"color: var(--text-secondary)\" x-show=\"hl.note\" x-html=\"'📝 ' + hl.noteHtml\"></span></a> <button @click=\"deleteHighlightById(hl.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete highlight\" aria-label=\"Delete highlight\">✕</button></div></template><template x-if=\"highlightItems.length === 0\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Select text in the book to highlight it</p></template></div><!-- Notes --><div x-show=\"annotationsTab === 'notes'\"><textarea x-model=\"newNoteText\" rows=\"2\" class=\"reader-note-input\" placeholder=\"Add a note at the current position…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"newNoteText.length > 0\" :style=\"noteOverLimit(newNoteText) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(newNoteText)\"></div><button @click=\"addNote(newNoteText); newNoteText = ''\" :disabled=\"noteOverLimit(newNoteText)\" class=\"w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\">+ Add Note</button><div class=\"space-y-2\"><template x-for=\"note in noteItems\" :key=\"note.id\"><div class=\"reader-hl-row group\"><a href=\"#\" @click.prevent=\"closeDrawers()\" class=\"flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700\"><span class=\"block text-sm truncate reader-note-rich\" x-html=\"note.contentHtml\"></span> <span class=\"block text-xs mt-0.5 truncate\" style=\"color: var(--text-secondary)\" x-text=\"note.positionLabel\"></span></a> <button @click=\"deleteNoteById(note.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete note\" aria-label=\"Delete note\">✕</button></div></template><template x-if=\"noteItems.length === 0\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No notes yet</p></template></div></div><!-- Bookmarks --><div x-show=\"annotationsTab === 'bookmarks'\" class=\"space-y-2\"><template x-for=\"bookmark in bookmarkItems\" :key=\"bookmark.id\"><div class=\"flex items-center gap-1 group\"><a href=\"#\" @click.prevent=\"goToBookmark(bookmark)\" class=\"flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2\" x-show=\"!bookmark.renameOpen\"><span class=\"font-medium block truncate\" x-text=\"bookmark.title\"></span> <span class=\"text-xs block truncate\" style=\"color: var(--text-secondary)\" x-text=\"bookmark.positionLabel\"></span></a><div class=\"flex-1 min-w-0 py-2\" x-show=\"bookmark.renameOpen\" @click.stop><input type=\"text\" class=\"reader-note-input\" x-model=\"bookmark.renameText\" @keydown.enter=\"renameBookmark(bookmark)\" @keydown.escape=\"bookmark.renameOpen = false\" placeholder=\"Bookmark name…\"><div class=\"flex gap-2 mt-1\"><button @click=\"renameBookmark(bookmark)\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700\">Save</button> <button @click=\"bookmark.renameOpen = false\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\">Cancel</button></div></div><button @click=\"startBookmarkRename(bookmark)\" class=\"p-2 rounded hover:bg-gray-600 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Rename bookmark\" aria-label=\"Rename bookmark\">✎</button> <button @click=\"deleteBookmark(bookmark.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete bookmark\" aria-label=\"Delete bookmark\">✕</button></div></template><template x-if=\"bookmarkItems.length ==Line truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"reader-tabs\"><button :class=\"annotationsTab === 'highlights' ? 'active' : ''\" @click=\"annotationsTab = 'highlights'\">Highlights <span class=\"reader-tab-count\" x-text=\"highlightItems.length\">0</span></button> <button :class=\"annotationsTab === 'notes' ? 'active' : ''\" @click=\"annotationsTab = 'notes'\">Notes <span class=\"reader-tab-count\" x-text=\"noteItems.length\">0</span></button> <button :class=\"annotationsTab === 'bookmarks' ? 'active' : ''\" @click=\"annotationsTab = 'bookmarks'\">Bookmarks <span class=\"reader-tab-count\" x-text=\"bookmarkItems.length\">0</span></button></div><div class=\"reader-drawer-body\"><!-- Highlights --><div x-show=\"annotationsTab === 'highlights'\" class=\"space-y-2\"><template x-for=\"hl in highlightItems\" :key=\"hl.id\"><div class=\"reader-hl-row group\"><a href=\"#\" @click.prevent=\"goToHighlight(hl)\" class=\"flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700\"><span class=\"block text-sm truncate\" :style=\"'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'\" x-text=\"hl.text\"></span> <span class=\"block text-xs mt-0.5 truncate pl-2\" style=\"color: var(--text-secondary)\" x-show=\"hl.note\" x-html=\"'📝 ' + hl.noteHtml\"></span></a> <button @click=\"deleteHighlightById(hl.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete highlight\" aria-label=\"Delete highlight\">✕</button></div></template><template x-if=\"highlightItems.length === 0\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Select text in the book to highlight it</p></template></div><!-- Notes --><div x-show=\"annotationsTab === 'notes'\"><textarea x-model=\"newNoteText\" rows=\"2\" class=\"reader-note-input\" placeholder=\"Add a note at the current position…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"newNoteText.length > 0\" :style=\"noteOverLimit(newNoteText) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(newNoteText)\"></div><button @click=\"addNote(newNoteText); newNoteText = ''\" :disabled=\"noteOverLimit(newNoteText)\" class=\"w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\">+ Add Note</button><div class=\"space-y-2\"><template x-for=\"note in noteItems\" :key=\"note.id\"><div class=\"reader-hl-row group\"><template x-if=\"!note.editOpen\"><div class=\"flex\"><a href=\"#\" @click.prevent=\"closeDrawers()\" class=\"flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700\"><span class=\"block text-sm truncate reader-note-rich\" x-html=\"note.contentHtml\"></span> <span class=\"block text-xs mt-0.5 truncate\" style=\"color: var(--text-secondary)\" x-text=\"note.positionLabel\"></span></a> <button @click=\"startNoteEdit(note)\" class=\"p-2 rounded hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Edit note\" aria-label=\"Edit note\">✎</button> <button @click=\"deleteNoteById(note.id)\" class=\"p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity\" title=\"Delete note\" aria-label=\"Delete note\">✕</button></div></template><!-- Inline edit: mirrors the bookmark-rename pattern --><div class=\"w-full py-2\" x-show=\"note.editOpen\" @click.stop><textarea x-model=\"note.editText\" rows=\"3\" class=\"reader-note-input\"></textarea><div class=\"flex gap-2 mt-1\"><button @click=\"saveNoteEdit(note)\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"!String(note.editText || '').trim()\">Save</button> <button @click=\"cancelNoteEdit(note)\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\">Cancel</button></div></div></div></template><template x-if=\"noteItems.length === 0\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No notes yet</p></template></div></div><!-- Bookmarks --><div x-show=\"annotationsTab === 'bookmarks'\" class=\"space-y-2\"><template x-for=\"bookmark in bookmarkItems\" :key=\"bookmark.id\"><div class=\"flex items-center gap-1 group\"><a href=\"#\" @click.prevent=\"goToBookmark(bookmark)\" class=\"flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2\" x-show=\"!bookmark.renameOpen\"><span class=\"font-medium block truncate\" x-text=\"bookmark.title\"></span> <span class=\"text-xs block truncate\" style=\"color: var(--text-secondary)\" x-text=\"bookmark.positionLabel\"></span></a><div class=\"flex-1 min-w-0 py-2\" x-show=\"bookmark.renameOpen\" @click.stop><input type=\"text\" class=\"reader-note-input\" x-model=\"bookmark.renameText\" @keydown.enter=\"renameBookmark(bookmark)\" @keydownLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+5
View File
@@ -25,6 +25,9 @@ interface AnnotationRow {
loc: string; loc: string;
/** row accent color (swatch) */ /** row accent color (swatch) */
color: string; color: string;
/** highlight rows: whether a note rides on the highlight (Edit-note vs
* Add-note label). Note rows always true. */
hasNote: boolean;
expanded: boolean; expanded: boolean;
} }
@@ -96,6 +99,7 @@ function buildAnnotationRows(
rich: renderNoteHtml(content), rich: renderNoteHtml(content),
loc, loc,
color: "var(--accent)", color: "var(--accent)",
hasNote: true,
expanded: false, expanded: false,
}; };
rows.push(row); rows.push(row);
@@ -144,6 +148,7 @@ function buildAnnotationRows(
rich, rich,
loc, loc,
color, color,
hasNote: !!note,
expanded: false, expanded: false,
}; };
rows.push(row); rows.push(row);
+91
View File
@@ -174,6 +174,9 @@ Alpine.data("bookDetail", () => {
bannerRows: [] as AnnotationRow[], bannerRows: [] as AnnotationRow[],
bannerLoaded: false, bannerLoaded: false,
bannerError: "", bannerError: "",
bannerEditOpen: false,
bannerEditRow: null as AnnotationRow | null,
bannerEditText: "",
// raw source kept for the export builder (not exposed to the template) // raw source kept for the export builder (not exposed to the template)
bannerSources: [] as { row: AnnotationRow; source: string; highlightText: string }[], bannerSources: [] as { row: AnnotationRow; source: string; highlightText: string }[],
@@ -220,6 +223,94 @@ Alpine.data("bookDetail", () => {
this.annotationsBannerLoading = false; this.annotationsBannerLoading = false;
} }
}, },
// ----- note edit/delete at the banner (objective 8a) -----
startBannerEdit(row: AnnotationRow): void {
// Highlight rows edit the note ATTACHED to the highlight (never the
// highlight itself): rich = "<selection head>\n\n<note source>".
this.bannerEditRow = row;
this.bannerEditText =
row.kind === "highlight"
? String((row.rich.split("\n\n").slice(1).join("\n\n") || "")).trim()
: String(this.bannerSources.find(s => s.row.key === row.key)?.source ?? "");
this.bannerEditOpen = true;
},
async saveBannerNote() {
const row = this.bannerEditRow;
const content = String(this.bannerEditText || "").trim();
this.bannerEditOpen = false;
if (!row || (!content && row.kind === "note")) return;
const mediaId = getMediaId();
try {
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.
const position = (row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:")
? row.loc : undefined;
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
method: "PUT",
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ content, position }),
});
if (!resp.ok) throw new Error(String(resp.status));
} else {
// Highlight-attached note: the full-replace highlight PUT; empty
// content clears the note (allowed by the validator).
const hlId = row.key.slice("hl-".length);
const src = this.bannerSources.find(s => s.row.key === row.key);
if (content === String(src?.source ?? "")) return;
// The banner only ever changes note_text: read the highlight list,
// then full-replace the row with the new note text.
const hlListResp = await fetch(`/api/media-items/${mediaId}/highlights`, {
headers: { Authorization: getAuthHeader() },
});
if (!hlListResp.ok) throw new Error(String(hlListResp.status));
const all = await hlListResp.json();
const hl = all.find((h: any) => h.id === hlId);
if (!hl) throw new Error("highlight gone");
hl.note_text = content;
const resp = await fetch(`/api/media-items/${mediaId}/highlights/${hlId}`, {
method: "PUT",
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({
selection_text: typeof hl.selection_text === "string" ? hl.selection_text : "",
start_position: typeof (hl.start_position ?? "") === "string" ? hl.start_position : "",
end_position: typeof (hl.end_position ?? "") === "string" ? hl.end_position : "",
epubcfi_start: hl.epubcfi_start ?? "",
epubcfi_end: hl.epubcfi_end ?? "",
color: hl.color || undefined,
note_text: content,
note_id: hl.note_id ?? "",
percentage_start: hl.percentage_start ?? 0,
percentage_end: hl.percentage_end ?? 0,
chapter_reference: hl.chapter_reference ?? 0,
}),
});
if (!resp.ok) throw new Error(String(resp.status));
}
await this.fetchBannerAnnotations();
showToast("Note saved", "success");
} catch (e) {
showToast("Could not save the note", "error");
}
},
async deleteBannerNote(row: AnnotationRow) {
if (row.kind !== "note") return;
const mediaId = getMediaId();
const noteId = row.key.slice("note-".length);
try {
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) throw new Error(String(resp.status));
// Tombstoned server-side: restorable from the Deleted modal.
showToast("Note deleted — restorable from Deleted", "success");
await this.fetchBannerAnnotations();
} catch (_e) {
showToast("Could not delete the note", "error");
}
},
showExportDialog(): void { showExportDialog(): void {
if (this.bannerRows.length === 0) return; if (this.bannerRows.length === 0) return;
this.annotationsExportOpen = true; this.annotationsExportOpen = true;
+46
View File
@@ -2081,6 +2081,52 @@ document.addEventListener("alpine:init", () => {
/* ignore note errors */ /* ignore note errors */
} }
}, },
/** Note edit at the annotations slider (objective 8a): inline, mirrors
* the bookmark-rename pattern. The PUT carries the stored position —
* the server hard-sets it, so an omitted position would strip the
* jump anchor. */
startNoteEdit(note: any) {
note.editText = note.content ?? "";
note.editOpen = true;
},
cancelNoteEdit(note: any) {
note.editOpen = false;
note.editText = "";
},
async saveNoteEdit(note: any) {
const token = getToken();
if (!token || !this.mediaItemId) return;
const content = String(note.editText ?? "").trim();
if (!content) return;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/notes/${note.id}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
// The stored position MUST ride along: the server's PUT
// hard-sets the position column (an omitted position
// would strip the jump anchor).
body: JSON.stringify({
content,
position: note.positionLabel || note.content && note.position || "",
}),
},
);
if (!resp.ok) return;
const row: any = await resp.json();
note.content = row.content ?? content;
note.contentHtml = renderNoteHtml(note.content);
note.positionLabel = row.position ?? note.positionLabel;
note.editOpen = false;
note.editText = "";
} catch (_e) {
/* note errors are non-critical */
}
},
async deleteNoteById(id: string) { async deleteNoteById(id: string) {
const token = getToken(); const token = getToken();
if (!token || !this.mediaItemId) return; if (!token || !this.mediaItemId) return;