Phase 10.5.1: Add /custom-section frontend route - Added route handler in internal/router/frontend.go - Fetches user libraries and renders custom section builder template Phase 10.5.2: Create custom section builder template - Created templates/custom_section.templ with full UI - Includes section details form, filter rules builder, manual book selection - Live preview functionality with preview container - Form actions for save/cancel Phase 10.5.3: Create custom-section-builder TypeScript - Created web/src/custom-section-builder.ts with 13+ filter fields - Filter fields: title, author, genre, series, progress, rating, date_added, last_read, publisher, language, format, tags, narrators - Procedural/imperative style (no OOP) as per guidelines - Rule builder with AND/OR logic support - Book search and multi-select functionality - Live preview via /api/collections/preview endpoint - Form validation and submission to /api/collections Phase 10.5.4: Build TypeScript modules - Compiled custom-section-builder.ts to web/static/custom-section-builder.js - Verified successful compilation with no errors - All existing TypeScript modules continue to compile Phase 10.5.5: Add Bruno tests for custom section creation - create-custom-section-rules.bru: Test creating section with filter rules - create-custom-section-manual.bru: Test creating section with manual book selection - create-custom-section-missing-fields.bru: Test error handling for missing required fields Phase 10.6: Build Verification - ✅ TypeScript modules compile successfully - ✅ Templates generate successfully - ✅ Go build succeeds with no compilation errors - ✅ All build artifacts verified (dashboard.js, custom-section-builder.js, dashboard_templ.go, custom_section_templ.go) This completes the Custom Section Builder feature, allowing users to create personalized dashboard sections with flexible filter rules or manual book selection.
420 lines
13 KiB
Go
420 lines
13 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())
|
|
})
|
|
|
|
// Custom Section Builder page
|
|
frontendProtected.GET("/custom-section", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading user")
|
|
}
|
|
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
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,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.CustomSectionBuilder(user, libData).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",
|
|
})
|
|
})
|
|
}
|