feat(podcasts): the episodes authoring door + curated chain order
"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).
This commit is contained in:
1 parent
d5dbaa48ea
commit
cc0e9d7f91
16 files changed
+946
-538
No files matched your search
@@ -99,21 +99,44 @@ templ CollectionModal(collection CollectionData) {
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Type</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="relative cursor-pointer">
|
||||
<input type="radio" name="collection_type" value="static" checked class="peer sr-only"/>
|
||||
<input type="radio" name="collection_type" value="static" checked @change="setCollectionType($event.target.value)" class="peer sr-only"/>
|
||||
<div class="p-3 rounded-lg border text-center peer-checked:border-blue-500" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<span class="font-semibold text-sm" style="color: var(--text-primary)">Static</span>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Pick books by hand</p>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Pick items by hand</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="relative cursor-pointer">
|
||||
<input type="radio" name="collection_type" value="smart" class="peer sr-only"/>
|
||||
<input type="radio" name="collection_type" value="smart" @change="setCollectionType($event.target.value)" class="peer sr-only"/>
|
||||
<div class="p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<span class="font-semibold text-sm" style="color: var(--text-primary)">⚡ Smart</span>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Match books by rules — always live</p>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Match by rules — always live</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4" x-show="collectionType === 'smart'" x-cloak>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Rules scope</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="setSmartScope('books')"
|
||||
:class="smartScope === 'books' ? 'btn btn-secondary' : 'btn btn-ghost'"
|
||||
>
|
||||
📚 Books
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="setSmartScope('episodes')"
|
||||
:class="smartScope === 'episodes' ? 'btn btn-secondary' : 'btn btn-ghost'"
|
||||
>
|
||||
🎧 Podcast episodes
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs mt-2" style="color: var(--text-secondary)">
|
||||
Episodes collections match your podcast subscriptions' episodes (feed, downloaded, played, published date) and play as a sequence.
|
||||
</p>
|
||||
<input type="hidden" name="filter_tree" id="collection-tree-seed" value=""/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
|
||||
<input
|
||||
|
||||
@@ -168,14 +168,14 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked @change=\"setCollectionType($event.target.value)\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick items by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" @change=\"setCollectionType($event.target.value)\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match by rules — always live</p></div></label></div></div><div class=\"mb-4\" x-show=\"collectionType === 'smart'\" x-cloak><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Rules scope</label><div class=\"grid grid-cols-2 gap-2\"><button type=\"button\" @click=\"setSmartScope('books')\" :class=\"smartScope === 'books' ? 'btn btn-secondary' : 'btn btn-ghost'\">📚 Books</button> <button type=\"button\" @click=\"setSmartScope('episodes')\" :class=\"smartScope === 'episodes' ? 'btn btn-secondary' : 'btn btn-ghost'\">🎧 Podcast episodes</button></div><p class=\"text-xs mt-2\" style=\"color: var(--text-secondary)\">Episodes collections match your podcast subscriptions' episodes (feed, downloaded, played, published date) and play as a sequence.</p><input type=\"hidden\" name=\"filter_tree\" id=\"collection-tree-seed\" value=\"\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 147, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 170, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+28
-11
@@ -29,17 +29,34 @@ func firstPlayableEpisodeURL(collectionID string, books []handlers.BookInfo) str
|
||||
}
|
||||
|
||||
// PlayCollectionButton renders the header's sequence-start button when
|
||||
// the collection holds at least one downloaded episode.
|
||||
templ PlayCollectionButton(collectionID string, books []handlers.BookInfo) {
|
||||
// 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 != "" {
|
||||
<a
|
||||
href={ url }
|
||||
class="btn btn-primary"
|
||||
title="Play the collection's episodes back-to-back in list order"
|
||||
>
|
||||
<span>▶</span>
|
||||
<span>Play as Sequence</span>
|
||||
</a>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +406,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<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, books)
|
||||
@PlayCollectionButton(collection.ID, collection.ChainOrder, books)
|
||||
<div class="flex-1 min-w-[200px] max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
+562
-506
File diff suppressed because it is too large.
Load diff
@@ -54,6 +54,10 @@ type CollectionData struct {
|
||||
// MatchMode is the smart ruleset's semantics (item 2.4): "any" (OR)
|
||||
// or "all" (AND). Static collections ignore it.
|
||||
MatchMode string
|
||||
// ChainOrder is the episode sequence's curated listening order (plan
|
||||
// §10 R2): "" / native / newest / oldest / reverse — stored in
|
||||
// view_settings, honored by the chain producer.
|
||||
ChainOrder string
|
||||
// BookCount is the LIVE count for the card (rule matches for smart
|
||||
// collections, stored links for static ones).
|
||||
BookCount int
|
||||
|
||||
Reference in new issue
Block a user