Add books-grid wrapper div and pagination controls to BookShelf template to fix HTMX targeting issue. Changes: - Add id="books-grid" wrapper div around BooksGrid component - Add pagination section with Previous/Next buttons - Pagination uses HTMX to target #books-grid for updates - Include #filter-form in HTMX requests to preserve filters Fixes pagination displaying inside the grid instead of below it. The wrapper div ensures HTMX replaces only the grid content, not the pagination controls. Related: Issue #2 - Fix pagination display location
420 lines
15 KiB
Templ
420 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">
|
|
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
|
<!-- 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>
|
|
<!-- 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>
|
|
</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>
|
|
}
|