Files
bookhoard/internal/router/frontend.go
T
john-okeefe 77c7ef965f feat(dashboard): implement Phase 8 SSR template routes for Carousel-style dashboard
Update /dashboard route in frontend.go to use unified collections architecture:

Route Changes:
- Use DashboardService to fetch user dashboard preferences
- Get all dashboard sections (system + user collections)
- Pass sections and library data to template
- Support library_id query parameter for library switching
- Default to first visible library if no library_id specified

Service Integration:
- cfg.DashboardService.GetDashboardPreferences: Fetch user preferences
  * hidden_collections: Collections to hide from dashboard
  * collection_order: Custom collection ordering
  * items_per_section: Number of items per collection
- cfg.DashboardService.GetDashboardSections: Fetch all sections
  * System collections (user_id = NULL): continue-reading, recently-added, recently-read, not-started
  * User collections: User-created collections marked for dashboard
  * Applies user preferences: filters hidden, reorders, sorts by priority
- handlers.BuildSections: Convert service types to handler types

Data Flow:
1. Get user template data with theme
2. Get library_id from query param or default to first library
3. Fetch user dashboard preferences
4. Fetch dashboard sections with preferences applied
5. Convert to handler types for template rendering
6. Render template with sections and library data

Template Signature Change:
- OLD: templates.Dashboard(user)
- NEW: templates.Dashboard(user, sections, libData, currentLibraryID)

This implements Phase 8: SSR Template Routes with unified collections architecture.
2026-02-19 21:11:40 -05:00

388 lines
12 KiB
Go

package router
import (
"bytes"
"context"
"net/http"
"time"
"bookhoard/internal/handlers"
"bookhoard/templates"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
"github.com/google/uuid"
)
func registerFrontendRoutes(cfg *Config) {
e := cfg.Echo
// ============================================================================
// FRONTEND ROUTES - DO NOT DELETE
// These routes serve Server-Side Rendered (SSR) HTML pages for the web UI.
// They are NOT API endpoints and should NOT be removed during refactors.
// All authenticated frontend routes use the jwtMiddleware to validate tokens.
// ============================================================================
// Use existing JWT middleware (sets database user object in context)
jwtMiddleware := createJWTMiddleware(cfg)
// Protected route group for API routes
protected := e.Group("/api", jwtMiddleware)
// ============================================================================
// PUBLIC FRONTEND ROUTES (No authentication required)
// ============================================================================
// Public routes for login and registration pages
e.GET("/login", func(c echo.Context) error {
var buf bytes.Buffer
sessionExpired := c.QueryParam("session") == "expired"
err := templates.Login(sessionExpired).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
e.GET("/register", func(c echo.Context) error {
var buf bytes.Buffer
err := templates.Register().Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Root route - landing page with smart login detection
e.GET("/", func(c echo.Context) error {
var buf bytes.Buffer
var err error
tokenString := c.Request().Header.Get("Authorization")
if tokenString != "" && len(tokenString) > 7 && tokenString[:7] == "Bearer " {
tokenString = tokenString[7:]
} else {
cookie, err := c.Cookie("token")
if err == nil {
tokenString = cookie.Value
}
}
loggedIn := false
if tokenString != "" {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.Cfg.JWTSecret), nil
})
loggedIn = err == nil && token.Valid
}
err = templates.Index(loggedIn).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// ============================================================================
// PROTECTED FRONTEND ROUTES (Authentication required)
// ============================================================================
// Protected frontend routes (no /api prefix)
frontendProtected := e.Group("", jwtMiddleware)
// Helper to extract text from pgtype.Text
getText := func(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
// Dashboard page
frontendProtected.GET("/dashboard", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
libraryID := c.QueryParam("library_id")
if libraryID == "" {
userUUID, _ := uuid.Parse(user.ID)
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()
}
}
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
sections, err := cfg.DashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
libUUID,
int(prefs.ItemsPerSection.Int32),
prefs.CollectionOrder,
prefs.HiddenCollections,
)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
}
userUUID2, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
if err != nil {
return c.HTML(http.StatusInternalServerError, "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,
}
}
sectionData := handlers.BuildSections(sections)
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, libData, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Collections page
frontendProtected.GET("/collections", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
collections, err := cfg.CollectionHandler.GetCollectionsData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading collections")
}
colData := make([]templates.CollectionData, len(collections))
for i, col := range collections {
colData[i] = templates.CollectionData{
ID: uuid.UUID(col.ID.Bytes).String(),
Name: col.Name,
Description: getText(col.Description),
Color: getText(col.Color),
Icon: getText(col.Icon),
}
}
var buf bytes.Buffer
err = templates.Collection(user, colData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Progress page
frontendProtected.GET("/progress", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
progressData, err := cfg.ScannerHandler.GetAllProgressData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading progress")
}
var buf bytes.Buffer
err = templates.Progress(user, progressData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Devices page
frontendProtected.GET("/devices", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
devices, err := cfg.DeviceHandler.GetDevicesData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading devices")
}
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading pending")
}
pendingList := convertPending(pendingMaps)
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, cfg.Cfg.BaseURL).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Conflicts page
frontendProtected.GET("/conflicts", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
conflictsData, total, unresolved, err := cfg.ConflictHandler.GetConflictsData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading conflicts")
}
var buf bytes.Buffer
err = templates.Conflicts(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Analytics page
frontendProtected.GET("/analytics", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.Analytics(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// ============================================================================
// ADMIN FRONTEND ROUTES
// ============================================================================
// Admin routes
frontendProtected.GET("/admin", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.Admin(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
frontendProtected.GET("/admin/", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.Admin(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
frontendProtected.GET("/admin/profile", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.AdminProfile(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var buf bytes.Buffer
err = templates.AdminLibrary(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// ============================================================================
// LEGACY API ROUTES (for backward compatibility)
// ============================================================================
// Keep legacy routes under /api for existing API consumers
protected.GET("/devices-page", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
devices, err := cfg.DeviceHandler.GetDevicesData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading devices")
}
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading pending")
}
pendingList := convertPending(pendingMaps)
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, cfg.Cfg.BaseURL).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
protected.GET("/conflicts-page", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
conflictsData, total, unresolved, err := cfg.ConflictHandler.GetConflictsData(c)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading conflicts")
}
var buf bytes.Buffer
err = templates.Conflicts(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// ============================================================================
// HEALTH CHECK
// ============================================================================
// Health check
e.GET("/health", func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
defer cancel()
if err := pingDB(cfg, ctx); err != nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
"status": "unhealthy",
"error": "database unavailable",
})
}
return c.JSON(http.StatusOK, map[string]string{
"status": "healthy",
"database": "connected",
})
})
}