- 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.
385 lines
19 KiB
Templ
385 lines
19 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>
|
||
<!-- 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>
|
||
}
|