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:
+20
-3
@@ -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
@@ -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`,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user