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:
1 parent
7e586df48a
commit
bbedbfb13f
10 files changed
+990
-545
No files matched your search
@@ -180,3 +180,25 @@ cover_image_path, has_conflict, format_group, badge`.
|
||||
input leaves `genre` empty). Existing rows are unaffected until a
|
||||
rescan/re-extract.
|
||||
|
||||
## 2026-10-19 — Faceted navigation Batch B/C: chips, FacetLink, facets endpoint
|
||||
|
||||
- `GET /api/media-items/facets?facet=X&library_id=...` is a NEW pinned
|
||||
surface (registry-driven value+count listing for the filter panel):
|
||||
200 → `{facet, values[]: {value, display?, count}}` (display present
|
||||
only for language codes → names); 400 for unknown facets and for
|
||||
bool/range facets (they render as toggles, not lists); 403 for an
|
||||
explicit library_id outside the requester's visible set.
|
||||
- `GET /api/media-items/search` (JSON) and its HTMX grid partial now
|
||||
share ONE param parser (`handlers.ParseMediaSearchParams`). Behavior
|
||||
notes: the HTMX branch previously dropped `publisher_filter` and
|
||||
`narrator_filter` silently — it now honors every facet param; and an
|
||||
explicit hidden-library `library_id` is now a 403 on BOTH branches
|
||||
(was: silent empty set on JSON). Response shapes are untouched; the
|
||||
search row pin (47 keys) is unchanged.
|
||||
- `language_filter` now normalizes comma elements through the ingest
|
||||
language map, so display names ("English") match rows stored as ISO
|
||||
codes ("en"). The param name is unchanged; single-code values behave
|
||||
identically.
|
||||
- Web: the bookshelf gains an active-filter chips bar (client-local
|
||||
form state rendered by Alpine; templ owns the markup) and
|
||||
`applyURLFilters` accepts the new facet params.
|
||||
@@ -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 {
|
||||
|
||||
+494
-531
File diff suppressed because it is too large.
Load diff
@@ -286,6 +286,28 @@ templ BookShelf(
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Active-filter chips: a pure function of the client-side
|
||||
form state (Alpine renders them, templ owns the look —
|
||||
sanctioned TS island). Removing a chip clears that facet
|
||||
and 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)"
|
||||
>
|
||||
<span x-text="chip.label"></span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||
if errorMessage != "" {
|
||||
|
||||
@@ -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><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Narrator</label> <input type=\"text\" name=\"narrator_filter\" placeholder=\"Filter by narrator\" class=\"input\" list=\"narrator-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchNarratorValues($el)\"> <datalist id=\"narrator-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><!-- Active-filter chips: a pure function of the client-side\n\t\t\t\t\tform state (Alpine renders them, templ owns the look —\n\t\t\t\t\tsanctioned TS island). Removing a chip clears that facet\n\t\t\t\t\tand re-applies the form. --><div id=\"active-filter-chips\" class=\"flex flex-wrap items-center gap-2 mb-4\" x-show=\"activeFilters.length > 0\" x-cloak><template x-for=\"chip in activeFilters\" :key=\"chip.param\"><button type=\"button\" class=\"badge inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity cursor-pointer\" style=\"background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;\" @click=\"removeFilterChip(chip.param)\"Line truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -422,7 +422,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 315, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -445,7 +445,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 302, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 324, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -476,7 +476,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 311, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 333, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -489,7 +489,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 316, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 338, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bookhoard/internal/services"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Facet navigation helpers (faceted-navigation plan, Batch B).
|
||||
//
|
||||
// "Everything filterable should be clickable": every place a facet value
|
||||
// is DISPLAYED links to the bookshelf pre-filtered by that value. The
|
||||
// raw (stored) value rides the param — for genre that is the canonical
|
||||
// lowercase form, while the shown text is prettified — so matching and
|
||||
// display can never disagree. Series and tags keep their dedicated
|
||||
// pages (/series/detail, /tags/detail) by design and do NOT use these
|
||||
// helpers; author links predate them and stay as-is.
|
||||
|
||||
// FacetLink renders a facet value as an inline text link (metadata
|
||||
// grid cells, the narrators line) to /bookshelf?<facet>_filter=<value>.
|
||||
templ FacetLink(facet string, value string, display string) {
|
||||
<a href={ "/bookshelf?" + facet + "_filter=" + url.QueryEscape(value) } class="hover:underline" style="color: var(--text-primary);">{ display }</a>
|
||||
}
|
||||
|
||||
// FacetBadge is the badge-styled FacetLink (the detail page's
|
||||
// age-rating and story-arc badges).
|
||||
templ FacetBadge(facet string, value string, display string) {
|
||||
<a href={ "/bookshelf?" + facet + "_filter=" + url.QueryEscape(value) } class="badge hover:opacity-80 transition-opacity" style="border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;">{ display }</a>
|
||||
}
|
||||
|
||||
// languageDisplay renders a stored language value for display:
|
||||
// "en" → "English"; unknown/legacy values pass through verbatim.
|
||||
func languageDisplay(value string) string {
|
||||
if name := services.LanguageDisplayName(value); name != "" {
|
||||
return name
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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"
|
||||
|
||||
import (
|
||||
"bookhoard/internal/services"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Facet navigation helpers (faceted-navigation plan, Batch B).
|
||||
//
|
||||
// "Everything filterable should be clickable": every place a facet value
|
||||
// is DISPLAYED links to the bookshelf pre-filtered by that value. The
|
||||
// raw (stored) value rides the param — for genre that is the canonical
|
||||
// lowercase form, while the shown text is prettified — so matching and
|
||||
// display can never disagree. Series and tags keep their dedicated
|
||||
// pages (/series/detail, /tags/detail) by design and do NOT use these
|
||||
// helpers; author links predate them and stay as-is.
|
||||
|
||||
// FacetLink renders a facet value as an inline text link (metadata
|
||||
// grid cells, the narrators line) to /bookshelf?<facet>_filter=<value>.
|
||||
func FacetLink(facet string, value string, display string) 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, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 templ.SafeURL
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?" + facet + "_filter=" + url.QueryEscape(value))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_links.templ`, Line: 21, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"hover:underline\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(display)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_links.templ`, Line: 21, Col: 142}
|
||||
}
|
||||
_, 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, 3, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// FacetBadge is the badge-styled FacetLink (the detail page's
|
||||
// age-rating and story-arc badges).
|
||||
func FacetBadge(facet string, value string, display string) 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_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?" + facet + "_filter=" + url.QueryEscape(value))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_links.templ`, Line: 27, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"badge hover:opacity-80 transition-opacity\" style=\"border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(display)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `facet_links.templ`, Line: 27, Col: 226}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// languageDisplay renders a stored language value for display:
|
||||
// "en" → "English"; unknown/legacy values pass through verbatim.
|
||||
func languageDisplay(value string) string {
|
||||
if name := services.LanguageDisplayName(value); name != "" {
|
||||
return name
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -255,6 +256,41 @@ func conflictID(c *handlers.ConflictDetailResponse) string {
|
||||
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
|
||||
// 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, " ")
|
||||
}
|
||||
|
||||
// getAlternateSeries extracts the alternate series name from JSONB data
|
||||
func getAlternateSeries(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
|
||||
@@ -95,3 +95,27 @@ func TestLibraryTypeDisplayName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettifyGenre(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"science fiction", "Science Fiction"},
|
||||
{"sword & sorcery", "Sword & Sorcery"},
|
||||
{"action & adventure", "Action & Adventure"},
|
||||
{"war & military", "War & Military"},
|
||||
{"historical fiction", "Historical Fiction"},
|
||||
{"children's", "Children's"},
|
||||
{"middle grade", "Middle Grade"},
|
||||
{"fiction", "Fiction"},
|
||||
// Small words stay down except in first position.
|
||||
{"towers of midnight", "Towers of Midnight"},
|
||||
// Mixed case = user-authored; verbatim.
|
||||
{"Science Fiction", "Science Fiction"},
|
||||
{"HardSF", "HardSF"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := prettifyGenre(c.in); got != c.want {
|
||||
t.Errorf("prettifyGenre(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,9 @@ Alpine.data("bookshelf", () => ({
|
||||
tagSuggestions: [] as TagSuggestion[],
|
||||
showTagDropdown: false,
|
||||
tagHighlightIndex: -1,
|
||||
// Active-filter chips (Batch B): Alpine renders them from this state;
|
||||
// refreshed by refreshFilterChips after every grid refetch.
|
||||
activeFilters: [] as FilterChip[],
|
||||
|
||||
// Standalone function references (don't access component state)
|
||||
clearFilters,
|
||||
@@ -96,6 +99,32 @@ Alpine.data("bookshelf", () => ({
|
||||
showSaveFilterModal() {
|
||||
this.showSaveModal = true;
|
||||
},
|
||||
// Remove one active-filter chip: clear that facet's field (hidden
|
||||
// inputs are dropped outright) and re-apply the form. Chips refresh
|
||||
// via the shared htmx:afterRequest hook.
|
||||
removeFilterChip(param: string): void {
|
||||
const filterForm = document.getElementById(
|
||||
"filter-form",
|
||||
) as HTMLFormElement;
|
||||
if (!filterForm) return;
|
||||
if (param === "has_cover") {
|
||||
filterForm.querySelector('[name="has_cover"]')?.remove();
|
||||
this.hasCoverState = null;
|
||||
window.htmx.trigger(filterForm, "submit");
|
||||
return;
|
||||
}
|
||||
const input = filterForm.querySelector(
|
||||
`[name="${param}"]`,
|
||||
) as HTMLInputElement | null;
|
||||
if (input) {
|
||||
if (input.type === "hidden") {
|
||||
input.remove();
|
||||
} else {
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
window.htmx.trigger(filterForm, "submit");
|
||||
},
|
||||
// Helper method to calculate alignment
|
||||
calculateAlignment() {
|
||||
const button = document.querySelector("[data-load-filter-btn]");
|
||||
@@ -459,6 +488,9 @@ Alpine.data("bookshelf", () => ({
|
||||
// A2c §5.3: deep-linkable filters. Reads known filter params from the URL
|
||||
// into the form inputs; returns true when at least one was applied. A
|
||||
// comma-joined value is a multi-select (the server splits it per element).
|
||||
// Facet names mirror services/facet_registry.go; params without a visible
|
||||
// drawer input (Batch A facets, bool facets) land as hidden inputs like
|
||||
// loadFilter's, so chips and clearing work for every deep link.
|
||||
function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
if (!filterForm) return false;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -470,6 +502,17 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
"tags_filter",
|
||||
"narrator_filter",
|
||||
"language_filter",
|
||||
"story_arc_filter",
|
||||
"imprint_filter",
|
||||
"edition_filter",
|
||||
"age_rating_filter",
|
||||
"format_group_filter",
|
||||
"manga_type_filter",
|
||||
"reading_direction_filter",
|
||||
"explicit_filter",
|
||||
"abridged_filter",
|
||||
"year_min",
|
||||
"year_max",
|
||||
];
|
||||
let applied = false;
|
||||
for (const name of filterNames) {
|
||||
@@ -481,16 +524,152 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
if (input) {
|
||||
input.value = value;
|
||||
applied = true;
|
||||
} else if (name !== "has_cover") {
|
||||
// No visible drawer input (Batch C's panel will manage these
|
||||
// facets): hold the value in a hidden input so the form submit
|
||||
// carries it and a chip can offer removal.
|
||||
const hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = name;
|
||||
hidden.value = value;
|
||||
filterForm.appendChild(hidden);
|
||||
applied = true;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
// Active-filter chips (Batch B): a pure function of the filter form's
|
||||
// client state — Alpine renders them from `activeFilters`, templ owns
|
||||
// the markup. Removing a chip clears that facet and re-applies the form.
|
||||
interface FilterChip {
|
||||
param: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// prettifyGenreLabel ports templates.prettifyGenre (Batch B transform):
|
||||
// only entirely-lowercase values are title-cased; small words stay down
|
||||
// except in first position; anything else displays verbatim.
|
||||
function prettifyGenreLabel(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed !== trimmed.toLowerCase()) return trimmed;
|
||||
const small = new Set([
|
||||
"a", "an", "and", "of", "the", "in", "on", "for", "to", "or",
|
||||
]);
|
||||
return trimmed
|
||||
.split(/\s+/)
|
||||
.map((w, i) =>
|
||||
i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// facetChipLabel builds one chip's shown text.
|
||||
function facetChipLabel(name: string, value: string): string {
|
||||
if (name === "has_cover") {
|
||||
return value === "true" ? "Has cover" : "No cover";
|
||||
}
|
||||
const names: Record<string, string> = {
|
||||
author_filter: "Author",
|
||||
publisher_filter: "Publisher",
|
||||
narrator_filter: "Narrator",
|
||||
tags_filter: "Tags",
|
||||
series_filter: "Series",
|
||||
genre_filter: "Genre",
|
||||
language_filter: "Language",
|
||||
year_min: "From year",
|
||||
year_max: "To year",
|
||||
story_arc_filter: "Story arc",
|
||||
imprint_filter: "Imprint",
|
||||
edition_filter: "Edition",
|
||||
age_rating_filter: "Age rating",
|
||||
format_group_filter: "Format",
|
||||
manga_type_filter: "Manga type",
|
||||
reading_direction_filter: "Reading direction",
|
||||
explicit_filter: "Explicit",
|
||||
abridged_filter: "Abridged",
|
||||
};
|
||||
const label = names[name] ?? name;
|
||||
const shown =
|
||||
name === "genre_filter" ? prettifyGenreLabel(value) : value;
|
||||
return `${label}: ${shown}`;
|
||||
}
|
||||
|
||||
// refreshFilterChips rebuilds `activeFilters` from the form and syncs
|
||||
// the shareable URL (history.replaceState) — the single sync point for
|
||||
// form applies, URL deep-links, sort changes, and clears.
|
||||
function refreshFilterChips(): void {
|
||||
const filterForm = document.getElementById(
|
||||
"filter-form",
|
||||
) as HTMLFormElement;
|
||||
const bookshelfElem = document.querySelector('[x-data*="bookshelf"]');
|
||||
if (!filterForm || !bookshelfElem || !(bookshelfElem as any)._x_dataStack) {
|
||||
return;
|
||||
}
|
||||
const data = (bookshelfElem as any)._x_dataStack[0];
|
||||
if (typeof data.activeFilters === "undefined") return;
|
||||
|
||||
const chips: FilterChip[] = [];
|
||||
filterForm
|
||||
.querySelectorAll<HTMLInputElement>(
|
||||
'[name$="_filter"], [name="year_min"], [name="year_max"]',
|
||||
)
|
||||
.forEach((input) => {
|
||||
if (input.value) {
|
||||
chips.push({
|
||||
param: input.name,
|
||||
label: facetChipLabel(input.name, input.value),
|
||||
});
|
||||
}
|
||||
});
|
||||
const hasCoverInput = filterForm.querySelector(
|
||||
'[name="has_cover"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (hasCoverInput && hasCoverInput.value) {
|
||||
chips.push({ param: "has_cover", label: facetChipLabel("has_cover", hasCoverInput.value) });
|
||||
}
|
||||
|
||||
data.activeFilters = chips;
|
||||
|
||||
// URL stays in sync with the applied set — the filtered view remains
|
||||
// shareable (plan: chips bar is URL-synced).
|
||||
const url = new URL(window.location.href);
|
||||
const keep = new Set([
|
||||
"author_filter", "series_filter", "genre_filter", "publisher_filter",
|
||||
"tags_filter", "narrator_filter", "language_filter", "story_arc_filter",
|
||||
"imprint_filter", "edition_filter", "age_rating_filter",
|
||||
"format_group_filter", "manga_type_filter", "reading_direction_filter",
|
||||
"explicit_filter", "abridged_filter", "year_min", "year_max",
|
||||
"has_cover", "library_id",
|
||||
]);
|
||||
const next = new URLSearchParams();
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (keep.has(key)) next.set(key, value);
|
||||
});
|
||||
if (chips.length === 0 && !next.get("library_id")) {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
return;
|
||||
}
|
||||
const qs = next.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
qs ? `${window.location.pathname}?${qs}` : window.location.pathname,
|
||||
);
|
||||
}
|
||||
|
||||
function initBookshelf(): void {
|
||||
const filterForm = document.getElementById(
|
||||
"filter-form",
|
||||
) as HTMLFormElement;
|
||||
|
||||
// Active-filter chips refresh after every grid refetch — drawer
|
||||
// applies, sort changes, and pagination all include the form's
|
||||
// filters, so one bubbled htmx:afterRequest hook is the sync point.
|
||||
filterForm.addEventListener("htmx:afterRequest", () =>
|
||||
refreshFilterChips(),
|
||||
);
|
||||
|
||||
// A2c §5.3: deep-linkable filters — /bookshelf?author_filter=Sapkowski
|
||||
// pre-fills the form and applies it to the first grid load. Author
|
||||
// links on the detail page (and anything else) can share filtered
|
||||
|
||||
Reference in new issue
Block a user