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.
476 lines
17 KiB
Templ
476 lines
17 KiB
Templ
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"bookhoard/internal/handlers"
|
|
)
|
|
|
|
// pluralizeBooks renders "book"/"books" for count displays.
|
|
func pluralizeBooks(n int) string {
|
|
if n == 1 {
|
|
return "book"
|
|
}
|
|
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 class="flex flex-col items-center gap-0.5 mt-1">
|
|
<button type="button" class="icon-btn text-xs" title="Move earlier (playlist order)"
|
|
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", -1)" }>↑</button>
|
|
<button type="button" class="icon-btn text-xs" title="Move later (playlist order)"
|
|
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", 1)" }>↓</button>
|
|
</div>
|
|
</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">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Collections - Bookhoard</title>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
|
|
@Header(user, "/collections")
|
|
<div id="modal-container"></div>
|
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-8 flex flex-wrap justify-between items-center gap-4">
|
|
<div class="flex items-center gap-3">
|
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("folder", "h-5 w-5")
|
|
</span>
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">My Collections</h1>
|
|
<p class="text-sm" style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
hx-get="/collections/restore-modal"
|
|
hx-target="#modal-container"
|
|
hx-swap="innerHTML"
|
|
class="btn btn-secondary"
|
|
>
|
|
@Icon("refresh", "h-4 w-4")
|
|
<span>Restore System</span>
|
|
</button>
|
|
<button
|
|
hx-get="/collections/create-modal"
|
|
hx-target="#modal-container"
|
|
hx-swap="innerHTML"
|
|
class="btn btn-primary"
|
|
>
|
|
@Icon("plus", "h-4 w-4")
|
|
<span>New Collection</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
if len(collections) == 0 {
|
|
<div class="card text-center py-16 px-6 col-span-full">
|
|
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("folder", "h-7 w-7")
|
|
</div>
|
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
|
<p class="mb-5" style="color: var(--text-secondary)">Create collections to organize your books</p>
|
|
<button
|
|
hx-get="/collections/create-modal"
|
|
hx-target="#modal-container"
|
|
hx-swap="innerHTML"
|
|
class="btn btn-primary"
|
|
>
|
|
@Icon("plus", "h-4 w-4")
|
|
<span>Create Your First Collection</span>
|
|
</button>
|
|
</div>
|
|
}
|
|
for _, col := range collections {
|
|
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
|
<div
|
|
class="card p-6 rounded-2xl border-l-4 cursor-pointer"
|
|
data-color={ col.Color }
|
|
>
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div class="text-3xl">{ col.Icon }</div>
|
|
<div class="flex gap-1 items-center">
|
|
if col.IsSmart {
|
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
|
|
}
|
|
<button
|
|
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
|
hx-target="#modal-container"
|
|
hx-swap="innerHTML"
|
|
class="icon-btn"
|
|
aria-label="Edit collection"
|
|
>
|
|
@Icon("edit", "h-4 w-4")
|
|
</button>
|
|
<button
|
|
hx-delete={ "/api/collections/" + col.ID + "" }
|
|
hx-redirect="/collections"
|
|
hx-confirm="Are you sure you want to delete this collection?"
|
|
class="icon-btn"
|
|
aria-label="Delete collection"
|
|
>
|
|
@Icon("trash", "h-4 w-4")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
|
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p>
|
|
<p class="text-xs mt-3 flex items-center gap-1" style="color: var(--text-secondary);">
|
|
<span class="font-medium">{ fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)) }</span>
|
|
if col.IsSmart {
|
|
<span>· rules-matched live</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string, libData []LibraryData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ collection.Name } - Bookhoard</title>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
|
|
@Header(user, "/collections")
|
|
@LibrarySwitcher(libData, libraryID, "")
|
|
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-6">
|
|
<a href="/collections" class="btn btn-secondary mb-4">
|
|
@Icon("arrow-left", "h-4 w-4")
|
|
<span>Back to Collections</span>
|
|
</a>
|
|
<div class="flex items-center gap-4">
|
|
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style={ "background-color: var(--accent-muted); color: " + collection.Color }>{ collection.Icon }</div>
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2" style="color: var(--text-primary)">
|
|
{ collection.Name }
|
|
if collection.IsSmart {
|
|
<span class="badge text-xs" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
|
|
}
|
|
</h1>
|
|
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
|
|
if collection.IsSmart {
|
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Membership is rules-matched live — always current, nothing to refresh.</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
|
|
<div class="flex items-center gap-3">
|
|
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Books in this Collection</h2>
|
|
<span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2 items-center">
|
|
<div class="flex-1 min-w-[200px] max-w-md">
|
|
<input
|
|
type="text"
|
|
id="collection-search"
|
|
placeholder="Search within collection..."
|
|
@input="filterCollectionBooks()"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
if !collection.IsSystem {
|
|
<a
|
|
href={ "/collections/" + collection.ID + "/rules" }
|
|
class="btn btn-secondary"
|
|
>
|
|
@Icon("filter", "h-4 w-4")
|
|
<span>Edit Rules</span>
|
|
</a>
|
|
if !collection.IsSmart {
|
|
<button
|
|
@click="requestBulkRemove()"
|
|
:disabled="selectedBooks.length === 0"
|
|
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
|
class="btn btn-danger"
|
|
>
|
|
@Icon("trash", "h-4 w-4")
|
|
<span>Remove Selected</span>
|
|
</button>
|
|
<button
|
|
@click="$store.bookPicker.open()"
|
|
class="btn btn-primary"
|
|
>
|
|
@Icon("plus", "h-4 w-4")
|
|
<span>Add Books</span>
|
|
</button>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
@CollectionBooksGrid(collection, books)
|
|
</div>
|
|
</div>
|
|
if !collection.IsSystem && !collection.IsSmart {
|
|
<div
|
|
x-data="bookPicker"
|
|
x-show="$store.bookPicker.isOpen"
|
|
@keyup.escape.window="$store.bookPicker.close()"
|
|
@click.self="$store.bookPicker.close()"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none; background-color: var(--surface-overlay);"
|
|
>
|
|
<div
|
|
@click.stop
|
|
x-show="$store.bookPicker.isOpen"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
class="card rounded-2xl w-full max-w-6xl mx-4 my-8"
|
|
style="display: none; box-shadow: var(--shadow-pop);"
|
|
>
|
|
<div
|
|
class="flex justify-between items-center p-6 border-b"
|
|
style="border-color: var(--border);"
|
|
>
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">
|
|
Add Books to Collection
|
|
</h2>
|
|
<button
|
|
@click="$store.bookPicker.close()"
|
|
class="icon-btn"
|
|
aria-label="Close"
|
|
>
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="p-4 border-b"
|
|
style="border-color: var(--border);"
|
|
>
|
|
<div id="book-picker-filters" class="flex flex-wrap gap-3 items-center">
|
|
<div class="flex-1 min-w-[200px]">
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
placeholder="Search books..."
|
|
class="input"
|
|
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
|
|
hx-target="#book-picker-grid"
|
|
hx-trigger="keyup changed delay:300ms"
|
|
hx-include="#book-picker-filters"
|
|
/>
|
|
</div>
|
|
<div class="flex-1 min-w-[150px]">
|
|
<input
|
|
type="text"
|
|
name="author_filter"
|
|
placeholder="Author"
|
|
class="input"
|
|
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
|
|
hx-target="#book-picker-grid"
|
|
hx-trigger="change"
|
|
hx-include="#book-picker-filters"
|
|
/>
|
|
</div>
|
|
<div class="flex-1 min-w-[150px]">
|
|
<input
|
|
type="text"
|
|
name="genre_filter"
|
|
placeholder="Genre"
|
|
class="input"
|
|
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
|
|
hx-target="#book-picker-grid"
|
|
hx-trigger="change"
|
|
hx-include="#book-picker-filters"
|
|
/>
|
|
</div>
|
|
<button
|
|
@click="$store.bookPicker.clearFilters()"
|
|
class="btn btn-ghost"
|
|
>
|
|
@Icon("close", "h-4 w-4")
|
|
<span>Clear</span>
|
|
</button>
|
|
<input type="hidden" name="limit" value="50"/>
|
|
<input type="hidden" name="offset" value="0"/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
id="book-picker-grid"
|
|
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
|
|
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
|
|
hx-trigger="loadBooks"
|
|
hx-include="#book-picker-filters"
|
|
>
|
|
</div>
|
|
<div
|
|
class="p-4 border-t flex justify-between items-center"
|
|
style="border-color: var(--border);"
|
|
>
|
|
<div class="text-sm" style="color: var(--text-secondary);">
|
|
<span x-text="$store.bookPicker.selectedCount"></span> books selected
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
@click="$store.bookPicker.close()"
|
|
class="btn btn-secondary"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
@click="$store.bookPicker.submit()"
|
|
class="btn btn-primary"
|
|
>
|
|
Add Selected Books
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
<div
|
|
x-show="showConfirm"
|
|
@click.self="closeConfirm()"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
style="display: none; background-color: var(--surface-overlay);"
|
|
>
|
|
<div
|
|
@click.stop
|
|
class="card p-6 w-full max-w-md mx-4"
|
|
style="box-shadow: var(--shadow-pop);"
|
|
>
|
|
<h3 class="text-lg font-bold mb-2" style="color: var(--text-primary)">Remove from Collection</h3>
|
|
<p x-text="confirmMessage" class="text-sm mb-6" style="color: var(--text-secondary)"></p>
|
|
<div class="flex justify-end gap-3">
|
|
<button @click="closeConfirm()" class="btn btn-secondary">Cancel</button>
|
|
<button @click="executeConfirmed()" class="btn btn-danger">
|
|
@Icon("trash", "h-4 w-4")
|
|
<span x-text="confirmLabel"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
<div
|
|
id="collection-data"
|
|
data-id={ collection.ID }
|
|
data-library-id={ libraryID }
|
|
data-is-system={ collection.IsSystem }
|
|
data-is-smart={ collection.IsSmart }
|
|
style="display: none;"
|
|
></div>
|
|
</html>
|
|
}
|
|
|
|
templ BookPickerGrid(books []handlers.BookInfo) {
|
|
for _, book := range books {
|
|
<div
|
|
class="relative cursor-pointer rounded-lg overflow-hidden"
|
|
@click={ "$store.bookPicker.toggleBook('" + book.MediaItemID + "')" }
|
|
>
|
|
<div class="relative">
|
|
if book.CoverImagePath != "" {
|
|
<img src={ book.CoverImagePath } alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy" onerror="this.src='/static/placeholder-book.svg'"/>
|
|
} else {
|
|
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/>
|
|
}
|
|
<div
|
|
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
|
|
class="absolute inset-0 border-4 rounded-lg pointer-events-none"
|
|
style="border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);"
|
|
></div>
|
|
<div
|
|
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
|
|
class="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none"
|
|
style="background-color: var(--accent); color: var(--bg-primary);"
|
|
>
|
|
✓
|
|
</div>
|
|
</div>
|
|
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</p>
|
|
</div>
|
|
}
|
|
}
|