From fb9c47195967d3652e6c7248b8868a55ebf2ef0b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 10 May 2026 16:12:44 -0400 Subject: [PATCH] feat(bookshelf): replace broken datalist tag filter with custom autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML approach for tag autocomplete was unreliable across browsers — showed empty suggestions or no dropdown at all. Replace with a custom Alpine.js dropdown: - New tag-dropdown.ts shared module: searchTagSuggestions() queries /api/media-items/search?tags=...&library_id=... and returns results - Bookshelf: absolute-positioned dropdown below tags_filter input, shows tag name + book count per suggestion - Keyboard navigation: ArrowUp/Down to highlight, Enter to select, Escape to close - Click suggestion to populate the filter input --- templates/bookshelf.templ | 25 ++++++++++++++--- templates/bookshelf_templ.go | 24 ++++++++--------- web/src/bookshelf.ts | 52 ++++++++++++++++++++++++++++++++++-- web/src/tag-dropdown.ts | 28 +++++++++++++++++++ 4 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 web/src/tag-dropdown.ts diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index a38b3d5..f18e7cb 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -93,7 +93,7 @@ templ BookShelf( -
+
- +
+ +
// //
diff --git a/templates/bookshelf_templ.go b/templates/bookshelf_templ.go index 977c092..30f4aaa 100644 --- a/templates/bookshelf_templ.go +++ b/templates/bookshelf_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. @@ -69,11 +69,11 @@ func BookShelf( return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID) + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 58, Col: 34} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -100,11 +100,11 @@ func BookShelf( return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 60, Col: 34} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -128,7 +128,7 @@ func BookShelf( } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
= 2) fetchAuthorValues($el)\">
= 2) fetchTagValues($el)\">
= 2) fetchSeriesValues($el)\">
= 2) fetchLanguageValues($el)\">

Saved Filters

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
= 2) fetchAuthorValues($el)\">
= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\">
= 2) fetchSeriesValues($el)\">
= 2) fetchLanguageValues($el)\">

Saved Filters

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -138,11 +138,11 @@ func BookShelf( return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(uuidToString(filter.ID)) + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 277, Col: 173} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 296, Col: 173} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -153,7 +153,7 @@ func BookShelf( var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 279, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 298, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -194,7 +194,7 @@ func BookShelf( var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 313, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 332, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -227,7 +227,7 @@ func BookShelf( var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 332, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 351, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts index 9f7fd99..30438d2 100644 --- a/web/src/bookshelf.ts +++ b/web/src/bookshelf.ts @@ -2,6 +2,7 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; +import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown"; function clearFilters(): void { // Clear all form state (reuses helper) @@ -82,6 +83,9 @@ Alpine.data("bookshelf", () => ({ hasCoverState: null as boolean | null, dropdownAlign: "right" as "left" | "right", resizeTimeout: null as number | null, + tagSuggestions: [] as TagSuggestion[], + showTagDropdown: false, + tagHighlightIndex: -1, // Standalone function references (don't access component state) clearFilters, @@ -382,9 +386,53 @@ Alpine.data("bookshelf", () => ({ await this.fetchFieldValues("author", input.value, "author-datalist"); }, - // Fetch tag values for autocomplete + // Fetch tag values for autocomplete (custom dropdown) async fetchTagValues(input: HTMLInputElement): Promise { - await this.fetchFieldValues("tags", input.value, "tags-datalist"); + const libraryId = (document.getElementById("library-select") as HTMLSelectElement)?.value; + if (!libraryId || input.value.length < 2) { + this.tagSuggestions = []; + this.showTagDropdown = false; + return; + } + const results = await searchTagSuggestions(input.value, libraryId); + this.tagSuggestions = results; + this.showTagDropdown = results.length > 0; + this.tagHighlightIndex = -1; + }, + + selectTagSuggestion(tag: string) { + const input = document.querySelector('input[name="tags_filter"]') as HTMLInputElement; + if (input) input.value = tag; + this.showTagDropdown = false; + this.tagSuggestions = []; + this.tagHighlightIndex = -1; + }, + + hideTagDropdown() { + setTimeout(() => { this.showTagDropdown = false; this.tagHighlightIndex = -1; }, 200); + }, + + onTagFilterKeydown(event: KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + if (this.showTagDropdown && this.tagSuggestions.length > 0) { + this.tagHighlightIndex = (this.tagHighlightIndex + 1) % this.tagSuggestions.length; + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + if (this.showTagDropdown && this.tagSuggestions.length > 0) { + this.tagHighlightIndex = this.tagHighlightIndex <= 0 + ? this.tagSuggestions.length - 1 + : this.tagHighlightIndex - 1; + } + } else if (event.key === "Enter" && this.tagHighlightIndex >= 0 && this.showTagDropdown) { + event.preventDefault(); + this.selectTagSuggestion(this.tagSuggestions[this.tagHighlightIndex].value); + } else if (event.key === "Escape") { + this.showTagDropdown = false; + this.tagSuggestions = []; + this.tagHighlightIndex = -1; + } }, // // Fetch genre values for autocomplete diff --git a/web/src/tag-dropdown.ts b/web/src/tag-dropdown.ts new file mode 100644 index 0000000..6791081 --- /dev/null +++ b/web/src/tag-dropdown.ts @@ -0,0 +1,28 @@ +export interface TagSuggestion { + value: string; + count: number; +} + +export async function searchTagSuggestions( + query: string, + libraryId: string, +): Promise { + if (!query || query.length < 2 || !libraryId) return []; + + const token = localStorage.getItem("token"); + if (!token) return []; + + try { + const response = await fetch( + `/api/media-items/search?tags=${encodeURIComponent(query)}&library_id=${libraryId}&limit=20`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + + if (!response.ok) return []; + + const data = await response.json(); + return (data.results || []) as TagSuggestion[]; + } catch { + return []; + } +}