feat(dashboard): implement Phase 4 API handlers for Carousel-style dashboard
Add dashboard API endpoints with handler layer: Step 1: Add SectionData to collections.go - SectionData struct represents dashboard section (carousel of books) - Used by: Dashboard handler, Templates (SSR), API JSON responses - Shared type from collections.go (no duplicate definitions) - Fields: ID, IsSystem, Title, Description, Icon, Items, ViewAllURL, Priority Step 2: Create dashboard.go handler - DashboardHandler struct with injected database and dashboard service - GetSections: Returns dashboard sections as JSON (mobile apps, web UI TypeScript, plugins) * Validates library_id parameter * Fetches user dashboard preferences * Configurable limit (default 20, max 100) * Calls service layer for business logic * Converts service types to handler types for JSON serialization - UpdatePreferences: Saves dashboard preferences * Validates library_id * Upserts user dashboard preferences - RestoreSystemCollection: Resets system collection to defaults * Validates collection_name against allowed system collections * Deletes user's copy (system collection reappears automatically) - BuildSections: Converts service DashboardSection to handler SectionData * Converts database.MediaItems to handlers.BookInfo * Uses shared types from collections.go - getViewAllURL: Maps system collections to their view-all URLs - Reuses existing textToString helper from collections.go Architecture Compliance: - Generic API handler for reuse by SSR, mobile, plugins - Uses shared types from collections.go (SectionData, BookInfo) - IsSystem bool matches database field (no string conversion) - Single service method returns structured data (simpler, less bugs) - Handler just converts types (no matching logic needed) - Reusable by mobile apps, web UI, plugins
This commit is contained in:
@@ -70,6 +70,17 @@ type BookInfo struct {
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
}
|
||||
|
||||
type SectionData struct {
|
||||
ID string `json:"id"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Items []BookInfo `json:"items"`
|
||||
ViewAllURL string `json:"view_all_url"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) CreateCollection(c echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type DashboardHandler struct {
|
||||
db *database.Queries
|
||||
dashboardService *services.DashboardService
|
||||
}
|
||||
|
||||
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
|
||||
return &DashboardHandler{
|
||||
db: db,
|
||||
dashboardService: services.NewDashboardService(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) GetSections(c echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||
|
||||
limit := 20
|
||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
sections, err := h.dashboardService.GetDashboardSections(
|
||||
c.Request().Context(),
|
||||
userUUID,
|
||||
libUUID,
|
||||
limit,
|
||||
prefs.CollectionOrder,
|
||||
prefs.HiddenCollections,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
|
||||
}
|
||||
|
||||
sectionData := BuildSections(sections)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) UpdatePreferences(c echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
var req struct {
|
||||
LibraryID string `json:"library_id"`
|
||||
HiddenCollections []string `json:"hidden_collections"`
|
||||
CollectionOrder []string `json:"collection_order"`
|
||||
ItemsPerSection int `json:"items_per_section"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
libUUID, err := uuid.Parse(req.LibraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
|
||||
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
HiddenCollections: req.HiddenCollections,
|
||||
CollectionOrder: req.CollectionOrder,
|
||||
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
var req struct {
|
||||
CollectionName string `json:"collection_name"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
if req.CollectionName == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
|
||||
}
|
||||
|
||||
validCollections := map[string]bool{
|
||||
"continue-reading": true,
|
||||
"recently-added": true,
|
||||
"recently-read": true,
|
||||
"not-started": true,
|
||||
}
|
||||
if !validCollections[req.CollectionName] {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
|
||||
}
|
||||
|
||||
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
|
||||
}
|
||||
|
||||
func BuildSections(sections []services.DashboardSection) []SectionData {
|
||||
var result []SectionData
|
||||
|
||||
for _, ds := range sections {
|
||||
bookCards := make([]BookInfo, len(ds.Items))
|
||||
for i, item := range ds.Items {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookCards[i] = BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: textToString(item.CoverImagePath),
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, SectionData{
|
||||
ID: ds.CollectionName,
|
||||
IsSystem: ds.IsSystem,
|
||||
Title: ds.Title,
|
||||
Description: ds.Description,
|
||||
Icon: ds.Icon,
|
||||
Items: bookCards,
|
||||
ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType),
|
||||
Priority: ds.Priority,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getViewAllURL(key, queryType string) string {
|
||||
urls := map[string]string{
|
||||
"continue-reading": "/section/continue-reading",
|
||||
"recently-added": "/section/recently-added",
|
||||
"recently-read": "/history",
|
||||
"not-started": "/section/not-started",
|
||||
}
|
||||
if url, exists := urls[queryType]; exists {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user