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:
1 parent
6152137156
commit
f52bb6f073
18 files changed
+3001
-911
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user