Files
bookhoard/templates/bookshelf.templ
T
John O'Keefe ebb7f4a5b0 feat(filters): panel removal + All-Books field-values fix (faceted plan Phase 8)
- Remove the Phase 7 facet-panel UI: SSR route, facet_panel.templ,
  FacetCountsForView, drawer panel block, panel TS + .facet-* CSS.
  The ENGINE stays (FacetCountsUnderFilters, all-facets endpoint,
  drift guard) — it becomes the Phase 9 builder's machinery.
- Fix the All-Books bug: field-values autocomplete (author/genre/
  tags/narrator/series/language) treats library_id as OPTIONAL —
  absent aggregates every VISIBLE library (family lens in-query);
  explicit out-of-set library_id is now 403, never a silent pass.
  The four scalar Search*Values queries get the nullable shape.
- toast.ts: suggestion fetches are exempt from error toasts
  (console-only) — a failed background hint never interrupts.
- TestFacetPanelPartial deleted with the behavior; TestFieldValuesAll-
  Libraries pins the fix (200 + cross-library aggregation, family
  lens, 403). Contract-pins changelog entry added.
2026-10-20 12:20:51 -04:00

389 lines
16 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"fmt"
)
templ BookShelf(
user User,
libraries []LibraryData,
currentLibraryID string,
errorMessage string,
savedFilters []database.SavedFilters,
books []handlers.BookInfo,
limit int,
offset int,
count int,
) {
<!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"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</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-6">
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<div class="relative flex-1 min-w-[200px]">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
@Icon("search", "h-4 w-4")
</span>
<input
name="q"
type="text"
class="input pl-10"
placeholder="Search all fields…"
/>
</div>
<select
name="sort"
class="input w-auto cursor-pointer"
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>
<button type="button" @click="filtersOpen = true" class="btn btn-secondary">
@Icon("filter", "h-4 w-4")
<span>Filters</span>
</button>
<button type="button" @click="showSaveFilterModal()" class="btn btn-secondary">
@Icon("save", "h-4 w-4")
<span>Save</span>
</button>
<div class="relative">
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-secondary">
@Icon("folder", "h-4 w-4")
<span>Load</span>
</button>
<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 right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl shadow-lg z-50 card p-4"
x-cloak
>
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Saved Filters</h3>
<div class="space-y-2" id="saved-filters-list">
for _, filter := range savedFilters {
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded text-sm" style="color: var(--text-primary);">
{ filter.Name }
</button>
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-8 w-8" title="Delete filter">
@Icon("trash", "h-4 w-4")
</button>
</div>
}
</div>
if len(savedFilters) == 0 {
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
No saved filters yet
</div>
}
</div>
</div>
<button type="button" @click="clearFilters()" class="btn btn-ghost">
@Icon("close", "h-4 w-4")
<span>Clear</span>
</button>
</div>
<!-- Filter drawer -->
<div
x-show="filtersOpen"
x-cloak
class="fixed inset-0 z-[65]"
@keydown.escape.window="filtersOpen = false"
>
<div class="absolute inset-0" style="background-color: var(--surface-overlay);" @click="filtersOpen = false"></div>
<div
class="absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto"
style="background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
>
<div class="sticky top-0 flex items-center justify-between px-6 py-4 border-b" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Filters</h2>
<button type="button" @click="filtersOpen = false" class="icon-btn" aria-label="Close filters">
@Icon("close", "h-5 w-5")
</button>
</div>
<div class="p-6 space-y-5">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library</label>
<!-- Do NOT wire a `change` handler (or hx-trigger="change")
on this select: header.ts restoreLibrarySelection may
fire a synthetic change on page load, and a handler
here would re-fetch the grid at offset 0, stomping
SSR pagination (?offset=). User changes go through
bookshelf.ts's own wiring only. -->
<select id="library-select" name="library_id" class="input">
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.Offline {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
}
} else 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>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label>
<input
type="text"
name="author_filter"
placeholder="Filter by author"
class="input"
list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
/>
<datalist id="author-datalist"></datalist>
</div>
<div class="relative">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label>
<input
type="text"
name="tags_filter"
placeholder="Filter by tags"
class="input"
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
@keydown="onTagFilterKeydown($event)"
@blur="hideTagDropdown()"
/>
<div
x-show="showTagDropdown"
x-transition
x-cloak
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto"
style="background-color: var(--bg-primary); border: 1px solid 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>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Series</label>
<input
type="text"
name="series_filter"
placeholder="Filter by series"
class="input"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/>
<datalist id="series-datalist"></datalist>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Narrator</label>
<input
type="text"
name="narrator_filter"
placeholder="Filter by narrator"
class="input"
list="narrator-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchNarratorValues($el)"
/>
<datalist id="narrator-datalist"></datalist>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label>
<input
type="text"
name="language_filter"
placeholder="Filter by language"
class="input"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/>
<datalist id="language-datalist"></datalist>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Year Range</label>
<div class="flex gap-2">
<input type="number" name="year_min" placeholder="From" class="input"/>
<input type="number" name="year_max" placeholder="To" class="input"/>
</div>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Cover</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 ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
@click="cycleHasCover()"
/>
<template x-if="hasCoverState !== null">
<input
type="hidden"
name="has_cover"
:value="hasCoverState ? 'true' : 'false'"
/>
</template>
</div>
</div>
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<button type="submit" @click="filtersOpen = false" class="btn btn-primary w-full">Apply Filters</button>
</div>
</div>
</div>
</form>
<!-- Active-filter chips: a pure function of the client-side
form state (Alpine renders them, templ owns the look —
sanctioned TS island). Removing a chip clears that facet
and re-applies the form. -->
<div
id="active-filter-chips"
class="flex flex-wrap items-center gap-2 mb-4"
x-show="activeFilters.length > 0"
x-cloak
>
<template x-for="chip in activeFilters" :key="chip.param">
<button
type="button"
class="badge inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity cursor-pointer"
style="background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;"
@click="removeFilterChip(chip.param)"
>
<span x-text="chip.label"></span>
<span aria-hidden="true">×</span>
</button>
</template>
</div>
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
@BooksGrid(books, limit, offset, count, currentLibraryID)
if errorMessage != "" {
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
<p style="color: var(--text-primary)">{ errorMessage }</p>
</div>
}
</div>
<div id="pagination" class="mt-6 flex justify-center items-center gap-2">
if count > 0 {
<button
type="button"
class="btn btn-secondary disabled:opacity-40"
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit) }
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset <= 0 }
>
@Icon("chevron-left", "h-4 w-4")
<span>Prev</span>
</button>
<span class="px-4 py-2 text-sm" style="color: var(--text-secondary);">
Page { offset / limit + 1 }
</span>
<button
type="button"
class="btn btn-secondary disabled:opacity-40"
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit) }
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset+limit >= count }
>
<span>Next</span>
@Icon("chevron-right", "h-4 w-4")
</button>
}
</div>
</div>
<div
x-show="showSaveModal"
@click.self="showSaveModal = false"
x-transition
x-cloak
class="fixed inset-0 z-[70] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div @click.stop class="card rounded-2xl p-6 w-full max-w-md">
<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="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</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-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label>
<input type="text" name="filter_name" placeholder="My Custom Filter" class="input" required/>
<div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></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="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form>
</div>
</div>
</body>
</html>
}