From 504f145f647be8287cbf11d67184ec8cadc971cf Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 10 May 2026 16:13:07 -0400 Subject: [PATCH] feat(metadata-editor): replace tags text input with badge picker + autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the comma-separated text input for tags in the metadata editor with a badge-based tag picker: - Current tags shown as removable pill badges (✕ button per tag) - Autocomplete input queries existing tags via shared tag-dropdown module - Results shown as inline dropdown (not absolute, avoids overflow clipping from the modal's overflow-y-auto content area) - Keyboard navigation: ArrowUp/Down, Enter to select, comma to add new, Escape to close - Supports adding new tags not in the database (type + Enter/comma) - Hidden data-editor-tag spans seed initial tags from server-side render - collectFormData() now accepts editorTags array, skips the removed tags text input --- templates/book_detail_modals.templ | 48 +- templates/book_detail_modals_templ.go | 608 +++++++++++++------------- web/src/book-detail.ts | 96 +++- 3 files changed, 440 insertions(+), 312 deletions(-) diff --git a/templates/book_detail_modals.templ b/templates/book_detail_modals.templ index fa645bf..043dfcb 100644 --- a/templates/book_detail_modals.templ +++ b/templates/book_detail_modals.templ @@ -267,10 +267,50 @@ templ MetadataEditorModal(book handlers.MediaDetail) { >{ textToString(book.Summary) }
- - + +
+ +
+ for _, tag := range book.Tags { + + } +
+ +
+ +
+
diff --git a/templates/book_detail_modals_templ.go b/templates/book_detail_modals_templ.go index 471b134..2e7381f 100644 --- a/templates/book_detail_modals_templ.go +++ b/templates/book_detail_modals_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -354,11 +354,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.CoverImagePath.String) + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 184, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -367,11 +367,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 185, Col: 24} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -385,11 +385,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 191, Col: 24} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -403,11 +403,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 245, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -416,11 +416,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Author)) + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author)) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 251, Col: 74} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -450,448 +450,458 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tagSliceToString(book.Tags)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 271, Col: 74} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\">
Right to Left
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index b1d72e8..9e05909 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -1,6 +1,7 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; import { generateCoverBlob } from "./cover-generator"; +import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown"; function getMediaId(): string { const parts = window.location.pathname.split("/"); @@ -46,7 +47,7 @@ function hideMetadataEditor(): void { if (modal) modal.classList.add("hidden"); } -function collectFormData(): Record { +function collectFormData(editorTags: string[]): Record { const modal = document.getElementById("metadata-editor-modal"); if (!modal) return {}; @@ -55,6 +56,7 @@ function collectFormData(): Record { "input[name], textarea[name], select[name]", ); for (const el of inputs) { + if (el.name === "tags") continue; if (el.type === "number") { const val = parseFloat(el.value); data[el.name] = isNaN(val) ? 0 : val; @@ -65,13 +67,7 @@ function collectFormData(): Record { } } - const tagsStr = data.tags as string; - if (typeof tagsStr === "string") { - data.tags = tagsStr - .split(",") - .map((t: string) => t.trim()) - .filter(Boolean); - } + data.tags = editorTags; const contribStr = data.contributors as string; if (typeof contribStr === "string") { @@ -124,6 +120,13 @@ Alpine.data("bookDetail", () => { const coverSrc = coverPreviewEl?.src || ""; const fileUrl = coverSrc && !coverSrc.includes("placeholder") ? coverSrc : ""; + const initialTags: string[] = []; + const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]"); + tagBadges.forEach((el) => { + const tag = el.getAttribute("data-editor-tag"); + if (tag) initialTags.push(tag); + }); + return { openSections: { basic: true } as Record, coverGenerating: false, @@ -132,6 +135,11 @@ Alpine.data("bookDetail", () => { coverFile: null as Blob | null, coverAction: "keep", saving: false, + editorTags: initialTags, + tagSearch: "", + tagSuggestions: [] as TagSuggestion[], + showTagDropdown: false, + highlightedTagIndex: -1, showReaderPlaceholder, showMetadataEditor, @@ -237,7 +245,7 @@ Alpine.data("bookDetail", () => { try { const mediaId = getMediaId(); - const formData = collectFormData(); + const formData = collectFormData(this.editorTags); formData.cover_action = this.coverAction; if (this.coverFile) { @@ -291,5 +299,75 @@ Alpine.data("bookDetail", () => { this.saving = false; } }, + + async searchEditorTags() { + const libraryId = document.body.getAttribute("data-library-id") || ""; + if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) { + this.tagSuggestions = []; + this.showTagDropdown = false; + return; + } + const results = await searchTagSuggestions(this.tagSearch, libraryId); + const current = this.editorTags.map((t: string) => t.toLowerCase()); + this.tagSuggestions = results.filter((r: TagSuggestion) => !current.includes(r.value.toLowerCase())); + this.showTagDropdown = this.tagSuggestions.length > 0; + this.highlightedTagIndex = -1; + }, + + addEditorTag(tag: string) { + const trimmed = tag.trim(); + if (!trimmed) return; + const lower = trimmed.toLowerCase(); + if (this.editorTags.some((t: string) => t.toLowerCase() === lower)) return; + this.editorTags.push(trimmed); + this.tagSearch = ""; + this.showTagDropdown = false; + this.tagSuggestions = []; + this.highlightedTagIndex = -1; + }, + + removeEditorTag(index: number) { + this.editorTags.splice(index, 1); + }, + + selectEditorTagSuggestion(tag: string) { + this.addEditorTag(tag); + }, + + hideEditorTagDropdown() { + setTimeout(() => { this.showTagDropdown = false; }, 200); + }, + + onTagKeydown(event: KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + if (this.showTagDropdown && this.tagSuggestions.length > 0) { + this.highlightedTagIndex = (this.highlightedTagIndex + 1) % this.tagSuggestions.length; + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + if (this.showTagDropdown && this.tagSuggestions.length > 0) { + this.highlightedTagIndex = this.highlightedTagIndex <= 0 + ? this.tagSuggestions.length - 1 + : this.highlightedTagIndex - 1; + } + } else if (event.key === "Enter") { + event.preventDefault(); + if (this.highlightedTagIndex >= 0 && this.showTagDropdown) { + this.addEditorTag(this.tagSuggestions[this.highlightedTagIndex].value); + } else if (this.tagSearch.trim()) { + this.addEditorTag(this.tagSearch); + } + } else if (event.key === ",") { + event.preventDefault(); + if (this.tagSearch.trim()) { + this.addEditorTag(this.tagSearch); + } + } else if (event.key === "Escape") { + this.showTagDropdown = false; + this.tagSuggestions = []; + this.highlightedTagIndex = -1; + } + }, } as MetadataEditorState; });