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:
+384
-112
@@ -707,17 +707,213 @@ hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&
|
|||||||
|
|
||||||
**Note:** Change parameter name from `search` to `q` to match backend handler expectation.
|
**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`
|
**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
|
```html
|
||||||
<!-- Filter Form with Autocomplete + Search Button -->
|
<!-- Search Input -->
|
||||||
<form id="filter-form">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<div class="flex flex-wrap gap-4 mb-4">
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
<!-- Author Filter -->
|
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]">
|
<div class="flex-1 min-w-[150px]">
|
||||||
<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)">
|
||||||
Author
|
Author
|
||||||
@@ -728,17 +924,16 @@ hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&
|
|||||||
placeholder="Filter by author"
|
placeholder="Filter by author"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
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-get="/api/media-items/search"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
|
list="author-datalist"
|
||||||
|
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||||
/>
|
/>
|
||||||
<datalist id="author-datalist"></datalist>
|
<datalist id="author-datalist"></datalist>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Genre Filter with Autocomplete -->
|
||||||
<!-- Genre Filter -->
|
|
||||||
<div class="flex-1 min-w-[150px]">
|
<div class="flex-1 min-w-[150px]">
|
||||||
<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)">
|
||||||
Genre
|
Genre
|
||||||
@@ -749,17 +944,16 @@ hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&
|
|||||||
placeholder="Filter by genre"
|
placeholder="Filter by genre"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
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-get="/api/media-items/search"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
|
list="genre-datalist"
|
||||||
|
@input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
|
||||||
/>
|
/>
|
||||||
<datalist id="genre-datalist"></datalist>
|
<datalist id="genre-datalist"></datalist>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Series Filter with Autocomplete (NEW) -->
|
||||||
<!-- Series Filter -->
|
|
||||||
<div class="flex-1 min-w-[150px]">
|
<div class="flex-1 min-w-[150px]">
|
||||||
<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)">
|
||||||
Series
|
Series
|
||||||
@@ -770,17 +964,16 @@ hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&
|
|||||||
placeholder="Filter by series"
|
placeholder="Filter by series"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
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-get="/api/media-items/search"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
|
list="series-datalist"
|
||||||
|
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
||||||
/>
|
/>
|
||||||
<datalist id="series-datalist"></datalist>
|
<datalist id="series-datalist"></datalist>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Language Filter with Autocomplete (NEW) -->
|
||||||
<!-- Language Filter -->
|
|
||||||
<div class="flex-1 min-w-[150px]">
|
<div class="flex-1 min-w-[150px]">
|
||||||
<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)">
|
||||||
Language
|
Language
|
||||||
@@ -791,128 +984,183 @@ hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&
|
|||||||
placeholder="Filter by language"
|
placeholder="Filter by language"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
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-get="/api/media-items/search"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
|
list="language-datalist"
|
||||||
|
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
||||||
/>
|
/>
|
||||||
<datalist id="language-datalist"></datalist>
|
<datalist id="language-datalist"></datalist>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Year Range -->
|
||||||
<!-- Year Range Filters -->
|
<div class="flex-1 min-w-[200px]">
|
||||||
<div class="flex-1 min-w-[100px]">
|
|
||||||
<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)">
|
||||||
Year From
|
Year Range
|
||||||
</label>
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="year_min"
|
name="year_min"
|
||||||
placeholder="From"
|
placeholder="From"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
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-get="/api/media-items/search"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||||
hx-include="#filter-form"
|
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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="year_max"
|
name="year_max"
|
||||||
placeholder="To"
|
placeholder="To"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
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-get="/api/media-items/search"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Has Cover Filter -->
|
<!-- Has Cover Filter -->
|
||||||
<div class="flex-1 min-w-[120px]">
|
<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)">
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||||
Has Cover
|
Sort By
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="has_cover"
|
name="sort"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
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-get="/api/media-items/search"
|
||||||
hx-target="#books-grid"
|
hx-target="#books-grid"
|
||||||
|
hx-trigger="change"
|
||||||
hx-include="#filter-form"
|
hx-include="#filter-form"
|
||||||
>
|
>
|
||||||
<option value="">All</option>
|
<option value="title ASC">Title (A-Z)</option>
|
||||||
<option value="true">Yes</option>
|
<option value="title DESC">Title (Z-A)</option>
|
||||||
<option value="false">No</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Save Filter Button (PRESERVED) -->
|
||||||
|
<div class="flex items-end">
|
||||||
<!-- Search Button -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
@click="showSaveFilterModal()"
|
||||||
class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
class="px-4 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-include="#filter-form"
|
|
||||||
>
|
>
|
||||||
Search
|
💾 Save Filter
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<!-- 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`
|
**File:** `web/src/bookshelf.ts`
|
||||||
|
|
||||||
**Add at end of file:**
|
**Add these functions to the existing `Alpine.data("bookshelf", () => ({ ... }))` component:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Fetch field values for autocomplete dropdowns
|
Alpine.data("bookshelf", () => ({
|
||||||
async function fetchFieldValues(
|
// ... existing state and methods ...
|
||||||
|
|
||||||
|
// Autocomplete helper function
|
||||||
|
async fetchFieldValues(
|
||||||
field: string,
|
field: string,
|
||||||
search: string,
|
search: string,
|
||||||
datalistId: string
|
datalistId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.error("Not authenticated");
|
console.error("Not authenticated");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search.length < 2) return; // Wait for at least 2 characters
|
if (search.length < 2) return;
|
||||||
|
|
||||||
const currentLibraryId = (
|
const currentLibraryId = (
|
||||||
document.getElementById("library-select") as HTMLSelectElement
|
document.getElementById("library-select") as HTMLSelectElement
|
||||||
@@ -939,7 +1187,7 @@ async function fetchFieldValues(
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Update datalist
|
// Update datalist via DOM manipulation
|
||||||
const datalist = document.getElementById(datalistId);
|
const datalist = document.getElementById(datalistId);
|
||||||
if (!datalist) {
|
if (!datalist) {
|
||||||
console.error(`Datalist ${datalistId} not found`);
|
console.error(`Datalist ${datalistId} not found`);
|
||||||
@@ -959,43 +1207,67 @@ async function fetchFieldValues(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching field values:", error);
|
console.error("Error fetching field values:", error);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
// Fetch author values for autocomplete
|
// Fetch author values for autocomplete
|
||||||
async function fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
async fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
||||||
const search = input.value;
|
await this.fetchFieldValues("author", input.value, "author-datalist");
|
||||||
await fetchFieldValues("author", search, "author-datalist");
|
},
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch genre values for autocomplete
|
// Fetch genre values for autocomplete
|
||||||
async function fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
async fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
||||||
const search = input.value;
|
await this.fetchFieldValues("genre", input.value, "genre-datalist");
|
||||||
await fetchFieldValues("genre", search, "genre-datalist");
|
},
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch series values for autocomplete
|
// Fetch series values for autocomplete
|
||||||
async function fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
async fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
||||||
const search = input.value;
|
await this.fetchFieldValues("series", input.value, "series-datalist");
|
||||||
await fetchFieldValues("series", search, "series-datalist");
|
},
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch language values for autocomplete
|
// Fetch language values for autocomplete
|
||||||
async function fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
||||||
const search = input.value;
|
await this.fetchFieldValues("language", input.value, "language-datalist");
|
||||||
await fetchFieldValues("language", search, "language-datalist");
|
},
|
||||||
}
|
|
||||||
|
|
||||||
// Register functions globally
|
// ... rest of existing methods ...
|
||||||
(window as any).fetchAuthorValues = fetchAuthorValues;
|
}))
|
||||||
(window as any).fetchGenreValues = fetchGenreValues;
|
|
||||||
(window as any).fetchSeriesValues = fetchSeriesValues;
|
|
||||||
(window as any).fetchLanguageValues = fetchLanguageValues;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Verify TypeScript compilation:**
|
**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.
|
||||||
```bash
|
|
||||||
cd web && npm run build
|
|
||||||
```
|
---
|
||||||
|
|
||||||
|
## 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