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:
1 parent
c448fa438f
commit
65f257f377
12 files changed
+533
-80
No files matched your search
@@ -2208,3 +2208,129 @@ func TestEpisodeCollectionsFormSeedCreate(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusOK, code)
|
require.Equal(t, http.StatusOK, code)
|
||||||
assert.Contains(t, string(body), `"scope":"episodes"`, "the seeded scope round-trips")
|
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -619,3 +619,33 @@ offered-vocabulary narrowing, not a parse change:
|
|||||||
- Client id-cells are uniform: every operator on an id-valued field
|
- 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
|
accepts a set, so all id cells say "pick one or more" and accumulate
|
||||||
picks — no per-operator placeholder/pick distinction to explain.
|
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.
|
||||||
@@ -243,6 +243,11 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se
|
|||||||
|
|
||||||
// ExecuteSearch performs search and returns results with count
|
// ExecuteSearch performs search and returns results with count
|
||||||
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
// 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) {
|
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||||
return h.searchService.ExecuteSearch(ctx, params)
|
return h.searchService.ExecuteSearch(ctx, params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
var buf bytes.Buffer
|
||||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -706,12 +716,14 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
// with the rules-array storage — the tree is the only form.)
|
// with the rules-array storage — the tree is the only form.)
|
||||||
scope := "books"
|
scope := "books"
|
||||||
treeJSON := "{}"
|
treeJSON := "{}"
|
||||||
|
var parsedTree *services.FilterTree
|
||||||
if len(collection.FilterTree) > 0 {
|
if len(collection.FilterTree) > 0 {
|
||||||
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
|
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
|
||||||
scope = t.FilterScope()
|
scope = t.FilterScope()
|
||||||
if b, merr := json.Marshal(t); merr == nil {
|
if b, merr := json.Marshal(t); merr == nil {
|
||||||
treeJSON = string(b)
|
treeJSON = string(b)
|
||||||
}
|
}
|
||||||
|
parsedTree = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
|
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
|
||||||
@@ -720,6 +732,13 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
fieldsJSON := string(fieldsPayload)
|
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{
|
colData := templates.CollectionData{
|
||||||
ID: collectionID,
|
ID: collectionID,
|
||||||
Name: collection.Name,
|
Name: collection.Name,
|
||||||
@@ -731,7 +750,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,32 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
|||||||
// detail page and the books-grid partial: smart evaluation, a dashboard
|
// detail page and the books-grid partial: smart evaluation, a dashboard
|
||||||
// query section, or manual membership. Callers resolve the scope:
|
// query section, or manual membership. Callers resolve the scope:
|
||||||
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
// 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) {
|
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()
|
colService := cfg.CollectionHandler.CollectionService()
|
||||||
isSmart = services.IsCollectionSmart(collection)
|
isSmart = services.IsCollectionSmart(collection)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ templ BookShelf(
|
|||||||
limit int,
|
limit int,
|
||||||
offset int,
|
offset int,
|
||||||
count int,
|
count int,
|
||||||
|
labelsJSON string,
|
||||||
) {
|
) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -178,6 +179,7 @@ templ BookShelf(
|
|||||||
the tree (depth = indent); groups nest via + group. -->
|
the tree (depth = indent); groups nest via + group. -->
|
||||||
<div x-data="filterBuilderExtra" x-cloak
|
<div x-data="filterBuilderExtra" x-cloak
|
||||||
data-filter-fields={ JSONArg(services.FilterFieldsPayload("books")) }
|
data-filter-fields={ JSONArg(services.FilterFieldsPayload("books")) }
|
||||||
|
data-id-labels={ labelsJSON }
|
||||||
x-effect="filtersOpen === true && ensureReadyRule()"
|
x-effect="filtersOpen === true && ensureReadyRule()"
|
||||||
>
|
>
|
||||||
@FilterBuilderRows("book")
|
@FilterBuilderRows("book")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func BookShelf(
|
|||||||
limit int,
|
limit int,
|
||||||
offset int,
|
offset int,
|
||||||
count int,
|
count int,
|
||||||
|
labelsJSON string,
|
||||||
) templ.Component {
|
) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
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
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
@@ -120,7 +121,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -133,7 +134,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -196,7 +197,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -214,7 +215,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -235,7 +236,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -248,7 +249,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -261,7 +262,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -279,7 +280,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -292,7 +293,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -305,7 +306,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -324,7 +325,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -337,7 +338,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -350,7 +351,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -368,7 +369,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -381,7 +382,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -394,7 +395,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -414,13 +415,26 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(services.FilterFieldsPayload("books")))
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(services.FilterFieldsPayload("books")))
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -428,7 +442,7 @@ func BookShelf(
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -437,53 +451,53 @@ func BookShelf(
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if errorMessage != "" {
|
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)\">")
|
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_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=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
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 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if offset <= 0 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -491,43 +505,43 @@ func BookShelf(
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 ")
|
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_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=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
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 {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if offset+limit >= count {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -535,12 +549,12 @@ func BookShelf(
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -548,7 +562,7 @@ func BookShelf(
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ package templates
|
|||||||
// the qf engine drives membership everywhere. Legacy rules arrays
|
// the qf engine drives membership everywhere. Legacy rules arrays
|
||||||
// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade
|
// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade
|
||||||
// happens at the first Apply, never silently at load.
|
// 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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -55,6 +55,7 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t
|
|||||||
data-filter-fields={ fieldsJSON }
|
data-filter-fields={ fieldsJSON }
|
||||||
data-scope={ scope }
|
data-scope={ scope }
|
||||||
data-tree={ treeJSON }
|
data-tree={ treeJSON }
|
||||||
|
data-id-labels={ labelsJSON }
|
||||||
class="card p-6"
|
class="card p-6"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3 px-1">
|
<div class="flex items-center justify-between mb-3 px-1">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import templruntime "github.com/a-h/templ/runtime"
|
|||||||
// the qf engine drives membership everywhere. Legacy rules arrays
|
// the qf engine drives membership everywhere. Legacy rules arrays
|
||||||
// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade
|
// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade
|
||||||
// happens at the first Apply, never silently at load.
|
// 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) {
|
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
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -214,7 +227,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,3 +496,77 @@ describe("id-kind value cells", () => {
|
|||||||
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lewis"]);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -462,6 +462,19 @@ export function filterBuilderData() {
|
|||||||
// source-of-truth; never invent rows client-side.
|
// 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
|
// Collection mounts (item 3.4): the page carries the collection's
|
||||||
// scope (row universe for counts/apply) and its PREFILL tree (the
|
// scope (row universe for counts/apply) and its PREFILL tree (the
|
||||||
// stored tree, or the legacy rules' conversion).
|
// stored tree, or the legacy rules' conversion).
|
||||||
@@ -478,9 +491,18 @@ export function filterBuilderData() {
|
|||||||
} catch {
|
} catch {
|
||||||
// no prefill — start empty
|
// 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
|
// Anything still unlabeled (a tree hydrated from a form the SSR
|
||||||
// cells render names from the first paint after reload.
|
// didn't know about) resolves through the quiet fetch fallback.
|
||||||
this.resolveIdLabels();
|
this.resolveIdLabels();
|
||||||
},
|
},
|
||||||
// treeJSON is the APPLY wire for collection mounts: the page's
|
// treeJSON is the APPLY wire for collection mounts: the page's
|
||||||
|
|||||||
Reference in new issue
Block a user