Files
bookhoard/templates/bookshelf.templ
T
john-okeefe 28e40e302c feat(ui): rebuild shared chrome and core browse/read pages
- Header: sticky app-wide, active nav highlighting, data-driven theme
  picker with active swatch, unified SVG icons, deduped login fragment
  (was duplicated verbatim for desktop + mobile), removed stray console.log
- LibrarySwitcher: cleaner sticky bar, SVG action icons, theme-aware
- BookCard: the atomic unit now has a real surface (was floating text
  when wood-paneling was off due to an undefined --wood-border), with
  cohesive shadow + hover lift; reused across dashboard/bookshelf/series
- Dashboard: calmer section headers, snap carousels with SVG chevrons
- Bookshelf: recompose the filter wall into a search toolbar + collapsible
  Filters panel (all 9 filters + save/load/clear preserved), redesigned
  empty state and pagination
- Book detail, series, collections, browse: cohesive cards, SVG action
  icons, uppercase muted labels, theme-aware badges/links
2026-08-05 16:01:09 -04:00

322 lines
12 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"/>
</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" class="space-y-3">
<!-- Toolbar -->
<div class="card p-3">
<div class="flex flex-wrap items-center gap-2">
<!-- Search (prominent) -->
<div class="relative min-w-[14rem] flex-1">
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
@Icon("search", "h-4 w-4")
</div>
<input
name="q"
type="text"
class="input pl-9"
placeholder="Search all fields…"
/>
</div>
<!-- Library -->
<select name="library_id" class="input w-auto">
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>
<!-- Sort -->
<select
name="sort"
class="input w-auto"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="change"
hx-include="#filter-form"
>
<option value="title ASC">Title (AZ)</option>
<option value="title DESC">Title (ZA)</option>
<option value="author ASC">Author (AZ)</option>
<option value="created_at DESC">Date Added</option>
<option value="page_count DESC">Page Count</option>
</select>
<!-- Filters toggle -->
<button type="button" @click="filtersOpen = !filtersOpen" class="btn btn-secondary">
@Icon("filter", "h-4 w-4")
Filters
</button>
<div class="ml-auto flex items-center gap-1">
<button type="button" @click="showSaveFilterModal()" class="btn btn-ghost" title="Save filter">
@Icon("save", "h-4 w-4")
</button>
<div class="relative">
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-ghost" title="Load filter">
@Icon("folder", "h-4 w-4")
</button>
<!-- Saved filters dropdown -->
<div
x-show="showFiltersDropdown"
x-cloak
@click.outside="showFiltersDropdown = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line bg-surface-raised p-3 shadow-pop"
style="display: none;"
>
<p class="pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">Saved Filters</p>
<div class="space-y-1" id="saved-filters-list">
for _, filter := range savedFilters {
<div class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 truncate text-left text-sm text-content">{ filter.Name }</button>
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-7 w-7" title="Delete filter">
@Icon("trash", "h-4 w-4")
</button>
</div>
}
if len(savedFilters) == 0 {
<p class="py-3 text-center text-sm text-content-muted">No saved filters yet</p>
}
</div>
</div>
</div>
<button type="button" @click="clearFilters()" class="btn btn-ghost" title="Clear filters">
@Icon("close", "h-4 w-4")
</button>
<button type="submit" class="btn btn-primary">
@Icon("search", "h-4 w-4")
Search
</button>
</div>
</div>
</div>
<!-- Collapsible filters panel -->
<div x-show="filtersOpen" x-cloak x-transition class="card p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Author -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Author</label>
<input
type="text"
name="author_filter"
class="input"
placeholder="Filter by author"
list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
/>
<datalist id="author-datalist"></datalist>
</div>
<!-- Tags -->
<div class="relative">
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Tags</label>
<input
type="text"
name="tags_filter"
class="input"
placeholder="Filter by tags"
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
@keydown="onTagFilterKeydown($event)"
@blur="hideTagDropdown()"
/>
<div
x-show="showTagDropdown"
x-cloak
x-transition
class="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-line bg-surface-raised shadow-pop"
style="display: none;"
>
<template x-for="sug in tagSuggestions" :key="sug.value">
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-surface-hover"
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''"
@click="selectTagSuggestion(sug.value)"
>
<span class="text-content" x-text="sug.value"></span>
<span class="text-xs text-content-muted" x-text="sug.count + ' books'"></span>
</button>
</template>
</div>
</div>
<!-- Series -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Series</label>
<input
type="text"
name="series_filter"
class="input"
placeholder="Filter by series"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/>
<datalist id="series-datalist"></datalist>
</div>
<!-- Language -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Language</label>
<input
type="text"
name="language_filter"
class="input"
placeholder="Filter by language"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/>
<datalist id="language-datalist"></datalist>
</div>
<!-- Year range -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Year Range</label>
<div class="flex gap-2">
<input type="number" name="year_min" class="input" placeholder="From"/>
<input type="number" name="year_max" class="input" placeholder="To"/>
</div>
</div>
<!-- Cover filter -->
<div>
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Cover</label>
<input
type="button"
id="has-cover-tristate"
class="tristate-btn input cursor-pointer flex items-center justify-center gap-2 font-medium"
: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()"
/>
<template x-if="hasCoverState !== null">
<input type="hidden" name="has_cover" :value="hasCoverState ? 'true' : 'false'"/>
</template>
</div>
</div>
</div>
</form>
<!-- Books grid -->
<div id="books-grid" class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
@BooksGrid(books, limit, offset, count, currentLibraryID)
if errorMessage != "" {
<div class="col-span-full mt-2 rounded-xl border border-danger/40 bg-danger/10 p-4">
<p class="text-sm text-danger">{ errorMessage }</p>
</div>
}
</div>
<!-- Pagination -->
<div id="pagination" class="mt-8 flex items-center justify-center gap-3">
if count > 0 {
<button
type="button"
class="btn btn-secondary disabled:opacity-40"
hx-get={ "/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset-limit) }
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset <= 0 }
>
@Icon("chevron-left", "h-4 w-4")
Previous
</button>
<span class="text-sm text-content-muted">Page { offset/limit + 1 }</span>
<button
type="button"
class="btn btn-secondary disabled:opacity-40"
hx-get={ "/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset+limit) }
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset+limit >= count }
>
Next
@Icon("chevron-right", "h-4 w-4")
</button>
}
</div>
</div>
<!-- Save Filter Modal -->
<div
x-show="showSaveModal"
x-cloak
@click.self="showSaveModal = false"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="background-color: var(--surface-overlay); display: none;"
>
<div class="card w-full max-w-md p-6" @click.stop>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-content">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="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Filter Name</label>
<input type="text" name="filter_name" class="input" placeholder="My Custom Filter" required/>
<div id="filter-save-error" class="mt-2 hidden rounded-lg bg-danger/10 p-3 text-sm text-danger"></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>
}