feat(series): add SeriesHandler, API routes, and SSR browse page
Create SeriesHandler with two API endpoints: - GET /api/series (paginated series list with covers) - GET /api/series/books (books in a specific series) Uses query param ?name=X instead of path param to avoid URL encoding issues with special characters in series names. Add GetSeriesCardsData helper returning services.SeriesInfo for use by the SSR route (avoids handlers→templates import cycle). Register /api/series routes via registerSeriesRoutes in router. Add /series SSR route in frontend.go with library-scoped pagination and error handling, matching the dashboard/bookshelf patterns. Add SeriesHandler to router Config and instantiate in main.go. Add SeriesCardData type to templates/types.go. Add Continue Series as the 5th valid system collection in dashboard handler and auth handler's CreateDefaultCollectionsForUser.
This commit is contained in:
@@ -87,6 +87,7 @@ func main() {
|
||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||
dashboardService := services.NewDashboardService(queries)
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
@@ -149,6 +150,7 @@ func main() {
|
||||
FiltersHandler: filtersHandler,
|
||||
DashboardHandler: dashboardHandler,
|
||||
DashboardService: dashboardService,
|
||||
SeriesHandler: seriesHandler,
|
||||
OPDSHandler: opdsHandler,
|
||||
Worker: worker,
|
||||
SystemSettingsHandler: systemSettingsHandler,
|
||||
|
||||
@@ -1015,6 +1015,7 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
|
||||
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
|
||||
@@ -133,6 +133,7 @@ func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
|
||||
"Recently Added": true,
|
||||
"Recently Read": true,
|
||||
"Not Started": true,
|
||||
"Continue Series": true,
|
||||
}
|
||||
if !validCollections[req.CollectionName] {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type SeriesHandler struct {
|
||||
seriesService *services.SeriesService
|
||||
}
|
||||
|
||||
func NewSeriesHandler(db *database.Queries) *SeriesHandler {
|
||||
return &SeriesHandler{
|
||||
seriesService: services.NewSeriesService(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||||
}
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
|
||||
limit := 20
|
||||
if l := c.QueryParam("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
||||
limit = v
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
offset := 0
|
||||
if o := c.QueryParam("offset"); o != "" {
|
||||
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
||||
offset = v
|
||||
}
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, limit, offset)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
||||
}
|
||||
|
||||
type SeriesResponse struct {
|
||||
Name string `json:"name"`
|
||||
BookCount int64 `json:"book_count"`
|
||||
TotalInSeries int `json:"total_in_series"`
|
||||
CoverPaths []string `json:"cover_paths"`
|
||||
LastEntryAt string `json:"last_entry_at"`
|
||||
}
|
||||
|
||||
response := make([]SeriesResponse, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
response = append(response, SeriesResponse{
|
||||
Name: s.Name,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: s.CoverPaths,
|
||||
LastEntryAt: s.LastEntryAt,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"series": response,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||||
}
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
|
||||
seriesName := c.QueryParam("name")
|
||||
if seriesName == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||
}
|
||||
|
||||
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), libUUID, seriesName)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
|
||||
}
|
||||
|
||||
bookCards := make([]BookInfo, 0, len(books))
|
||||
for _, item := range books {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookCards = append(bookCards, BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"name": seriesName,
|
||||
"books": bookCards,
|
||||
"total": len(bookCards),
|
||||
})
|
||||
}
|
||||
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID uuid.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||
svc := services.NewSeriesService(db)
|
||||
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
|
||||
}
|
||||
@@ -119,6 +119,100 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Series browse page
|
||||
frontendProtected.GET("/series", 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
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
perSeriesPage := 24
|
||||
page := 1
|
||||
if p := c.QueryParam("page"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
}
|
||||
offset := (page - 1) * perSeriesPage
|
||||
|
||||
var seriesCards []templates.SeriesCardData
|
||||
totalPages := 1
|
||||
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err == nil {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libUUID, perSeriesPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
} else {
|
||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
covers := s.CoverPaths
|
||||
if covers == nil {
|
||||
covers = []string{}
|
||||
}
|
||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||
Name: s.Name,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if seriesCards == nil {
|
||||
seriesCards = []templates.SeriesCardData{}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, 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 {
|
||||
|
||||
@@ -56,6 +56,7 @@ type Config struct {
|
||||
FiltersHandler *handlers.FiltersHandler
|
||||
DashboardHandler *handlers.DashboardHandler
|
||||
DashboardService *services.DashboardService
|
||||
SeriesHandler *handlers.SeriesHandler
|
||||
OPDSHandler *handlers.OPDSHandler
|
||||
SystemSettingsHandler *handlers.SystemSettingsHandler
|
||||
ConnManager *sync.ConnectionManager
|
||||
@@ -211,6 +212,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
registerSystemRoutes(cfg)
|
||||
registerSyncRoutes(cfg)
|
||||
registerCollectionsRoutes(cfg)
|
||||
registerSeriesRoutes(cfg)
|
||||
registerDashboardRoutes(cfg)
|
||||
registerMediaRoutes(cfg)
|
||||
registerSearchRoutes(cfg)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package router
|
||||
|
||||
func registerSeriesRoutes(cfg *Config) {
|
||||
jwtMiddleware := createJWTMiddleware(cfg)
|
||||
protected := cfg.Echo.Group("/api", jwtMiddleware)
|
||||
|
||||
series := protected.Group("/series")
|
||||
series.GET("", cfg.SeriesHandler.GetSeries)
|
||||
series.GET("/books", cfg.SeriesHandler.GetSeriesBooks)
|
||||
}
|
||||
@@ -39,6 +39,13 @@ type LibraryData struct {
|
||||
TypeName string
|
||||
}
|
||||
|
||||
type SeriesCardData struct {
|
||||
Name string
|
||||
BookCount int64
|
||||
TotalInSeries int
|
||||
CoverPaths []string
|
||||
}
|
||||
|
||||
type PendingRegistrationData struct {
|
||||
RegistrationID string
|
||||
DeviceName string
|
||||
|
||||
Reference in New Issue
Block a user