From 52464581dc7b74109f16e1388ce9fd4f6911d6aa Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 8 May 2026 20:50:59 -0400 Subject: [PATCH] feat(series): add dedicated series detail page instead of bookshelf filter Create a new /series/detail?name=X&library_id=Y SSR page that shows all books in a specific series, replacing the broken approach of linking to /bookshelf?series_filter=X (the bookshelf SSR handler ignores all filter query params). The series detail page features: - Back link to /series browse page - Library selector dropdown (full page navigation on change) - Series name header with book count badge - Book grid using the shared BookCard template - Empty state for series with no books Update all links to point to the new page: - Series cards on /series browse page - Series badge on book detail page - JS-rendered cards in series.ts switchLibrary Add seriesDetailPage Alpine component for the detail page's library switcher (simple navigation, no AJAX needed). --- internal/router/frontend.go | 83 +++++++++++++ templates/book_detail.templ | 2 +- templates/book_detail_templ.go | 4 +- templates/series.templ | 2 +- templates/series_detail.templ | 72 ++++++++++++ templates/series_detail_templ.go | 194 +++++++++++++++++++++++++++++++ templates/series_templ.go | 4 +- web/src/series.ts | 21 +++- 8 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 templates/series_detail.templ create mode 100644 templates/series_detail_templ.go diff --git a/internal/router/frontend.go b/internal/router/frontend.go index aa9b16a..1e44204 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -213,6 +213,89 @@ func registerFrontendRoutes(cfg *Config) { return c.HTML(http.StatusOK, buf.String()) }) + // Series detail page (books in a specific series) + frontendProtected.GET("/series/detail", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + + var errorMsg string + + seriesName := c.QueryParam("name") + if seriesName == "" { + return renderErrorPage(c, "Series name required", "bad_request") + } + + libraryID := c.QueryParam("library_id") + userUUID, _ := uuid.Parse(user.ID) + if libraryID == "" { + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err == nil && len(libraries) > 0 { + libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) + libraryID = libUUID.String() + } else { + errorMsg = "No libraries available" + } + } + + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err != nil { + log.Printf("GetUserVisibleLibraries failed: %v", err) + libraries = []database.GetUserVisibleLibrariesRow{} + if errorMsg == "" { + errorMsg = "Error loading libraries" + } + } + + libData := make([]templates.LibraryData, len(libraries)) + for i, lib := range libraries { + libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) + libData[i] = templates.LibraryData{ + ID: libUUID.String(), + Name: lib.Name, + Description: getText(lib.Description), + TypeName: lib.TypeName, + } + } + + var bookInfoList []handlers.BookInfo + + if libraryID != "" && errorMsg == "" { + libUUID, err := uuid.Parse(libraryID) + if err == nil { + svc := services.NewSeriesService(cfg.Queries) + books, err := svc.GetSeriesBooks(c.Request().Context(), libUUID, seriesName) + if err != nil { + log.Printf("GetSeriesBooks failed: %v", err) + errorMsg = "Error loading series books" + } else { + bookInfoList = make([]handlers.BookInfo, 0, len(books)) + for _, item := range books { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookInfoList = append(bookInfoList, handlers.BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: textToString(item.Author), + CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), + }) + } + } + } + } + + if bookInfoList == nil { + bookInfoList = []handlers.BookInfo{} + } + + var buf bytes.Buffer + err = templates.SeriesDetail(user, seriesName, bookInfoList, libData, libraryID, errorMsg).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + frontendProtected.GET("/bookshelf", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { diff --git a/templates/book_detail.templ b/templates/book_detail.templ index 0853be0..ad715bd 100644 --- a/templates/book_detail.templ +++ b/templates/book_detail.templ @@ -122,7 +122,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { if book.Series.Valid && book.Series.String != "" {
diff --git a/templates/book_detail_templ.go b/templates/book_detail_templ.go index 7c0d2f6..94f23f2 100644 --- a/templates/book_detail_templ.go +++ b/templates/book_detail_templ.go @@ -244,9 +244,9 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ return templ_7745c5c3_Err } var templ_7745c5c3_Var11 templ.SafeURL - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?series_filter=" + url.QueryEscape(book.Series.String) + "&sort=series") + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(book.Series.String)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 125, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 125, Col: 76} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { diff --git a/templates/series.templ b/templates/series.templ index 245a57a..41e0f6f 100644 --- a/templates/series.templ +++ b/templates/series.templ @@ -95,7 +95,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr } templ SeriesCard(series SeriesCardData, libraryID string) { - +
for i, cover := range series.CoverPaths { diff --git a/templates/series_detail.templ b/templates/series_detail.templ new file mode 100644 index 0000000..1e5bbe0 --- /dev/null +++ b/templates/series_detail.templ @@ -0,0 +1,72 @@ +package templates + +import ( + "bookhoard/internal/handlers" + "fmt" +) + +templ SeriesDetail(user User, seriesName string, books []handlers.BookInfo, libData []LibraryData, currentLibraryID string, errorMessage string) { + + + + + + { seriesName } - Bookhoard + + + + @Header(user, "/series") + +
+
+
+ + ← All Series + + | + + +
+
+
+ +
+
+ + 📚 Series + +

{ seriesName }

+ { fmt.Sprintf("%d", len(books)) } books +
+ + if len(books) > 0 { +
+ for _, book := range books { + @BookCard(book) + } +
+ } else { +
+
📚
+

No Books Found

+

This series doesn't have any books in this library yet

+
+ } +
+ @ErrorToast(errorMessage) + + +} diff --git a/templates/series_detail_templ.go b/templates/series_detail_templ.go new file mode 100644 index 0000000..a90d265 --- /dev/null +++ b/templates/series_detail_templ.go @@ -0,0 +1,194 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "bookhoard/internal/handlers" + "fmt" +) + +func SeriesDetail(user User, seriesName string, books []handlers.BookInfo, libData []LibraryData, currentLibraryID string, 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(seriesName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `series_detail.templ`, Line: 14, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Header(user, "/series").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
← All Series |
📚 Series

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(seriesName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `series_detail.templ`, Line: 51, Col: 83} + } + _, 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, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `series_detail.templ`, Line: 52, Col: 95} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " books
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(books) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, book := range books { + templ_7745c5c3_Err = BookCard(book).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
📚

No Books Found

This series doesn't have any books in this library yet

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ErrorToast(errorMessage).Render(ctx, templ_7745c5c3_Buffer) + 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 + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/series_templ.go b/templates/series_templ.go index 8d646f1..5007379 100644 --- a/templates/series_templ.go +++ b/templates/series_templ.go @@ -255,9 +255,9 @@ func SeriesCard(series SeriesCardData, libraryID string) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var11 templ.SafeURL - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?series_filter=" + url.QueryEscape(series.Name) + "&sort=series&library_id=" + libraryID) + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 98, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `series.templ`, Line: 98, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { diff --git a/web/src/series.ts b/web/src/series.ts index aa1785f..616b466 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -11,7 +11,7 @@ interface SeriesItem { } function renderSeriesCard(series: SeriesItem, libraryId: string): string { - const href = `/bookshelf?series_filter=${encodeURIComponent(series.name)}&sort=series&library_id=${libraryId}`; + const href = `/series/detail?name=${encodeURIComponent(series.name)}&library_id=${libraryId}`; const coverCount = series.cover_paths.length; const coverClass = `cover-count-${coverCount}`; @@ -152,3 +152,22 @@ Alpine.data("seriesPage", () => ({ } }, })); + +Alpine.data("seriesDetailPage", () => ({ + initSeriesDetailPage() { + const librarySelect = document.getElementById( + "library-select", + ) as HTMLSelectElement; + if (librarySelect) { + librarySelect.addEventListener("change", () => { + if (librarySelect.value) { + const url = new URL(window.location.href); + const currentLib = url.searchParams.get("library_id"); + if (currentLib === librarySelect.value) return; + url.searchParams.set("library_id", librarySelect.value); + window.location.href = url.toString(); + } + }); + } + }, +}));