feat(facets): all-facets panel with under-filter counts (faceted plan Phase 7)

The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.

- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
  listable facets in a single round trip: each arm omits its OWN
  facet's filter block and carries every other registered facet's
  block (copied mechanically from SearchMediaItemsUnified) — classic
  faceted semantics: with filters active each facet counts under the
  others; with none, blocks are neutral and the counts are the v1
  global-per-library ones. One query serves both cases. The 14
  per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
  ({facets: {param: [{value, display?, count}]}}), filters bound via
  the shared ParseMediaSearchParams, library_id optional (the
  All-Libraries view, family lens still enforced); facet=X keeps the
  pinned single-facet shape (now routed through the same query with
  neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
  value lists — registry order, Batch D library-type scoping
  (FacetsForLibraryType: manga_type on manga, reading_direction on
  manga+comics, narrator on audiobooks; All view = union), prettified
  genre labels + language display names, active values marked. Family
  lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
  apply with the CURRENT filter query string; a value click toggles
  that facet's comma-joined filter (hidden input where the drawer has
  no field — same as deep links) and re-applies the form; chips and
  URL stay in sync. Values are server-rendered (rendering doctrine);
  bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
  implementation (services' display enrichment shares it; templ wraps
  it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
  registry facet's param, the bools as pgtype.Bool.

Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
This commit is contained in:
John O'Keefe committed 2026-10-20 08:24:36 -04:00
1 parent 6152137156
commit f52bb6f073
18 files changed
+3001 -911

No files matched your search

+123
View File
@@ -386,6 +386,129 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
})
// Facet panel partial (faceted plan Phase 7): the bookshelf filter
// drawer's value-list sections, server-rendered from the facet
// registry + the under-filter counts. The drawer fetches it with the
// CURRENT filter query string, so the counts always reflect exactly
// what the grid shows; a value click is bookshelf.ts toggling that
// facet's filter and re-applying the form.
frontendProtected.GET("/media-items/facet-panel", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Family lens (AGENTS standing rule): the panel lists media
// metadata, so an explicit library_id outside the visible set is
// a 403 — never a silent fallback.
if requested := c.QueryParam("library_id"); requested != "" {
if requestedUUID, parseErr := uuid.Parse(requested); parseErr == nil {
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
if visErr != nil {
log.Printf("visibleLibraryIDsFor failed: %v", visErr)
return c.HTML(http.StatusInternalServerError, "<div class=\"p-4 text-sm\">Failed to resolve library visibility</div>")
}
if !visibleSet[requestedUUID.String()] {
return c.HTML(http.StatusForbidden, "<div class=\"p-4 text-sm\">Library not visible</div>")
}
}
}
// Same library resolution the page/grid uses (param → cookie →
// first visible), so panel scope and grid scope agree.
libRes := resolveLibrary(c, cfg, user.ID)
userUUID, _ := uuid.Parse(user.ID)
params := handlers.ParseMediaSearchParams(c)
params.UserID = pgtype.UUID{Bytes: userUUID, Valid: true}
params.LibraryID = libRes.LibUUID
counts, err := cfg.MediaHandler.FacetCountsForView(c.Request().Context(), params.UserID, libRes.LibUUID, params, 50)
if err != nil {
log.Printf("facet panel counts failed: %v", err)
return c.HTML(http.StatusInternalServerError, "<div class=\"p-4 text-sm\">Failed to load facet values</div>")
}
// Active filter values per wire param — a value is Active when
// it appears in its facet's bound set (language normalized the
// same way the query binds it).
wireValues := []struct {
wire string
val string
}{
{"author_filter", params.AuthorFilter},
{"publisher_filter", params.PublisherFilter},
{"narrator_filter", params.NarratorFilter},
{"tags_filter", params.TagsFilter},
{"series_filter", params.SeriesFilter},
{"genre_filter", params.GenreFilter},
{"language_filter", services.NormalizeLanguageFilter(params.LanguageFilter)},
{"story_arc_filter", params.StoryArcFilter},
{"imprint_filter", params.ImprintFilter},
{"edition_filter", params.EditionFilter},
{"age_rating_filter", params.AgeRatingFilter},
{"format_group_filter", params.FormatGroupFilter},
{"manga_type_filter", params.MangaTypeFilter},
{"reading_direction_filter", params.ReadingDirectionFilter},
}
activeValues := make(map[string][]string, len(wireValues))
for _, wv := range wireValues {
activeValues[wv.wire] = services.SplitFacetValues(wv.val)
}
// Batch D rendering rules: the view's library type picks the
// facets (All Libraries → the union); only value-bearing kinds
// render sections — bool/range facets are the drawer's typed
// inputs.
typeName := ""
if !libRes.IsAll {
for _, ld := range libRes.Libraries {
if ld.ID == libRes.LibraryID {
typeName = ld.TypeName
break
}
}
}
sections := make([]templates.FacetPanelSection, 0, len(counts))
for _, f := range services.FacetsForLibraryType(typeName) {
if f.Kind != services.FacetArray && f.Kind != services.FacetScalar {
continue
}
sec := templates.FacetPanelSection{
WireParam: f.WireParam(),
Title: templates.FacetPanelTitle(f.Param),
}
for _, v := range counts[f.Param] {
display := v.Display
if display == "" {
display = v.Value
}
active := false
for _, a := range activeValues[f.WireParam()] {
if a == v.Value {
active = true
break
}
}
sec.Values = append(sec.Values, templates.FacetPanelValue{
Value: v.Value,
Display: display,
Count: v.Count,
Active: active,
})
}
sections = append(sections, sec)
}
var buf bytes.Buffer
if err := templates.FacetPanel(sections).Render(c.Request().Context(), &buf); err != nil {
log.Printf("facet panel render failed: %v", err)
return c.HTML(http.StatusInternalServerError, "<div class=\"p-4 text-sm\">Failed to render facet panel</div>")
}
return c.HTMLBlob(http.StatusOK, buf.Bytes())
})
// Dashboard page
frontendProtected.GET("/dashboard", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)