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
+71
-62
@@ -14,6 +14,76 @@ func pluralizeBooks(n int) string {
|
||||
return "books"
|
||||
}
|
||||
|
||||
// CollectionBooksGrid renders the content of #books-container: the
|
||||
// collection's book cards, or the empty state. Shared by the collection
|
||||
// detail page and the /collections/:id/books-grid partial route (the
|
||||
// library switcher refreshes the grid through it — rendering ownership:
|
||||
// templ renders, JS only swaps).
|
||||
templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) {
|
||||
if len(books) == 0 {
|
||||
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||
}
|
||||
for _, book := range books {
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<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(" + JSONArg(book.MediaItemID) + ")" }
|
||||
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
|
||||
{ book.Title }
|
||||
</h3>
|
||||
</a>
|
||||
if 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.MediaItemID }>
|
||||
if book.CoverImagePath != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath }
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
/>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove from Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -210,68 +280,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
</div>
|
||||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
if len(books) == 0 {
|
||||
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||
}
|
||||
for _, book := range books {
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<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(" + JSONArg(book.MediaItemID) + ")" }
|
||||
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
|
||||
{ book.Title }
|
||||
</h3>
|
||||
</a>
|
||||
if 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.MediaItemID }>
|
||||
if book.CoverImagePath != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath }
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
/>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove from Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@CollectionBooksGrid(collection, books)
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
|
||||
Reference in new issue
Block a user