Replace inline book grid and pagination HTML with reusable BooksGrid component. This eliminates 43 lines of duplicate code and follows DRY principle. - Replace inline books grid (lines 322-364) with @BooksGrid() call - Pagination now rendered by BooksGrid component - Maintains same functionality with cleaner code - Generated bookshelf_templ.go updated by templ compiler
389 lines
14 KiB
Templ
389 lines
14 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 -->
|
|
@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>
|
|
<!-- 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>
|
|
}
|