- Add Tags filter input with autocomplete support - Update datalist from "genre-datalist" to "tags-datalist" - Update Alpine.js handler from fetchGenreValues to fetchTagValues - Genre HTML preserved in template comments for future use - Regenerate template Go files with templ generate Updates the bookshelf UI to filter by tags instead of genre, matching the Calibre data model where genre is always NULL but tags are populated. Relates to IMPLEMENTATION_TAGS_FILTER.md Phase 5
430 lines
15 KiB
Templ
430 lines
15 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
|
|
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
|
|
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>
|
|
<!-- 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>
|
|
}
|