feat(reader): note length counter and save guards in the reader UI

Oversized notes previously failed only at save time, surfacing the
raw backend validator error to the reader. The reader now handles
the limit inline and keeps oversized input editable:

- Live character counter under both note textareas (annotations
  drawer "Add a note" and the selection popover note editor),
  formatted "12,345/100,000" — muted normally, red once over
- Pasting is never truncated; the full text stays in the textarea
  so the reader can shrink it however they see fit
- The three save actions (Add Note, Highlight with note, Save
  note) disable while over the limit, with a toast fallback in
  addNote / createHighlight / saveHighlightChanges so the guard
  holds even outside the disabled-button path
- noteMaxLength mirrors the server-side cap (100,000, see
  internal/handlers/media.go) so client and server stay linked

Also truncate note rows in the drawer list — highlight rows
already truncated, but a long note previously stretched the
drawer body.

The resize handle needed no work: .reader-note-input already
ships resize: vertical in the compiled CSS; style.css is rebuilt
only for the new disabled:* utility classes.
This commit is contained in:
John O'Keefe
2026-10-02 21:31:13 -04:00
parent 1205526422
commit 66dd6c8096
4 changed files with 60 additions and 12 deletions
+31
View File
@@ -446,6 +446,10 @@ document.addEventListener("alpine:init", () => {
noteItems: [] as { id: string; content: string; positionLabel: string }[],
annotationsTab: "highlights" as string,
newNoteText: "",
// Mirrors the server-side validator cap on note content
// (internal/handlers/media.go). Enforced client-side so oversized
// input is caught before a doomed POST.
noteMaxLength: 100000 as number,
highlightColors: HIGHLIGHT_COLORS,
searchOpen: false,
searchQuery: "",
@@ -1652,6 +1656,13 @@ document.addEventListener("alpine:init", () => {
// The note editor writes into p.note live; "Highlight with note"
// creates the highlight AND its note in one POST.
const note = p.note.trim();
if (this.noteOverLimit(note)) {
showToast(
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${note.length.toLocaleString()}). Shrink it and try again.`,
"error",
);
return;
}
const pdfAnchor =
p.pdfPage >= 0
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
@@ -1721,6 +1732,13 @@ document.addEventListener("alpine:init", () => {
const p = this.selectionPopover;
const token = getToken();
if (!token || !this.mediaItemId || !p.id) return;
if (this.noteOverLimit(p.note)) {
showToast(
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${p.note.length.toLocaleString()}). Shrink it and try again.`,
"error",
);
return;
}
const anchor =
p.pdfPage >= 0
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
@@ -1997,9 +2015,22 @@ document.addEventListener("alpine:init", () => {
this.closeDrawers();
}
},
noteOverLimit(text: string): boolean {
return text.length > this.noteMaxLength;
},
noteCountLabel(text: string): string {
return `${text.length.toLocaleString()}/${this.noteMaxLength.toLocaleString()}`;
},
async addNote(content: string) {
const token = getToken();
if (!token || !this.mediaItemId || !content.trim()) return;
if (this.noteOverLimit(content)) {
showToast(
`Note exceeds the ${this.noteMaxLength.toLocaleString()} character limit (${content.length.toLocaleString()}). Shrink it and try again.`,
"error",
);
return;
}
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/notes`,
+1 -1
View File
File diff suppressed because one or more lines are too long