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
@@ -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