diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 36d004ab..c4657977 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -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) +} diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index d3075f7f..d505c1ee 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -86,10 +86,10 @@ func parseRequestFilterTree(payload any) ([]byte, error) { } type UpdateCollectionRequest struct { - Name string `form:"name" json:"name" validate:"required"` + Name string `form:"name" json:"name" validate:"required"` // Smart-collection match semantics (item 2.4): "any" (OR) | "all" // (AND). Empty/invalid keeps the stored value. - RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"` + RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"` // The unified filter tree (item 3.2): when present the collection's // smart membership runs through the qf engine. INVALID trees are a // 400 (ParseFilterTree at the handler); a tree supersedes @@ -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 { diff --git a/internal/handlers/collections_chain_order_test.go b/internal/handlers/collections_chain_order_test.go new file mode 100644 index 00000000..1e9267ed --- /dev/null +++ b/internal/handlers/collections_chain_order_test.go @@ -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) + } + }) + } +} diff --git a/internal/router/collections.go b/internal/router/collections.go index 39adcb27..04c4ef17 100644 --- a/internal/router/collections.go +++ b/internal/router/collections.go @@ -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) diff --git a/internal/router/helpers.go b/internal/router/helpers.go index f0a8de05..e273ab5f 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -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, } } diff --git a/internal/router/reader.go b/internal/router/reader.go index b2d1524e..69f29e05 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -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 }, ) diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index 5e0bdd73..25bef923 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -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 diff --git a/internal/services/filter_query.go b/internal/services/filter_query.go index 232a9737..7132ea8f 100644 --- a/internal/services/filter_query.go +++ b/internal/services/filter_query.go @@ -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) + ` diff --git a/internal/services/filter_registry.go b/internal/services/filter_registry.go index 69a5ae3c..e14ed7b0 100644 --- a/internal/services/filter_registry.go +++ b/internal/services/filter_registry.go @@ -222,14 +222,14 @@ var filterOps = map[string]FilterOpWire{ // FilterFieldWire is one field's presentation entry (the registry's // wire face): the frontend menu is scope-filtered OFF this list. type FilterFieldWire struct { - Key string `json:"key"` - Label string `json:"label"` - Group string `json:"group"` - ValueKind string `json:"valueKind"` - Ops []FilterOpWire `json:"ops"` - DefaultOp string `json:"defaultOp"` - Suggest bool `json:"suggest"` - Options []string `json:"options,omitempty"` + Key string `json:"key"` + Label string `json:"label"` + Group string `json:"group"` + ValueKind string `json:"valueKind"` + Ops []FilterOpWire `json:"ops"` + DefaultOp string `json:"defaultOp"` + Suggest bool `json:"suggest"` + Options []string `json:"options,omitempty"` } // valueKinds — what the value cell renders (the registry's Kind mapped @@ -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. diff --git a/internal/services/filter_registry_test.go b/internal/services/filter_registry_test.go index f14179d7..dcfbfece 100644 --- a/internal/services/filter_registry_test.go +++ b/internal/services/filter_registry_test.go @@ -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") + } +} diff --git a/templates/collection_modal.templ b/templates/collection_modal.templ index f49744a7..83e80889 100644 --- a/templates/collection_modal.templ +++ b/templates/collection_modal.templ @@ -99,21 +99,44 @@ templ CollectionModal(collection CollectionData) {
+
+ +
+ + +
+

+ Episodes collections match your podcast subscriptions' episodes (feed, downloaded, played, published date) and play as a sequence. +

+ +

Episodes collections match your podcast subscriptions' episodes (feed, downloaded, played, published date) and play as a sequence.

- ▶ - Play as Sequence - +
+ + ▶ + Play as Sequence + + +
} } @@ -389,7 +406,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
- @PlayCollectionButton(collection.ID, books) + @PlayCollectionButton(collection.ID, collection.ChainOrder, books)
▶ Play as Sequence") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"btn btn-primary\" title=\"Play the collection's episodes back-to-back\">▶ Play as Sequence
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -102,81 +158,81 @@ func EpisodePickerGrid(eps []database.SearchEpisodesForPickerRow) templ.Componen }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var3 := templ.GetChildren(ctx) - if templ_7745c5c3_Var3 == nil { - templ_7745c5c3_Var3 = templ.NopComponent + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, ep := range eps { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
🎧

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">

🎧

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ep.FeedTitle) + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 60, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 76, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if ep.DownloadState != "downloaded" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "not downloaded") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + ep.ID.String() + "')") + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(ep.FeedTitle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 83} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" 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);\">✓

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.DownloadState != "downloaded" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "not downloaded") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
✓
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(eps) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

No episodes from your subscriptions match.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

No episodes from your subscriptions match.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -206,51 +262,51 @@ func CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) t }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if len(books) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
Nothing in this collection yet.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Nothing in this collection yet.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } for _, book := range books { if book.Hidden { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
◌

Unavailable

Not subscribed (or not visible to you) — the feed's episodes leave the queue until you follow it again.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">
◌

Unavailable

Not subscribed (or not visible to you) — the feed's episodes leave the queue until you follow it again.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Remove entry") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if book.IsEpisode { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" data-media-id=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 120} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">↑
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 139, Col: 23} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

🎧 ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FeedTitle) + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 142, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 156, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

🎧 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.FeedTitle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 159, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.DownloadState == "downloaded" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "▶ Play") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"btn btn-primary text-sm mt-2\">▶ Play") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Not downloaded") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "Not downloaded") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Remove from Collection
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" data-media-id=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 187, Col: 147} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">↑
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 193, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 208, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 210, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Author != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

by ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

by ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 198, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 215, Col: 27} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "Remove from Collection") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -694,34 +750,34 @@ func Collection(user User, collections []CollectionData, errorMessage string) te }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var34 := templ.GetChildren(ctx) - if templ_7745c5c3_Var34 == nil { - templ_7745c5c3_Var34 = templ.NopComponent + templ_7745c5c3_Var35 := templ.GetChildren(ctx) + if templ_7745c5c3_Var35 == nil { + templ_7745c5c3_Var35 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Collections - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Collections - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var35 = []any{"theme-" + user.Theme} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...) + var templ_7745c5c3_Var36 = []any{"theme-" + user.Theme} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -729,7 +785,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -737,7 +793,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

My Collections

Organize your books into custom collections

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "New Collection
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(collections) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -766,7 +822,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "

No Collections Yet

Create collections to organize your books

No Collections Yet

Create collections to organize your books

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "Create Your First Collection
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } for _, col := range collections { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" class=\"block\">
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if col.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "⚡ Smart ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 333, Col: 92} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 334, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 350, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount))) + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 336, Col: 103} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 351, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 103} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if col.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "· rules-matched live") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "· rules-matched live") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -933,7 +989,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -957,47 +1013,47 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var45 := templ.GetChildren(ctx) - if templ_7745c5c3_Var45 == nil { - templ_7745c5c3_Var45 = templ.NopComponent + templ_7745c5c3_Var46 := templ.GetChildren(ctx) + if templ_7745c5c3_Var46 == nil { + templ_7745c5c3_Var46 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 357, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 374, Col: 27} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var47 = []any{"theme-" + user.Theme} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) + var templ_7745c5c3_Var48 = []any{"theme-" + user.Theme} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var48...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1009,7 +1065,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1017,105 +1073,105 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "Back to Collections
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "Back to Collections

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 374, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 388, Col: 169} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "⚡ Smart") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 379, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 391, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

Membership is rules-matched live — always current, nothing to refresh.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "⚡ Smart") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

Items in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = PlayCollectionButton(collection.ID, books).Render(ctx, templ_7745c5c3_Buffer) + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 396, Col: 87} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

Membership is rules-matched live — always current, nothing to refresh.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

Items in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PlayCollectionButton(collection.ID, collection.ChainOrder, books).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" class=\"btn btn-secondary\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1123,12 +1179,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Edit Rules ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "Edit Rules ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "Add Items") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1158,12 +1214,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

Add Items to Collection

Clear
selected
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\" hx-target=\"#episode-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#episode-picker-filters\">
selected
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "

Remove from Collection

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\" data-is-smart=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 656, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\" style=\"display: none;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1346,119 +1402,119 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var64 := templ.GetChildren(ctx) - if templ_7745c5c3_Var64 == nil { - templ_7745c5c3_Var64 = templ.NopComponent + templ_7745c5c3_Var65 := templ.GetChildren(ctx) + if templ_7745c5c3_Var65 == nil { + templ_7745c5c3_Var65 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, book := range books { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.CoverImagePath != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"")") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\"")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
✓

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\" 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);\">

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\" 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);\">✓

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var72 string + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 687, Col: 87} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index ff270ae6..f86438a0 100644 --- a/templates/types.go +++ b/templates/types.go @@ -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 diff --git a/web/src/collections.ts b/web/src/collections.ts index 68dcd9b7..722c9b39 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -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);