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
+20 -3
View File
@@ -179,10 +179,17 @@ templ Reader(user User, metadata ReaderMetadata) {
class="reader-note-input" class="reader-note-input"
placeholder="Note…" placeholder="Note…"
></textarea> ></textarea>
<div
class="text-xs mt-0.5 text-right"
x-show="selectionPopover.note.length > 0"
:style="noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'"
x-text="noteCountLabel(selectionPopover.note)"
></div>
<div class="flex gap-2 mt-1"> <div class="flex gap-2 mt-1">
<button <button
type="button" type="button"
class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700" 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="noteOverLimit(selectionPopover.note)"
@click="selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()" @click="selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()"
x-text="selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'" x-text="selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'"
>Save</button> >Save</button>
@@ -651,14 +658,24 @@ templ ReaderAnnotationsDrawer() {
class="reader-note-input" class="reader-note-input"
placeholder="Add a note at the current position…" placeholder="Add a note at the current position…"
></textarea> ></textarea>
<button @click="addNote(newNoteText); newNoteText = ''" class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"> <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 + Add Note
</button> </button>
<div class="space-y-2"> <div class="space-y-2">
<template x-for="note in noteItems" :key="note.id"> <template x-for="note in noteItems" :key="note.id">
<div class="reader-hl-row group"> <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"> <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" x-text="note.content"></span> <span class="block text-sm truncate" x-text="note.content"></span>
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span> <span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
</a> </a>
<button <button
File diff suppressed because one or more lines are too long
+31
View File
@@ -446,6 +446,10 @@ document.addEventListener("alpine:init", () => {
noteItems: [] as { id: string; content: string; positionLabel: string }[], noteItems: [] as { id: string; content: string; positionLabel: string }[],
annotationsTab: "highlights" as string, annotationsTab: "highlights" as string,
newNoteText: "", 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, highlightColors: HIGHLIGHT_COLORS,
searchOpen: false, searchOpen: false,
searchQuery: "", searchQuery: "",
@@ -1652,6 +1656,13 @@ document.addEventListener("alpine:init", () => {
// The note editor writes into p.note live; "Highlight with note" // The note editor writes into p.note live; "Highlight with note"
// creates the highlight AND its note in one POST. // creates the highlight AND its note in one POST.
const note = p.note.trim(); 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 = const pdfAnchor =
p.pdfPage >= 0 p.pdfPage >= 0
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
@@ -1721,6 +1732,13 @@ document.addEventListener("alpine:init", () => {
const p = this.selectionPopover; const p = this.selectionPopover;
const token = getToken(); const token = getToken();
if (!token || !this.mediaItemId || !p.id) return; 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 = const anchor =
p.pdfPage >= 0 p.pdfPage >= 0
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects }) ? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
@@ -1997,9 +2015,22 @@ document.addEventListener("alpine:init", () => {
this.closeDrawers(); 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) { async addNote(content: string) {
const token = getToken(); const token = getToken();
if (!token || !this.mediaItemId || !content.trim()) return; 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 { try {
const resp = await fetch( const resp = await fetch(
`/api/media-items/${this.mediaItemId}/notes`, `/api/media-items/${this.mediaItemId}/notes`,
+1 -1
View File
File diff suppressed because one or more lines are too long