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:
1 parent
f52bb6f073
commit
ebb7f4a5b0
14 files changed
+192
-643
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 != ''
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
Reference in new issue
Block a user