The UI had no surface showing how many media items have been imported. Surface the total in the library switcher shown on the Dashboard, Series, and Collections pages (via the LibrarySwitcher component) and in the Bookshelf's inline library filter. - Add a MediaCount field to LibraryData and a TotalMediaCount helper to sum counts for the "All Libraries" / "All Books" option. - resolveLibrary() now fetches per-library counts (one query) and maps them onto each LibraryData entry, so the switcher reflects the active scope without changing the component's signature. - Each library option renders "(N)" and the "All" option renders the grand total across the user's visible libraries. The "All" total is the sum of the user's visible libraries, correctly respecting per-user library visibility rather than a raw global count. Regenerated templ files for library_switcher and bookshelf.
440 lines
17 KiB
Templ
440 lines
17 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);"
|
|
>
|
|
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
|
|
<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"
|
|
name="library_id"
|
|
class="w-full px-3 py-2 border rounded-lg"
|
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
>
|
|
if len(libraries) == 0 {
|
|
<option value="">No libraries available</option>
|
|
} else {
|
|
if currentLibraryID == "" {
|
|
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
|
|
} else {
|
|
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
|
}
|
|
for _, lib := range libraries {
|
|
if lib.ID == currentLibraryID {
|
|
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
|
} else {
|
|
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
|
}
|
|
}
|
|
}
|
|
</select>
|
|
</div>
|
|
<!-- Search Input -->
|
|
<div class="flex-1 min-w-[150px]">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Search
|
|
</label>
|
|
<input
|
|
name="q"
|
|
type="text"
|
|
class="w-full px-3 py-2 border rounded-lg"
|
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
|
placeholder="Search all fields..."
|
|
/>
|
|
</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);"
|
|
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] relative">
|
|
<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);"
|
|
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
|
@keydown="onTagFilterKeydown($event)"
|
|
@blur="hideTagDropdown()"
|
|
/>
|
|
<div
|
|
x-show="showTagDropdown"
|
|
x-transition
|
|
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
|
>
|
|
<template x-for="sug in tagSuggestions" :key="sug.value">
|
|
<button
|
|
type="button"
|
|
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
|
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
|
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
|
@click="selectTagSuggestion(sug.value)"
|
|
>
|
|
<span x-text="sug.value"></span>
|
|
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</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);"
|
|
// 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);"
|
|
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);"
|
|
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);"
|
|
/>
|
|
<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);"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- Has Cover Filter - TriState Button -->
|
|
<div class="flex-1 min-w-[150px]">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Cover Filter
|
|
</label>
|
|
<input
|
|
type="button"
|
|
id="has-cover-tristate"
|
|
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
|
|
:class="{
|
|
'state-null': hasCoverState === null,
|
|
'state-true': hasCoverState === true,
|
|
'state-false': hasCoverState === false
|
|
}"
|
|
:value="hasCoverState === null ? '○ Cover: Any' : hasCoverState === true ? '✓ Has Cover' : '✗ No Cover'"
|
|
@click="cycleHasCover()"
|
|
/>
|
|
<!-- Hidden input for HTMX form submission - conditionally rendered -->
|
|
<template x-if="hasCoverState !== null">
|
|
<input
|
|
type="hidden"
|
|
name="has_cover"
|
|
:value="hasCoverState ? 'true' : 'false'"
|
|
/>
|
|
</template>
|
|
</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>
|
|
<!-- Submit Button -->
|
|
<div class="flex items-end">
|
|
<button
|
|
type="submit"
|
|
class="px-6 py-2 rounded-lg font-medium"
|
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
|
>
|
|
🔍 Search
|
|
</button>
|
|
</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()"
|
|
data-load-filter-btn
|
|
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 w-80 rounded-lg shadow-lg z-50"
|
|
:class="dropdownAlign === 'left' ? 'left-0' : 'right-0'"
|
|
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 {
|
|
<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" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);">
|
|
{ filter.Name }
|
|
</button>
|
|
<button data-action="delete-filter" @click="deleteFilter($event)" 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>
|
|
</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
|
|
hx-post="/api/saved-filters"
|
|
hx-target="#saved-filters-list"
|
|
hx-swap="beforeend"
|
|
hx-include="#filter-form"
|
|
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
|
|
>
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Filter Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="filter_name"
|
|
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 id="filter-save-error" style="display: none; color: #ef4444; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;"></div>
|
|
<input type="hidden" name="resource_type" value="media-items"/>
|
|
</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>
|
|
<!-- Hidden pagination state -->
|
|
<input type="hidden" name="limit" value="50"/>
|
|
<input type="hidden" name="offset" value="0"/>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
}
|