feat(filters+nav): author navigation end-to-end — arrays on every media response, deep-linkable bookshelf filters, clickable authors

Completes the A2c acceptance scenario: click an author on a book
detail page -> land on the bookshelf filtered to everything that
author wrote (including their share of co-authored books).

- Media detail + unscoped list resolved maps expose subtitle,
  explicit, authors, publishers (the library-scoped raw branch and
  the search endpoint already did — raw rows follow the SELECT).
- SearchMediaItemsUnified SELECT carries the new columns; the search
  endpoint serializes the row directly, so clients get them there too.
- book_detail.templ renders each author as a link to
  /bookshelf?author_filter=<name>.
- Bookshelf honors filter params in the URL on load (author_filter,
  series_filter, genre_filter, publisher_filter, tags_filter,
  narrator_filter, language_filter): pre-fills the form, applies it to
  the first grid load. Comma-joined values are multi-select via the
  server-side string_to_array split. Filtered views are shareable.

Pins widened: search rows (+7 keys), resolved keys (+4); changelog
entries in docs/contract_pins.md. Android follow-up plan written to
~/.opencode/plan/bookhoard-app-v2-compat.md.
This commit is contained in:
John O'Keefe committed 2026-10-19 16:16:58 -04:00
1 parent f94e7472b4
commit 7537063cb7
8 files changed
+851 -728

No files matched your search

+48 -10
View File
@@ -456,28 +456,66 @@ Alpine.data("bookshelf", () => ({
}));
// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts)
// 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).
function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
if (!filterForm) return false;
const params = new URLSearchParams(window.location.search);
const filterNames = [
"author_filter",
"series_filter",
"genre_filter",
"publisher_filter",
"tags_filter",
"narrator_filter",
"language_filter",
];
let applied = false;
for (const name of filterNames) {
const value = params.get(name);
if (!value) continue;
const input = filterForm.querySelector(
`[name="${name}"]`,
) as HTMLInputElement | null;
if (input) {
input.value = value;
applied = true;
}
}
return applied;
}
function initBookshelf(): void {
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
// 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
// views; a comma-joined value is a multi-select (server splits it).
const urlApplied = applyURLFilters(filterForm);
// Check if books were already rendered server-side
const booksGrid = document.getElementById("books-grid");
const hasServerBooks =
booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
if (!hasServerBooks) {
// Only trigger HTMX if no SSR books rendered
if (!hasServerBooks || urlApplied) {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
librarySelect?.addEventListener("change", () => {
localStorage.setItem("selectedLibrary", librarySelect.value);
});
if (librarySelect && librarySelect.value) {
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
if (filterForm) {
// Trigger initial HTMX load
window.htmx.trigger(librarySelect, "change");
}
if (urlApplied && filterForm) {
// A URL filter replaces the grid with the filtered set — the form
// submit serializes the library select too (it lives in the form).
window.htmx.trigger(filterForm, "submit");
} else if (librarySelect && librarySelect.value && filterForm) {
// Normal unfiltered initial HTMX load
window.htmx.trigger(librarySelect, "change");
}
}
// Setup listener for HTMX swap events on the filters list