diff --git a/cmd/server/main.go b/cmd/server/main.go index 2c8bd23..a09e5a3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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, diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 242f251..64c28fc 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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 { diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 8f0c244..651562f 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -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"}) diff --git a/internal/handlers/series.go b/internal/handlers/series.go new file mode 100644 index 0000000..6c29970 --- /dev/null +++ b/internal/handlers/series.go @@ -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) +} diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 2dca858..aa9b16a 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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 { diff --git a/internal/router/router.go b/internal/router/router.go index cdbcc44..19e21e8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/router/series.go b/internal/router/series.go new file mode 100644 index 0000000..4e63183 --- /dev/null +++ b/internal/router/series.go @@ -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) +} diff --git a/templates/types.go b/templates/types.go index 83448d4..7ace8ba 100644 --- a/templates/types.go +++ b/templates/types.go @@ -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