fix(filter-builder): id labels resolve at render — refresh keeps names

Refresh showed raw uuids in id-kind value cells (feed/library/
collection/added_by). Two defects, one story:

1. Only ids persist in filter trees; the client's id→label cache dies
   with the page. The quiet re-resolution fetch reused the suggest
   facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items +
   the tree's sibling rules as a constraint) — so an id whose media
   doesn't match got no row and no label. Live repro: "feed in [A,B,C]
   AND title contains Chapter" labeled one feed of three (live-request
   evidence: 200 with total:1). A label is a property of the entity
   row, not of what currently matches.

   Fix: pages that render a tree resolve labels SERVER-SIDE at render
   and embed the map on the mount (data-id-labels) — the collection
   editor (stored tree) and the bookshelf (URL qf). New
   SearchService.FilterTreeLabels reads the entity tables with no
   media join and no tree constraint; scoping rides the request
   identity through the scoped handle (podcast_feeds/collections RLS —
   an invisible id resolves to nothing; pinned), libraries mirror the
   facet's visibility join. A feed with ZERO episodes resolves its
   title — pinned; that case was structurally impossible before.

2. The bookshelf drawer never hydrated at page load at all: the only
   load-time hydrate calls live in the PARENT's x-init
   (initBookshelf), and Alpine initializes parents before nested
   components — builderData() was null there, so hydrateFromForm
   no-oped and a URL-qf refresh left the drawer with an empty seeded
   rule while the grid stayed filtered. The drawer now hydrates in its
   OWN init when the mount carries no data-tree; the init-order
   contract is pinned in vitest (185/185).

The quiet suggest fetch remains only as a fallback for trees the SSR
never saw; embedded labels make it a no-op on refresh. Interactive
menus are untouched (their constrained, count-bearing behavior is
correct for choosing).

Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render,
bookshelf qf library label, hidden library NOT labeled);
filter/episode-tree/search-contract/RLS/playlist container slices
green; contract_pins entry documents the embed contract.
This commit is contained in:
John O'Keefe committed 2026-11-01 10:44:48 -05:00
1 parent c448fa438f
commit 65f257f377
12 files changed
+533 -80

No files matched your search

+126
View File
@@ -2208,3 +2208,129 @@ func TestEpisodeCollectionsFormSeedCreate(t *testing.T) {
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"scope":"episodes"`, "the seeded scope round-trips")
}
// ── SSR id-label resolution (2026-11-01) ─────────────────────────────
// Pages rendering a filter tree embed the id→name map at render time
// (data-id-labels). The pre-fix resolution path — the media-constrained
// suggest facet — could only label ids whose media matches the tree's
// SIBLING rules: live repro "feed in [A,B] AND title contains X"
// labeled one feed of three. The resolver reads the entity tables: a
// feed with ZERO episodes labels fine; a hidden library does not.
func TestRulesPageEmbedsIdLabels(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test Labels Podcasts")
marker := fmt.Sprintf("labelsembed%d", time.Now().UnixNano()%100000)
// Feed A: subscribed, one downloaded episode matching the sibling
// rule. Feed B: subscribed, ZERO episodes — no media row exists, so
// the facet path structurally could never label it.
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "Labels Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "Labels Feed B " + marker,
})
require.NoError(t, err)
aEp := upsertTestEpisode(t, setup, feedA, marker+"-a", "Labels A Ep "+marker, 1)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000
WHERE id = $1`, aEp.ID)
subscribeRaw(t, setup, regUUID, feedA.ID)
subscribeRaw(t, setup, regUUID, feedB.ID)
feedAID := uuid.UUID(feedA.ID.Bytes).String()
feedBID := uuid.UUID(feedB.ID.Bytes).String()
feedBTitle := "Labels Feed B " + marker
feedATitle := "Labels Feed A " + marker
// The user's exact shape: feed in [A,B] AND title contains "…".
tree := map[string]interface{}{
"scope": "episodes", "mode": "all",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{feedAID + "," + feedBID}},
{"field": "title", "op": "contains", "values": []string{"Labels A Ep"}},
},
}
treeJSON, _ := json.Marshal(tree)
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Labels Coll `+marker+`","filter_tree":`+string(treeJSON)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
t.Run("rules_page_labels_zero_episode_feed", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+coll.ID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
pageBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
html := string(pageBody)
require.Contains(t, html, "data-id-labels", "the embed attribute renders")
// Feed B's title can ONLY come from the labels embed — the feed
// has no media, so nothing else on the page can name it.
assert.Contains(t, html, feedBTitle,
"a zero-episode feed resolves its label at render")
assert.Contains(t, html, feedATitle)
})
// Books-scope: a URL-carried qf library rule embeds the library's
// name — until the family lens hides that library, at which point
// the resolver must NOT name it (the facet's visibility shape).
// Library creation is admin-only; the regular user still SEES the
// library (default-ALLOW visibility) until the subtest hides it.
booksLibID := createTestLibrary(t, setup.Server, setup.Token, "test Labels Books Lib "+marker)
libUUID := uuid.MustParse(booksLibID)
qf := url.QueryEscape(`{"scope":"books","mode":"all","children":[` +
`{"field":"library","op":"is","values":["` + booksLibID + `"]}]}`)
bookshelf := func() string {
req, _ := http.NewRequest("GET", setup.Server.URL+"/bookshelf?qf="+qf, nil)
req.Header.Set("Authorization", "Bearer "+regToken)
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
pageBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
return string(pageBody)
}
t.Run("bookshelf_labels_url_qf_library", func(t *testing.T) {
html := bookshelf()
require.Contains(t, html, "data-id-labels")
assert.Contains(t, html, "test Labels Books Lib "+marker,
"the qf library rule's id resolves to its name at render")
})
t.Run("hidden_library_not_labeled", func(t *testing.T) {
seedExec(t, setup.DBPool,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
pgtype.UUID{Bytes: libUUID, Valid: true},
pgtype.UUID{Bytes: [16]byte(regUUID), Valid: true})
t.Cleanup(func() {
seedExec(t, setup.DBPool,
`DELETE FROM library_visibility WHERE library_id = $1 AND user_id = $2`,
pgtype.UUID{Bytes: libUUID, Valid: true},
pgtype.UUID{Bytes: [16]byte(regUUID), Valid: true})
})
html := bookshelf()
assert.NotContains(t, html, "test Labels Books Lib "+marker,
"a family-lens-hidden library is not named, even by explicit id")
})
}
+30
View File
@@ -619,3 +619,33 @@ offered-vocabulary narrowing, not a parse change:
- Client id-cells are uniform: every operator on an id-valued field
accepts a set, so all id cells say "pick one or more" and accumulate
picks — no per-operator placeholder/pick distinction to explain.
## 2026-11-01 — id labels resolve at render (SSR embed): refresh keeps names
Pages that render a filter tree embed the tree's id→name map at render
time (`data-id-labels` on the builder mount) — the collection editor
(stored tree) and the bookshelf (URL `qf`). Before this, refresh showed
raw uuids: only ids persist in trees; the client's id→label cache dies
with the page, and the quiet suggest fetch reused the MEDIA-CONSTRAINED
facet, so an id whose media doesn't match the tree's sibling rules got
no row and no label (live repro: `feed in [A,B,C] AND title contains
Chapter` labeled one feed of three — two feeds had no "Chapter"
episodes).
- `SearchService.FilterTreeLabels` reads the ENTITY tables (no media
join, no tree constraint): podcast_feeds and collections are
RLS-governed through the scoped handle (invisible ids resolve to
nothing); libraries mirror the facet's visibility join; added_by is a
plain username lookup. A zero-episode feed resolves its title —
pinned (TestRulesPageEmbedsIdLabels), as is the hidden-library
non-resolution.
- The bookshelf drawer also HYDRATES at component init now (no
data-tree mount → read the form): the parent's x-init
(initBookshelf) runs BEFORE the nested builder component exists
(Alpine initializes parents first), so its builderData() call was
null and a URL-qf refresh left the drawer with an empty seeded rule
while the grid stayed filtered. Init-order contract pinned in
filter_builder.test.ts.
- The quiet suggest fetch remains only as a fallback for trees the SSR
never saw (client-side apply flows); embedded labels make it a no-op
on refresh.
+5
View File
@@ -243,6 +243,11 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se
// ExecuteSearch performs search and returns results with count
// Public wrapper for shared search logic used by both JSON and HTML endpoints
// SearchService exposes the shared search engine for SSR surfaces
// that render a filter tree (the collection editor and bookshelf pages
// resolve id labels through it — 2026-11-01).
func (h *MediaHandler) SearchService() *services.SearchService { return h.searchService }
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
return h.searchService.ExecuteSearch(ctx, params)
}
+21 -2
View File
@@ -409,9 +409,19 @@ func registerFrontendRoutes(cfg *Config) {
}
}
// SSR id-label resolution for a URL-carried qf tree: the drawer's
// value cells show NAMES from first paint instead of raw uuids
// (2026-11-01 — same story as the collection editor above).
labelsJSON := "{}"
if qfRaw := c.QueryParam("qf"); qfRaw != "" {
if t, perr := services.ParseFilterTree(qfRaw); perr == nil {
labelsJSON = resolveTreeLabelsJSON(c, cfg, user.ID, t)
}
}
var buf bytes.Buffer
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount, labelsJSON).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -706,12 +716,14 @@ func registerFrontendRoutes(cfg *Config) {
// with the rules-array storage — the tree is the only form.)
scope := "books"
treeJSON := "{}"
var parsedTree *services.FilterTree
if len(collection.FilterTree) > 0 {
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
scope = t.FilterScope()
if b, merr := json.Marshal(t); merr == nil {
treeJSON = string(b)
}
parsedTree = t
}
}
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
@@ -720,6 +732,13 @@ func registerFrontendRoutes(cfg *Config) {
}
fieldsJSON := string(fieldsPayload)
// SSR id-label resolution: the stored tree arrives as bare ids;
// embed the id→name map so value cells render NAMES at first
// paint (2026-11-01: the client's pick-time cache dies with the
// page, and the quiet suggest fetch can't label ids whose media
// doesn't match the tree's sibling rules — raw uuids on refresh).
labelsJSON := resolveTreeLabelsJSON(c, cfg, user.ID, parsedTree)
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
@@ -731,7 +750,7 @@ func registerFrontendRoutes(cfg *Config) {
}
var buf bytes.Buffer
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope).Render(c.Request().Context(), &buf)
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope, labelsJSON).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
+26
View File
@@ -297,6 +297,32 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
// detail page and the books-grid partial: smart evaluation, a dashboard
// query section, or manual membership. Callers resolve the scope:
// libUUID/libScope/isAll/scoped per their own resolution rules.
// resolveTreeLabelsJSON — SSR id-label resolution for a page-rendered
// filter tree: walks the tree's id-kind rules, resolves uuids to display
// names through the search engine (RLS/visibility ride the request
// identity), and returns the JSON map embedded into the builder mount.
// Best-effort: any failure yields "{}" and the page renders with the
// client's fallbacks intact (2026-11-01, filter_labels.go for the WHY).
func resolveTreeLabelsJSON(c *echo.Context, cfg *Config, userID string, tree *services.FilterTree) string {
if tree == nil || len(tree.Children) == 0 {
return "{}"
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return "{}"
}
labels := cfg.MediaHandler.SearchService().FilterTreeLabels(
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}, tree)
if len(labels) == 0 {
return "{}"
}
b, merr := json.Marshal(labels)
if merr != nil {
return "{}"
}
return string(b)
}
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
colService := cfg.CollectionHandler.CollectionService()
isSmart = services.IsCollectionSmart(collection)
+121
View File
@@ -0,0 +1,121 @@
package services
import (
"context"
"log"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// ════════════════════════════════════════════════════════════════════
// SSR id-label resolution (2026-11-01): pages that render a filter tree
// (the collection editor's stored tree, the bookshelf's URL qf) embed
// the id→name map so value cells show NAMES at first paint.
//
// Why not reuse the suggest facet: its rows are media-constrained
// (INNER JOIN media_items + the other rules as a constraint), so an id
// whose media doesn't match the tree's sibling rules returns NO row and
// its label is lost — live repro: feed-in-3 + title-contains-Chapter
// labeled one feed of three. A label is a property of the ENTITY row,
// not of what currently matches: resolution reads the source tables
// directly, no media join, no tree constraint.
//
// Scoping rides the request identity through s.raw (the ScopedDB —
// every statement declares app.user_id): podcast_feeds and collections
// are RLS-governed (an invisible feed/collection resolves to nothing),
// libraries mirror the facet's explicit visibility join, and users
// (added_by) are plain lookups — the viewer already holds the id from
// a legal pick, the resolver only names it.
// ════════════════════════════════════════════════════════════════════
// maxResolveIDs bounds one field's resolution query (picks accumulate
// per rule; menus cap far below this).
const maxResolveIDs = 100
// FilterTreeLabels resolves every id-kind rule value in the tree to its
// display name. Best-effort by design: a resolve failure degrades to
// fewer labels (cells fall back to raw ids — the pre-fix behavior),
// never a failed page render. Returns uuid → display.
func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID, tree *FilterTree) map[string]string {
labels := map[string]string{}
if tree == nil {
return labels
}
// Collect ids per field, split comma-joined picks (the builder
// stores one comma-joined value per multi-pick), cap the query.
byField := map[string][]uuid.UUID{}
var walk func(children []FilterChild)
walk = func(children []FilterChild) {
for _, c := range children {
if len(c.Children) > 0 {
walk(c.Children)
continue
}
if c.Field == "" || len(c.Values) == 0 {
continue
}
f, ok := FilterFieldByKey(c.Field)
if !ok || f.Suggest == "" {
continue
}
if valueKindFor(f.Kind, c.Field) != VKId {
continue
}
for _, id := range parseUUIDValues(c.Values) {
if len(byField[c.Field]) >= maxResolveIDs {
return
}
byField[c.Field] = append(byField[c.Field], id)
}
}
}
walk(tree.Children)
for field, ids := range byField {
f, _ := FilterFieldByKey(field)
idArgs := make([]uuid.UUID, len(ids))
copy(idArgs, ids)
var sqlStr string
var args []any
switch f.Suggest {
case suggestLibrary:
// The facet's visibility shape, minus the media join: a
// hidden library is invisible even by id (family lens).
sqlStr = `SELECT l.id::text, l.name
FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true AND l.id = ANY($1)`
args = []any{idArgs, userID}
case suggestFeed:
// RLS-governed (subscription + visibility predicate): an
// unsubscribed feed id resolves to nothing.
sqlStr = `SELECT id::text, title FROM podcast_feeds WHERE id = ANY($1)`
args = []any{idArgs}
case suggestCollection:
// RLS-governed (app_can_read_collection).
sqlStr = `SELECT id::text, name FROM collections WHERE id = ANY($1)`
args = []any{idArgs}
case suggestAddedBy:
sqlStr = `SELECT id::text, username FROM users WHERE id = ANY($1)`
args = []any{idArgs}
default:
continue
}
rows, err := s.raw.Query(ctx, sqlStr, args...)
if err != nil {
log.Printf("[filter-labels] resolve %q failed: %v", field, err)
continue
}
for rows.Next() {
var v, display string
if err := rows.Scan(&v, &display); err != nil {
break
}
labels[v] = display
}
rows.Close()
}
return labels
}
+2
View File
@@ -17,6 +17,7 @@ templ BookShelf(
limit int,
offset int,
count int,
labelsJSON string,
) {
<!DOCTYPE html>
<html lang="en">
@@ -178,6 +179,7 @@ templ BookShelf(
the tree (depth = indent); groups nest via + group. -->
<div x-data="filterBuilderExtra" x-cloak
data-filter-fields={ JSONArg(services.FilterFieldsPayload("books")) }
data-id-labels={ labelsJSON }
x-effect="filtersOpen === true && ensureReadyRule()"
>
@FilterBuilderRows("book")
+86 -72
View File
@@ -25,6 +25,7 @@ func BookShelf(
limit int,
offset int,
count int,
labelsJSON string,
) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -120,7 +121,7 @@ func BookShelf(
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 93, Col: 135}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 94, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -133,7 +134,7 @@ func BookShelf(
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 95, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 96, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -196,7 +197,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 153, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -214,7 +215,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 155, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -235,7 +236,7 @@ func BookShelf(
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -248,7 +249,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -261,7 +262,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -279,7 +280,7 @@ func BookShelf(
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 163, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -292,7 +293,7 @@ func BookShelf(
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 163, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -305,7 +306,7 @@ func BookShelf(
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 163, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -324,7 +325,7 @@ func BookShelf(
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 166, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -337,7 +338,7 @@ func BookShelf(
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 166, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -350,7 +351,7 @@ func BookShelf(
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 166, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -368,7 +369,7 @@ func BookShelf(
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 168, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -381,7 +382,7 @@ func BookShelf(
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 168, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -394,7 +395,7 @@ func BookShelf(
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 168, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -414,13 +415,26 @@ func BookShelf(
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(services.FilterFieldsPayload("books")))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 180, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 181, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" x-effect=\"filtersOpen === true && ensureReadyRule()\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" data-id-labels=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(labelsJSON)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 182, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" x-effect=\"filtersOpen === true && ensureReadyRule()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -428,7 +442,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</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\n\t\t\t\t\tform state (Alpine renders them, templ owns the look —\n\t\t\t\t\tsanctioned TS island). Removing a chip clears that facet\n\t\t\t\t\tand 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\n\t\t\t\t\t\t\t\t? '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);'\n\t\t\t\t\t\t\t\t: '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\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</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\n\t\t\t\t\tform state (Alpine renders them, templ owns the look —\n\t\t\t\t\tsanctioned TS island). Removing a chip clears that facet\n\t\t\t\t\tand 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\n\t\t\t\t\t\t\t\t? '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);'\n\t\t\t\t\t\t\t\t: '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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -437,53 +451,53 @@ func BookShelf(
return templ_7745c5c3_Err
}
if errorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<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)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 221, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if count > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<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)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 230, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 223, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if count > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 232, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if offset <= 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " disabled")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " disabled")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -491,43 +505,43 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 239, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 244, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 241, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 246, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if offset+limit >= count {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " disabled")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " disabled")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "><span>Next</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "><span>Next</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -535,12 +549,12 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</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\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -548,7 +562,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</button></div><form id=\"save-filter-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\" id=\"save-filter-name\" 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-secondary\">Save Filter</button><!-- Item 3.5: the bridge — the current tree becomes a live\n\t\t\t\t\t\t\t smart collection (the qf engine drives it everywhere:\n\t\t\t\t\t\t\t shelf, collections, play chains). --><button type=\"button\" @click=\"saveFilterAsCollection()\" class=\"btn btn-primary\">Save as Collection</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</button></div><form id=\"save-filter-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\" id=\"save-filter-name\" 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-secondary\">Save Filter</button><!-- Item 3.5: the bridge — the current tree becomes a live\n\t\t\t\t\t\t\t smart collection (the qf engine drives it everywhere:\n\t\t\t\t\t\t\t shelf, collections, play chains). --><button type=\"button\" @click=\"saveFilterAsCollection()\" class=\"btn btn-primary\">Save as Collection</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+2 -1
View File
@@ -7,7 +7,7 @@ package templates
// the qf engine drives membership everywhere. Legacy rules arrays
// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade
// happens at the first Apply, never silently at load.
templ CollectionRules(user User, collection CollectionData, fieldsJSON string, treeJSON string, scope string) {
templ CollectionRules(user User, collection CollectionData, fieldsJSON string, treeJSON string, scope string, labelsJSON string) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -55,6 +55,7 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t
data-filter-fields={ fieldsJSON }
data-scope={ scope }
data-tree={ treeJSON }
data-id-labels={ labelsJSON }
class="card p-6"
>
<div class="flex items-center justify-between mb-3 px-1">
+16 -3
View File
@@ -15,7 +15,7 @@ import templruntime "github.com/a-h/templ/runtime"
// the qf engine drives membership everywhere. Legacy rules arrays
// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade
// happens at the first Apply, never silently at load.
func CollectionRules(user User, collection CollectionData, fieldsJSON string, treeJSON string, scope string) templ.Component {
func CollectionRules(user User, collection CollectionData, fieldsJSON string, treeJSON string, scope string, labelsJSON string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -206,7 +206,20 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"card p-6\"><div class=\"flex items-center justify-between mb-3 px-1\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filter tree</h2></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-id-labels=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(labelsJSON)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 58, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"card p-6\"><div class=\"flex items-center justify-between mb-3 px-1\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filter tree</h2></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -214,7 +227,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"mt-4 pt-4 border-t flex justify-end gap-2\" style=\"border-color: var(--border);\"><button type=\"button\" @click=\"backToCollection()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"applyCollectionTree()\" class=\"btn btn-primary\">Apply to Collection</button></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"mt-4 pt-4 border-t flex justify-end gap-2\" style=\"border-color: var(--border);\"><button type=\"button\" @click=\"backToCollection()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"applyCollectionTree()\" class=\"btn btn-primary\">Apply to Collection</button></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+74
View File
@@ -496,3 +496,77 @@ describe("id-kind value cells", () => {
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lewis"]);
});
});
// ── SSR-embedded id labels + the bookshelf init-order fix (2026-11-01)
// The page handler resolves the tree's id-kind values at render time and
// embeds the id→name map on the mount (data-id-labels) — value cells
// show NAMES from the very first paint, with NO resolution fetch. And
// bookshelf mounts (no data-tree) hydrate their tree from the form at
// THIS component's init: the parent's x-init runs before this nested
// component exists (Alpine initializes parents first), so its
// builderData() call was null and refresh left the drawer empty.
describe("init: embedded labels + form hydration", () => {
beforeEach(() => {
ingestFilterFields(builderFields);
});
afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});
const mount = (attrs: Record<string, string>): HTMLElement => {
const el = document.createElement("div");
el.setAttribute("x-data", "filterBuilderExtra");
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
document.body.appendChild(el);
return el;
};
const qfJSON = JSON.stringify({
mode: "all",
children: [{ field: "library", op: "is", values: ["lib-1"] }],
});
it("init ingests the embedded labels — cells show names, no fetch", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const el = mount({
"data-filter-fields": JSON.stringify(builderFields),
"data-tree": JSON.stringify({
mode: "all",
children: [{ field: "library", op: "is", values: ["lib-1"] }],
}),
"data-id-labels": JSON.stringify({ "lib-1": "Comics" }),
});
const c = filterBuilderData();
(c as unknown as { $el: HTMLElement }).$el = el;
c.init();
expect(c.filterTree.children).toHaveLength(1);
expect(c.valueCellText([0])).toBe("Comics");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("init without data-tree hydrates from the form's qf (bookshelf)", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const form = document.createElement("form");
form.id = "filter-form";
const qf = document.createElement("input");
qf.name = "qf";
qf.value = qfJSON;
form.appendChild(qf);
document.body.appendChild(form);
const el = mount({
"data-filter-fields": JSON.stringify(builderFields),
"data-id-labels": JSON.stringify({ "lib-1": "Comics" }),
});
const c = filterBuilderData();
(c as unknown as { $el: HTMLElement }).$el = el;
c.init();
// The tree hydrated (pre-fix it stayed EMPTY — the parent's x-init
// could not reach this component), and the cell shows the embedded
// label, not the raw id.
expect(c.filterTree.children).toEqual([{ field: "library", op: "is", values: ["lib-1"] }]);
expect(c.valueCellText([0])).toBe("Comics");
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+24 -2
View File
@@ -462,6 +462,19 @@ export function filterBuilderData() {
// source-of-truth; never invent rows client-side.
}
}
// SSR-embedded id labels (2026-11-01): the page handler resolved
// the tree's id-kind values to names at render time and embedded
// the map here — ingest it BEFORE any tree lands, so the cells
// show names from the very first paint and resolveIdLabels (the
// quiet fetch below) early-returns instead of hitting the server.
const labelsRaw = el?.dataset.idLabels;
if (labelsRaw) {
try {
Object.assign(this.idLabels, JSON.parse(labelsRaw) as Record<string, string>);
} catch {
// malformed embed — cells fall back to raw ids, as before
}
}
// Collection mounts (item 3.4): the page carries the collection's
// scope (row universe for counts/apply) and its PREFILL tree (the
// stored tree, or the legacy rules' conversion).
@@ -478,9 +491,18 @@ export function filterBuilderData() {
} catch {
// no prefill — start empty
}
} else {
// Bookshelf mounts carry NO data-tree: the wire state lives in
// the form (applyURLFilters put the URL's qf there). It must
// hydrate HERE, at this component's init — the parent's x-init
// (initBookshelf) runs BEFORE this nested component exists
// (Alpine initializes parents first), so its builderData()
// call is null and its hydrateFromForm never lands (2026-11-01:
// a refresh left the drawer empty — no rules at all).
this.hydrateFromForm();
}
// Stored id values arrive bare: resolve their labels now so the
// cells render names from the first paint after reload.
// Anything still unlabeled (a tree hydrated from a form the SSR
// didn't know about) resolves through the quiet fetch fallback.
this.resolveIdLabels();
},
// treeJSON is the APPLY wire for collection mounts: the page's