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
@@ -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