From d1f9ad3b25a7829ab26e58923c11a900c7a19949 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 14:53:45 -0400 Subject: [PATCH] feat(series): sort and filter the series page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /series page rendered its grid in one fixed order (newest first) with no way to find a series beyond eyeballing cards. The page now has a toolbar — series-name search, author filter, viewer-progress filter and a 12-key sort select — wired as a plain GET form (the page's existing model: the URL is the single source of truth, and the library switcher now carries q/author/status/sort along on switch). Extensibility is deliberate (user request): sorts live in a registry (services.ResolveSeriesSort) mirrored by a CASE cascade in GetDistinctSeries and a mirror of the SQL CASE cascade in +// GetDistinctSeries and the registry in services.ResolveSeriesSort — +// every value here must have a matching SQL arm and registry entry. +var seriesSortOptions = []struct { + Value string + Label string +}{ + {"recent", "Recently added"}, + {"recent_asc", "Oldest added"}, + {"name_asc", "Name (A–Z)"}, + {"name_desc", "Name (Z–A)"}, + {"author_asc", "Author (A–Z)"}, + {"author_desc", "Author (Z–A)"}, + {"books_desc", "Most books"}, + {"books_asc", "Fewest books"}, + {"progress_desc", "Most complete"}, + {"progress_asc", "Least complete"}, + {"last_read_desc", "Recently read"}, + {"last_read_asc", "Least recently read"}, +} + +// seriesStatusOptions is the progress-filter +
+ + @Icon("search", "h-4 w-4") + +
+ + + + + + if len(seriesList) == 0 { + if hasActiveSeriesFilters(state) { +
+
+ @Icon("search", "h-7 w-7") +
+

No Series Match

+

Nothing matches the current search or filters

+ + Clear filters + +
+ } else { +
+
+ @Icon("layers", "h-7 w-7") +
+

No Series Found

+

Books with series metadata will appear here

+
+ } }
for _, series := range seriesList { @@ -47,7 +177,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
} + +// readPercent converts (finished, total) into a clamped 0..100 int for +// the card's progress bar. +func readPercent(finished, total int64) int { + if total <= 0 { + return 0 + } + pct := int(finished * 100 / total) + if pct < 0 { + return 0 + } + if pct > 100 { + return 100 + } + return pct +} diff --git a/templates/series_templ.go b/templates/series_templ.go index 80f856b6..0cf734bd 100644 --- a/templates/series_templ.go +++ b/templates/series_templ.go @@ -11,9 +11,73 @@ import templruntime "github.com/a-h/templ/runtime" import ( "fmt" "net/url" + "strconv" ) -func Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, errorMessage string) templ.Component { +// seriesSortOptions is the (mirror of the +// status registry in services.ResolveSeriesSort's sibling map). +var seriesStatusOptions = []struct { + Value string + Label string +}{ + {"", "Any progress"}, + {"unstarted", "Unstarted"}, + {"reading", "In progress"}, + {"finished", "Finished"}, +} + +// seriesPageURL builds a /series URL carrying the full page state so +// pagination and clear-filters links never drop sort/filter state. +func seriesPageURL(state SeriesPageState, libraryID string, page int) string { + vals := url.Values{} + if libraryID != "" { + vals.Set("library_id", libraryID) + } + if state.Sort != "" { + vals.Set("sort", state.Sort) + } + if state.Query != "" { + vals.Set("q", state.Query) + } + if state.Author != "" { + vals.Set("author", state.Author) + } + if state.Status != "" { + vals.Set("status", state.Status) + } + vals.Set("page", strconv.Itoa(page)) + return "/series?" + vals.Encode() +} + +// hasActiveSeriesFilters reports whether the state deviates from the +// page's defaults — used to swap the empty state for a +// "no matches" + clear-filters variant. +func hasActiveSeriesFilters(state SeriesPageState) bool { + return state.Query != "" || state.Author != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent") +} + +func Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, state SeriesPageState, errorMessage string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -76,25 +140,200 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Series

Grouped by series metadata

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

Series

Grouped by series metadata

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(seriesList) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("layers", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

No Series Found

Books with series metadata will appear here

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if hasActiveSeriesFilters(state) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("search", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

No Series Match

Nothing matches the current search or filters

Clear filters
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("layers", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

No Series Found

Books with series metadata will appear here

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -104,30 +343,30 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if totalPages > 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if currentPage > 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"btn btn-secondary\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -135,56 +374,56 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Previous ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Previous ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Page ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Page ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage)) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 188, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " of ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " of ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages)) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 188, Col: 82} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if currentPage < totalPages { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Next") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" class=\"btn btn-secondary\">Next") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -192,17 +431,17 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -210,7 +449,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -234,102 +473,102 @@ func SeriesCard(series SeriesCardData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" class=\"block\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 = []any{"stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) + var templ_7745c5c3_Var18 = []any{"stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for i, cover := range series.CoverPaths { - var templ_7745c5c3_Var12 = []any{"cover-img cover-" + fmt.Sprintf("%d", i)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) + var templ_7745c5c3_Var20 = []any{"cover-img cover-" + fmt.Sprintf("%d", i)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) 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, 50, "\" loading=\"lazy\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(series.CoverPaths) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -337,51 +576,87 @@ func SeriesCard(series SeriesCardData) templ.Component { 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, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 98, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 228, Col: 99} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount)) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 230, Col: 42} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " of ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " of ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries)) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 230, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " books

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

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if series.ReadCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d of %d read", series.ReadCount, series.BookCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 240, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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, 60, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -389,4 +664,20 @@ func SeriesCard(series SeriesCardData) templ.Component { }) } +// readPercent converts (finished, total) into a clamped 0..100 int for +// the card's progress bar. +func readPercent(finished, total int64) int { + if total <= 0 { + return 0 + } + pct := int(finished * 100 / total) + if pct < 0 { + return 0 + } + if pct > 100 { + return 100 + } + return pct +} + var _ = templruntime.GeneratedTemplate diff --git a/templates/types.go b/templates/types.go index 6587c4c9..6fdedd7f 100644 --- a/templates/types.go +++ b/templates/types.go @@ -142,6 +142,19 @@ type SeriesCardData struct { BookCount int64 TotalInSeries int CoverPaths []string + // Viewer's finished books in this series (drives the card's + // progress bar / "X of N read" line). + ReadCount int64 +} + +// SeriesPageState is the /series page's sort/filter state, echoed back +// into the toolbar controls and the pagination URLs (the URL is the +// single source of truth on this plain-GET page). +type SeriesPageState struct { + Sort string + Query string + Author string + Status string } type PendingRegistrationData struct { diff --git a/web/src/series.ts b/web/src/series.ts index 1ebbf206..fecffd49 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -1,16 +1,23 @@ import { Alpine } from "./alpine"; import { initLibrarySwitcher } from "./library-switcher"; -// Rendering ownership (AGENTS.md): the series grid and its paginator are -// rendered by templates/series.templ. This page intentionally has NO -// TypeScript renderer — the old one here duplicated the templ markup via -// innerHTML and re-fetched at hardcoded page 1, stomping SSR pagination -// whenever the header's library restore fired. A library switch is a plain -// SSR navigation so the URL stays the single source of truth for -// library_id and page state. +// Rendering ownership (AGENTS.md): the series grid, its paginator and +// its sort/filter toolbar are rendered by templates/series.templ. This +// page intentionally has NO TypeScript renderer — the old one here +// duplicated the templ markup via innerHTML and re-fetched at hardcoded +// page 1, stomping SSR pagination whenever the header's library restore +// fired. A library switch is a plain SSR navigation so the URL stays +// the single source of truth for library_id, page AND sort/filter state +// (the switch carries the current q/author/status/sort params along). function navigateToSeries(libraryId: string): void { - const param = libraryId ? `library_id=${encodeURIComponent(libraryId)}&` : ""; - window.location.href = `/series?${param}page=1`; + const params = new URLSearchParams(window.location.search); + if (libraryId) { + params.set("library_id", libraryId); + } else { + params.delete("library_id"); + } + params.set("page", "1"); + window.location.href = `/series?${params.toString()}`; } Alpine.data("seriesPage", () => ({