feat(facets): all-facets panel with under-filter counts (faceted plan Phase 7)
The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.
- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
listable facets in a single round trip: each arm omits its OWN
facet's filter block and carries every other registered facet's
block (copied mechanically from SearchMediaItemsUnified) — classic
faceted semantics: with filters active each facet counts under the
others; with none, blocks are neutral and the counts are the v1
global-per-library ones. One query serves both cases. The 14
per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
({facets: {param: [{value, display?, count}]}}), filters bound via
the shared ParseMediaSearchParams, library_id optional (the
All-Libraries view, family lens still enforced); facet=X keeps the
pinned single-facet shape (now routed through the same query with
neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
value lists — registry order, Batch D library-type scoping
(FacetsForLibraryType: manga_type on manga, reading_direction on
manga+comics, narrator on audiobooks; All view = union), prettified
genre labels + language display names, active values marked. Family
lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
apply with the CURRENT filter query string; a value click toggles
that facet's comma-joined filter (hidden input where the drawer has
no field — same as deep links) and re-applies the form; chips and
URL stay in sync. Values are server-rendered (rendering doctrine);
bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
implementation (services' display enrichment shares it; templ wraps
it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
registry facet's param, the bools as pgtype.Bool.
Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
This commit is contained in:
1 parent
6152137156
commit
f52bb6f073
18 files changed
+3001
-911
No files matched your search
@@ -310,3 +310,261 @@ func mergeParams(base, extra url.Values) url.Values {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// facetFixtureBooks is the under-filter counts fixture: two fantasy
|
||||||
|
// books (Pinecrest / Quartzmill) and one history book (Pinecrest).
|
||||||
|
// Publisher names are trigram-disjoint on purpose — the scalar-fuzzy
|
||||||
|
// filter is word_similarity, and near-identical names ("Ace Books" vs
|
||||||
|
// "Baen Books") cross-match at 0.3+ (session gotcha).
|
||||||
|
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
books := []struct {
|
||||||
|
title string
|
||||||
|
genre string
|
||||||
|
publisher string
|
||||||
|
}{
|
||||||
|
{prefix + " Alpha", "fantasy", "Pinecrest"},
|
||||||
|
{prefix + " Beta", "fantasy", "Quartzmill"},
|
||||||
|
{prefix + " Gamma", "history", "Pinecrest"},
|
||||||
|
}
|
||||||
|
for _, b := range books {
|
||||||
|
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: libraryID,
|
||||||
|
Title: b.title,
|
||||||
|
Authors: []string{prefix + " Author"},
|
||||||
|
Genre: pgtype.Text{String: b.genre, Valid: true},
|
||||||
|
Publishers: []string{b.publisher},
|
||||||
|
Language: pgtype.Text{String: "en", Valid: true},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/facet-counts/%s-%d.epub", b.title, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// facetCountValue is one decoded facet value+count.
|
||||||
|
type facetCountValue struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Count float64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// facetCountsGet runs the all-facets endpoint (no facet param) and
|
||||||
|
// decodes {"facets": {param: [{value, count}, …]}}.
|
||||||
|
func facetCountsGet(t *testing.T, setup *TestServerSetup, params url.Values) (int, map[string][]facetCountValue) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?"+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 >= 400 {
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Facets map[string][]facetCountValue `json:"facets"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
return resp.StatusCode, body.Facets
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireFacetCount asserts one value's count within a facet.
|
||||||
|
func requireFacetCount(t *testing.T, facet []facetCountValue, value string, want float64) {
|
||||||
|
t.Helper()
|
||||||
|
for _, v := range facet {
|
||||||
|
if v.Value == value {
|
||||||
|
require.Equal(t, want, v.Count, "facet value %q count", value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Failf(t, "facet value missing", "%q not in facet", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFacetCountsUnderFilters (faceted plan Phase 7): the all-facets
|
||||||
|
// shape computes each facet's counts UNDER the other active filters —
|
||||||
|
// a facet's own block is omitted from its arm (classic faceted
|
||||||
|
// semantics: the genre list stays complete while genre filters the
|
||||||
|
// rest), multi-select is OR-within, and the family lens holds on the
|
||||||
|
// new shape too.
|
||||||
|
func TestFacetCountsUnderFilters(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||||
|
require.NoError(t, err)
|
||||||
|
libName := fmt.Sprintf("Test Facet Counts %d", time.Now().UnixNano())
|
||||||
|
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: libName,
|
||||||
|
Description: pgtype.Text{String: "facet under-filter counts test library", Valid: true},
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||||
|
|
||||||
|
hiddenLib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: fmt.Sprintf("Test Facet Counts Hidden %d", time.Now().UnixNano()),
|
||||||
|
Description: pgtype.Text{String: "hidden for the regular user", Valid: true},
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, hiddenLib.ID) })
|
||||||
|
_, 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)
|
||||||
|
|
||||||
|
facetFixtureBooks(t, setup, library.ID, "Counts")
|
||||||
|
// A book in the HIDDEN library: distinctive values that must never
|
||||||
|
// surface for the regular user.
|
||||||
|
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: hiddenLib.ID,
|
||||||
|
Title: "Counts Hidden Book",
|
||||||
|
Authors: []string{"Counts Secret Author"},
|
||||||
|
Genre: pgtype.Text{String: "cryptobotany", Valid: true},
|
||||||
|
Publishers: []string{"Shadowpress"},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/facet-counts/hidden-%d.epub", time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// No filters: the v1 global-per-library counts.
|
||||||
|
status, counts := facetCountsGet(t, setup, url.Values{"library_id": {libUUID.String()}})
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
requireFacetCount(t, counts["genre"], "fantasy", 2)
|
||||||
|
requireFacetCount(t, counts["genre"], "history", 1)
|
||||||
|
requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
|
||||||
|
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
|
||||||
|
|
||||||
|
// Under genre=fantasy: the genre facet STILL lists everything with
|
||||||
|
// unfiltered counts (its own block is omitted from its arm), while
|
||||||
|
// the publisher facet counts only within fantasy.
|
||||||
|
status, counts = facetCountsGet(t, setup, url.Values{
|
||||||
|
"library_id": {libUUID.String()},
|
||||||
|
"genre_filter": {"fantasy"},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
requireFacetCount(t, counts["genre"], "fantasy", 2)
|
||||||
|
requireFacetCount(t, counts["genre"], "history", 1)
|
||||||
|
requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
|
||||||
|
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
|
||||||
|
|
||||||
|
// OR-within: both genres → Pinecrest regains Alpha (fantasy) and
|
||||||
|
// Gamma (history).
|
||||||
|
status, counts = facetCountsGet(t, setup, url.Values{
|
||||||
|
"library_id": {libUUID.String()},
|
||||||
|
"genre_filter": {"fantasy,history"},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
|
||||||
|
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
|
||||||
|
|
||||||
|
// AND-across: genre=history + publisher=Pinecrest → the genre facet
|
||||||
|
// is unfiltered by ITSELF (its own block is omitted) but still
|
||||||
|
// counts under the active publisher filter: both Pinecrest books
|
||||||
|
// (Alpha fantasy, Gamma history) surface as 1 each, Beta (Quartzmill)
|
||||||
|
// is filtered away. The publisher facet sees only history books, so
|
||||||
|
// Pinecrest counts Gamma alone.
|
||||||
|
status, counts = facetCountsGet(t, setup, url.Values{
|
||||||
|
"library_id": {libUUID.String()},
|
||||||
|
"genre_filter": {"history"},
|
||||||
|
"publisher_filter": {"Pinecrest"},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
requireFacetCount(t, counts["genre"], "fantasy", 1)
|
||||||
|
requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
|
||||||
|
|
||||||
|
// All-Libraries view (no library_id): the visible library's values
|
||||||
|
// are present, the hidden library's are absent everywhere.
|
||||||
|
status, counts = facetCountsGet(t, setup, url.Values{})
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
requireFacetCount(t, counts["genre"], "fantasy", 2)
|
||||||
|
for _, v := range counts["genre"] {
|
||||||
|
require.NotEqual(t, "cryptobotany", v.Value, "hidden-library genre must be absent")
|
||||||
|
}
|
||||||
|
for _, v := range counts["publisher"] {
|
||||||
|
require.NotEqual(t, "Shadowpress", v.Value, "hidden-library publisher must be absent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family lens: an explicit HIDDEN library_id on the new shape is a
|
||||||
|
// 403 (AGENTS standing rule — never a silent pass).
|
||||||
|
status, _ = facetCountsGet(t, setup, url.Values{"library_id": {hiddenUUID.String()}})
|
||||||
|
require.Equal(t, http.StatusForbidden, status)
|
||||||
|
|
||||||
|
// The pinned single-facet shape is untouched by the extension.
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?facet=genre&library_id="+libUUID.String(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var single struct {
|
||||||
|
Facet string `json:"facet"`
|
||||||
|
Values []facetCountValue `json:"values"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&single))
|
||||||
|
require.Equal(t, "genre", single.Facet)
|
||||||
|
requireFacetCount(t, single.Values, "fantasy", 2)
|
||||||
|
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) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
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},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||||
|
|
||||||
|
facetFixtureBooks(t, setup, library.ID, "Panel")
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -257,3 +257,26 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
from the live server's scans started reaching the socket first.
|
from the live server's scans started reaching the socket first.
|
||||||
Keepalive is the standard protocol-level ping the hub already sends.
|
Keepalive is the standard protocol-level ping the hub already sends.
|
||||||
Never-built behavior is not a pinnable contract.
|
Never-built behavior is not a pinnable contract.
|
||||||
|
|
||||||
|
## 2026-10-20 — Scanner Phase 7: all-facets shape + SSR facet panel
|
||||||
|
|
||||||
|
- `GET /api/media-items/facets` gains a SECOND shape: with NO `facet`
|
||||||
|
param it answers `{"facets": {<param>: [{"value","display?","count"},
|
||||||
|
…]}}` — every listable facet in one response, counts computed under
|
||||||
|
the ACTIVE FILTERS (the same query params the search endpoint takes;
|
||||||
|
each facet is counted under the others — its own filter does not
|
||||||
|
restrict its own list). `library_id` is optional on this shape:
|
||||||
|
absent = the All-Libraries view. Family-lens unchanged: an explicit
|
||||||
|
out-of-set `library_id` is 403 on both shapes.
|
||||||
|
- The PINNED single-facet shape (`?facet=X&library_id=…` →
|
||||||
|
`{facet, values[]}`) is byte-identical in semantics; its
|
||||||
|
implementation now routes through the same UNION-ALL query with
|
||||||
|
neutral filters (the 14 per-column SQL queries were deleted — no
|
||||||
|
wire change).
|
||||||
|
- NEW SSR partial: `GET /media-items/facet-panel` (session-cookie
|
||||||
|
auth, bookshelf-protected group) renders the drawer's value lists —
|
||||||
|
templ-rendered, registry order, library-type-scoped sections, active
|
||||||
|
values marked. An out-of-set explicit `library_id` is 403 HTML.
|
||||||
|
- 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.
|
||||||
@@ -164,27 +164,27 @@ type Querier interface {
|
|||||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||||
// label before stats); a capture row must only reference a REAL device.
|
// label before stats); a capture row must only reference a REAL device.
|
||||||
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
|
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
|
||||||
FacetAgeRatingValues(ctx context.Context, arg FacetAgeRatingValuesParams) ([]FacetAgeRatingValuesRow, error)
|
// Facet-value listings (faceted-navigation Batch C v2, faceted plan
|
||||||
// Facet-value listings (faceted-navigation Batch C): top values + counts
|
// Phase 7): ALL listable facets' top values + counts in ONE statement —
|
||||||
// for the filter panel, one static query per media_items column (same
|
// one round trip serves the filter drawer's panel, with classic faceted
|
||||||
// sqlc-compatibility constraint as the Search*Values autocomplete above).
|
// semantics: each arm OMITS its own facet's filter block and carries
|
||||||
|
// every OTHER registered facet's block (copied mechanically from
|
||||||
|
// SearchMediaItemsUnified — keep the two in lockstep), so with filters
|
||||||
|
// active each facet counts under the others, and with no filters the
|
||||||
|
// blocks are neutral and the counts are the v1 global-per-library ones.
|
||||||
|
// One query serves both cases.
|
||||||
|
//
|
||||||
// Visibility: same family-lens join as the search query — a user's
|
// Visibility: same family-lens join as the search query — a user's
|
||||||
// hidden-library rows are absent, and the handler 403s an out-of-set
|
// hidden-library rows are absent; the handler 403s an out-of-set
|
||||||
// explicit library_id. Counts are v1 global-per-library (not
|
// explicit library_id. library_id is nullable: NULL = the All-Libraries
|
||||||
// recomputed under the other active filters).
|
// view (no scope beyond the lens). Bool/range facets (has_cover,
|
||||||
FacetAuthorsValues(ctx context.Context, arg FacetAuthorsValuesParams) ([]FacetAuthorsValuesRow, error)
|
// explicit, abridged, year) filter every arm but list no values — they
|
||||||
FacetEditionValues(ctx context.Context, arg FacetEditionValuesParams) ([]FacetEditionValuesRow, error)
|
// render as toggles/inputs, not lists.
|
||||||
FacetFormatGroupValues(ctx context.Context, arg FacetFormatGroupValuesParams) ([]FacetFormatGroupValuesRow, error)
|
//
|
||||||
FacetGenreValues(ctx context.Context, arg FacetGenreValuesParams) ([]FacetGenreValuesRow, error)
|
// Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
|
||||||
FacetImprintValues(ctx context.Context, arg FacetImprintValuesParams) ([]FacetImprintValuesRow, error)
|
// select bind per-arm only in parenthesized subqueries). The same
|
||||||
FacetLanguageValues(ctx context.Context, arg FacetLanguageValuesParams) ([]FacetLanguageValuesRow, error)
|
// sqlc.narg names repeat across arms; sqlc binds them once.
|
||||||
FacetMangaTypeValues(ctx context.Context, arg FacetMangaTypeValuesParams) ([]FacetMangaTypeValuesRow, error)
|
FacetCountsUnderFilters(ctx context.Context, arg FacetCountsUnderFiltersParams) ([]FacetCountsUnderFiltersRow, error)
|
||||||
FacetNarratorsValues(ctx context.Context, arg FacetNarratorsValuesParams) ([]FacetNarratorsValuesRow, error)
|
|
||||||
FacetPublishersValues(ctx context.Context, arg FacetPublishersValuesParams) ([]FacetPublishersValuesRow, error)
|
|
||||||
FacetReadingDirectionValues(ctx context.Context, arg FacetReadingDirectionValuesParams) ([]FacetReadingDirectionValuesRow, error)
|
|
||||||
FacetSeriesValues(ctx context.Context, arg FacetSeriesValuesParams) ([]FacetSeriesValuesRow, error)
|
|
||||||
FacetStoryArcValues(ctx context.Context, arg FacetStoryArcValuesParams) ([]FacetStoryArcValuesRow, error)
|
|
||||||
FacetTagsValues(ctx context.Context, arg FacetTagsValuesParams) ([]FacetTagsValuesRow, error)
|
|
||||||
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||||
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||||
|
|||||||
+1007
-595
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+75
-28
@@ -2446,22 +2446,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return services.SearchParams{
|
return services.SearchParams{
|
||||||
SearchQuery: c.QueryParam("q"),
|
SearchQuery: c.QueryParam("q"),
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
AuthorFilter: c.QueryParam("author_filter"),
|
AuthorFilter: c.QueryParam("author_filter"),
|
||||||
SeriesFilter: c.QueryParam("series_filter"),
|
SeriesFilter: c.QueryParam("series_filter"),
|
||||||
GenreFilter: c.QueryParam("genre_filter"),
|
GenreFilter: c.QueryParam("genre_filter"),
|
||||||
PublisherFilter: c.QueryParam("publisher_filter"),
|
PublisherFilter: c.QueryParam("publisher_filter"),
|
||||||
TagsFilter: c.QueryParam("tags_filter"),
|
TagsFilter: c.QueryParam("tags_filter"),
|
||||||
NarratorFilter: c.QueryParam("narrator_filter"),
|
NarratorFilter: c.QueryParam("narrator_filter"),
|
||||||
LanguageFilter: c.QueryParam("language_filter"),
|
LanguageFilter: c.QueryParam("language_filter"),
|
||||||
YearMin: yearMin,
|
YearMin: yearMin,
|
||||||
YearMax: yearMax,
|
YearMax: yearMax,
|
||||||
HasCover: boolFacet("has_cover"),
|
HasCover: boolFacet("has_cover"),
|
||||||
StoryArcFilter: c.QueryParam("story_arc_filter"),
|
StoryArcFilter: c.QueryParam("story_arc_filter"),
|
||||||
ImprintFilter: c.QueryParam("imprint_filter"),
|
ImprintFilter: c.QueryParam("imprint_filter"),
|
||||||
EditionFilter: c.QueryParam("edition_filter"),
|
EditionFilter: c.QueryParam("edition_filter"),
|
||||||
AgeRatingFilter: c.QueryParam("age_rating_filter"),
|
AgeRatingFilter: c.QueryParam("age_rating_filter"),
|
||||||
FormatGroupFilter: c.QueryParam("format_group_filter"),
|
FormatGroupFilter: c.QueryParam("format_group_filter"),
|
||||||
MangaTypeFilter: c.QueryParam("manga_type_filter"),
|
MangaTypeFilter: c.QueryParam("manga_type_filter"),
|
||||||
ReadingDirectionFilter: c.QueryParam("reading_direction_filter"),
|
ReadingDirectionFilter: c.QueryParam("reading_direction_filter"),
|
||||||
@@ -2490,11 +2490,21 @@ func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFacets handles GET /api/media-items/facets?facet=X&library_id=...
|
// GetFacets handles GET /api/media-items/facets — the filter panel's
|
||||||
// — the filter panel's value+count listing (faceted-navigation Batch C).
|
// value+count listing (faceted-navigation Batch C, extended in Phase 7).
|
||||||
// Registry-driven: the facet param must name a FacetArray or
|
//
|
||||||
// FacetScalar entry (bool/range facets render as toggles, not lists).
|
// Two shapes:
|
||||||
// Family-lens: an explicit library_id outside the visible set is 403.
|
// - ?facet=X&library_id=… → the PINNED single-facet shape
|
||||||
|
// ({facet, values[]}); facet must name a FacetArray or FacetScalar
|
||||||
|
// entry (bool/range facets render as toggles, not lists).
|
||||||
|
// - no facet param → the all-facets shape ({"facets": {param:
|
||||||
|
// [value,…]}}), computed under the ACTIVE FILTERS (the same query
|
||||||
|
// params the search endpoint takes) — each facet counts under the
|
||||||
|
// others, the SQL omitting each arm's own block. library_id is
|
||||||
|
// optional: absent = the All-Libraries view.
|
||||||
|
//
|
||||||
|
// Family-lens: an explicit library_id outside the visible set is 403
|
||||||
|
// in both shapes.
|
||||||
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
||||||
user, ok := c.Get("user").(database.Users)
|
user, ok := c.Get("user").(database.Users)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -2503,8 +2513,43 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
|||||||
|
|
||||||
facetParam := c.QueryParam("facet")
|
facetParam := c.QueryParam("facet")
|
||||||
libraryID := c.QueryParam("library_id")
|
libraryID := c.QueryParam("library_id")
|
||||||
if facetParam == "" || libraryID == "" {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet and library_id are required"})
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
if facetParam == "" {
|
||||||
|
// All-facets shape: filters bind exactly like the search
|
||||||
|
// endpoint's (ParseMediaSearchParams), so the panel's counts
|
||||||
|
// and the grid's rows can never disagree about what's active.
|
||||||
|
var libPg pgtype.UUID
|
||||||
|
if 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"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts, err := mh.searchService.FacetCounts(c.Request().Context(), user.ID, libPg, ParseMediaSearchParams(c), limit)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger().Error("facet counts error", "error", err.Error())
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"facets": counts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if libraryID == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
|
||||||
}
|
}
|
||||||
facet := services.FacetByParam(facetParam)
|
facet := services.FacetByParam(facetParam)
|
||||||
if facet == nil {
|
if facet == nil {
|
||||||
@@ -2527,11 +2572,6 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
||||||
if limit == 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
values, err := mh.searchService.FacetValues(c.Request().Context(), user.ID, *facet, libPg, limit)
|
values, err := mh.searchService.FacetValues(c.Request().Context(), user.ID, *facet, libPg, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Error("facet values error", "error", err.Error(), "facet", facetParam)
|
c.Logger().Error("facet values error", "error", err.Error(), "facet", facetParam)
|
||||||
@@ -2543,6 +2583,13 @@ 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.)
|
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
|
||||||
// Returns distinct field values with counts and similarity scores for dropdown population
|
// Returns distinct field values with counts and similarity scores for dropdown population
|
||||||
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
||||||
|
|||||||
@@ -386,6 +386,129 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
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
|
// Dashboard page
|
||||||
frontendProtected.GET("/dashboard", func(c *echo.Context) error {
|
frontendProtected.GET("/dashboard", func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
|||||||
@@ -109,6 +109,35 @@ func TestFacetRegistrySQLDrift(t *testing.T) {
|
|||||||
t.Errorf("Batch A facet %s missing from SearchMediaItemsUnified", wire)
|
t.Errorf("Batch A facet %s missing from SearchMediaItemsUnified", wire)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 7: the counts query (FacetCountsUnderFilters) carries the
|
||||||
|
// same filter blocks — copied mechanically from the search query —
|
||||||
|
// so the panel's under-filter counts and the grid's rows can never
|
||||||
|
// disagree about what a filter means. Reflect the generated params
|
||||||
|
// struct the same way.
|
||||||
|
countParamsType := reflect.TypeOf(database.FacetCountsUnderFiltersParams{})
|
||||||
|
countTags := map[string]bool{}
|
||||||
|
for i := 0; i < countParamsType.NumField(); i++ {
|
||||||
|
tag := strings.Split(countParamsType.Field(i).Tag.Get("db"), ",")[0]
|
||||||
|
countTags[tag] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"user_id", "library_id", "limit"} {
|
||||||
|
if !countTags[want] {
|
||||||
|
t.Errorf("FacetCountsUnderFilters missing %s param", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range facetRegistry {
|
||||||
|
wire := f.WireParam()
|
||||||
|
if f.Kind == FacetRange {
|
||||||
|
if !countTags["year_min"] || !countTags["year_max"] {
|
||||||
|
t.Error("year range must bind year_min/year_max in FacetCountsUnderFilters")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !countTags[wire] {
|
||||||
|
t.Errorf("registry facet %q missing from FacetCountsUnderFilters (each arm binds every OTHER facet's block, so every facet's param must exist)", wire)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacetsForLibraryType(t *testing.T) {
|
func TestFacetsForLibraryType(t *testing.T) {
|
||||||
|
|||||||
+94
-150
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -154,6 +155,14 @@ func normalizeLanguageFilter(wireValue string) string {
|
|||||||
return strings.Join(out, ",")
|
return strings.Join(out, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeLanguageFilter is the exported form of the search-path
|
||||||
|
// normalization for callers that must compare RAW filter values
|
||||||
|
// against stored codes without issuing a query (the facet panel marks
|
||||||
|
// a value Active by matching the bound filter set).
|
||||||
|
func NormalizeLanguageFilter(wireValue string) string {
|
||||||
|
return normalizeLanguageFilter(wireValue)
|
||||||
|
}
|
||||||
|
|
||||||
// FacetValue is one facet value + count for the filter panel.
|
// FacetValue is one facet value + count for the filter panel.
|
||||||
type FacetValue struct {
|
type FacetValue struct {
|
||||||
Value string `json:"value"` // the raw value (rides the filter param)
|
Value string `json:"value"` // the raw value (rides the filter param)
|
||||||
@@ -163,159 +172,94 @@ type FacetValue struct {
|
|||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FacetValues lists one facet's top values + counts, scoped to a single
|
// facetCountsLimit is the per-facet top-N bound the panel renders.
|
||||||
// library (v1 counts are global-per-library, not recomputed under the
|
const facetCountsLimit = 50
|
||||||
// other active filters). Registry-driven: only FacetArray and
|
|
||||||
// FacetScalar facets list values — bool/range facets render as
|
// FacetCounts computes every listable facet's top values + counts under
|
||||||
// toggles/ranges in the panel, not value lists.
|
// the active filters (faceted plan Phase 7): ONE UNION-ALL round trip
|
||||||
func (s *SearchService) FacetValues(ctx context.Context, userID pgtype.UUID, facet Facet, libraryID pgtype.UUID, limit int) ([]FacetValue, error) {
|
// (FacetCountsUnderFilters) in which each facet is counted under the
|
||||||
lim := pgtype.Int4{Int32: int32(limit), Valid: true}
|
// OTHER facets' filters — the SQL omits each arm's own block, classic
|
||||||
switch facet.Param {
|
// faceted-search semantics. With no filters bound every block is
|
||||||
case "author":
|
// neutral and the counts are the v1 global-per-library ones, so one
|
||||||
rows, err := s.db.FacetAuthorsValues(ctx, database.FacetAuthorsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
// query serves both cases. libraryID may be NULL (the All-Libraries
|
||||||
if err != nil {
|
// view). Display enrichment matches the wire contract: language codes
|
||||||
return nil, err
|
// → display names, genre values prettified — both only when they
|
||||||
}
|
// differ from the raw value (Display is "the prettified form when it
|
||||||
out := make([]FacetValue, len(rows))
|
// differs", per FacetValue).
|
||||||
for i, r := range rows {
|
func (s *SearchService) FacetCounts(ctx context.Context, userID, libraryID pgtype.UUID, params SearchParams, limit int) (map[string][]FacetValue, error) {
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
if limit <= 0 {
|
||||||
}
|
limit = facetCountsLimit
|
||||||
return out, nil
|
}
|
||||||
case "publisher":
|
// Language filter UX (same normalization the search path runs —
|
||||||
rows, err := s.db.FacetPublishersValues(ctx, database.FacetPublishersValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
// see SearchMediaItemsUnified): display names in, ISO codes bound.
|
||||||
if err != nil {
|
langFilter := normalizeLanguageFilter(params.LanguageFilter)
|
||||||
return nil, err
|
|
||||||
}
|
rows, err := s.db.FacetCountsUnderFilters(ctx, database.FacetCountsUnderFiltersParams{
|
||||||
out := make([]FacetValue, len(rows))
|
UserID: userID,
|
||||||
for i, r := range rows {
|
LibraryID: libraryID,
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
}
|
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
|
||||||
return out, nil
|
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
|
||||||
case "narrator":
|
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||||
rows, err := s.db.FacetNarratorsValues(ctx, database.FacetNarratorsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
|
||||||
if err != nil {
|
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||||
return nil, err
|
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
|
||||||
}
|
LanguageFilter: pgtype.Text{String: langFilter, Valid: true},
|
||||||
out := make([]FacetValue, len(rows))
|
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
|
||||||
for i, r := range rows {
|
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
|
||||||
}
|
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
|
||||||
return out, nil
|
FormatGroupFilter: pgtype.Text{String: params.FormatGroupFilter, Valid: true},
|
||||||
case "tags":
|
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
|
||||||
rows, err := s.db.FacetTagsValues(ctx, database.FacetTagsValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
ReadingDirectionFilter: pgtype.Text{String: params.ReadingDirectionFilter, Valid: true},
|
||||||
if err != nil {
|
ExplicitFilter: params.ExplicitFilter,
|
||||||
return nil, err
|
AbridgedFilter: params.AbridgedFilter,
|
||||||
}
|
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||||
out := make([]FacetValue, len(rows))
|
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||||
for i, r := range rows {
|
HasCover: params.HasCover,
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
})
|
||||||
}
|
if err != nil {
|
||||||
return out, nil
|
return nil, err
|
||||||
case "series":
|
}
|
||||||
rows, err := s.db.FacetSeriesValues(ctx, database.FacetSeriesValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
out := make(map[string][]FacetValue, 14)
|
||||||
return nil, err
|
for _, r := range rows {
|
||||||
}
|
v := FacetValue{Value: r.Value, Count: r.Count}
|
||||||
out := make([]FacetValue, len(rows))
|
switch r.Facet {
|
||||||
for i, r := range rows {
|
case "language":
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
if name := LanguageDisplayName(r.Value); name != "" && name != r.Value {
|
||||||
}
|
v.Display = name
|
||||||
return out, nil
|
}
|
||||||
case "genre":
|
case "genre":
|
||||||
rows, err := s.db.FacetGenreValues(ctx, database.FacetGenreValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
if d := utils.PrettifyGenre(r.Value); d != r.Value {
|
||||||
if err != nil {
|
v.Display = d
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case "language":
|
|
||||||
rows, err := s.db.FacetLanguageValues(ctx, database.FacetLanguageValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
if name := LanguageDisplayName(r.Value); name != "" {
|
|
||||||
out[i].Display = name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
out[r.Facet] = append(out[r.Facet], v)
|
||||||
case "story_arc":
|
|
||||||
rows, err := s.db.FacetStoryArcValues(ctx, database.FacetStoryArcValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case "imprint":
|
|
||||||
rows, err := s.db.FacetImprintValues(ctx, database.FacetImprintValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case "edition":
|
|
||||||
rows, err := s.db.FacetEditionValues(ctx, database.FacetEditionValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case "age_rating":
|
|
||||||
rows, err := s.db.FacetAgeRatingValues(ctx, database.FacetAgeRatingValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case "format_group":
|
|
||||||
rows, err := s.db.FacetFormatGroupValues(ctx, database.FacetFormatGroupValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case "manga_type":
|
|
||||||
rows, err := s.db.FacetMangaTypeValues(ctx, database.FacetMangaTypeValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
case "reading_direction":
|
|
||||||
rows, err := s.db.FacetReadingDirectionValues(ctx, database.FacetReadingDirectionValuesParams{UserID: userID, LibraryID: libraryID, Limit: lim})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]FacetValue, len(rows))
|
|
||||||
for i, r := range rows {
|
|
||||||
out[i] = FacetValue{Value: r.Value, Count: r.Count}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FacetValues lists ONE facet's top values + counts — the pinned
|
||||||
|
// single-facet shape behind GET /api/media-items/facets?facet=X. It is
|
||||||
|
// the all-facets query with every filter neutral (v1 counts:
|
||||||
|
// global-per-library, not recomputed under other filters).
|
||||||
|
// Registry-driven: only FacetArray and FacetScalar facets list values —
|
||||||
|
// bool/range facets render as toggles/ranges in the panel, not value
|
||||||
|
// lists.
|
||||||
|
func (s *SearchService) FacetValues(ctx context.Context, userID pgtype.UUID, facet Facet, libraryID pgtype.UUID, limit int) ([]FacetValue, error) {
|
||||||
|
if facet.Kind != FacetArray && facet.Kind != FacetScalar {
|
||||||
|
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
|
||||||
|
}
|
||||||
|
counts, err := s.FacetCounts(ctx, userID, libraryID, SearchParams{}, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values := counts[facet.Param]
|
||||||
|
if values == nil {
|
||||||
|
return []FacetValue{}, nil
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FieldSearchParams contains parameters for field-specific search (autocomplete)
|
// FieldSearchParams contains parameters for field-specific search (autocomplete)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
// Genre display rendering (faceted plan Batch B / Phase 7): the
|
||||||
|
// canonically-lowercase genre value is title-cased at DISPLAY time
|
||||||
|
// only — never in the DB. Canonical home: the filter panel (services),
|
||||||
|
// templ (templates wraps this), and the TS port in bookshelf.ts all
|
||||||
|
// share these exact rules.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genreSmallWords stay lowercase in title-case rendering (except the
|
||||||
|
// first word of the value).
|
||||||
|
var genreSmallWords = map[string]bool{
|
||||||
|
"a": true, "an": true, "and": true, "of": true, "the": true,
|
||||||
|
"in": true, "on": true, "for": true, "to": true, "or": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrettifyGenre renders the canonically-lowercase genre value in title
|
||||||
|
// case at display time (Batch B transform — never in the DB). Only
|
||||||
|
// values that are ENTIRELY lowercase get transformed; anything else is
|
||||||
|
// returned verbatim, protecting user-entered mixed case. Small words
|
||||||
|
// stay down except in the first position; "&" has no case and passes
|
||||||
|
// through. "science fiction" → "Science Fiction", "sword & sorcery" →
|
||||||
|
// "Sword & Sorcery".
|
||||||
|
func PrettifyGenre(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if trimmed != strings.ToLower(trimmed) {
|
||||||
|
return trimmed // mixed case = user-authored; display as written
|
||||||
|
}
|
||||||
|
words := strings.Fields(trimmed)
|
||||||
|
out := make([]string, len(words))
|
||||||
|
for i, w := range words {
|
||||||
|
if i > 0 && genreSmallWords[w] {
|
||||||
|
out[i] = w
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runes := []rune(w)
|
||||||
|
out[i] = string(append([]rune{unicode.ToUpper(runes[0])}, runes[1:]...))
|
||||||
|
}
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ templ BookShelf(
|
|||||||
<option value="created_at DESC">Date Added</option>
|
<option value="created_at DESC">Date Added</option>
|
||||||
<option value="page_count DESC">Page Count</option>
|
<option value="page_count DESC">Page Count</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" @click="filtersOpen = true" class="btn btn-secondary">
|
<button type="button" @click="filtersOpen = true; loadFacetPanel()" class="btn btn-secondary">
|
||||||
@Icon("filter", "h-4 w-4")
|
@Icon("filter", "h-4 w-4")
|
||||||
<span>Filters</span>
|
<span>Filters</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -279,6 +279,19 @@ templ BookShelf(
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
||||||
<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);">
|
<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>
|
<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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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\" 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; loadFacetPanel()\" class=\"btn btn-secondary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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><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
|
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
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -422,7 +422,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 315, Col: 59}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 328, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -445,7 +445,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var21 string
|
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))
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 324, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 337, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -476,7 +476,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 333, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 346, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -489,7 +489,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var23 string
|
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))
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 338, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 351, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// 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
|
||||||
+5
-32
@@ -3,13 +3,13 @@ package templates
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
|
"bookhoard/internal/utils"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -256,39 +256,12 @@ func conflictID(c *handlers.ConflictDetailResponse) string {
|
|||||||
return c.ID
|
return c.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// genreSmallWords stay lowercase in title-case rendering (except the
|
|
||||||
// first word of the value).
|
|
||||||
var genreSmallWords = map[string]bool{
|
|
||||||
"a": true, "an": true, "and": true, "of": true, "the": true,
|
|
||||||
"in": true, "on": true, "for": true, "to": true, "or": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// prettifyGenre renders the canonically-lowercase genre value in title
|
// prettifyGenre renders the canonically-lowercase genre value in title
|
||||||
// case at display time (Batch B transform — never in the DB). Only
|
// case at display time — the canonical implementation lives in
|
||||||
// values that are ENTIRELY lowercase get transformed; anything else is
|
// internal/utils (services' facet panel shares it); this wrapper keeps
|
||||||
// returned verbatim, protecting user-entered mixed case. Small words
|
// the templ call sites short.
|
||||||
// stay down except in the first position; "&" has no case and passes
|
|
||||||
// through. "science fiction" → "Science Fiction", "sword & sorcery" →
|
|
||||||
// "Sword & Sorcery".
|
|
||||||
func prettifyGenre(value string) string {
|
func prettifyGenre(value string) string {
|
||||||
trimmed := strings.TrimSpace(value)
|
return utils.PrettifyGenre(value)
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if trimmed != strings.ToLower(trimmed) {
|
|
||||||
return trimmed // mixed case = user-authored; display as written
|
|
||||||
}
|
|
||||||
words := strings.Fields(trimmed)
|
|
||||||
out := make([]string, len(words))
|
|
||||||
for i, w := range words {
|
|
||||||
if i > 0 && genreSmallWords[w] {
|
|
||||||
out[i] = w
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
runes := []rune(w)
|
|
||||||
out[i] = string(append([]rune{unicode.ToUpper(runes[0])}, runes[1:]...))
|
|
||||||
}
|
|
||||||
return strings.Join(out, " ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAlternateSeries extracts the alternate series name from JSONB data
|
// getAlternateSeries extracts the alternate series name from JSONB data
|
||||||
|
|||||||
+95
-3
@@ -94,6 +94,7 @@ Alpine.data("bookshelf", () => ({
|
|||||||
// Standalone function references (don't access component state)
|
// Standalone function references (don't access component state)
|
||||||
clearFilters,
|
clearFilters,
|
||||||
initBookshelf,
|
initBookshelf,
|
||||||
|
loadFacetPanel,
|
||||||
|
|
||||||
// Inline method - opens the modal
|
// Inline method - opens the modal
|
||||||
showSaveFilterModal() {
|
showSaveFilterModal() {
|
||||||
@@ -658,6 +659,74 @@ 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 {
|
function initBookshelf(): void {
|
||||||
const filterForm = document.getElementById(
|
const filterForm = document.getElementById(
|
||||||
"filter-form",
|
"filter-form",
|
||||||
@@ -666,9 +735,32 @@ function initBookshelf(): void {
|
|||||||
// Active-filter chips refresh after every grid refetch — drawer
|
// Active-filter chips refresh after every grid refetch — drawer
|
||||||
// applies, sort changes, and pagination all include the form's
|
// applies, sort changes, and pagination all include the form's
|
||||||
// filters, so one bubbled htmx:afterRequest hook is the sync point.
|
// filters, so one bubbled htmx:afterRequest hook is the sync point.
|
||||||
filterForm.addEventListener("htmx:afterRequest", () =>
|
filterForm.addEventListener("htmx:afterRequest", () => {
|
||||||
refreshFilterChips(),
|
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
|
// A2c §5.3: deep-linkable filters — /bookshelf?author_filter=Sapkowski
|
||||||
// pre-fills the form and applies it to the first grid load. Author
|
// pre-fills the form and applies it to the first grid load. Author
|
||||||
|
|||||||
@@ -874,6 +874,41 @@
|
|||||||
color: var(--text-secondary);
|
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 {
|
.loading-spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 40px;
|
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: }
|
*,: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