Files
bookhoard/templates/bookshelf.templ
T
John O'Keefe 4c03116ec7 fix(filters): value menu flash-on-click, empty state, sticky field filter (user report)
- The flash was a race: on localhost the suggestion fetch resolved
  between the focus (mousedown) and the click event, so the just-opened
  menu was closed by its own click-outside handler — the input sits
  outside the menu div. Clicks anywhere inside the value cell now
  never close the menu; outside clicks do.
- The menu opens immediately in a loading state; an empty browse
  renders 'No matching values' instead of nothing. Plain free-input
  fields (page_count etc.) open no menu at all.
- Field menu: the filter input moved outside the scroll region — it
  sticks while the grouped list scrolls beneath it.
2026-10-20 15:36:31 -04:00

385 lines
19 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>
<!-- The rule builder (faceted plan Phase 9): the drawer's
ONLY filter UI. Templ owns the look, Alpine owns the
rule-tree state (the sanctioned island — same pattern
as the chips bar; server JSON rendered by Alpine, no
innerHTML templating). Rows are a flat projection of
the tree (depth = indent); groups nest via + group. -->
<div x-data="filterBuilderExtra" x-cloak
x-effect="if (filtersOpen === true) { ensureReadyRule(); }"
>
<template x-for="row in builderRows" :key="row.key">
<div>
<template x-if="row.type === 'groupOpen'">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="`margin-left:${row.depth * 14}px`">
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
<span class="flex-1"></span>
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
<button type="button" class="fb-mini-btn" @click="addGroup(row.path)">+ group</button>
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove group">×</button>
</div>
</template>
<template x-if="row.type === 'rule'">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="`margin-left:${(row.depth + 1) * 14}px`">
<button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="if (row.chipClickable) toggleRuleConnective(row.path)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button>
<!-- field picker: grouped menu -->
<div class="relative">
<button type="button" class="fb-picker" :class="{'fb-picker-active': row.rule.field !== ''}" @click="toggleMenu(row.key + ':field')" x-text="fieldLabelOf(row.rule.field)"></button>
<div class="fb-menu fb-menu-fields" x-show="openMenu === row.key + ':field'" @click.outside="openMenu = ''">
<div class="fb-menu-filter-wrap">
<input type="text" class="fb-menu-filter" placeholder="Filter fields…" x-model="fieldFilter" @keydown.enter.prevent @click.stop/>
</div>
<div class="fb-menu-scroll">
<template x-for="grp in visibleFieldGroups" :key="grp.group">
<div>
<div class="fb-menu-group" x-text="grp.group"></div>
<template x-for="f in grp.fields" :key="f.key">
<button type="button" class="fb-menu-item" :class="row.rule.field === f.key ? 'fb-menu-item-active' : ''" @click="setRuleField(row.path, f.key)" x-text="f.label"></button>
</template>
</div>
</template>
<div class="fb-menu-empty" x-show="visibleFieldGroups.length === 0">No matching fields</div>
</div>
</div>
</div>
<!-- operator picker (negation lives here) -->
<div class="relative">
<button type="button" class="fb-picker fb-picker-op" :class="{'fb-negated-text': isNegated(row.rule)}" @click="toggleMenu(row.key + ':op')" x-text="opLabelOf(row.rule)"></button>
<div class="fb-menu" x-show="openMenu === row.key + ':op'" @click.outside="openMenu = ''">
<template x-for="o in opsFor(row.rule.field)" :key="o.key">
<button type="button" class="fb-menu-item" :class="[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']" @click="setRuleOp(row.path, o.key)" x-text="o.label"></button>
</template>
</div>
</div>
<!-- value cell: shape follows the operator + field kind -->
<template x-if="hasValueCell(row.rule) && valueArity(row.rule) === 'two'">
<span class="flex items-center gap-1">
<input type="text" class="input fb-value" :value="row.rule.values[0] || ''" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @click.outside="suggestionsOpen = ''" placeholder="From"/>
<span class="text-xs" style="color: var(--text-secondary)">and</span>
<input type="text" class="input fb-value" :value="row.rule.values[1] || ''" @input.debounce.250ms="setRuleValue(row.path, 1, $el.value)" placeholder="To"/>
</span>
</template>
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
<span class="fb-value-cell relative flex items-center gap-1">
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'"/>
<template x-if="fieldMeta(row.rule.field)?.valueKind === 'days'">
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
</template>
<!-- field-aware value menu: server JSON (the other
rules bound), or the field's fixed options.
Clicks anywhere inside the value cell never close
it — the opening focus-click must not race the
outside handler. -->
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = ''">
<template x-for="s in (suggestions[row.key] || [])" :key="s.value">
<button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)">
<span x-text="s.display || s.value"></span>
<span class="text-xs" style="color: var(--text-secondary)" x-show="s.count > 0" x-text="s.count + ' books'"></span>
</button>
</template>
<div class="fb-menu-empty" x-show="suggestionsLoading === row.key">Loading…</div>
<div class="fb-menu-empty" x-show="suggestionsLoading !== row.key && !(suggestions[row.key] || []).length">No matching values</div>
</div>
</span>
</template>
<!-- delete rule -->
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove rule">×</button>
</div>
</template>
<template x-if="row.type === 'groupClose' && row.depth > 0">
<div class="fb-group-end" :style="`margin-left:${row.depth * 14}px`"></div>
</template>
</div>
</template>
<!-- footer: add rule/group + the debounced live count
(the composed engine's true COUNT — what the grid will
show once applied) -->
<div class="flex items-center gap-2 pt-2 flex-wrap">
<button type="button" class="fb-mini-btn" @click="addRule([])">+ Rule</button>
<button type="button" class="fb-mini-btn" @click="addGroup([])">+ Group</button>
<span class="flex-1"></span>
<span class="text-xs" style="color: var(--text-secondary)" x-show="matchCount !== null" x-text="matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 book matches' : matchCount + ' books match')"></span>
</div>
</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"
:class="chip.negated ? 'fb-chip-negated' : ''"
:style="chip.negated
? 'background-color: color-mix(in srgb, var(--status-danger) 12%, transparent); color: var(--status-danger); border: 1px solid color-mix(in srgb, var(--status-danger) 40%, transparent);'
: '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>
}