Files
bookhoard/templates/bookshelf.templ
T
john-okeefe a2c7062690 feat: update templates to use unified search endpoint with autocomplete
This commit migrates the frontend templates from the deprecated
/api/media-items/filtered endpoint to the new unified /api/media-items/search
endpoint and adds initial autocomplete support for the author filter.

**Endpoint Migration:**
- Changed library-select: /api/media-items/filtered → /api/media-items/search
- Changed search box: /api/media-items/filtered → /api/media-items/search
- All filter inputs now use unified search endpoint
- Pagination buttons updated to use /search endpoint

**Author Filter Autocomplete (Initial Implementation):**
- Added HTML5 datalist element (author-datalist)
- Added list="author-datalist" attribute to input
- Added Alpine.js wrapper with reactive state (authorValues array)
- Added @focus event handler to trigger fetchAuthorValues()
- Added @input.debounce.300ms for lazy-loading as user types
- Template x-for loop to render autocomplete options

**Current Implementation Notes:**
- Uses Alpine.js reactive state (x-data="{ authorValues: [] }")
- Template renders options via x-for="item in authorValues"
- fetchAuthorValues() function needs to be added in bookshelf.ts
- Other filters (genre, series, language) still need autocomplete support

**Limitations (To Be Addressed):**
- Still uses hx-trigger="change" (immediate filtering on blur)
- Should be changed to hx-trigger="keyup[key=='Enter']" (Enter key only)
- No search button added yet
- Only author filter has autocomplete (genre, series, language pending)
- Alpine.js state may conflict with native DOM manipulation in TypeScript

**Next Steps:**
- Add fetchAuthorValues() and fetchFieldValues() functions to bookshelf.ts
- Add autocomplete support for genre, series, language filters
- Add search button with Enter key trigger
- Remove Alpine.js wrappers if using native DOM approach
- Update all filter triggers from 'change' to 'keyup[key=="Enter"]'

**Migration Path:**
This is a transitional commit. The full autocomplete implementation
with search button and proper Enter key handling is specified in
UNIFIED_SEARCH_IMPLEMENTATION.md Phase 4.2-4.4.
2026-03-23 21:02:44 -04:00

379 lines
13 KiB
Templ

package templates
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
)
templ BookShelf(
user User,
libraries []LibraryData,
currentLibraryID string,
errorMessage string,
savedFilters []database.SavedFilters,
books []handlers.BookInfo, // NEW
limit int, // NEW
offset int, // NEW
count int, // NEW
) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Library - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body
class="theme-{ user.Theme }"
x-data="bookshelf"
x-init="initBookshelf()"
>
@Header(user, "/bookshelf")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<!-- Filter Bar -->
<div
class="mb-6 card p-4 rounded-lg border"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div class="flex flex-wrap gap-4 items-center">
<!-- Library Selector -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Library
</label>
<select
id="library-select"
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"
>
if len(libraries) == 0 {
<option value="">No libraries available</option>
} else {
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
}
}
</select>
</div>
<!-- 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
type="text"
name="search"
placeholder="Search title, 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 changed delay:300ms"
hx-include="#filter-form"
/>
</div>
<!-- Author Filter -->
<!-- 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>
<div class="relative" x-data="{ authorValues: [] }">
<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="change"
hx-include="#filter-form"
list="author-datalist"
@focus="fetchAuthorValues($el)"
@input.debounce.300ms="if($el.value === '') authorValues = []"
/>
<datalist id="author-datalist">
<template x-for="item in authorValues" :key="item.value">
<option :value="item.value" x-text="`${item.value} (${item.count})`"></option>
</template>
</datalist>
</div>
</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);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="change"
hx-include="#filter-form"
/>
</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="change"
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="change"
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 -->
<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 -->
<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 -->
<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 -->
<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 -->
<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 -->
<form id="filter-form" class="hidden">
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form>
</div>
<!-- Books Grid -->
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
if len(books) > 0 {
for _, book := range books {
@BookCard(book)
}
} else {
<!-- Empty state -->
<div class="col-span-full text-center py-12" style="color: var(--text-secondary);">
<p class="text-lg mb-2">📚 No books found</p>
<p class="text-sm">Try adjusting your filters or add some books to your library.</p>
</div>
}
</div>
<!-- Pagination -->
<div id="pagination" class="mt-6 flex justify-center gap-2">
if count > 0 {
<button
type="button"
class="px-4 py-2 rounded-lg border disabled:opacity-50"
style="border-color: var(--border); color: var(--text-primary);"
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset <= 0 }
>
Previous
</button>
<span class="px-4 py-2" style="color: var(--text-secondary);">
Page { offset / limit + 1 }
</span>
<button
class="px-4 py-2 rounded-lg border disabled:opacity-50"
style="border-color: var(--border); color: var(--text-primary);"
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }"
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset+limit >= count }
>
Next
</button>
}
</div>
<!-- Error Message -->
if errorMessage != "" {
<div class="mt-6 p-4 rounded-lg border bg-red-500/10 border-red-500">
<p style="color: var(--text-primary)">{ errorMessage }</p>
</div>
}
</div>
<!-- Save Filter Modal -->
<div
x-show="showSaveModal"
@click.self="showSaveModal = false"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
>
<div
@click.stop
class="card rounded-lg p-6 w-full max-w-md mx-4"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
<button
@click="showSaveModal = false"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-primary)"
></button>
</div>
<form @submit="saveFilter($event)">
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Filter Name
</label>
<input
type="text"
x-model="filterName"
placeholder="My Custom Filter"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
required
/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
@click="showSaveModal = false"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
>
Save
</button>
</div>
</form>
</div>
</div>
</body>
</html>
}