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:
1 parent
f94e7472b4
commit
7537063cb7
8 files changed
+788
-665
No files matched your search
@@ -662,6 +662,11 @@ func TestContractPin_SearchJSON(t *testing.T) {
|
|||||||
// EXPLAIN plan-identical) and the search endpoint serializes the
|
// EXPLAIN plan-identical) and the search endpoint serializes the
|
||||||
// row directly, so they emit here.
|
// row directly, so they emit here.
|
||||||
"abridged", "audio_metadata",
|
"abridged", "audio_metadata",
|
||||||
|
// A2b/A2c (2026-10-19): + subtitle, explicit, authors,
|
||||||
|
// authors_search, publishers, publishers_search, narrators_search
|
||||||
|
// (docs/contract_pins.md changelog).
|
||||||
|
"subtitle", "explicit", "authors", "authors_search",
|
||||||
|
"publishers", "publishers_search", "narrators_search",
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
||||||
@@ -739,10 +744,10 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
|||||||
"library_type_name_2",
|
"library_type_name_2",
|
||||||
}
|
}
|
||||||
resolvedKeys := []string{
|
resolvedKeys := []string{
|
||||||
"id", "library_id", "title", "author", "isbn", "description",
|
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
||||||
"file_path", "file_size", "mime_type", "cover_image_path",
|
"file_path", "file_size", "mime_type", "cover_image_path",
|
||||||
"series", "series_number", "tags", "asin", "date_published",
|
"series", "series_number", "tags", "asin", "date_published",
|
||||||
"publisher", "contributors", "language", "edition", "page_count",
|
"publisher", "publishers", "explicit", "contributors", "language", "edition", "page_count",
|
||||||
"genre", "created_at", "updated_at", "format_group",
|
"genre", "created_at", "updated_at", "format_group",
|
||||||
"manga_type", "reading_direction",
|
"manga_type", "reading_direction",
|
||||||
"series_count", "volume", "imprint", "age_rating", "web_url",
|
"series_count", "volume", "imprint", "age_rating", "web_url",
|
||||||
|
|||||||
@@ -152,3 +152,12 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
- User collections are dashboard-eligible by default (the
|
- User collections are dashboard-eligible by default (the
|
||||||
show_on_dashboard gate is gone from GetUserCollectionsForDashboard);
|
show_on_dashboard gate is gone from GetUserCollectionsForDashboard);
|
||||||
hiding stays dashboard-prefs (`hidden_collections`) territory.
|
hiding stays dashboard-prefs (`hidden_collections`) territory.
|
||||||
|
- `GET /api/media-items` (unscoped resolved-map branch) and
|
||||||
|
`GET /api/media-items/:id` gained the same ADDITIVE keys as the raw
|
||||||
|
branch: `subtitle`, `explicit`, `authors`, `publishers`.
|
||||||
|
- `GET /api/media-items/search` rows gained the same ADDITIVE keys plus
|
||||||
|
the search pairs `authors_search`, `publishers_search`,
|
||||||
|
`narrators_search` (the unified SELECT now carries the new columns).
|
||||||
|
- `author_filter` / `publisher_filter` are now per-element against the
|
||||||
|
arrays; a comma-joined value is OR-within-facet multi-select. The
|
||||||
|
param NAMES and single-value behavior are unchanged.
|
||||||
@@ -13201,7 +13201,14 @@ SELECT
|
|||||||
mi.tags_search,
|
mi.tags_search,
|
||||||
mi.contributors_search,
|
mi.contributors_search,
|
||||||
mi.narrators,
|
mi.narrators,
|
||||||
|
mi.narrators_search,
|
||||||
mi.abridged,
|
mi.abridged,
|
||||||
|
mi.subtitle,
|
||||||
|
mi.explicit,
|
||||||
|
mi.authors,
|
||||||
|
mi.authors_search,
|
||||||
|
mi.publishers,
|
||||||
|
mi.publishers_search,
|
||||||
mi.audio_metadata,
|
mi.audio_metadata,
|
||||||
mi.file_sha256,
|
mi.file_sha256,
|
||||||
mi.opf_identifier,
|
mi.opf_identifier,
|
||||||
@@ -13429,7 +13436,14 @@ type SearchMediaItemsUnifiedRow struct {
|
|||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
||||||
Narrators []string `db:"narrators" json:"narrators"`
|
Narrators []string `db:"narrators" json:"narrators"`
|
||||||
|
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
||||||
Abridged bool `db:"abridged" json:"abridged"`
|
Abridged bool `db:"abridged" json:"abridged"`
|
||||||
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
||||||
|
Explicit bool `db:"explicit" json:"explicit"`
|
||||||
|
Authors []string `db:"authors" json:"authors"`
|
||||||
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
||||||
|
Publishers []string `db:"publishers" json:"publishers"`
|
||||||
|
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
||||||
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
||||||
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||||
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
||||||
@@ -13508,7 +13522,14 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
|||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
&i.ContributorsSearch,
|
&i.ContributorsSearch,
|
||||||
&i.Narrators,
|
&i.Narrators,
|
||||||
|
&i.NarratorsSearch,
|
||||||
&i.Abridged,
|
&i.Abridged,
|
||||||
|
&i.Subtitle,
|
||||||
|
&i.Explicit,
|
||||||
|
&i.Authors,
|
||||||
|
&i.AuthorsSearch,
|
||||||
|
&i.Publishers,
|
||||||
|
&i.PublishersSearch,
|
||||||
&i.AudioMetadata,
|
&i.AudioMetadata,
|
||||||
&i.FileSha256,
|
&i.FileSha256,
|
||||||
&i.OpfIdentifier,
|
&i.OpfIdentifier,
|
||||||
|
|||||||
@@ -662,7 +662,14 @@ SELECT
|
|||||||
mi.tags_search,
|
mi.tags_search,
|
||||||
mi.contributors_search,
|
mi.contributors_search,
|
||||||
mi.narrators,
|
mi.narrators,
|
||||||
|
mi.narrators_search,
|
||||||
mi.abridged,
|
mi.abridged,
|
||||||
|
mi.subtitle,
|
||||||
|
mi.explicit,
|
||||||
|
mi.authors,
|
||||||
|
mi.authors_search,
|
||||||
|
mi.publishers,
|
||||||
|
mi.publishers_search,
|
||||||
mi.audio_metadata,
|
mi.audio_metadata,
|
||||||
mi.file_sha256,
|
mi.file_sha256,
|
||||||
mi.opf_identifier,
|
mi.opf_identifier,
|
||||||
|
|||||||
@@ -844,7 +844,9 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
|||||||
"id": uuid.UUID(item.ID.Bytes).String(),
|
"id": uuid.UUID(item.ID.Bytes).String(),
|
||||||
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
||||||
"title": item.Title,
|
"title": item.Title,
|
||||||
|
"subtitle": utils.PgTextString(item.Subtitle),
|
||||||
"author": utils.PgTextString(item.Author),
|
"author": utils.PgTextString(item.Author),
|
||||||
|
"authors": item.Authors,
|
||||||
"isbn": utils.PgTextString(item.Isbn),
|
"isbn": utils.PgTextString(item.Isbn),
|
||||||
"description": utils.PgTextString(item.Description),
|
"description": utils.PgTextString(item.Description),
|
||||||
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
||||||
@@ -857,6 +859,8 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
|||||||
"asin": utils.PgTextString(item.Asin),
|
"asin": utils.PgTextString(item.Asin),
|
||||||
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
||||||
"publisher": utils.PgTextString(item.Publisher),
|
"publisher": utils.PgTextString(item.Publisher),
|
||||||
|
"publishers": item.Publishers,
|
||||||
|
"explicit": item.Explicit,
|
||||||
"contributors": item.Contributors,
|
"contributors": item.Contributors,
|
||||||
"language": utils.PgTextString(item.Language),
|
"language": utils.PgTextString(item.Language),
|
||||||
"edition": utils.PgTextString(item.Edition),
|
"edition": utils.PgTextString(item.Edition),
|
||||||
@@ -916,7 +920,9 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
|||||||
"id": uuid.UUID(item.ID.Bytes).String(),
|
"id": uuid.UUID(item.ID.Bytes).String(),
|
||||||
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
||||||
"title": item.Title,
|
"title": item.Title,
|
||||||
|
"subtitle": utils.PgTextString(item.Subtitle),
|
||||||
"author": utils.PgTextString(item.Author),
|
"author": utils.PgTextString(item.Author),
|
||||||
|
"authors": item.Authors,
|
||||||
"isbn": utils.PgTextString(item.Isbn),
|
"isbn": utils.PgTextString(item.Isbn),
|
||||||
"description": utils.PgTextString(item.Description),
|
"description": utils.PgTextString(item.Description),
|
||||||
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
||||||
@@ -929,6 +935,8 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
|||||||
"asin": utils.PgTextString(item.Asin),
|
"asin": utils.PgTextString(item.Asin),
|
||||||
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
||||||
"publisher": utils.PgTextString(item.Publisher),
|
"publisher": utils.PgTextString(item.Publisher),
|
||||||
|
"publishers": item.Publishers,
|
||||||
|
"explicit": item.Explicit,
|
||||||
"contributors": item.Contributors,
|
"contributors": item.Contributors,
|
||||||
"language": utils.PgTextString(item.Language),
|
"language": utils.PgTextString(item.Language),
|
||||||
"edition": utils.PgTextString(item.Edition),
|
"edition": utils.PgTextString(item.Edition),
|
||||||
|
|||||||
@@ -68,7 +68,13 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
}
|
}
|
||||||
if len(book.Authors) > 0 {
|
if len(book.Authors) > 0 {
|
||||||
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||||
by { strings.Join(book.Authors, ", ") }
|
by
|
||||||
|
for i, authorName := range book.Authors {
|
||||||
|
if i > 0 {
|
||||||
|
<span>, </span>
|
||||||
|
}
|
||||||
|
<a href={ "/bookshelf?author_filter=" + url.QueryEscape(authorName) } class="hover:underline" style="color: var(--text-secondary);">{ authorName }</a>
|
||||||
|
}
|
||||||
</h2>
|
</h2>
|
||||||
} else if book.Author.Valid && book.Author.String != "" {
|
} else if book.Author.Valid && book.Author.String != "" {
|
||||||
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||||
|
|||||||
+682
-653
File diff suppressed because it is too large.
Load diff
+47
-9
@@ -456,30 +456,68 @@ Alpine.data("bookshelf", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts)
|
// 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 {
|
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
|
// Check if books were already rendered server-side
|
||||||
const booksGrid = document.getElementById("books-grid");
|
const booksGrid = document.getElementById("books-grid");
|
||||||
const hasServerBooks =
|
const hasServerBooks =
|
||||||
booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
|
booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
|
||||||
|
|
||||||
if (!hasServerBooks) {
|
if (!hasServerBooks || urlApplied) {
|
||||||
// Only trigger HTMX if no SSR books rendered
|
|
||||||
const librarySelect = document.getElementById(
|
const librarySelect = document.getElementById(
|
||||||
"library-select",
|
"library-select",
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
librarySelect?.addEventListener("change", () => {
|
librarySelect?.addEventListener("change", () => {
|
||||||
localStorage.setItem("selectedLibrary", librarySelect.value);
|
localStorage.setItem("selectedLibrary", librarySelect.value);
|
||||||
});
|
});
|
||||||
if (librarySelect && librarySelect.value) {
|
if (urlApplied && filterForm) {
|
||||||
const filterForm = document.getElementById(
|
// A URL filter replaces the grid with the filtered set — the form
|
||||||
"filter-form",
|
// submit serializes the library select too (it lives in the form).
|
||||||
) as HTMLFormElement;
|
window.htmx.trigger(filterForm, "submit");
|
||||||
if (filterForm) {
|
} else if (librarySelect && librarySelect.value && filterForm) {
|
||||||
// Trigger initial HTMX load
|
// Normal unfiltered initial HTMX load
|
||||||
window.htmx.trigger(librarySelect, "change");
|
window.htmx.trigger(librarySelect, "change");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Setup listener for HTMX swap events on the filters list
|
// Setup listener for HTMX swap events on the filters list
|
||||||
const filtersList = document.getElementById("saved-filters-list");
|
const filtersList = document.getElementById("saved-filters-list");
|
||||||
if (filtersList) {
|
if (filtersList) {
|
||||||
|
|||||||
Reference in new issue
Block a user