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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+8
-54
@@ -2,19 +2,16 @@ import { Alpine } from "./alpine";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
async function refreshConflicts(): Promise<void> {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
// Rendering ownership: the list and stats are server-rendered markup.
|
||||
// Swap in the templ partial (stats arrive as an out-of-band swap)
|
||||
// instead of re-rendering cards from JSON.
|
||||
try {
|
||||
const response = await fetch("/api/conflicts", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
await window.htmx.ajax("GET", "/conflicts/list", {
|
||||
target: "#conflicts-list",
|
||||
swap: "outerHTML",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: ConflictListResponse = await response.json();
|
||||
renderConflicts(data.conflicts);
|
||||
updateConflictStats(data);
|
||||
}
|
||||
const list = document.getElementById("conflicts-list");
|
||||
if (list) Alpine.initTree(list);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh conflicts:", error);
|
||||
}
|
||||
@@ -123,49 +120,6 @@ async function dismissAllResolved(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
|
||||
const container = document.getElementById("conflicts-list");
|
||||
if (!container) return;
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
container.innerHTML =
|
||||
'<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = conflicts
|
||||
.map(
|
||||
(conflict) => `
|
||||
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
|
||||
</div>
|
||||
${
|
||||
conflict.resolution_status === "unresolved"
|
||||
? `
|
||||
<div class="flex space-x-2">
|
||||
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function updateConflictStats(data: ConflictListResponse): void {
|
||||
const totalEl = document.getElementById("conflicts-total");
|
||||
const unresolvedEl = document.getElementById("conflicts-unresolved");
|
||||
|
||||
if (totalEl) totalEl.textContent = String(data.total);
|
||||
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
|
||||
}
|
||||
|
||||
function showResolveModal(conflictId: string): void {
|
||||
const modal = document.getElementById("resolve-modal");
|
||||
const conflictIdInput = document.getElementById(
|
||||
|
||||
Vendored
+20
@@ -1,2 +1,22 @@
|
||||
declare module "markdown-it-footnote";
|
||||
declare module "turndown-plugin-gfm";
|
||||
|
||||
// htmx is loaded as a classic script on every page and used from TS for
|
||||
// partial swaps (rendering-ownership rule: templ renders, JS swaps).
|
||||
interface HtmxAjaxOptions {
|
||||
target?: string;
|
||||
swap?: string;
|
||||
values?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
htmx: {
|
||||
ajax(
|
||||
method: string,
|
||||
url: string,
|
||||
options?: HtmxAjaxOptions,
|
||||
): Promise<boolean>;
|
||||
process(element: Element): void;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user