feat(web): templ partials replace the last UI twins (collections grid, conflicts list)
Both pages had a templ render (initial load) and a TypeScript twin (innerHTML rebuild on refresh) of the same fragment. The twins are deleted; the server-rendered markup is now the only markup. - collections: the library switcher swaps in the new books-grid partial (GET /collections/:id/books-grid) instead of re-rendering cards from the /api/collections JSON. Book loading is extracted into Config.loadCollectionBooks, shared verbatim by the page and the partial (smart / dashboard-query / manual branches, family-lens scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot drift. - conflicts: resolve/dismiss refreshes swap the new list partial (GET /conflicts/list) with the stats bar as an out-of-band swap, instead of the TS twin — which rendered a degraded card (no checkboxes, no timestamps), pointed at a window.showResolveModal that does not exist, and updated stats via element ids the SSR never had. - shims.d.ts: window.htmx typing (ajax/process) for TS callers. Visibility note: the books-grid partial inherits its family-lens scoping from the shared loader (identical to the detail page); a dedicated hidden-library pin test for the partial route is still worth adding in the next test pass.
This commit is contained in:
1 parent
1fdf306f2d
commit
f1844591b4
9 files changed
+1078
-889
No files matched your search
+9
-68
@@ -53,62 +53,6 @@ function initializeCollectionWebSocket(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderCollectionBooks(books: BookInfo[]): void {
|
||||
const container = document.getElementById("books-container");
|
||||
if (!container) return;
|
||||
|
||||
const dataEl = document.getElementById("collection-data");
|
||||
const isSystem = dataEl?.dataset.isSystem === "true";
|
||||
// Smart collections are rule-managed: no hand-pick actions either way.
|
||||
const canEditMembership =
|
||||
!isSystem && dataEl?.dataset.isSmart !== "true";
|
||||
|
||||
if (books.length === 0) {
|
||||
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = books
|
||||
.map(
|
||||
(book) => `
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title="${book.title}" data-author="${book.author || ""}" data-media-id="${book.media_item_id}">
|
||||
<div class="flex gap-4">
|
||||
${canEditMembership ? `
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||
<input type="checkbox" class="w-5 h-5"
|
||||
:checked="selectedBooks.includes('${book.media_item_id}')"
|
||||
@change="toggleSelection('${book.media_item_id}')"/>
|
||||
</label>
|
||||
</div>` : ""}
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="/media/${book.media_item_id}">
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">${book.title}</h3>
|
||||
</a>
|
||||
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">by ${book.author}</p>` : ""}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
<a href="/media/${book.media_item_id}">
|
||||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
${canEditMembership ? `
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button @click="requestRemoveBook('${book.media_item_id}')" class="btn btn-secondary w-full">
|
||||
🗑️ Remove from Collection
|
||||
</button>
|
||||
</div>` : ""}
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
Alpine.initTree(container);
|
||||
}
|
||||
|
||||
async function loadCollectionRules(collectionId: string): Promise<void> {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
@@ -480,19 +424,16 @@ function initCollectionsPage(): void {
|
||||
if (dataEl) dataEl.dataset.libraryId = libraryId;
|
||||
|
||||
await switchWithTransition("books-container", async () => {
|
||||
const param = libraryId ? `?library_id=${libraryId}` : "";
|
||||
const response = await fetch(
|
||||
`/api/collections/${collId}${param}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
// Rendering ownership: the grid is server-rendered markup. Swap
|
||||
// in the templ partial instead of re-rendering from JSON.
|
||||
const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
|
||||
await window.htmx.ajax(
|
||||
"GET",
|
||||
`/collections/${collId}/books-grid${param}`,
|
||||
{ target: "#books-container", swap: "innerHTML" },
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to load collection");
|
||||
const data = await response.json();
|
||||
renderCollectionBooks(data.books || []);
|
||||
const container = document.getElementById("books-container");
|
||||
if (container) Alpine.initTree(container);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user