"Newest episodes from my selected podcasts" is now buildable in the UI, end to end — the user could not find ANY podcast-related filter surface because none existed: the New Collection modal could only create book-scope smart collections, and the engine's episodes scope (item 3.1) had no authoring door. The scope door: - The create modal's Smart card gains a Rules-scope segmented control (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart means "match books by rules". Choosing Episodes seeds an EMPTY episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the collection is born episode-scoped: the editor opens with the episode vocabulary (feed/downloaded/played/published date) and membership runs the episode engine from the first rule. - ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay invalid) — the seed's legal shape. The root arrives at depth 1 by call convention, hence the depth>1 exemption. - The composers define the empty-tree semantics: zero children matches NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring EvaluateSmartCollection's zero-enabled-rules convention. Without the guard the composed query degrades to the bare scope head and a fresh collection would swallow every episode in scope — pinned by TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0) and TestEmptyRootTreeSeedsEpisodesScope. The curated chain order (plan §10 R2, user need: the briefing plays NEWEST-first): - view_settings.chain_order ("native"/"newest"/"oldest"/"reverse") stored on the collection — curated content, not a player-side knob. - PUT /api/collections/:id/view-settings merges the key into the stored JSONB (a blind write would clobber the playlist toggles). - buildSmartCollectionSequenceContext routes OrderEpisodes through handlers.CollectionChainOrder; the detail header's Play-as-Sequence gains the order select next to it (PlayCollectionButton), which PUTs and reloads. Pinned by chain_follows_the_curated_order (reverse flips the chain indexes and hops). Caught by the new test, a latent clobber fixed: UpdateCollection treated a NIL view_settings payload as "write NULL" — any edit that carried no settings dropped every stored key (chain_order, playlist toggles from API clients, imported provenance). Nil now means untouched, the same convention filterTree already had. Composer guards also cover ComposedCount, so the builder's live count reads the same semantics. Tests: collectionChainOrder unit matrix, empty-root parse unit pin, two integration pins (seed matches nothing; curated order drives the chain and survives whole-map edits).
691 lines
26 KiB
Templ
691 lines
26 KiB
Templ
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
)
|
|
|
|
// pluralizeBooks renders "book"/"books" for count displays.
|
|
func pluralizeBooks(n int) string {
|
|
if n == 1 {
|
|
return "book"
|
|
}
|
|
return "books"
|
|
}
|
|
|
|
// firstPlayableEpisodeURL returns the reader URL that starts the
|
|
// collection sequence from the first downloaded episode (plan item 2.3
|
|
// "Play collection as sequence" — the queue param makes the reader
|
|
// build the collection chain), or "" when nothing can play.
|
|
func firstPlayableEpisodeURL(collectionID string, books []handlers.BookInfo) string {
|
|
for _, b := range books {
|
|
if b.IsEpisode && !b.Hidden && b.DownloadState == "downloaded" {
|
|
return "/readers/" + b.MediaItemID + "?queue=collection:" + collectionID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// PlayCollectionButton renders the header's sequence-start button when
|
|
// the collection holds at least one downloaded episode — alongside the
|
|
// collection's CURATED listening order select (plan §10 R2: the chain
|
|
// order lives on the collection, honored by the chain producer; the
|
|
// player shows no order control for collection sources).
|
|
templ PlayCollectionButton(collectionID string, chainOrder string, books []handlers.BookInfo) {
|
|
if url := firstPlayableEpisodeURL(collectionID, books); url != "" {
|
|
<div class="flex items-center gap-2">
|
|
<a
|
|
href={ url }
|
|
class="btn btn-primary"
|
|
title="Play the collection's episodes back-to-back"
|
|
>
|
|
<span>▶</span>
|
|
<span>Play as Sequence</span>
|
|
</a>
|
|
<select
|
|
id="chain-order-select"
|
|
class="input !w-auto"
|
|
aria-label="Listening order"
|
|
title="The order the sequence plays in — stored on this collection"
|
|
@change={ "setChainOrder('" + collectionID + "', $event.target.value)" }
|
|
>
|
|
<option value="native" selected?={ chainOrder == "" || chainOrder == "native" }>Feed order</option>
|
|
<option value="reverse" selected?={ chainOrder == "reverse" }>Feed order (reversed)</option>
|
|
<option value="oldest" selected?={ chainOrder == "oldest" }>Oldest first (pub date)</option>
|
|
<option value="newest" selected?={ chainOrder == "newest" }>Newest first (pub date)</option>
|
|
</select>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// EpisodePickerGrid renders the episode scope of the collection picker
|
|
// (plan item 2.3): YOUR subscriptions' episodes (RLS scopes), compact
|
|
// rows with the shared bookPicker selection store. Checksum ✓ overlay
|
|
// mirrors BookPickerGrid so selection survives htmx swaps.
|
|
templ EpisodePickerGrid(eps []database.SearchEpisodesForPickerRow) {
|
|
for _, ep := range eps {
|
|
<div
|
|
class="relative cursor-pointer rounded-lg border p-3 flex items-center gap-3"
|
|
style="border-color: var(--border);"
|
|
@click={ "$store.bookPicker.toggleBook('" + ep.ID.String() + "')" }
|
|
>
|
|
<div class="w-9 h-9 rounded-md grid place-items-center shrink-0 text-lg" style="background-color: var(--surface-hover);">🎧</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm truncate" style="color: var(--text-primary)">{ ep.Title }</p>
|
|
<p class="text-xs truncate" style="color: var(--text-secondary)">{ ep.FeedTitle }</p>
|
|
</div>
|
|
if ep.DownloadState != "downloaded" {
|
|
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" style="background-color: var(--surface-hover); color: var(--text-secondary)">not downloaded</span>
|
|
}
|
|
<div
|
|
x-show={ "$store.bookPicker.isSelected('" + ep.ID.String() + "')" }
|
|
class="absolute top-2 right-2 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>
|
|
}
|
|
if len(eps) == 0 {
|
|
<p class="text-sm text-center py-8" style="color: var(--text-secondary)">No episodes from your subscriptions match.</p>
|
|
}
|
|
}
|
|
|
|
// 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)">Nothing in this collection yet.</div>
|
|
}
|
|
for _, book := range books {
|
|
if book.Hidden {
|
|
// RLS-invisible membership (plan item 2.3): an
|
|
// unsubscribed episode — or a family-lens hidden
|
|
// book. The entry stays in the list (removable);
|
|
// its facts are withheld by the engine, not the UI.
|
|
<div class="card p-4 rounded-2xl opacity-75" data-media-id={ book.MediaItemID }>
|
|
<div class="flex gap-4 items-center">
|
|
<div class="w-10 h-10 rounded-lg grid place-items-center shrink-0 text-lg" style="background-color: var(--surface-hover); color: var(--text-secondary)">◌</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-semibold text-lg" style="color: var(--text-secondary)">Unavailable</h3>
|
|
<p class="text-sm" style="color: var(--text-secondary)">Not subscribed (or not visible to you) — the feed's episodes leave the queue until you follow it again.</p>
|
|
</div>
|
|
if !collection.IsSystem && !collection.IsSmart {
|
|
<button
|
|
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
|
|
class="btn btn-secondary text-sm"
|
|
>
|
|
@Icon("trash", "h-4 w-4")
|
|
<span>Remove entry</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
} else if book.IsEpisode {
|
|
// Episode card (plan item 2.3): play resumes the
|
|
// collection chain — ?queue=collection:<id> makes
|
|
// the reader feed prev/next from the collection's
|
|
// order (Play collection as sequence).
|
|
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } 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">
|
|
<h3 class="font-semibold text-lg mb-0.5 line-clamp-2" style="color: var(--text-primary)">
|
|
{ book.Title }
|
|
</h3>
|
|
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
|
|
🎧 { book.FeedTitle }
|
|
</p>
|
|
if book.DownloadState == "downloaded" {
|
|
<a
|
|
href={ "/readers/" + book.MediaItemID + "?queue=collection:" + collection.ID }
|
|
class="btn btn-primary text-sm mt-2"
|
|
>
|
|
<span>▶</span>
|
|
<span>Play</span>
|
|
</a>
|
|
} else {
|
|
<span class="badge text-xs mt-2 inline-block" style="background-color: var(--surface-hover); color: var(--text-secondary)">Not downloaded</span>
|
|
}
|
|
</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>
|
|
} else {
|
|
<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"
|
|
|
|
/>
|
|
} 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>
|
|
<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, $event)" 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>
|
|
<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, "", nil)
|
|
<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)">Items 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">
|
|
@PlayCollectionButton(collection.ID, collection.ChainOrder, books)
|
|
<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 Items</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-[65] 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 flex flex-col max-h-[85vh]"
|
|
style="display: none; box-shadow: var(--shadow-pop);"
|
|
>
|
|
<div
|
|
class="flex justify-between items-center p-6 border-b shrink-0"
|
|
style="border-color: var(--border);"
|
|
>
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">
|
|
Add Items to Collection
|
|
</h2>
|
|
<button
|
|
@click="$store.bookPicker.close()"
|
|
class="icon-btn"
|
|
aria-label="Close"
|
|
>
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
<!-- Scope tabs (plan item 2.3): books stay the default; the
|
|
episodes tab is YOUR subscriptions' episodes (RLS scopes).
|
|
Pure client-local state — the tab click triggers the
|
|
hidden grid's htmx load. -->
|
|
<div class="flex gap-2 px-6 pt-4 shrink-0">
|
|
<button
|
|
type="button"
|
|
@click="$store.bookPicker.switchScope('books')"
|
|
:class="$store.bookPicker.scope === 'episodes' ? 'btn btn-ghost' : 'btn btn-secondary'"
|
|
>
|
|
📚 Books
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="$store.bookPicker.switchScope('episodes')"
|
|
:class="$store.bookPicker.scope === 'episodes' ? 'btn btn-secondary' : 'btn btn-ghost'"
|
|
>
|
|
🎧 Episodes
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="p-4 border-b shrink-0"
|
|
style="border-color: var(--border);"
|
|
x-show="$store.bookPicker.scope !== 'episodes'"
|
|
>
|
|
<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 flex-1 min-h-0 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>
|
|
<!-- Episode scope (plan item 2.3): one search field — the
|
|
episode list is feed-ordered, facets make no sense here.
|
|
RLS scopes to YOUR subscriptions server-side. -->
|
|
<div
|
|
class="p-4 border-b shrink-0"
|
|
style="border-color: var(--border);"
|
|
x-show="$store.bookPicker.scope === 'episodes'"
|
|
x-cloak
|
|
>
|
|
<div id="episode-picker-filters" class="flex flex-wrap gap-3 items-center">
|
|
<div class="flex-1 min-w-[200px]">
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
placeholder="Search episodes..."
|
|
class="input"
|
|
hx-get={ "/api/media-items/search?show_checkbox=true&scope=episodes&collection_id=" + collection.ID }
|
|
hx-target="#episode-picker-grid"
|
|
hx-trigger="keyup changed delay:300ms"
|
|
hx-include="#episode-picker-filters"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
id="episode-picker-grid"
|
|
class="flex flex-col gap-2 p-4 flex-1 min-h-0 overflow-y-auto"
|
|
x-show="$store.bookPicker.scope === 'episodes'"
|
|
x-cloak
|
|
hx-get={ "/api/media-items/search?show_checkbox=true&scope=episodes&collection_id=" + collection.ID }
|
|
hx-trigger="loadEpisodes"
|
|
hx-include="#episode-picker-filters"
|
|
>
|
|
</div>
|
|
<div
|
|
class="p-4 border-t flex justify-between items-center shrink-0"
|
|
style="border-color: var(--border);"
|
|
>
|
|
<div class="text-sm" style="color: var(--text-secondary);">
|
|
<span x-text="$store.bookPicker.selectedCount"></span> 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
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
<div
|
|
x-show="showConfirm"
|
|
@click.self="closeConfirm()"
|
|
class="fixed inset-0 z-[70] 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" />
|
|
} 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>
|
|
}
|
|
}
|