refactor: convert filter form from hidden to visible structure

Restructured the bookshelf filter form to be a proper visible form
instead of individual inputs with HTMX attributes pointing to a
hidden form.

Changes:
- Wrapped all filter inputs in a visible <form id="filter-form">
  with hx-get="/api/media-items/search" and hx-target="#books-grid"
- Removed redundant HTMX attributes from individual inputs since
  they're now part of the form
- Added "Search" submit button to explicitly trigger form submission
- Moved hidden pagination state inputs (limit, offset) inside the form
- Preserved all existing functionality: autocomplete, fuzzy search,
  saved filters, clear filters button
- Added checked attribute to has_cover checkbox for default state

This change fixes the architectural issue where filter inputs were
outside the form and relied on hx-include, which was fragile and
made form handling complex. The new structure is more maintainable
and follows standard HTML form patterns.

The form now properly includes all filter parameters when submitted,
ensuring that search, filters, and pagination work correctly together.
This commit is contained in:
2026-03-27 18:08:01 -04:00
parent ba243c223d
commit 77cbeb0bcf
2 changed files with 248 additions and 276 deletions
+236 -264
View File
@@ -37,286 +37,255 @@ templ BookShelf(
class="mb-6 card p-4 rounded-lg border" class="mb-6 card p-4 rounded-lg border"
style="background-color: var(--bg-secondary); border-color: var(--border);" style="background-color: var(--bg-secondary); border-color: var(--border);"
> >
<div class="flex flex-wrap gap-4 items-center"> <form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
<!-- Library Selector --> <div class="flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]"> <!-- Library Selector -->
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> <div class="flex-1 min-w-[200px]">
Library <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
</label> Library
<select </label>
id="library-select" <select
class="w-full px-3 py-2 border rounded-lg" id="library-select"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" class="w-full px-3 py-2 border rounded-lg"
hx-get="/api/media-items/search" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-target="#books-grid" >
hx-trigger="change" if len(libraries) == 0 {
hx-include="#filter-form" <option value="">No libraries available</option>
> } else {
if len(libraries) == 0 { for _, lib := range libraries {
<option value="">No libraries available</option> if lib.ID == currentLibraryID {
} else { <option value={ lib.ID } selected>{ lib.Name }</option>
for _, lib := range libraries { } else {
if lib.ID == currentLibraryID { <option value={ lib.ID }>{ lib.Name }</option>
<option value={ lib.ID } selected>{ lib.Name }</option> }
} else {
<option value={ lib.ID }>{ lib.Name }</option>
} }
} }
} </select>
</select> </div>
</div> <!-- Search Input -->
<!-- Search Input --> <div class="flex-1 min-w-[200px]">
<div class="flex-1 min-w-[200px]"> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> Search
Search </label>
</label>
<input
name="q"
type="text"
placeholder="Search all fields..."
hx-get="/api/media-items/search"
hx-trigger="keyup[key=='Enter'] from:#filter-form, keyup changed delay:500ms"
hx-target="#books-grid"
hx-include="#filter-form, #library-select"
/>
</div>
<!-- Author Filter with Autocomplete -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Author
</label>
<input
type="text"
name="author_filter"
placeholder="Filter by author"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-include="#filter-form"
list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
/>
<datalist id="author-datalist"></datalist>
</div>
<!-- Tags Filter with Autocomplete -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
<input
type="text"
name="tags_filter"
placeholder="Filter by tags"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-include="#filter-form"
list="tags-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
/>
<datalist id="tags-datalist"></datalist>
</div>
// <!-- Genre Filter with Autocomplete -->
// <div class="flex-1 min-w-[150px]">
// <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
// Genre
// </label>
// <input
// type="text"
// name="genre_filter"
// placeholder="Filter by genre"
// class="w-full px-3 py-2 border rounded-lg"
// style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
// hx-get="/api/media-items/search"
// hx-target="#books-grid"
// hx-trigger="keyup[key=='Enter'] from:#filter-form"
// hx-include="#filter-form"
// list="genre-datalist"
// @input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
// />
// <datalist id="genre-datalist"></datalist>
// </div>
<!-- Series Filter with Autocomplete (NEW) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Series
</label>
<input
type="text"
name="series_filter"
placeholder="Filter by series"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-include="#filter-form"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/>
<datalist id="series-datalist"></datalist>
</div>
<!-- Language Filter with Autocomplete (NEW) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Language
</label>
<input
type="text"
name="language_filter"
placeholder="Filter by language"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-include="#filter-form"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/>
<datalist id="language-datalist"></datalist>
</div>
<!-- Year Range -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Year Range
</label>
<div class="flex gap-2">
<input <input
type="number" name="q"
name="year_min" type="text"
placeholder="From" placeholder="Search all fields..."
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-include="#filter-form"
/>
<input
type="number"
name="year_max"
placeholder="To"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="keyup[key=='Enter'] from:#filter-form"
hx-include="#filter-form"
/> />
</div> </div>
</div> <!-- Author Filter with Autocomplete -->
<!-- Has Cover Filter --> <div class="flex-1 min-w-[150px]">
<div class="flex items-end"> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
<label class="flex items-center gap-2 cursor-pointer"> Author
</label>
<input <input
type="checkbox" type="text"
name="has_cover" name="author_filter"
value="true" placeholder="Filter by author"
class="w-4 h-4 rounded" class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
/>
<datalist id="author-datalist"></datalist>
</div>
<!-- Tags Filter with Autocomplete -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
<input
type="text"
name="tags_filter"
placeholder="Filter by tags"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="tags-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
/>
<datalist id="tags-datalist"></datalist>
</div>
// <!-- Genre Filter with Autocomplete -->
// <div class="flex-1 min-w-[150px]">
// <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
// Genre
// </label>
// <input
// type="text"
// name="genre_filter"
// placeholder="Filter by genre"
// class="w-full px-3 py-2 border rounded-lg"
// style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
// list="genre-datalist"
// @input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
// />
// <datalist id="genre-datalist"></datalist>
// </div>
<!-- Series Filter with Autocomplete (NEW) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Series
</label>
<input
type="text"
name="series_filter"
placeholder="Filter by series"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/>
<datalist id="series-datalist"></datalist>
</div>
<!-- Language Filter with Autocomplete (NEW) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Language
</label>
<input
type="text"
name="language_filter"
placeholder="Filter by language"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/>
<datalist id="language-datalist"></datalist>
</div>
<!-- Year Range -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Year Range
</label>
<div class="flex gap-2">
<input
type="number"
name="year_min"
placeholder="From"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
<input
type="number"
name="year_max"
placeholder="To"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div>
</div>
<!-- Has Cover Filter -->
<div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input type="hidden" name="has_cover" value="false"/>
<input
type="checkbox"
name="has_cover"
value="true"
checked
class="w-4 h-4 rounded"
/>
<span class="text-sm" style="color: var(--text-primary)">Has Cover</span>
</label>
</div>
<!-- Sort By Dropdown (PRESERVED) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Sort By
</label>
<select
name="sort"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search" hx-get="/api/media-items/search"
hx-target="#books-grid" hx-target="#books-grid"
hx-trigger="change" hx-trigger="change"
hx-include="#filter-form" hx-include="#filter-form"
/> >
<span class="text-sm" style="color: var(--text-primary)">Has Cover</span> <option value="title ASC">Title (A-Z)</option>
</label> <option value="title DESC">Title (Z-A)</option>
</div> <option value="author ASC">Author (A-Z)</option>
<!-- Sort By Dropdown (PRESERVED) --> <option value="created_at DESC">Date Added</option>
<div class="flex-1 min-w-[150px]"> <option value="page_count DESC">Page Count</option>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> </select>
Sort By </div>
</label> <!-- Submit Button -->
<select <div class="flex items-end">
name="sort" <button
class="w-full px-3 py-2 border rounded-lg" type="submit"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" class="px-6 py-2 rounded-lg font-medium"
hx-get="/api/media-items/search" style="background-color: var(--accent); color: var(--bg-primary);"
hx-target="#books-grid" >
hx-trigger="change" 🔍 Search
hx-include="#filter-form" </button>
> </div>
<option value="title ASC">Title (A-Z)</option> <!-- Save Filter Button (PRESERVED) -->
<option value="title DESC">Title (Z-A)</option> <div class="flex items-end">
<option value="author ASC">Author (A-Z)</option> <button
<option value="created_at DESC">Date Added</option> @click="showSaveFilterModal()"
<option value="page_count DESC">Page Count</option> class="px-4 py-2 rounded-lg font-medium"
</select> style="background-color: var(--accent); color: var(--bg-primary);"
</div> >
<!-- Save Filter Button (PRESERVED) --> 💾 Save Filter
<div class="flex items-end"> </button>
<button </div>
@click="showSaveFilterModal()" <!-- Load Filter Button (PRESERVED) -->
class="px-4 py-2 rounded-lg font-medium" <div class="flex items-end relative">
style="background-color: var(--accent); color: var(--bg-primary);" <button
> @click="toggleFiltersDropdown()"
💾 Save Filter class="px-4 py-2 rounded-lg font-medium border"
</button> style="border-color: var(--border); color: var(--text-primary);"
</div> >
<!-- Load Filter Button (PRESERVED) --> 📂 Load Filter
<div class="flex items-end relative"> </button>
<button <!-- Saved Filters Dropdown (PRESERVED) -->
@click="toggleFiltersDropdown()" <div
class="px-4 py-2 rounded-lg font-medium border" x-show="showFiltersDropdown"
style="border-color: var(--border); color: var(--text-primary);" @click.outside="showFiltersDropdown = false"
> x-transition:enter="transition ease-out duration-200"
📂 Load Filter x-transition:enter-start="opacity-0 scale-95"
</button> x-transition:enter-end="opacity-100 scale-100"
<!-- Saved Filters Dropdown (PRESERVED) --> x-transition:leave="transition ease-in duration-150"
<div x-transition:leave-start="opacity-100 scale-100"
x-show="showFiltersDropdown" x-transition:leave-end="opacity-0 scale-95"
@click.outside="showFiltersDropdown = false" class="absolute top-full mt-2 right-0 w-80 rounded-lg shadow-lg z-50"
x-transition:enter="transition ease-out duration-200" style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
x-transition:enter-start="opacity-0 scale-95" >
x-transition:enter-end="opacity-100 scale-100" <div class="p-4">
x-transition:leave="transition ease-in duration-150" <h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">
x-transition:leave-start="opacity-100 scale-100" Saved Filters
x-transition:leave-end="opacity-0 scale-95" </h3>
class="absolute top-full mt-2 right-0 w-80 rounded-lg shadow-lg z-50" <!-- Filter List -->
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;" <div class="space-y-2" id="saved-filters-list">
> for _, filter := range savedFilters {
<div class="p-4"> filterUUID := string(filter.ID.Bytes[0:16])
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)"> <div class="flex items-center justify-between p-2 rounded hover:opacity-80" style="background-color: var(--bg-primary);" data-filter-id={ uuidToString(filter.ID) }>
Saved Filters <button data-action="load-filter" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);">
</h3> { filter.Name }
<!-- Filter List --> </button>
<div class="space-y-2" id="saved-filters-list"> <button data-action="delete-filter" class="p-1 hover:opacity-70 rounded" style="color: var(--text-secondary);" title="Delete filter">🗑️</button>
for _, filter := range savedFilters { </div>
filterUUID := string(filter.ID.Bytes[0:16]) }
<div class="flex items-center justify-between p-2 rounded hover:opacity-80" style="background-color: var(--bg-primary);" data-filter-id={ uuidToString(filter.ID) }> </div>
<button data-action="load-filter" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);"> <!-- Empty State -->
{ filter.Name } if len(savedFilters) == 0 {
</button> <div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
<button data-action="delete-filter" class="p-1 hover:opacity-70 rounded" style="color: var(--text-secondary);" title="Delete filter">🗑️</button> No saved filters yet
</div> </div>
} }
</div> </div>
<!-- Empty State -->
if len(savedFilters) == 0 {
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
No saved filters yet
</div>
}
</div> </div>
</div> </div>
<!-- Clear Filters Button (PRESERVED) -->
<div class="flex items-end">
<button
@click="clearFilters()"
class="px-4 py-2 rounded-lg font-medium border"
style="border-color: var(--border); color: var(--text-primary);"
>
Clear
</button>
</div>
</div> </div>
<!-- Clear Filters Button (PRESERVED) -->
<div class="flex items-end">
<button
@click="clearFilters()"
class="px-4 py-2 rounded-lg font-medium border"
style="border-color: var(--border); color: var(--text-primary);"
>
Clear
</button>
</div>
</div>
<!-- Hidden form for HTMX include (PRESERVED) -->
<form id="filter-form" class="hidden">
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form> </form>
</div> </div>
<!-- Books Grid --> <!-- Books Grid -->
@@ -411,6 +380,9 @@ templ BookShelf(
Save Save
</button> </button>
</div> </div>
<!-- Hidden pagination state -->
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form> </form>
</div> </div>
</div> </div>
File diff suppressed because one or more lines are too long