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:
1 parent
1dbb0ce406
commit
f94e7472b4
20 files changed
+1711
-1339
No files matched your search
@@ -98,6 +98,20 @@ function collectFormData(editorTags: string[]): Record<string, unknown> {
|
||||
else delete data.narrators;
|
||||
}
|
||||
|
||||
// A2b: authors/publishers are arrays server-side (write truth); the form
|
||||
// fields are comma-separated strings exactly like narrators.
|
||||
for (const key of ["authors", "publishers"] as const) {
|
||||
const raw = data[key] as string;
|
||||
if (typeof raw === "string") {
|
||||
const list = raw
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter(Boolean);
|
||||
if (list.length) data[key] = list;
|
||||
else delete data[key];
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -199,13 +199,36 @@ async function restoreSystemCollection(
|
||||
}
|
||||
|
||||
function renderSectionHTML(section: SectionData): string {
|
||||
// A2c §3.5: a fully-read playlist (hide_finished drained the queue)
|
||||
// renders its completion state instead of book cards.
|
||||
if (section.complete) {
|
||||
return `
|
||||
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">${section.icon}</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}
|
||||
<span class="badge ml-2" style="background-color: var(--accent-muted); color: var(--accent);">✓ Playlist complete</span>
|
||||
</h2>
|
||||
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ""}
|
||||
</div>
|
||||
<div class="card p-6 text-sm" style="color: var(--text-secondary)">Every book in this playlist is finished. Time to add the next one.</div>
|
||||
</div>`;
|
||||
}
|
||||
const playlistBadge = section.is_playlist
|
||||
? `<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);" title="Curated reading order">📖 Playlist</span>`
|
||||
: "";
|
||||
return `
|
||||
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">${section.icon}</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}</h2>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}${playlistBadge}</h2>
|
||||
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+4
@@ -119,6 +119,10 @@ interface SectionData {
|
||||
items: BookInfo[];
|
||||
view_all_url: string;
|
||||
priority: number;
|
||||
// A2c (2026-10-19): imported reading-order playlist + hide_finished
|
||||
// completion state — see docs/contract_pins.md.
|
||||
is_playlist: boolean;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
// Matches database.UserDashboardPreferences and dashboard preferences API
|
||||
|
||||
Reference in new issue
Block a user