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
+1053
-645
No files matched your search
@@ -1049,6 +1049,37 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(body, &out))
|
||||
assert.NotNil(t, out.Tree, "an edit that sends no tree keeps the stored one")
|
||||
})
|
||||
|
||||
// Plan §10 R2 + the 2026-10-31 chain-order option: the collection's
|
||||
// CURATED listening order (view_settings.chain_order) governs the
|
||||
// chain. reverse flips the feed positions, so the episode that was
|
||||
// 1/2 under native becomes 2/2 and gains a prev hop.
|
||||
t.Run("chain_follows_the_curated_order", func(t *testing.T) {
|
||||
code, body := podcastAuthed(t, setup, regToken, "PUT",
|
||||
"/api/collections/"+collID+"/view-settings", `{"chain_order":"reverse"}`)
|
||||
require.Equal(t, http.StatusOK, code, "%s", string(body))
|
||||
|
||||
// The edit-form save path must not clobber the merged key.
|
||||
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
|
||||
`{"name":"test TreeColl `+marker+`","description":"order survives edits"}`)
|
||||
require.Equal(t, http.StatusCreated, code, "%s", string(body))
|
||||
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, string(body), `"chain_order":"reverse"`,
|
||||
"view_settings merge keeps chain_order across whole-map writes")
|
||||
|
||||
// aNew is feed position 1: FIRST under native, LAST under reverse.
|
||||
code, body = podcastAuthed(t, setup, regToken, "GET",
|
||||
"/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
html := string(body)
|
||||
assert.Contains(t, html, esc(`"episode_index":2`),
|
||||
"reverse order puts feed position 1 last")
|
||||
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
|
||||
"the reversed chain hops back through feed position 5")
|
||||
assert.NotContains(t, html, esc(`"next_url"`),
|
||||
"the last episode of the chain has no next hop")
|
||||
})
|
||||
}
|
||||
|
||||
// ---- download worker: state machine, idempotency, file layout ----
|
||||
@@ -2080,3 +2111,42 @@ func TestAdminDeleteEpisodeFiles(t *testing.T) {
|
||||
assert.NoFileExists(t, pathFor(ep), "no file re-materialized without an explicit download")
|
||||
}
|
||||
}
|
||||
|
||||
// The episodes scope door (2026-10-31): the create modal seeds an EMPTY
|
||||
// episodes root so a fresh Episodes smart collection has a stored scope
|
||||
// before its first rule. The seed must be accepted at write, and the
|
||||
// collection must match NOTHING — the composed query must never degrade
|
||||
// to the bare scope head, which would swallow every episode in scope.
|
||||
func TestEpisodeCollectionsEmptySeedMatchesNothing(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
regToken := setup.RegularToken
|
||||
marker := fmt.Sprintf("emptyseed%d", time.Now().UnixNano()%100000)
|
||||
|
||||
seed := `{"scope":"episodes","mode":"all","children":[]}`
|
||||
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
|
||||
`{"name":"test EmptySeed `+marker+`","collection_type":"smart","filter_tree":`+seed+`}`)
|
||||
require.Equal(t, http.StatusCreated, code, "the empty seed must parse at write: %s", string(body))
|
||||
var coll struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &coll))
|
||||
t.Cleanup(func() {
|
||||
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
|
||||
})
|
||||
|
||||
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, string(body), `"is_smart":true`,
|
||||
"a seeded tree collection IS smart without any rules array")
|
||||
|
||||
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
var out struct {
|
||||
BookCount int `json:"book_count"`
|
||||
Books []interface{} `json:"books"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &out))
|
||||
assert.Equal(t, 0, out.BookCount,
|
||||
"an empty-seed episodes collection matches nothing — a composer that degraded to the bare scope head would report the whole library here")
|
||||
assert.Empty(t, out.Books)
|
||||
}
|
||||
@@ -547,6 +547,88 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
||||
return c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// ValidChainOrders — the collection chain's listening-order vocabulary
|
||||
// (plan §10 R2: a collection's playback order is CURATED on the
|
||||
// collection, not a player-side knob). Mirrors OrderEpisodes' orders;
|
||||
// "" means native (the pre-chain-order default).
|
||||
var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true}
|
||||
|
||||
// CollectionChainOrder resolves a collection row's curated chain order
|
||||
// from its view_settings JSONB — "" (native) when absent/unknown. The
|
||||
// episode chain producer routes OrderEpisodes through this; the
|
||||
// view-settings PUT writes the key (SetCollectionChainOrder).
|
||||
func CollectionChainOrder(viewSettings []byte) string {
|
||||
if len(viewSettings) == 0 {
|
||||
return ""
|
||||
}
|
||||
var vs map[string]interface{}
|
||||
if json.Unmarshal(viewSettings, &vs) != nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetCollectionChainOrder — PUT /api/collections/:id/view-settings with
|
||||
// {"chain_order": "..."}: merges the key into the collection's stored
|
||||
// view_settings (the playlist toggles share that JSONB — a blind write
|
||||
// would clobber them). The episode chain producer
|
||||
// (buildSmartCollectionSequenceContext) honors it.
|
||||
func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ChainOrder string `json:"chain_order"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if !ValidChainOrders[req.ChainOrder] {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "chain_order must be one of: native, newest, oldest, reverse"})
|
||||
}
|
||||
|
||||
collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||
}
|
||||
|
||||
settings := map[string]interface{}{}
|
||||
if len(collection.ViewSettings) > 0 {
|
||||
if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"})
|
||||
}
|
||||
}
|
||||
if req.ChainOrder == "" || req.ChainOrder == "native" {
|
||||
// Both spellings mean the default — drop the key entirely so the
|
||||
// stored doc stays clean.
|
||||
delete(settings, "chain_order")
|
||||
} else {
|
||||
settings["chain_order"] = req.ChainOrder
|
||||
}
|
||||
blob, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
ViewSettings: blob,
|
||||
}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"chain_order": req.ChainOrder,
|
||||
"view_settings": settings,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
// The collection chain's curated order (plan §10 R2): the resolver
|
||||
// reads view_settings.chain_order, tolerates absent/corrupt docs, and
|
||||
// refuses unknown orders (they mean native).
|
||||
func TestCollectionChainOrder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
vs string
|
||||
want string
|
||||
}{
|
||||
{"absent doc", "", ""},
|
||||
{"empty object", `{}`, ""},
|
||||
{"reverse", `{"chain_order":"reverse"}`, "reverse"},
|
||||
{"newest", `{"chain_order":"newest"}`, "newest"},
|
||||
{"merged with toggles", `{"hide_finished":true,"chain_order":"oldest"}`, "oldest"},
|
||||
{"unknown order means native", `{"chain_order":"shuffle"}`, ""},
|
||||
{"non-string means native", `{"chain_order":3}`, ""},
|
||||
{"corrupt doc means native", `not json`, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := CollectionChainOrder([]byte(tc.vs)); got != tc.want {
|
||||
t.Fatalf("CollectionChainOrder(%s) = %q, want %q", tc.vs, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ func registerCollectionsRoutes(cfg *Config) {
|
||||
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
||||
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
||||
collections.PUT("/:id/match-mode", cfg.CollectionHandler.SetMatchMode)
|
||||
// The chain's listening order lives ON the collection (plan §10 R2):
|
||||
// merges into view_settings; the episode chain producer honors it.
|
||||
collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder)
|
||||
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
||||
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
||||
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
||||
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -270,6 +271,15 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
|
||||
// collectionDataFromRow builds the template collection payload shared by
|
||||
// the collection detail page and its books-grid partial.
|
||||
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
|
||||
chainOrder := ""
|
||||
if len(collection.ViewSettings) > 0 {
|
||||
var vs map[string]interface{}
|
||||
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
||||
if s, ok := vs["chain_order"].(string); ok {
|
||||
chainOrder = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return templates.CollectionData{
|
||||
ID: collectionID,
|
||||
Name: collection.Name,
|
||||
@@ -279,6 +289,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
||||
IsSystem: collection.IsSystemCollection.Bool,
|
||||
IsSmart: isSmart,
|
||||
MatchMode: collection.RuleMatchMode,
|
||||
ChainOrder: chainOrder,
|
||||
BookCount: bookCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +374,10 @@ func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *dat
|
||||
if len(ordered) == 0 {
|
||||
return nil
|
||||
}
|
||||
services.OrderEpisodes(ordered, "native",
|
||||
// Plan §10 R2: the collection's CURATED listening order (stored in
|
||||
// view_settings.chain_order, set from the detail header) — not a
|
||||
// player-side knob. "" / native / unknown all mean feed order.
|
||||
services.OrderEpisodes(ordered, handlers.CollectionChainOrder(coll.ViewSettings),
|
||||
func(e seqEp) int32 { return e.pos },
|
||||
func(e seqEp) string { return e.date },
|
||||
)
|
||||
|
||||
@@ -189,6 +189,17 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
viewSettings = merged
|
||||
}
|
||||
}
|
||||
// Same convention as filterTree: a NIL view_settings payload
|
||||
// means untouched — keep the stored doc. Without this, an edit
|
||||
// that carries no settings wrote NULL over the row and silently
|
||||
// dropped every stored key (chain_order, imported provenance);
|
||||
// the 2026-10-31 chain-order test pinned exactly this clobber.
|
||||
if viewSettings == nil && len(existing.ViewSettings) > 0 {
|
||||
var stored map[string]any
|
||||
if json.Unmarshal(existing.ViewSettings, &stored) == nil && len(stored) > 0 {
|
||||
viewSettings = stored
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ValidRuleMatchMode(ruleMatchMode) {
|
||||
ruleMatchMode = existingMode
|
||||
@@ -493,8 +504,6 @@ func IsCollectionSmart(coll database.Collections) bool {
|
||||
return len(rules) > 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
||||
// tree-bearing collections evaluate through the unified filter engine;
|
||||
// legacy rule arrays keep per-item evaluation until rewritten. One
|
||||
|
||||
@@ -883,6 +883,15 @@ func composedQueryBlock(qBind, exactBind string) string {
|
||||
// Sqlc embed for tree collections; ordering matches the legacy
|
||||
// candidate query (title ASC).
|
||||
func (s *SearchService) ComposedLiveMedia(ctx context.Context, userID, libraryID pgtype.UUID, visibleIDs []pgtype.UUID, tree *FilterTree) ([]database.MediaItems, error) {
|
||||
// A rules-LESS tree (root with zero children — legal since the
|
||||
// episodes scope door seeds one at create-modal time) matches
|
||||
// NOTHING, mirroring the legacy engine's zero-enabled-rules → empty
|
||||
// convention (EvaluateSmartCollection). Without this guard the
|
||||
// composed query degrades to the bare scope head below and a fresh
|
||||
// Episodes collection would swallow every episode in the library.
|
||||
if len(tree.Children) == 0 {
|
||||
return []database.MediaItems{}, nil
|
||||
}
|
||||
b := newFilterBuilder(userID)
|
||||
head := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b)
|
||||
visibleClause := ""
|
||||
@@ -913,6 +922,11 @@ ORDER BY mi.title ASC`
|
||||
// ComposedCount — the live match count for the builder (rules + scope,
|
||||
// no pagination): a true COUNT(*), not the page length.
|
||||
func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) {
|
||||
// Same empty-tree convention as ComposedLiveMedia: rules-less means
|
||||
// zero matches, not "everything".
|
||||
if len(tree.Children) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
b := newFilterBuilder(userID)
|
||||
libBind := b.bind(libraryID)
|
||||
whereSQL := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b) + `
|
||||
|
||||
@@ -633,7 +633,15 @@ func validateGroup(g *FilterChild, depth int, scope string) error {
|
||||
if g.Mode != "all" && g.Mode != "any" {
|
||||
return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
|
||||
}
|
||||
if len(g.Children) == 0 {
|
||||
// The ROOT arrives as depth 1 (call convention below), so "depth > 1"
|
||||
// exempts exactly the root: an EMPTY ROOT is legal as of the episodes
|
||||
// scope door — the create modal seeds
|
||||
// `{"scope":"episodes","mode":"all","children":[]}` so a fresh
|
||||
// Episodes smart collection has a stored scope before its first
|
||||
// rule. Nested empty groups stay invalid, and the composers define
|
||||
// the semantics: zero children matches nothing (mirrors
|
||||
// EvaluateSmartCollection's zero-rules convention).
|
||||
if len(g.Children) == 0 && depth > 1 {
|
||||
return fmt.Errorf("filter group has no children")
|
||||
}
|
||||
for _, c := range g.Children {
|
||||
@@ -650,7 +658,6 @@ func validateGroup(g *FilterChild, depth int, scope string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// numericParsers map each numeric field's wire values to the Go value
|
||||
// bound into SQL — validation and clause-building share one parser per
|
||||
// field, so a value that passes validation always binds.
|
||||
|
||||
@@ -171,3 +171,28 @@ func TestFilterFieldsByGroupOrdering(t *testing.T) {
|
||||
require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
|
||||
}
|
||||
}
|
||||
|
||||
// The episodes scope door (2026-10-31): the create modal seeds an EMPTY
|
||||
// episodes root so a fresh Episodes smart collection has a stored scope
|
||||
// before its first rule. The parser accepts an empty ROOT (nested empty
|
||||
// groups stay invalid), and the composers define the semantics — zero
|
||||
// children matches NOTHING, mirroring EvaluateSmartCollection's
|
||||
// zero-rules convention (a bare scope head would match everything).
|
||||
func TestEmptyRootTreeSeedsEpisodesScope(t *testing.T) {
|
||||
tree, err := ParseFilterTree(`{"scope":"episodes","mode":"all","children":[]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilterTree(seed): %v", err)
|
||||
}
|
||||
if got := tree.FilterScope(); got != ScopeEpisodes {
|
||||
t.Fatalf("FilterScope = %q, want %q", got, ScopeEpisodes)
|
||||
}
|
||||
if len(tree.Children) != 0 {
|
||||
t.Fatalf("children = %d, want 0", len(tree.Children))
|
||||
}
|
||||
|
||||
// Nested empty groups remain invalid.
|
||||
nested := `{"mode":"all","children":[{"mode":"any","children":[]}]}`
|
||||
if _, err := ParseFilterTree(nested); err == nil {
|
||||
t.Fatal("nested empty group must stay invalid")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href={ url }
|
||||
class="btn btn-primary"
|
||||
title="Play the collection's episodes back-to-back in list order"
|
||||
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"
|
||||
|
||||
+686
-630
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
|
||||
|
||||
@@ -380,11 +380,65 @@ Alpine.data("collections", () => ({
|
||||
showAllIcons,
|
||||
toggleSelection,
|
||||
|
||||
// ── create-modal: type + smart scope (the episodes scope door) ──
|
||||
// Static/Smart radio state and, for smart collections, which item
|
||||
// kind the rules match. The modal copy no longer claims smart means
|
||||
// "books": episodes-scope smart collections are first-class.
|
||||
collectionType: "static" as string,
|
||||
smartScope: "books" as string,
|
||||
|
||||
setCollectionType(this: any, t: string) {
|
||||
this.collectionType = t === "smart" ? "smart" : "static";
|
||||
this.syncTreeSeed();
|
||||
},
|
||||
|
||||
setSmartScope(this: any, s: string) {
|
||||
this.smartScope = s === "episodes" ? "episodes" : "books";
|
||||
this.syncTreeSeed();
|
||||
},
|
||||
|
||||
// syncTreeSeed — the create form's hidden filter_tree input. An
|
||||
// Episodes-scope smart collection seeds an EMPTY episodes tree so the
|
||||
// editor opens with the episode vocabulary and membership runs the
|
||||
// episode engine from birth; Books stays tree-less (the editor's
|
||||
// books default).
|
||||
syncTreeSeed(this: any) {
|
||||
const seed =
|
||||
this.collectionType === "smart" && this.smartScope === "episodes"
|
||||
? '{"scope":"episodes","mode":"all","children":[]}'
|
||||
: "";
|
||||
const input = document.getElementById("collection-tree-seed") as HTMLInputElement | null;
|
||||
if (input) input.value = seed;
|
||||
},
|
||||
|
||||
showConfirm: false,
|
||||
confirmMessage: "",
|
||||
confirmLabel: "Remove",
|
||||
pendingAction: null as null | (() => void),
|
||||
|
||||
// setChainOrder — the detail header's listening-order select (plan
|
||||
// §10 R2: the collection's chain order is curated HERE, not on the
|
||||
// player). Merges into view_settings server-side, then reloads so
|
||||
// the chain producer's stored order takes effect.
|
||||
setChainOrder(this: any, collectionID: string, order: string) {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
void fetch("/api/collections/" + collectionID + "/view-settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ chain_order: order }),
|
||||
}).then((r) => {
|
||||
if (r.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
void import("./toast-error").then((m) => m.showErrorToast("Could not save the listening order"));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
requestRemoveBook(this: any, id: string) {
|
||||
this.confirmMessage = "Remove this book from the collection?";
|
||||
this.pendingAction = () => doRemoveBook(id);
|
||||
|
||||
Reference in new issue
Block a user