docs: update unified search implementation plan with completion status
- Update implementation status to reflect completed phases (1-9) - Document Section 4.3 completion (all 5 steps: SQL sort support, service sort, handler sort, template filters, TypeScript functions) - Add discovery notes about SQL duplicate ORDER BY fix and frontend.go compatibility - Note Bruno files are for API interaction, not automated testing - Document remaining work (Phase 10 manual testing) Plan provides complete roadmap for consolidating /filtered and /search endpoints into unified fuzzy search with autocomplete dropdowns.
This commit is contained in:
+535
-263
@@ -707,295 +707,567 @@ hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&
|
||||
|
||||
**Note:** Change parameter name from `search` to `q` to match backend handler expectation.
|
||||
|
||||
#### 4.3 Add Autocomplete Dropdowns with Search Button
|
||||
#### 4.3 COMPLETE IMPLEMENTATION - All Fixes in One Section
|
||||
|
||||
**IMPORTANT:** This section contains ALL code changes needed to preserve your sort/save/load features while adding autocomplete. Implement everything in this section only - no need to modify earlier phases.
|
||||
|
||||
---
|
||||
|
||||
### Step 1: Update SQL Query to Support Sort Parameter
|
||||
|
||||
**File:** `internal/database/queries/queries.sql`
|
||||
|
||||
**Find the `SearchMediaItemsUnified` query (you added this in Phase 2) and replace the ORDER BY clause:**
|
||||
|
||||
**BEFORE:**
|
||||
```sql
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN sqlc.narg('search_query') != '' THEN
|
||||
GREATEST(...)
|
||||
ELSE 0
|
||||
END DESC,
|
||||
mi.title ASC
|
||||
```
|
||||
|
||||
**AFTER:**
|
||||
```sql
|
||||
ORDER BY
|
||||
-- Primary sort: relevance score when searching
|
||||
CASE
|
||||
WHEN sqlc.narg('search_query') != '' THEN
|
||||
GREATEST(
|
||||
CASE WHEN sqlc.narg('is_exact_search') = false THEN
|
||||
word_similarity(sqlc.narg('search_query'), mi.title)
|
||||
ELSE 0 END,
|
||||
CASE WHEN sqlc.narg('is_exact_search') = false THEN
|
||||
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))
|
||||
ELSE 0 END,
|
||||
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
|
||||
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, ''))
|
||||
)
|
||||
ELSE 0
|
||||
END DESC,
|
||||
-- Secondary sort: user-specified sort parameter
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
-- Tertiary sort: title (default fallback)
|
||||
mi.title ASC
|
||||
```
|
||||
|
||||
**Regenerate Go code:**
|
||||
```bash
|
||||
sqlc generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Update Search Service to Support Sort
|
||||
|
||||
**File:** `internal/services/search.go`
|
||||
|
||||
**Add `Sort` field to `SearchParams` struct:**
|
||||
|
||||
```go
|
||||
// SearchParams contains parameters for unified search
|
||||
type SearchParams struct {
|
||||
UserID pgtype.UUID
|
||||
LibraryID pgtype.UUID
|
||||
AuthorFilter string
|
||||
SeriesFilter string
|
||||
GenreFilter string
|
||||
LanguageFilter string
|
||||
YearMin int
|
||||
YearMax int
|
||||
HasCover bool
|
||||
SearchQuery string
|
||||
Sort string // ADD THIS LINE
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
```
|
||||
|
||||
**Update the database parameters in `SearchMediaItemsUnified` method:**
|
||||
|
||||
```go
|
||||
// Build database parameters
|
||||
dbParams := database.SearchMediaItemsUnifiedParams{
|
||||
UserID: params.UserID,
|
||||
LibraryID: params.LibraryID,
|
||||
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
|
||||
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
|
||||
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
HasCover: pgtype.Bool{Bool: params.HasCover, Valid: true},
|
||||
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
|
||||
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
|
||||
SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
|
||||
Sort: pgtype.Text{String: params.Sort, Valid: true}, // ADD THIS LINE
|
||||
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Update Handler to Extract Sort Parameter
|
||||
|
||||
**File:** `internal/handlers/media.go`
|
||||
|
||||
**In the `SearchMediaItems` handler (you added this in Phase 4.1), add sort parameter extraction:**
|
||||
|
||||
**Find this section:**
|
||||
```go
|
||||
hasCover := c.QueryParam("has_cover") == "true"
|
||||
|
||||
// Build search params
|
||||
params := services.SearchParams{
|
||||
```
|
||||
|
||||
**Replace with:**
|
||||
```go
|
||||
hasCover := c.QueryParam("has_cover") == "true"
|
||||
|
||||
// Extract sort parameter
|
||||
sortParam := c.QueryParam("sort")
|
||||
if sortParam == "" {
|
||||
sortParam = "title ASC" // Default sort
|
||||
}
|
||||
|
||||
// Build search params
|
||||
params := services.SearchParams{
|
||||
```
|
||||
|
||||
**Then add `Sort` to the params struct:**
|
||||
```go
|
||||
params := services.SearchParams{
|
||||
UserID: userID.ID,
|
||||
LibraryID: libUUID,
|
||||
SearchQuery: query,
|
||||
AuthorFilter: authorFilter,
|
||||
SeriesFilter: seriesFilter,
|
||||
GenreFilter: genreFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: hasCover,
|
||||
Sort: sortParam, // ADD THIS LINE
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Update Template with All Filters + Autocomplete
|
||||
|
||||
**File:** `templates/bookshelf.templ`
|
||||
|
||||
**Replace filter form section (includes all filters) with (lines 40-262):**
|
||||
**Replace lines 83-262 (from Search input through end of filter bar) with:**
|
||||
|
||||
```html
|
||||
<!-- Filter Form with Autocomplete + Search Button -->
|
||||
<form id="filter-form">
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
<!-- Author Filter -->
|
||||
<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);"
|
||||
list="author-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<datalist id="author-datalist"></datalist>
|
||||
</div>
|
||||
|
||||
<!-- Genre Filter -->
|
||||
<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)"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<datalist id="genre-datalist"></datalist>
|
||||
</div>
|
||||
|
||||
<!-- Series Filter -->
|
||||
<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)"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<datalist id="series-datalist"></datalist>
|
||||
</div>
|
||||
|
||||
<!-- Language Filter -->
|
||||
<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)"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<datalist id="language-datalist"></datalist>
|
||||
</div>
|
||||
|
||||
<!-- Year Range Filters -->
|
||||
<div class="flex-1 min-w-[100px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Year From
|
||||
</label>
|
||||
<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);"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[100px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Year To
|
||||
</label>
|
||||
<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-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Has Cover Filter -->
|
||||
<div class="flex-1 min-w-[120px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Has Cover
|
||||
</label>
|
||||
<select
|
||||
name="has_cover"
|
||||
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-trigger="change from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Button -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#library-select"
|
||||
onclick="document.querySelectorAll('#filter-form input').forEach(i => i.value = '')"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Search Input -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Search
|
||||
</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>
|
||||
<!-- 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
|
||||
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);"
|
||||
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>
|
||||
<!-- Has Cover Filter -->
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="has_cover"
|
||||
value="true"
|
||||
class="w-4 h-4 rounded"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<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-target="#books-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#filter-form"
|
||||
>
|
||||
<option value="title ASC">Title (A-Z)</option>
|
||||
<option value="title DESC">Title (Z-A)</option>
|
||||
<option value="author ASC">Author (A-Z)</option>
|
||||
<option value="created_at DESC">Date Added</option>
|
||||
<option value="page_count DESC">Page Count</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Save Filter Button (PRESERVED) -->
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="showSaveFilterModal()"
|
||||
class="px-4 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
>
|
||||
💾 Save Filter
|
||||
</button>
|
||||
</div>
|
||||
<!-- Load Filter Button (PRESERVED) -->
|
||||
<div class="flex items-end relative">
|
||||
<button
|
||||
@click="toggleFiltersDropdown()"
|
||||
class="px-4 py-2 rounded-lg font-medium border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
📂 Load Filter
|
||||
</button>
|
||||
<!-- Saved Filters Dropdown (PRESERVED) -->
|
||||
<div
|
||||
x-show="showFiltersDropdown"
|
||||
@click.outside="showFiltersDropdown = false"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute top-full mt-2 right-0 w-80 rounded-lg shadow-lg z-50"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
||||
>
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">
|
||||
Saved Filters
|
||||
</h3>
|
||||
<!-- Filter List -->
|
||||
<div class="space-y-2" id="saved-filters-list">
|
||||
for _, filter := range savedFilters {
|
||||
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) }>
|
||||
<button data-action="load-filter" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);">
|
||||
{ filter.Name }
|
||||
</button>
|
||||
<button data-action="delete-filter" class="p-1 hover:opacity-70 rounded" style="color: var(--text-secondary);" title="Delete filter">🗑️</button>
|
||||
</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>
|
||||
<!-- 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>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Key changes:**
|
||||
- ❌ No Alpine.js `x-data` wrapper (removed)
|
||||
- ❌ No `hx-trigger="change"` (no immediate filtering)
|
||||
- ✅ Added `@input.debounce.300ms` for autocomplete (300ms delay)
|
||||
- ✅ Added `hx-trigger="keyup[key=='Enter'] from:#filter-form"` (Enter key only)
|
||||
- ✅ All filter inputs wrapped in `<form id="filter-form">`
|
||||
- ✅ Search button with `hx-get` to trigger filtering
|
||||
- ✅ Clear filters button to reset all inputs
|
||||
- ✅ Empty `<datalist>` elements (populated by JavaScript)
|
||||
- ✅ `list="author-datalist"` attribute for native HTML5 autocomplete
|
||||
---
|
||||
|
||||
#### 4.4 Add Frontend Functions
|
||||
### Step 5: Add Autocomplete Functions to TypeScript
|
||||
|
||||
**File:** `web/src/bookshelf.ts`
|
||||
|
||||
**Add at end of file:**
|
||||
**Add these functions to the existing `Alpine.data("bookshelf", () => ({ ... }))` component:**
|
||||
|
||||
```typescript
|
||||
// Fetch field values for autocomplete dropdowns
|
||||
async function fetchFieldValues(
|
||||
field: string,
|
||||
search: string,
|
||||
datalistId: string
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
console.error("Not authenticated");
|
||||
return;
|
||||
}
|
||||
Alpine.data("bookshelf", () => ({
|
||||
// ... existing state and methods ...
|
||||
|
||||
if (search.length < 2) return; // Wait for at least 2 characters
|
||||
// Autocomplete helper function
|
||||
async fetchFieldValues(
|
||||
field: string,
|
||||
search: string,
|
||||
datalistId: string
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
console.error("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLibraryId = (
|
||||
document.getElementById("library-select") as HTMLSelectElement
|
||||
)?.value;
|
||||
if (!currentLibraryId) {
|
||||
console.error("No library selected");
|
||||
return;
|
||||
}
|
||||
if (search.length < 2) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/search?${field}=${encodeURIComponent(
|
||||
search
|
||||
)}&library_id=${currentLibraryId}&limit=50`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
const currentLibraryId = (
|
||||
document.getElementById("library-select") as HTMLSelectElement
|
||||
)?.value;
|
||||
if (!currentLibraryId) {
|
||||
console.error("No library selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/search?${field}=${encodeURIComponent(
|
||||
search
|
||||
)}&library_id=${currentLibraryId}&limit=50`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch field values");
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch field values");
|
||||
return;
|
||||
const data = await response.json();
|
||||
|
||||
// Update datalist via DOM manipulation
|
||||
const datalist = document.getElementById(datalistId);
|
||||
if (!datalist) {
|
||||
console.error(`Datalist ${datalistId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing options
|
||||
datalist.innerHTML = "";
|
||||
|
||||
// Add new options
|
||||
data.results.forEach((item: { value: string; count: number }) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.value;
|
||||
option.textContent = `${item.value} (${item.count})`;
|
||||
datalist.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching field values:", error);
|
||||
}
|
||||
},
|
||||
|
||||
const data = await response.json();
|
||||
// Fetch author values for autocomplete
|
||||
async fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("author", input.value, "author-datalist");
|
||||
},
|
||||
|
||||
// Update datalist
|
||||
const datalist = document.getElementById(datalistId);
|
||||
if (!datalist) {
|
||||
console.error(`Datalist ${datalistId} not found`);
|
||||
return;
|
||||
}
|
||||
// Fetch genre values for autocomplete
|
||||
async fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("genre", input.value, "genre-datalist");
|
||||
},
|
||||
|
||||
// Clear existing options
|
||||
datalist.innerHTML = "";
|
||||
// Fetch series values for autocomplete
|
||||
async fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("series", input.value, "series-datalist");
|
||||
},
|
||||
|
||||
// Add new options
|
||||
data.results.forEach((item: { value: string; count: number }) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.value;
|
||||
option.textContent = `${item.value} (${item.count})`;
|
||||
datalist.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching field values:", error);
|
||||
}
|
||||
}
|
||||
// Fetch language values for autocomplete
|
||||
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("language", input.value, "language-datalist");
|
||||
},
|
||||
|
||||
// Fetch author values for autocomplete
|
||||
async function fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
||||
const search = input.value;
|
||||
await fetchFieldValues("author", search, "author-datalist");
|
||||
}
|
||||
|
||||
// Fetch genre values for autocomplete
|
||||
async function fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
||||
const search = input.value;
|
||||
await fetchFieldValues("genre", search, "genre-datalist");
|
||||
}
|
||||
|
||||
// Fetch series values for autocomplete
|
||||
async function fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
||||
const search = input.value;
|
||||
await fetchFieldValues("series", search, "series-datalist");
|
||||
}
|
||||
|
||||
// Fetch language values for autocomplete
|
||||
async function fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
||||
const search = input.value;
|
||||
await fetchFieldValues("language", search, "language-datalist");
|
||||
}
|
||||
|
||||
// Register functions globally
|
||||
(window as any).fetchAuthorValues = fetchAuthorValues;
|
||||
(window as any).fetchGenreValues = fetchGenreValues;
|
||||
(window as any).fetchSeriesValues = fetchSeriesValues;
|
||||
(window as any).fetchLanguageValues = fetchLanguageValues;
|
||||
// ... rest of existing methods ...
|
||||
}))
|
||||
```
|
||||
|
||||
**Verify TypeScript compilation:**
|
||||
```bash
|
||||
cd web && npm run build
|
||||
```
|
||||
**Note:** These are regular methods (not reactive), so they won't trigger template re-renders. They just manipulate the DOM directly to populate the `<datalist>` elements. The functions are available in the template because they're part of the Alpine component scope.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
**What this section does:**
|
||||
1. ✅ Adds sort parameter support to SQL query (Step 1)
|
||||
2. ✅ Updates service to pass sort parameter (Step 2)
|
||||
3. ✅ Updates handler to extract sort parameter (Step 3)
|
||||
4. ✅ Adds series and language filters with autocomplete (Step 4)
|
||||
5. ✅ Preserves sort dropdown, save/load buttons, modal (Step 4)
|
||||
6. ✅ Changes triggers to Enter key instead of instant (Step 4)
|
||||
7. ✅ Adds TypeScript autocomplete functions using simple pattern (Step 5)
|
||||
|
||||
**What's preserved:**
|
||||
- ✅ Your sort dropdown (all 5 options)
|
||||
- ✅ Save Filter button and modal
|
||||
- ✅ Load Filter button and dropdown
|
||||
- ✅ Clear Filters button
|
||||
- ✅ Your existing save/load filter logic
|
||||
|
||||
**What's new:**
|
||||
- ✅ Series filter with autocomplete
|
||||
- ✅ Language filter with autocomplete
|
||||
- ✅ Genre filter autocomplete
|
||||
- ✅ Sort parameter support in unified search
|
||||
- ✅ **Simple autocomplete pattern**: Empty `<datalist>` elements populated via DOM manipulation (no Alpine.js reactive state)
|
||||
|
||||
#### 4.4 NOTE - All Frontend Code is in Section 4.3
|
||||
|
||||
All TypeScript autocomplete functions are included in **Section 4.3, Step 5** above.
|
||||
|
||||
No additional changes needed to `web/src/bookshelf.ts` beyond what's in Section 4.3.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user