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

+108 -37
View File
@@ -518,11 +518,14 @@ func TestFacetCountsUnderFilters(t *testing.T) {
requireFacetCount(t, single.Values, "history", 1)
}
// TestFacetPanelPartial covers the drawer's SSR partial
// (GET /media-items/facet-panel): server-rendered registry-ordered
// sections, library-type scoping (Batch D), active-value marking, and
// the family lens on the HTML surface.
func TestFacetPanelPartial(t *testing.T) {
// TestFieldValuesAllLibraries pins the All-Books suggestion path
// (filter-plan Phase 8): the field-values autocomplete treats
// library_id as OPTIONAL — absent aggregates every library VISIBLE to
// the caller (the per-user family lens is enforced inside the query),
// and an explicit out-of-set library_id is a 403. This is the
// regression net for the hard "library_id is required" 400 that broke
// typing in the filter drawer on the All-Books view.
func TestFieldValuesAllLibraries(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
@@ -531,40 +534,108 @@ func TestFacetPanelPartial(t *testing.T) {
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err)
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Test Facet Panel %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "facet panel partial test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
makeLib := func(name string) database.Libraries {
t.Helper()
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Test Field Values %s %d", name, time.Now().UnixNano()),
Description: pgtype.Text{String: "field values all-libraries test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
return lib
}
visibleLib := makeLib("Visible")
hiddenLib := makeLib("Hidden")
hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
require.NoError(t, err)
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
// One book per library with IDENTICAL distinctive values — the
// All-Books answer must aggregate across both libraries, and every
// value is trigram-disjoint from any real data ("Verdana…" nonsense).
for _, lib := range []struct {
id pgtype.UUID
name string
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: lib.id,
Title: fmt.Sprintf("Field Values %s Book", lib.name),
Authors: []string{"Verdana Scribe"},
Genre: pgtype.Text{String: "verdanafantasy", Valid: true},
Series: pgtype.Text{String: "Verdana Saga", Valid: true},
Language: pgtype.Text{String: "verdanish", Valid: true},
FilePath: fmt.Sprintf("/tmp/field-values/%s-%d.epub", lib.name, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
}
fieldValuesGet := func(params url.Values) (int, []map[string]interface{}) {
t.Helper()
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, nil
}
var body struct {
Results []map[string]interface{} `json:"results"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return resp.StatusCode, body.Results
}
findValue := func(results []map[string]interface{}, value string) (found bool, count float64) {
t.Helper()
for _, r := range results {
if r["value"] == value {
found = true
count, _ = r["count"].(float64)
}
}
return found, count
}
// Phase A — NO library_id (the All-Books view): 200, and the same
// author surfaces ONCE with count 2 — values aggregate across every
// visible library. The old bug answered 400 here.
status, results := fieldValuesGet(url.Values{"author": {"Verdana"}})
require.Equal(t, http.StatusOK, status, "no library_id must be 200, not the old hard 400")
found, count := findValue(results, "Verdana Scribe")
require.True(t, found, "author value present on the All-Books path")
require.Equal(t, float64(2), count, "value aggregated across both visible libraries")
for field, value := range map[string]string{
"genre": "verdanafantasy", "series": "Verdana Saga", "language": "verdanish",
} {
status, results = fieldValuesGet(url.Values{field: {value}})
require.Equal(t, http.StatusOK, status, "%s autocomplete with no library_id", field)
found, count = findValue(results, value)
require.True(t, found, "%s value present on the All-Books path", field)
require.Equal(t, float64(2), count, "%s aggregated across both libraries", field)
}
// Phase B — family lens on the same All-Books path: hiding one
// library from this user halves the counts without any library_id
// in the request at all.
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
hiddenLib.ID, userID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
facetFixtureBooks(t, setup, library.ID, "Panel")
status, results = fieldValuesGet(url.Values{"author": {"Verdana"}})
require.Equal(t, http.StatusOK, status)
found, count = findValue(results, "Verdana Scribe")
require.True(t, found)
require.Equal(t, float64(1), count, "hidden library's books leave the All-Books suggestions")
// Baseline: genre + publisher sections render with prettified
// genre labels and counts; type-restricted facets stay off the
// ebooks view (Batch D).
code, html := ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+libUUID.String(), setup.RegularToken)
require.Equal(t, http.StatusOK, code)
require.Contains(t, html, `data-param="genre_filter"`)
require.Contains(t, html, `data-param="publisher_filter"`)
require.Contains(t, html, "Fantasy", "genre values render prettified")
require.Contains(t, html, `data-value="fantasy"`)
require.NotContains(t, html, `data-param="manga_type_filter"`, "manga_type is manga-only (Batch D)")
require.NotContains(t, html, `data-param="narrator_filter"`, "narrator is audiobooks-only (Batch D)")
// Under genre=fantasy: the fantasy button is marked active and the
// history value is STILL listed (its own block is omitted), proving
// the partial's counts come from the under-filter query.
code, html = ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+libUUID.String()+"&genre_filter=fantasy", setup.RegularToken)
require.Equal(t, http.StatusOK, code)
require.Contains(t, html, "facet-value-active")
require.Contains(t, html, `data-value="history"`, "a facet's own filter never removes its values")
// Family lens on the HTML surface: an out-of-set library_id is 403.
code, _ = ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+uuid.New().String(), setup.RegularToken)
require.Equal(t, http.StatusForbidden, code)
// Phase C — an explicit out-of-set library_id is a 403, never a
// silent pass (AGENTS family-lens standing rule).
status, _ = fieldValuesGet(url.Values{"author": {"Verdana"}, "library_id": {hiddenUUID.String()}})
require.Equal(t, http.StatusForbidden, status)
status, _ = fieldValuesGet(url.Values{"author": {"Verdana"}, "library_id": {uuid.New().String()}})
require.Equal(t, http.StatusForbidden, status)
}
+21
View File
@@ -280,3 +280,24 @@ cover_image_path, has_conflict, format_group, badge`.
- The 14 per-column facet SQL queries are replaced by one
`FacetCountsUnderFilters` UNION-ALL statement; the registry drift
guard now reflects over both generated param structs.
## 2026-10-20 — Filter plumbing: facet-panel partial removed; field-values `library_id` optional
- `GET /media-items/facet-panel` (the SSR partial documented above,
same day) is REMOVED — the panel UX was rejected on scale grounds
and the filter drawer becomes a rule builder with no other mode.
Never contract-pinned while it lived (same-day entry, same-day
removal); `TestFacetPanelPartial` was deleted with the behavior.
The all-facets JSON endpoint and its two shapes are UNCHANGED and
stay pinned; `FacetCountsUnderFilters` and the registry drift guard
keep their roles (the engine outlives the panel).
- `GET /api/media-items/search?field=value` autocomplete
(author/genre/tags/narrator/series/language): `library_id` is now
OPTIONAL — absent means every library VISIBLE to the caller (the
per-user family lens applies inside the query), fixing the All-Books
view's "library_id is required" 400. An explicit out-of-set
`library_id` is now a 403 on this endpoint too (was: silent empty
result for hidden libraries — the silent pass is the anti-pattern
the standing rule forbids). The single-facet
`/api/media-items/facets?facet=X` shape still REQUIRES `library_id`
— that pin is unchanged.
+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)
+1 -14
View File
@@ -62,7 +62,7 @@ templ BookShelf(
<option value="created_at DESC">Date Added</option>
<option value="page_count DESC">Page Count</option>
</select>
<button type="button" @click="filtersOpen = true; loadFacetPanel()" class="btn btn-secondary">
<button type="button" @click="filtersOpen = true" class="btn btn-secondary">
@Icon("filter", "h-4 w-4")
<span>Filters</span>
</button>
@@ -279,19 +279,6 @@ templ BookShelf(
/>
</template>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Browse by facet</label>
<!-- Phase 7: facet values are server data — this partial is
rendered by templ (templates/facet_panel.templ) and fetched
with the CURRENT filter query string on drawer open and
after every apply, so counts are computed under the active
filters. A value click toggles that facet's filter input
(hidden where the drawer has no field) and re-applies the
form — bookshelf.ts owns the click, templ owns the look. -->
<div id="facet-panel" class="space-y-2">
<p class="text-xs" style="color: var(--text-secondary);">Open the filters to browse facet values.</p>
</div>
</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>
+6 -6
View File
@@ -83,7 +83,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> <input name=\"q\" type=\"text\" class=\"input pl-10\" placeholder=\"Search all fields…\"></div><select name=\"sort\" class=\"input w-auto cursor-pointer\" hx-get=\"/api/media-items/search\" hx-target=\"#books-grid\" hx-trigger=\"change\" hx-include=\"#filter-form\"><option value=\"title ASC\">Title (A-Z)</option> <option value=\"title DESC\">Title (Z-A)</option> <option value=\"author ASC\">Author (A-Z)</option> <option value=\"created_at DESC\">Date Added</option> <option value=\"page_count DESC\">Page Count</option></select> <button type=\"button\" @click=\"filtersOpen = true; loadFacetPanel()\" class=\"btn btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> <input name=\"q\" type=\"text\" class=\"input pl-10\" placeholder=\"Search all fields…\"></div><select name=\"sort\" class=\"input w-auto cursor-pointer\" hx-get=\"/api/media-items/search\" hx-target=\"#books-grid\" hx-trigger=\"change\" hx-include=\"#filter-form\"><option value=\"title ASC\">Title (A-Z)</option> <option value=\"title DESC\">Title (Z-A)</option> <option value=\"author ASC\">Author (A-Z)</option> <option value=\"created_at DESC\">Date Added</option> <option value=\"page_count DESC\">Page Count</option></select> <button type=\"button\" @click=\"filtersOpen = true\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -406,7 +406,7 @@ func BookShelf(
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Narrator</label> <input type=\"text\" name=\"narrator_filter\" placeholder=\"Filter by narrator\" class=\"input\" list=\"narrator-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchNarratorValues($el)\"> <datalist id=\"narrator-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Browse by facet</label><!-- Phase 7: facet values are server data — this partial is\n\t\t\t\t\t\t\t\t\t\trendered by templ (templates/facet_panel.templ) and fetched\n\t\t\t\t\t\t\t\t\t\twith the CURRENT filter query string on drawer open and\n\t\t\t\t\t\t\t\t\t\tafter every apply, so counts are computed under the active\n\t\t\t\t\t\t\t\t\t\tfilters. A value click toggles that facet's filter input\n\t\t\t\t\t\t\t\t\t\t(hidden where the drawer has no field) and re-applies the\n\t\t\t\t\t\t\t\t\t\tform — bookshelf.ts owns the click, templ owns the look. --><div id=\"facet-panel\" class=\"space-y-2\"><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Open the filters to browse facet values.</p></div></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) Line truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Narrator</label> <input type=\"text\" name=\"narrator_filter\" placeholder=\"Filter by narrator\" class=\"input\" list=\"narrator-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchNarratorValues($el)\"> <datalist id=\"narrator-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></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\" style=\"background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;\" @click=\"removeFilterChip(chip.param)\"Line truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -422,7 +422,7 @@ func BookShelf(
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 328, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 315, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -445,7 +445,7 @@ func BookShelf(
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, 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: 337, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 324, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -476,7 +476,7 @@ func BookShelf(
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 346, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 333, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -489,7 +489,7 @@ func BookShelf(
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: 351, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 338, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
-85
View File
@@ -1,85 +0,0 @@
package templates
// Facet panel partial (faceted plan Phase 7): the filter drawer's
// value-list sections. The values are SERVER DATA — registry order,
// library-type scoped (Batch D), counts computed under the active
// filters — so templ renders them; the click behavior is the drawer's
// existing client-state flow (bookshelf.ts toggles the facet's filter
// value and re-applies the form). No JSON innerHTML anywhere.
import "strconv"
// FacetPanelValue is one browsable facet value.
type FacetPanelValue struct {
Value string // raw value (rides the filter param)
Display string // display form (prettified/display name); falls back to Value
Count int64
Active bool // currently selected in this facet's filter
}
// FacetPanelSection is one facet's collapsible value list.
type FacetPanelSection struct {
WireParam string // the facet's query param (genre_filter, ...)
Title string // human label
Values []FacetPanelValue
}
// facetPanelTitles maps a registry param to its section label (mirrors
// bookshelf.ts's facetChipLabel names).
var facetPanelTitles = map[string]string{
"author": "Author",
"publisher": "Publisher",
"narrator": "Narrator",
"tags": "Tags",
"series": "Series",
"genre": "Genre",
"language": "Language",
"story_arc": "Story arc",
"imprint": "Imprint",
"edition": "Edition",
"age_rating": "Age rating",
"format_group": "Format",
"manga_type": "Manga type",
"reading_direction": "Reading direction",
}
// FacetPanelTitle is the section label for a registry param (the param
// itself when unmapped — same fallback the chips use).
func FacetPanelTitle(param string) string {
if t, ok := facetPanelTitles[param]; ok {
return t
}
return param
}
templ FacetPanel(sections []FacetPanelSection) {
<div id="facet-panel-content" class="space-y-2">
for _, sec := range sections {
if len(sec.Values) > 0 {
<details
class="facet-section rounded-lg border overflow-hidden"
style="border-color: var(--border);"
data-param={ sec.WireParam }
>
<summary class="facet-section-summary flex items-center justify-between px-3 py-2 cursor-pointer select-none text-sm font-semibold" style="color: var(--text-primary); background-color: var(--bg-secondary);">
<span>{ sec.Title }</span>
<span class="text-xs font-normal" style="color: var(--text-secondary);">{ strconv.Itoa(len(sec.Values)) }</span>
</summary>
<div class="facet-values max-h-56 overflow-y-auto px-2 py-1.5 space-y-0.5">
for _, v := range sec.Values {
<button
type="button"
class={ "facet-value-btn w-full flex items-center justify-between gap-2 px-2.5 py-1.5 rounded text-left text-sm transition-colors", templ.KV("facet-value-active", v.Active) }
data-param={ sec.WireParam }
data-value={ v.Value }
>
<span class="truncate" style="color: var(--text-primary);">{ v.Display }</span>
<span class="text-xs shrink-0 tabular-nums" style="color: var(--text-secondary);">{ strconv.FormatInt(v.Count, 10) }</span>
</button>
}
</div>
</details>
}
}
</div>
}
-223
View File
@@ -1,223 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// Facet panel partial (faceted plan Phase 7): the filter drawer's
// value-list sections. The values are SERVER DATA — registry order,
// library-type scoped (Batch D), counts computed under the active
// filters — so templ renders them; the click behavior is the drawer's
// existing client-state flow (bookshelf.ts toggles the facet's filter
// value and re-applies the form). No JSON innerHTML anywhere.
import "strconv"
// FacetPanelValue is one browsable facet value.
type FacetPanelValue struct {
Value string // raw value (rides the filter param)
Display string // display form (prettified/display name); falls back to Value
Count int64
Active bool // currently selected in this facet's filter
}
// FacetPanelSection is one facet's collapsible value list.
type FacetPanelSection struct {
WireParam string // the facet's query param (genre_filter, ...)
Title string // human label
Values []FacetPanelValue
}
// facetPanelTitles maps a registry param to its section label (mirrors
// bookshelf.ts's facetChipLabel names).
var facetPanelTitles = map[string]string{
"author": "Author",
"publisher": "Publisher",
"narrator": "Narrator",
"tags": "Tags",
"series": "Series",
"genre": "Genre",
"language": "Language",
"story_arc": "Story arc",
"imprint": "Imprint",
"edition": "Edition",
"age_rating": "Age rating",
"format_group": "Format",
"manga_type": "Manga type",
"reading_direction": "Reading direction",
}
// FacetPanelTitle is the section label for a registry param (the param
// itself when unmapped — same fallback the chips use).
func FacetPanelTitle(param string) string {
if t, ok := facetPanelTitles[param]; ok {
return t
}
return param
}
func FacetPanel(sections []FacetPanelSection) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"facet-panel-content\" class=\"space-y-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, sec := range sections {
if len(sec.Values) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<details class=\"facet-section rounded-lg border overflow-hidden\" style=\"border-color: var(--border);\" data-param=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(sec.WireParam)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_panel.templ`, Line: 62, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><summary class=\"facet-section-summary flex items-center justify-between px-3 py-2 cursor-pointer select-none text-sm font-semibold\" style=\"color: var(--text-primary); background-color: var(--bg-secondary);\"><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(sec.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_panel.templ`, Line: 65, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span> <span class=\"text-xs font-normal\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(sec.Values)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_panel.templ`, Line: 66, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span></summary><div class=\"facet-values max-h-56 overflow-y-auto px-2 py-1.5 space-y-0.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, v := range sec.Values {
var templ_7745c5c3_Var5 = []any{"facet-value-btn w-full flex items-center justify-between gap-2 px-2.5 py-1.5 rounded text-left text-sm transition-colors", templ.KV("facet-value-active", v.Active)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<button type=\"button\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_panel.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-param=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(sec.WireParam)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_panel.templ`, Line: 73, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_panel.templ`, Line: 74, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><span class=\"truncate\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.Display)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_panel.templ`, Line: 76, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span> <span class=\"text-xs shrink-0 tabular-nums\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(v.Count, 10))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_panel.templ`, Line: 77, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
-92
View File
@@ -94,7 +94,6 @@ Alpine.data("bookshelf", () => ({
// Standalone function references (don't access component state)
clearFilters,
initBookshelf,
loadFacetPanel,
// Inline method - opens the modal
showSaveFilterModal() {
@@ -659,74 +658,6 @@ function refreshFilterChips(): void {
);
}
// splitFacetValues is the client twin of services.SplitFacetValues: a
// comma-joined filter value is a multi-select (OR within the facet,
// AND across facets).
function splitFacetValues(wireValue: string): string[] {
return wireValue
.split(",")
.map((v) => v.trim())
.filter((v) => v !== "");
}
// loadFacetPanel fetches the drawer's facet-value partial with the
// CURRENT filter query string (faceted plan Phase 7): the values are
// server-rendered by templ (templates/facet_panel.templ), and their
// counts are computed under the active filters by the all-facets query
// — one round trip for every facet at once.
async function loadFacetPanel(): Promise<void> {
const target = document.getElementById("facet-panel");
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement | null;
if (!target || !filterForm) return;
// Serialize the form's named fields (all inputs/selects — no
// multi-selects or file inputs here) — typed directly, since the
// project's tsconfig lib lacks DOM.Iterable (FormData.entries()).
const pairs: string[][] = [];
filterForm
.querySelectorAll<HTMLInputElement | HTMLSelectElement>("input, select")
.forEach((el) => {
if (el.name) pairs.push([el.name, el.value]);
});
const qs = new URLSearchParams(pairs).toString();
await window.htmx.ajax("GET", `/media-items/facet-panel?${qs}`, {
target: "#facet-panel",
swap: "innerHTML",
});
}
// toggleFacetValue applies one panel click: the value joins/leaves its
// facet's comma-joined filter. The facet's input may be a typed drawer
// field (set its value) or absent entirely — deep-link-only facets get
// a hidden input, exactly like applyURLFilters does, so chips and
// clearing keep working.
function toggleFacetValue(param: string, value: string): void {
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement | null;
if (!filterForm) return;
let input = filterForm.querySelector(
`[name="${param}"]`,
) as HTMLInputElement | null;
if (!input) {
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = param;
filterForm.appendChild(hidden);
input = hidden;
}
const values = splitFacetValues(input.value);
const idx = values.indexOf(value);
if (idx >= 0) {
values.splice(idx, 1);
} else {
values.push(value);
}
input.value = values.join(",");
window.htmx.trigger(filterForm, "submit");
}
function initBookshelf(): void {
const filterForm = document.getElementById(
"filter-form",
@@ -737,31 +668,8 @@ function initBookshelf(): void {
// filters, so one bubbled htmx:afterRequest hook is the sync point.
filterForm.addEventListener("htmx:afterRequest", () => {
refreshFilterChips();
// Facet counts must track the applied filters: refetch the panel
// while the drawer is open (the panel's own htmx.ajax requests
// dispatch on body, so this form-scoped hook never loops).
const bookshelfElem = document.querySelector(
'[x-data*="bookshelf"]',
) as any;
const data = bookshelfElem?._x_dataStack?.[0];
if (data?.filtersOpen) {
void loadFacetPanel();
}
});
// Panel value clicks: one delegated listener for every section's
// buttons (the partial re-renders on each refetch).
const facetPanel = document.getElementById("facet-panel");
if (facetPanel) {
facetPanel.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement).closest<HTMLElement>(
".facet-value-btn",
);
if (!btn || !btn.dataset.param || !btn.dataset.value) return;
toggleFacetValue(btn.dataset.param, btn.dataset.value);
});
}
// A2c §5.3: deep-linkable filters — /bookshelf?author_filter=Sapkowski
// pre-fills the form and applies it to the first grid load. Author
// links on the detail page (and anything else) can share filtered
+26 -3
View File
@@ -176,6 +176,18 @@ const setupHTMXListeners = (): void => {
});
};
// Suggestion fetches — field-value autocomplete (author=, genre=, …)
// on the search endpoint — are background hints for typed inputs. A
// failed hint must never interrupt the user with a toast: it logs to
// the console only. The grid's real search (q=/sort= params) is NOT a
// suggestion fetch and keeps its toasts.
const suggestionFieldRe = /[?&](author|genre|tags|narrator|series|language)=/;
function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.includes("/api/media-items/search?")) return false;
return suggestionFieldRe.test(url) && !/[?&](q|sort)=/.test(url);
}
// Setup fetch interceptor
const setupFetchInterceptor = (): void => {
const originalFetch = window.fetch;
@@ -209,17 +221,28 @@ const setupFetchInterceptor = (): void => {
return response;
}
// Handle other errors
// Handle other errors. Suggestion fetches are exempt (console
// only) — a failed background hint never interrupts.
if (!response.ok) {
const errorMessage = await parseFetchError(response);
showToast(errorMessage, "error");
if (isSuggestionFetch(args[0])) {
console.warn(
`Suggestion fetch failed (${response.status}): ${errorMessage}`,
);
} else {
showToast(errorMessage, "error");
}
}
return response;
} catch (error) {
// Don't show toast for redirect errors
if ((error as Error).message !== "Session expired") {
showToast("Network error: Unable to connect to server", "error");
if (isSuggestionFetch(args[0])) {
console.warn("Suggestion fetch failed: network error", error);
} else {
showToast("Network error: Unable to connect to server", "error");
}
}
throw error;
}
-35
View File
@@ -874,41 +874,6 @@
color: var(--text-secondary);
}
/* ---------- Facet panel (filter drawer, Phase 7) ---------- */
/* The values are templ-rendered (templates/facet_panel.templ); this
is look only. A value button is plain until active: active means
"currently in that facet's filter" — accent-tinted like a chip. */
.facet-section-summary {
list-style: none;
}
.facet-section-summary::-webkit-details-marker {
display: none;
}
.facet-section-summary::before {
content: "▸";
display: inline-block;
margin-right: 0.5rem;
color: var(--text-secondary);
transition: transform 0.15s ease;
font-size: 0.7rem;
}
.facet-section[open] .facet-section-summary::before {
transform: rotate(90deg);
}
.facet-value-btn {
background-color: transparent;
}
.facet-value-btn:hover {
background-color: var(--surface-hover);
}
.facet-value-btn.facet-value-active {
background-color: var(--accent-muted);
color: var(--accent);
}
.facet-value-btn.facet-value-active span {
color: var(--accent);
}
.loading-spinner {
display: inline-block;
width: 40px;
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated