feat(facets+ui): clickable facet values everywhere, genre prettify, active-filter chips

Frontend half of the faceted-navigation Phase 2. 'Everything filterable
should be clickable': every displayed facet value links to the bookshelf
pre-filtered by its raw stored value; display forms are prettified at
render, never in the DB.

- templates/facet_links.templ: FacetLink (inline links) + FacetBadge
  (badge links) to /bookshelf?<facet>_filter=<value>; the raw value
  rides the param while the shown text is prettified, so matching and
  display can never disagree. Series/tags keep their dedicated pages
  by design; the card media-type icon stays unlinked (a derived kind,
  not a column value — deep-linking it would send wrong values).
- book_detail.templ: publisher, language, edition, genre, imprint
  cells + narrators line + age-rating/story-arc badges are now links.
- prettifyGenre (templates/utils.go + unit tests): title-cases only
  entirely-lowercase values; small words stay down except first
  position; & preserved. TS port for chip labels documents the twin.
- bookshelf.templ + bookshelf.ts: active-filter chips bar — Alpine
  renders chips from client-local form state (sanctioned island);
  one bubbled htmx:afterRequest hook keeps chips + shareable URL in
  sync; removeFilterChip clears the facet and re-applies. URL deep
  links now accept all 18 facet params (hidden inputs where the
  drawer has no field).

Pins changelog: Batch B/C entry in docs/contract_pins.md. Rendering
ownership guard (make lint-rendering) green.
This commit is contained in:
John O'Keefe committed 2026-10-19 18:59:36 -04:00
1 parent 7e586df48a
commit bbedbfb13f
10 files changed
+990 -545

No files matched your search

+22 -9
View File
@@ -83,7 +83,13 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
}
if book.IsAudio() && len(book.Narrators) > 0 {
<h3 class="text-sm md:text-base mb-5" style="color: var(--text-secondary)">
Narrated by { strings.Join(book.Narrators, ", ") }
Narrated by
for i, narratorName := range book.Narrators {
if i > 0 {
<span>, </span>
}
@FacetLink("narrator", narratorName, narratorName)
}
</h3>
}
<!-- Action buttons -->
@@ -216,13 +222,13 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
}
if book.AgeRating.Valid && book.AgeRating.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span>
@FacetBadge("age_rating", book.AgeRating.String, book.AgeRating.String)
}
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span>
}
if book.StoryArc.Valid && book.StoryArc.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.StoryArc.String }</span>
@FacetBadge("story_arc", book.StoryArc.String, book.StoryArc.String)
}
</div>
if len(book.Tags) > 0 {
@@ -377,7 +383,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Publisher.Valid && book.Publisher.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
<p style="color: var(--text-primary)">{ book.Publisher.String }</p>
<p style="color: var(--text-primary)">@FacetLink("publisher", book.Publisher.String, book.Publisher.String)</p>
</div>
}
if book.DatePublished.Valid {
@@ -395,13 +401,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Language.Valid && book.Language.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
<p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
<p style="color: var(--text-primary)">@FacetLink("language", book.Language.String, languageDisplay(book.Language.String))</p>
</div>
}
if book.IsAudio() && len(book.Narrators) > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Narrator</p>
<p style="color: var(--text-primary)">{ strings.Join(book.Narrators, ", ") }</p>
<p style="color: var(--text-primary)">
for i, narratorName := range book.Narrators {
if i > 0 {
<span>, </span>
}
@FacetLink("narrator", narratorName, narratorName)
}
</p>
</div>
}
if book.IsAudio() && book.AudioDuration() > 0 {
@@ -419,7 +432,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Edition.Valid && book.Edition.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
<p style="color: var(--text-primary)">{ book.Edition.String }</p>
<p style="color: var(--text-primary)">@FacetLink("edition", book.Edition.String, book.Edition.String)</p>
</div>
}
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
@@ -431,7 +444,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Genre.Valid && book.Genre.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
<p style="color: var(--text-primary)">{ book.Genre.String }</p>
<p style="color: var(--text-primary)">@FacetLink("genre", book.Genre.String, prettifyGenre(book.Genre.String))</p>
</div>
}
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
@@ -449,7 +462,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Imprint.Valid && book.Imprint.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
<p style="color: var(--text-primary)">{ book.Imprint.String }</p>
<p style="color: var(--text-primary)">@FacetLink("imprint", book.Imprint.String, book.Imprint.String)</p>
</div>
}
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {