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

+5
View File
@@ -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);
+91
View File
@@ -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;
+46
View File
@@ -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;