From 5275f2ba383a84a61eafdba82ab8e3b20b7893db Mon Sep 17 00:00:00 2001
From: John O'Keefe
Date: Mon, 5 Oct 2026 11:36:14 -0400
Subject: [PATCH] feat(web): note editing at the reader slider and book banner;
Deleted rename
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
templates/annotations_banner.templ | 55 +++++++++++++++-
templates/annotations_banner_templ.go | 2 +-
templates/book_detail.templ | 8 +--
templates/book_detail_modals.templ | 4 +-
templates/book_detail_modals_templ.go | 4 +-
templates/book_detail_templ.go | 10 +--
templates/reader.templ | 60 +++++++++++++-----
templates/reader_templ.go | 2 +-
web/src/annotations-banner.ts | 5 ++
web/src/book-detail.ts | 91 +++++++++++++++++++++++++++
web/src/reader/reader.ts | 46 ++++++++++++++
11 files changed, 257 insertions(+), 30 deletions(-)
diff --git a/templates/annotations_banner.templ b/templates/annotations_banner.templ
index 756c170d..cedbc94d 100644
--- a/templates/annotations_banner.templ
+++ b/templates/annotations_banner.templ
@@ -78,15 +78,33 @@ templ AnnotationsBanner(book handlers.MediaDetail) {
-
+
@@ -96,6 +114,41 @@ templ AnnotationsBanner(book handlers.MediaDetail) {
+
+
+
+
+
Markdown supported
+
+
+ Save
+ Cancel
+
+
+
+
Export all notes and highlights Loading…
No notes or highlights on this book yet.
Export annotations Notes and highlights, as markdown files.
Single file — everything in one .md One file per item — zipped
Cancel ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Export all notes and highlights Loading…
No notes or highlights on this book yet.
Markdown supported
Save Cancel
Export annotations Notes and highlights, as markdown files.
Single file — everything in one .md One file per item — zipped
Cancel ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/templates/book_detail.templ b/templates/book_detail.templ
index b56135ce..20f9302e 100644
--- a/templates/book_detail.templ
+++ b/templates/book_detail.templ
@@ -101,10 +101,10 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
}
- @Icon("edit", "h-4 w-4")
- Notes
- if book.NotesCount + book.HighlightsCount > 0 {
- { book.NotesCount + book.HighlightsCount }
+ @Icon("trash", "h-4 w-4")
+ Deleted
+ if len(book.DeletedAnnotations) > 0 {
+ { len(book.DeletedAnnotations) }
}
diff --git a/templates/book_detail_modals.templ b/templates/book_detail_modals.templ
index 7ccb6fe1..8c3add39 100644
--- a/templates/book_detail_modals.templ
+++ b/templates/book_detail_modals.templ
@@ -144,8 +144,8 @@ templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
@Icon("edit", "h-5 w-5")
-
Notes & Highlights
-
{ book.Title }
+
Deleted annotations
+
{ book.Title } — restore or purge
Notes & Highlights ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
Deleted annotations ")
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, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " — restore or purge
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/templates/book_detail_templ.go b/templates/book_detail_templ.go
index dd824711..7eb3431d 100644
--- a/templates/book_detail_templ.go
+++ b/templates/book_detail_templ.go
@@ -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, "Notes ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Deleted ")
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, "")
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 {
diff --git a/templates/reader.templ b/templates/reader.templ
index cc0ed082..d9dc261b 100644
--- a/templates/reader.templ
+++ b/templates/reader.templ
@@ -676,20 +676,52 @@ templ ReaderAnnotationsDrawer() {
+ Add Note
-
-
-
-
-
-
-
- ✕
-
+
+
+
+
+
+
+
+
+
+ ✎
+
+
+ ✕
+
+
+
+
+
diff --git a/templates/reader_templ.go b/templates/reader_templ.go
index 3f9cf8a6..7948543b 100644
--- a/templates/reader_templ.go
+++ b/templates/reader_templ.go
@@ -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, "Highlights 0 Notes 0 Bookmarks 0
Select text in the book to highlight it
Markdown supported — rich-text paste converts to markdown
0\" :style=\"noteOverLimit(newNoteText) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(newNoteText)\">
+ Add Note No bookmarks yet
+ Add Bookmark ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Highlights 0 Notes 0 Bookmarks 0
Select text in the book to highlight it
Markdown supported — rich-text paste converts to markdown
0\" :style=\"noteOverLimit(newNoteText) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(newNoteText)\">
+ Add Note No bookmarks yet
+ Add Bookmark ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/src/annotations-banner.ts b/web/src/annotations-banner.ts
index 724cfd83..f6880781 100644
--- a/web/src/annotations-banner.ts
+++ b/web/src/annotations-banner.ts
@@ -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);
diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts
index 6dea10fe..009318e1 100644
--- a/web/src/book-detail.ts
+++ b/web/src/book-detail.ts
@@ -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 = "\n\n".
+ 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;
diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts
index 1194a14f..ffd5b4e7 100644
--- a/web/src/reader/reader.ts
+++ b/web/src/reader/reader.ts
@@ -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;