feat(filters+ui): per-element author/publisher filters, playlist toggles, Up Next API, reorder UI, dashboard badges

Phases 4 + 5 of the metadata overhaul (plan §4, §5) plus §3 handler
wiring.

Filters (§4.1): author_filter/publisher_filter now match per-element
against the authors[]/publishers[] arrays — a co-authored book matches
any one of its authors — with comma-joined params acting as
OR-within-facet multi-select (string_to_array + cross unnest). Legacy
single-value params unchanged on the wire; series/genre/language
filters stay on their varchar columns; narrator filter already
per-element.

Collections API (§3.3-3.5): collection detail carries viewer +
hide_finished into the listing query and exposes up_next (visible-
library-enforced reading-queue head); PUT
/api/collections/:id/items/order (owner-only, full-order rewrite);
UpdateCollectionRequest gains hide_finished/show_up_next ([]
string last-value-wins for the hidden-false-sibling checkbox pattern)
merged server-side OVER stored view_settings so imported_from_series
provenance survives edits; EditCollectionModal renders the toggles
from view_settings.

Dashboard (§5.5): sections JSON carries is_playlist/complete
(dashboard.ts is the AGENTS-documented JSON-render exception; first
paint stays templ). Fully-read playlists render '✓ Playlist complete'
instead of an empty rail; imported playlists get a 📖 Playlist badge.

Frontend: book detail shows subtitle + full authors + Explicit badge;
edit modal gains subtitle, comma-separated authors/publishers (client-
split to arrays exactly like narrators), step=any series number,
explicit checkbox; collection book cards get playlist move ↑/↓ buttons
persisting the full order via the reorder endpoint (templ partial
re-render on failure — DOM order is client-local, API order is truth).

AGENTS.md gotcha #3 amended per plan §3.7: user collections are
dashboard sections by default; the show_on_dashboard premise is
retired.

Verified: full affected integration sweep green (pins widened for
up_next/is_playlist/complete — changelog entries in
docs/contract_pins.md); unit suite green; go build + templ + vite
green.
This commit is contained in:
John O'Keefe committed 2026-10-19 15:43:59 -04:00
1 parent 1dbb0ce406
commit f94e7472b4
20 files changed
+1711 -1339

No files matched your search

+66
View File
@@ -541,6 +541,7 @@ export {
showAllIcons,
testRule,
toggleSelection,
moveCollectionBook,
};
Alpine.data("collections", () => ({
@@ -553,6 +554,7 @@ Alpine.data("collections", () => ({
initializeCollectionWebSocket,
initCollectionsPage,
loadCollectionRules,
moveCollectionBook,
navigateToCollection,
populateIconGrid,
selectColor,
@@ -594,3 +596,67 @@ Alpine.data("collections", () => ({
this.pendingAction = null;
},
}));
// A2c §3.2: playlist ordering — move a card one slot and persist the full
// resulting order. The DOM cards are the server-rendered truth for CURRENT
// order; the API is the truth for SAVED order. On failure the grid refresh
// from the templ partial so the UI never lies.
async function moveCollectionBook(
this: any,
mediaId: string,
direction: number,
): Promise<void> {
const container = document.getElementById("books-container");
if (!container) return;
const cards = Array.from(
container.querySelectorAll<HTMLElement>(".collection-book-card"),
);
const index = cards.findIndex(
(c) => c.dataset.mediaId === mediaId,
);
const target = index + direction;
if (index < 0 || target < 0 || target >= cards.length) return;
// Optimistic DOM swap (client-local state until the API confirms).
if (direction < 0) {
container.insertBefore(cards[index], cards[target]);
} else {
container.insertBefore(cards[target], cards[index]);
}
const orderedIds = Array.from(
container.querySelectorAll<HTMLElement>(".collection-book-card"),
)
.map((c) => c.dataset.mediaId)
.filter((id): id is string => Boolean(id));
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(
`/api/collections/${getCollectionId()}/items/order`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ item_ids: orderedIds }),
},
);
if (!response.ok) {
showToast("Failed to save the new order", "error");
// Rendering ownership: the templ partial re-renders the true order.
const gridTarget = document.getElementById("books-container");
if (gridTarget && (window as any).htmx) {
(window as any).htmx.ajax(
"GET",
`/collections/${getCollectionId()}/books-grid${window.location.search.startsWith("?") ? window.location.search : ""}`,
{ target: "#books-container", swap: "innerHTML" },
);
}
}
} catch {
showToast("Failed to save the new order", "error");
}
}