From cc0e9d7f910e5c2f401ab3c68fa31169141e3b1e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 31 Oct 2026 13:39:24 -0400 Subject: [PATCH] feat(podcasts): the episodes authoring door + curated chain order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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). --- cmd/server/tests/podcast_test.go | 70 ++ internal/handlers/collections.go | 86 +- .../handlers/collections_chain_order_test.go | 30 + internal/router/collections.go | 3 + internal/router/helpers.go | 11 + internal/router/reader.go | 5 +- internal/services/collection_service.go | 13 +- internal/services/filter_query.go | 14 + internal/services/filter_registry.go | 27 +- internal/services/filter_registry_test.go | 25 + templates/collection_modal.templ | 31 +- templates/collection_modal_templ.go | 4 +- templates/collections.templ | 39 +- templates/collections_templ.go | 1068 +++++++++-------- templates/types.go | 4 + web/src/collections.ts | 54 + 16 files changed, 946 insertions(+), 538 deletions(-) create mode 100644 internal/handlers/collections_chain_order_test.go 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);