feat(filters): panel removal + All-Books field-values fix (faceted plan Phase 8)

- Remove the Phase 7 facet-panel UI: SSR route, facet_panel.templ,
  FacetCountsForView, drawer panel block, panel TS + .facet-* CSS.
  The ENGINE stays (FacetCountsUnderFilters, all-facets endpoint,
  drift guard) — it becomes the Phase 9 builder's machinery.
- Fix the All-Books bug: field-values autocomplete (author/genre/
  tags/narrator/series/language) treats library_id as OPTIONAL —
  absent aggregates every VISIBLE library (family lens in-query);
  explicit out-of-set library_id is now 403, never a silent pass.
  The four scalar Search*Values queries get the nullable shape.
- toast.ts: suggestion fetches are exempt from error toasts
  (console-only) — a failed background hint never interrupts.
- TestFacetPanelPartial deleted with the behavior; TestFieldValuesAll-
  Libraries pins the fix (200 + cross-library aggregation, family
  lens, 403). Contract-pins changelog entry added.
This commit is contained in:
John O'Keefe committed 2026-10-20 12:20:51 -04:00
1 parent f52bb6f073
commit ebb7f4a5b0
14 files changed
+192 -643

No files matched your search

+4 -4
View File
@@ -13912,7 +13912,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = $3
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
AND word_similarity($1, COALESCE(mi.author, '')) > 0.3
AND mi.author IS NOT NULL
AND mi.author != ''
@@ -13972,7 +13972,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = $3
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
AND word_similarity($1, COALESCE(mi.genre, '')) > 0.3
AND mi.genre IS NOT NULL
AND mi.genre != ''
@@ -14032,7 +14032,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = $3
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
AND word_similarity($1, COALESCE(mi.language, '')) > 0.3
AND mi.language IS NOT NULL
AND mi.language != ''
@@ -14803,7 +14803,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = $3
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
AND word_similarity($1, COALESCE(mi.series, '')) > 0.3
AND mi.series IS NOT NULL
AND mi.series != ''
+4 -4
View File
@@ -907,7 +907,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.na
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
AND mi.author IS NOT NULL
AND mi.author != ''
@@ -926,7 +926,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.na
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
AND mi.genre IS NOT NULL
AND mi.genre != ''
@@ -979,7 +979,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.na
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
AND mi.series IS NOT NULL
AND mi.series != ''
@@ -998,7 +998,7 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.na
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
AND mi.language IS NOT NULL
AND mi.language != ''
+21 -16
View File
@@ -2583,24 +2583,29 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
})
}
// FacetCountsForView is the SSR facet panel's data path (faceted plan
// Phase 7): the all-facets counts under the active filters for one
// library — or across every visible library when libraryID is NULL.
func (mh *MediaHandler) FacetCountsForView(ctx context.Context, userID, libraryID pgtype.UUID, params services.SearchParams, limit int) (map[string][]services.FacetValue, error) {
return mh.searchService.FacetCounts(ctx, userID, libraryID, params, limit)
}
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
// Returns distinct field values with counts and similarity scores for dropdown population
//
// library_id is OPTIONAL: absent means "every library the requesting user
// can see" — the per-user family lens is enforced inside the query (the
// library_visibility join), so the All-Books view's suggestions aggregate
// the visible world. An explicit library_id outside the visible set is a
// 403 (AGENTS standing rule: never silently pass).
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
var libPg pgtype.UUID
if libraryID := c.QueryParam("library_id"); libraryID != "" {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libPg = pgtype.UUID{Bytes: libUUID, Valid: true}
visible, err := mh.libraryVisible(c, libPg)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
}
}
limit, _ := strconv.Atoi(c.QueryParam("limit"))
@@ -2611,7 +2616,7 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U
params := services.FieldSearchParams{
UserID: userID,
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
LibraryID: libPg,
FieldType: fieldType,
SearchQuery: searchQuery,
Limit: limit,
-123
View File
@@ -386,129 +386,6 @@ 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)