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
This commit is contained in:
+169
-287
@@ -3,6 +3,7 @@ package templates
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ BookShelf(
|
||||
@@ -11,10 +12,10 @@ templ BookShelf(
|
||||
currentLibraryID string,
|
||||
errorMessage string,
|
||||
savedFilters []database.SavedFilters,
|
||||
books []handlers.BookInfo, // NEW
|
||||
limit int, // NEW
|
||||
offset int, // NEW
|
||||
count int, // NEW
|
||||
books []handlers.BookInfo,
|
||||
limit int,
|
||||
offset int,
|
||||
count int,
|
||||
) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -25,31 +26,28 @@ templ BookShelf(
|
||||
<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()"
|
||||
>
|
||||
<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);"
|
||||
>
|
||||
<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 {
|
||||
@@ -66,328 +64,235 @@ templ BookShelf(
|
||||
}
|
||||
}
|
||||
}
|
||||
</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)">
|
||||
</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 (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>
|
||||
|
||||
<!-- 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
|
||||
</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..."
|
||||
/>
|
||||
</button>
|
||||
</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>
|
||||
</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"
|
||||
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>
|
||||
<!-- 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"
|
||||
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-cloak
|
||||
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);"
|
||||
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="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);'"
|
||||
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 x-text="sug.value"></span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
||||
<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>
|
||||
// <!-- 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>
|
||||
<!-- 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"
|
||||
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>
|
||||
<!-- 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"
|
||||
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>
|
||||
<!-- 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"
|
||||
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);"
|
||||
/>
|
||||
<input type="number" name="year_min" class="input" placeholder="From"/>
|
||||
<input type="number" name="year_max" class="input" placeholder="To"/>
|
||||
</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>
|
||||
<!-- 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 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'"
|
||||
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()"
|
||||
/>
|
||||
<!-- Hidden input for HTMX form submission - conditionally rendered -->
|
||||
<template x-if="hasCoverState !== null">
|
||||
<input
|
||||
type="hidden"
|
||||
name="has_cover"
|
||||
:value="hasCoverState ? 'true' : 'false'"
|
||||
/>
|
||||
<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">
|
||||
</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)
|
||||
<!-- 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 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-6 flex justify-center gap-2">
|
||||
<div id="pagination" class="mt-8 flex items-center justify-center gap-3">
|
||||
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 }"
|
||||
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 }
|
||||
>
|
||||
← Previous
|
||||
@Icon("chevron-left", "h-4 w-4")
|
||||
Previous
|
||||
</button>
|
||||
<span class="px-4 py-2" style="color: var(--text-secondary);">
|
||||
Page { offset / limit + 1 }
|
||||
</span>
|
||||
<span class="text-sm text-content-muted">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 }"
|
||||
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 →
|
||||
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"
|
||||
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style="background-color: var(--surface-overlay); 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 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"
|
||||
@@ -397,38 +302,15 @@ templ BookShelf(
|
||||
@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>
|
||||
<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="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>
|
||||
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
<!-- Hidden pagination state -->
|
||||
<input type="hidden" name="limit" value="50"/>
|
||||
<input type="hidden" name="offset" value="0"/>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user