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:
1 parent
0b83db8fce
commit
5275f2ba38
11 files changed
+257
-30
No files matched your search
@@ -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>
|
||||
</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">
|
||||
<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>
|
||||
@@ -96,6 +114,41 @@ templ AnnotationsBanner(book handlers.MediaDetail) {
|
||||
</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"
|
||||
|
||||
@@ -50,7 +50,7 @@ func AnnotationsBanner(book handlers.MediaDetail) templ.Component {
|
||||
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>")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -101,10 +101,10 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</button>
|
||||
}
|
||||
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Notes</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>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Deleted</span>
|
||||
if len(book.DeletedAnnotations) > 0 {
|
||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
|
||||
}
|
||||
</button>
|
||||
<button @click="toggleAnnotationsBanner()" class="btn btn-secondary px-5 py-2.5">
|
||||
|
||||
@@ -144,8 +144,8 @@ templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
|
||||
@Icon("edit", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Notes & Highlights</h2>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
||||
<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 } — restore or purge</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -364,7 +364,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 & 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -377,7 +377,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -315,23 +315,23 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+46
-14
@@ -676,20 +676,52 @@ templ ReaderAnnotationsDrawer() {
|
||||
+ 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>
|
||||
<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">
|
||||
|
||||
@@ -302,7 +302,7 @@ func ReaderAnnotationsDrawer() templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ interface AnnotationRow {
|
||||
loc: string;
|
||||
/** row accent color (swatch) */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ function buildAnnotationRows(
|
||||
rich: renderNoteHtml(content),
|
||||
loc,
|
||||
color: "var(--accent)",
|
||||
hasNote: true,
|
||||
expanded: false,
|
||||
};
|
||||
rows.push(row);
|
||||
@@ -144,6 +148,7 @@ function buildAnnotationRows(
|
||||
rich,
|
||||
loc,
|
||||
color,
|
||||
hasNote: !!note,
|
||||
expanded: false,
|
||||
};
|
||||
rows.push(row);
|
||||
|
||||
@@ -174,6 +174,9 @@ Alpine.data("bookDetail", () => {
|
||||
bannerRows: [] as AnnotationRow[],
|
||||
bannerLoaded: false,
|
||||
bannerError: "",
|
||||
bannerEditOpen: false,
|
||||
bannerEditRow: null as AnnotationRow | null,
|
||||
bannerEditText: "",
|
||||
// raw source kept for the export builder (not exposed to the template)
|
||||
bannerSources: [] as { row: AnnotationRow; source: string; highlightText: string }[],
|
||||
|
||||
@@ -220,6 +223,94 @@ Alpine.data("bookDetail", () => {
|
||||
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 {
|
||||
if (this.bannerRows.length === 0) return;
|
||||
this.annotationsExportOpen = true;
|
||||
|
||||
@@ -2081,6 +2081,52 @@ document.addEventListener("alpine:init", () => {
|
||||
/* 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) {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId) return;
|
||||
|
||||
Reference in new issue
Block a user