Bring the tighten-ui brand treatment into new-ui:
- Replace the emoji book (📚) in the sidebar header with the book-open
icon rendered in the theme accent color, matching the tighten-ui
header brand (templates/header.templ).
- Add web/static/favicon.svg (book-open glyph, tokyo-night accent
#7aa2f7 stroke) and reference it from the <head> of all 27 page
templates, so the favicon is present on login/setup/error pages too.
- Regenerate templ output for all affected templates.
343 lines
14 KiB
Templ
343 lines
14 KiB
Templ
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>
|
|
<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.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)">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>
|
|
<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>
|
|
}
|