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.
388 lines
12 KiB
Go
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",
|
|
})
|
|
})
|
|
}
|