Release / build-and-push (push) Successful in 4m54s
Book cards on the library (all-books) page were oversized at full window width, and both the all-books and series grids showed only 2 columns on phones, making covers unnecessarily large on mobile. Shift the all-books column ladder up one at every breakpoint (2/4/5/6 -> 3/5/6/7) so the responsive reflow the grid is liked for is preserved while every window size gets smaller cards; the series grid gains a column at mobile only (2 -> 3), leaving md/lg untouched per request. Dashboard carousel sizing (w-36/w-40) and the series stacked-covers component are deliberately unchanged. templ codegen and the tailwind build output commit with the source change that produced them (repo convention).
384 lines
19 KiB
Templ
384 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>
|
||
<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="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-3 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 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>
|
||
}
|