- Add LibraryData type to templates/types.go - Update bookshelf template to accept libraries parameter - Render libraries server-side for faster initial page load - Libraries now populated from server data instead of AJAX fetch - JavaScript still uses API for dynamic content (bookshelf items) - Update /bookshelf route to fetch libraries server-side before render - Properly handle UUID and pgtype.Text conversions - Maintain API endpoint compatibility for JavaScript calls This improves initial page load performance while preserving dynamic functionality via API calls.
637 lines
21 KiB
Go
637 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"bookhoard/internal/config"
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/docs"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/middleware"
|
|
ratelimit "bookhoard/internal/middleware"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/internal/sync"
|
|
"bookhoard/templates"
|
|
"bytes"
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/labstack/echo-jwt/v4"
|
|
"github.com/labstack/echo/v4"
|
|
echomiddleware "github.com/labstack/echo/v4/middleware"
|
|
)
|
|
|
|
// CustomValidator wraps the go-playground validator
|
|
type CustomValidator struct {
|
|
validator *validator.Validate
|
|
}
|
|
|
|
func (cv *CustomValidator) Validate(i interface{}) error {
|
|
return cv.validator.Struct(i)
|
|
}
|
|
|
|
func getTemplateUserWithTheme(c echo.Context, queries *database.Queries) (templates.User, error) {
|
|
userID := c.Get("user_id").(string)
|
|
userEmail := c.Get("user_email").(string)
|
|
userUsername := c.Get("user_username").(string)
|
|
userRole := c.Get("user_role").(string)
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return templates.User{}, err
|
|
}
|
|
|
|
userDB, err := queries.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
|
if err != nil {
|
|
return templates.User{}, err
|
|
}
|
|
|
|
userTheme := "tokyo-night"
|
|
if userDB.Theme.Valid {
|
|
userTheme = userDB.Theme.String
|
|
}
|
|
|
|
return templates.User{
|
|
ID: userID,
|
|
Email: userEmail,
|
|
Username: userUsername,
|
|
Role: userRole,
|
|
Theme: userTheme,
|
|
}, nil
|
|
}
|
|
|
|
func main() {
|
|
cfg := config.LoadConfig()
|
|
|
|
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
|
|
if err != nil {
|
|
log.Fatal("Failed to connect to database:", err)
|
|
}
|
|
defer dbPool.Close()
|
|
|
|
queries := database.New(dbPool)
|
|
|
|
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
|
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
|
|
|
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
|
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
|
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
|
|
|
// Create WebSocket connection manager
|
|
connManager := sync.NewConnectionManager()
|
|
connManager.StartCleanupTask()
|
|
|
|
// Create sync queue processor
|
|
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
|
go queueProcessor.Start(context.Background())
|
|
|
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
|
|
|
// Create conversion service for EPUB→KEPUB conversion
|
|
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
|
opdsHandler := handlers.NewOPDSHandler(queries, conversionService)
|
|
|
|
e := echo.New()
|
|
|
|
// Set up validator
|
|
v := validator.New()
|
|
|
|
// Register custom password complexity validator
|
|
if err := ratelimit.RegisterPasswordValidation(v); err != nil {
|
|
log.Fatal("Failed to register password validator:", err)
|
|
}
|
|
|
|
e.Validator = &CustomValidator{validator: v}
|
|
|
|
// Middleware
|
|
e.Use(echomiddleware.Logger())
|
|
e.Use(echomiddleware.Recover())
|
|
e.Use(echomiddleware.CORS())
|
|
e.Use(ratelimit.RequestTracingMiddleware(cfg))
|
|
|
|
// Rate limiter for auth endpoints
|
|
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
|
Enabled: cfg.RateLimitEnabled,
|
|
RequestsPerMinute: cfg.RequestsPerMinute,
|
|
CleanupInterval: 5 * time.Minute,
|
|
}
|
|
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
|
|
rateLimitMiddleware := ratelimit.RateLimiterMiddleware(rateLimiter)
|
|
|
|
// Auth routes (no auth required, but rate limited)
|
|
e.POST("/api/auth/register", rateLimitMiddleware(authHandler.Register))
|
|
e.POST("/api/auth/login", rateLimitMiddleware(authHandler.Login))
|
|
|
|
// JWT middleware for protected routes
|
|
jwtMiddleware := echojwt.WithConfig(echojwt.Config{
|
|
SigningKey: []byte(cfg.JWTSecret),
|
|
ContextKey: "user",
|
|
SuccessHandler: func(c echo.Context) {
|
|
token := c.Get("user").(*jwt.Token)
|
|
claims := token.Claims.(jwt.MapClaims)
|
|
c.Set("user_id", claims["user_id"])
|
|
c.Set("user_role", claims["user_role"])
|
|
c.Set("user_email", claims["user_email"])
|
|
c.Set("user_username", claims["user_username"])
|
|
// Parse UUID from string claims
|
|
userIDStr, _ := claims["user_id"].(string)
|
|
userUUID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID in token"})
|
|
return
|
|
}
|
|
|
|
c.Set("user", database.Users{
|
|
ID: pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true},
|
|
Email: claims["user_email"].(string),
|
|
Username: claims["user_username"].(string),
|
|
Role: claims["user_role"].(string),
|
|
})
|
|
},
|
|
})
|
|
|
|
// Protected routes
|
|
protected := e.Group("/api", jwtMiddleware)
|
|
|
|
// Setup ebook handler routes first (so we can use it for library scan)
|
|
h := handlers.SetupRoutes(protected, queries, connManager)
|
|
|
|
// Public library types endpoint (no authentication required)
|
|
e.GET("/api/libraries/types", libraryHandler.GetLibraryTypes)
|
|
|
|
// Refresh token endpoint (no authentication required - uses refresh token from body)
|
|
e.POST("/api/auth/refresh", authHandler.RefreshAccessToken)
|
|
|
|
// Logout endpoint (optional authentication - can revoke tokens if provided)
|
|
e.POST("/api/auth/logout", authHandler.Logout)
|
|
|
|
// Protected routes
|
|
protected = e.Group("/api", jwtMiddleware)
|
|
|
|
// Setup ebook handler routes first (so we can use it for library scan)
|
|
|
|
protected.GET("/auth/profile", authHandler.GetProfile)
|
|
protected.PUT("/auth/profile", authHandler.UpdateProfile)
|
|
|
|
// Admin-only routes for user and folder management
|
|
admin := protected.Group("/auth", handlers.AdminMiddleware)
|
|
admin.GET("/users", authHandler.ListUsers)
|
|
admin.PUT("/users/:id/max-devices", authHandler.UpdateUserMaxDevices)
|
|
|
|
// Library management routes
|
|
library := protected.Group("/libraries")
|
|
|
|
// Admin-only library routes
|
|
adminLibrary := library.Group("", handlers.AdminMiddleware)
|
|
adminLibrary.POST("", libraryHandler.CreateLibrary)
|
|
adminLibrary.GET("", libraryHandler.ListLibraries)
|
|
adminLibrary.GET("/:id", libraryHandler.GetLibrary)
|
|
adminLibrary.PUT("/:id", libraryHandler.UpdateLibrary)
|
|
adminLibrary.DELETE("/:id", libraryHandler.DeleteLibrary)
|
|
adminLibrary.POST("/:id/folders", libraryHandler.AddLibraryFolder)
|
|
adminLibrary.GET("/:id/folders", libraryHandler.GetLibraryFolders)
|
|
adminLibrary.DELETE("/:id/folders", libraryHandler.DeleteLibraryFolder)
|
|
adminLibrary.GET("/:id/stats", libraryHandler.GetLibraryStats)
|
|
adminLibrary.POST("/:id/scan", func(c echo.Context) error {
|
|
libraryID := c.Param("id")
|
|
scanReq := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
}
|
|
c.Set("scan_request", scanReq)
|
|
return h.ScanEbooks(c)
|
|
})
|
|
adminLibrary.GET("/:id/media-items", func(c echo.Context) error {
|
|
libraryID := c.Param("id")
|
|
c.QueryParams().Set("library_id", libraryID)
|
|
return h.ListMediaItems(c)
|
|
})
|
|
|
|
// User library visibility control
|
|
protected.POST("/libraries/visibility", libraryHandler.SetLibraryVisibility)
|
|
protected.GET("/libraries/visible", libraryHandler.GetUserVisibleLibraries)
|
|
|
|
protected.DELETE("/auth/account", authHandler.DeleteAccount)
|
|
protected.PUT("/library/scan-settings", authHandler.UpdateScanSettings)
|
|
protected.GET("/library/scan-settings", authHandler.GetScanSettings)
|
|
|
|
// Auth update routes
|
|
authGroup := e.Group("/api/auth", jwtMiddleware)
|
|
authGroup.PUT("/email", authHandler.UpdateEmail)
|
|
authGroup.PUT("/username", authHandler.UpdateUsername)
|
|
authGroup.PUT("/password", authHandler.UpdatePassword)
|
|
authGroup.PUT("/theme", authHandler.UpdateTheme)
|
|
// force rebuild
|
|
|
|
// Device management routes (public - for registration)
|
|
e.POST("/api/devices/register", deviceHandler.InitiateRegistration)
|
|
e.POST("/api/devices/register/status", deviceHandler.CheckRegistrationStatus)
|
|
|
|
// KOReader sync routes (device authentication required)
|
|
koreaderSync := e.Group("/api/sync/koreader")
|
|
koreaderSync.POST("/progress", deviceAuthMiddleware.Authenticate(koreaderHandler.SyncProgress))
|
|
koreaderSync.GET("/metadata/:uuid", deviceAuthMiddleware.Authenticate(koreaderHandler.GetMetadata))
|
|
koreaderSync.GET("/library", deviceAuthMiddleware.Authenticate(koreaderHandler.GetLibrary))
|
|
koreaderSync.POST("/bookmarks", deviceAuthMiddleware.Authenticate(koreaderHandler.SyncBookmarks))
|
|
|
|
// Kobo sync routes (device authentication required)
|
|
koboHandler := handlers.NewKoboHandler(queries, connManager)
|
|
koboSync := e.Group("/api/sync/kobo")
|
|
koboSync.POST("/markup", deviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
|
koboSync.POST("/bookmark", deviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
|
koboSync.POST("/v1/analytics/gettests", deviceAuthMiddleware.Authenticate(koboHandler.AnalyticsGettests))
|
|
koboSync.GET("/v1/initialization", deviceAuthMiddleware.Authenticate(koboHandler.Initialization))
|
|
koboSync.POST("/sync-from-server", deviceAuthMiddleware.Authenticate(koboHandler.SyncFromServer))
|
|
|
|
// Book matching and unlinked book resolution routes
|
|
sync := protected.Group("/sync")
|
|
sync.POST("/bulk-link-books", h.BulkLinkBooks)
|
|
sync.POST("/auto-link-books", h.AutoLinkBooks)
|
|
sync.GET("/unlinked-books/:id/suggestions", h.GetUnlinkedBookSuggestions)
|
|
|
|
// Media item routes (download and shelf management)
|
|
mediaHandler := handlers.NewMediaHandler(queries)
|
|
e.GET("/api/books/:uuid/download", mediaHandler.DownloadBook)
|
|
protected.POST("/devices/:id/shelves", mediaHandler.AddToShelf)
|
|
protected.GET("/devices/:id/shelves", mediaHandler.GetShelf)
|
|
protected.DELETE("/devices/:id/shelves", mediaHandler.RemoveFromShelf)
|
|
protected.DELETE("/devices/:id/shelves/clear", mediaHandler.ClearShelf)
|
|
|
|
// Bulk book operations (protected - require user auth)
|
|
books := protected.Group("/books")
|
|
books.POST("/bulk-delete", mediaHandler.HandleBulkDelete)
|
|
books.POST("/bulk-update", mediaHandler.HandleBulkUpdate)
|
|
|
|
// Device management routes (protected - require user auth)
|
|
devices := protected.Group("/devices")
|
|
devices.GET("", deviceHandler.ListDevices)
|
|
devices.GET("/:id", deviceHandler.GetDevice)
|
|
devices.PUT("/:id", deviceHandler.UpdateDevice)
|
|
devices.DELETE("/:id", deviceHandler.DeleteDevice)
|
|
devices.GET("/pending", deviceHandler.ListPendingRegistrations)
|
|
devices.GET("/approve/:registration_id", deviceHandler.ApproveDevice)
|
|
devices.POST("/reject/:registration_id", deviceHandler.RejectDevice)
|
|
|
|
// Conflict resolution routes (protected - require user auth)
|
|
conflicts := protected.Group("/conflicts")
|
|
conflicts.GET("", conflictHandler.ListConflicts)
|
|
conflicts.GET("/:id", conflictHandler.GetConflict)
|
|
conflicts.POST("/:id/resolve", conflictHandler.ResolveConflict)
|
|
conflicts.DELETE("/:id", conflictHandler.DeleteConflict)
|
|
conflicts.POST("/dismiss-all", conflictHandler.DismissAllResolved)
|
|
conflicts.POST("/bulk-resolve", conflictHandler.BulkResolveConflicts)
|
|
conflicts.POST("/bulk-dismiss", conflictHandler.BulkDismissConflicts)
|
|
|
|
// Analytics routes (protected - require user auth)
|
|
analytics := protected.Group("/analytics")
|
|
analytics.GET("/reading-stats", analyticsHandler.GetReadingStats)
|
|
analytics.GET("/device-usage", analyticsHandler.GetDeviceUsage)
|
|
analytics.GET("/popular-books", analyticsHandler.GetPopularBooks)
|
|
|
|
// Sync queue management routes (protected - require user auth)
|
|
queue := protected.Group("/queue")
|
|
queue.GET("/devices/:device_id/stats", queueHandler.GetDeviceQueueStats)
|
|
queue.GET("/devices/:device_id/items", queueHandler.ListDeviceQueueItems)
|
|
queue.POST("/items/:item_id/retry", queueHandler.RetryQueueItem)
|
|
queue.DELETE("/items/:item_id", queueHandler.DeleteQueueItem)
|
|
queue.DELETE("/devices/:device_id/clear", queueHandler.ClearDeviceQueue)
|
|
|
|
// Admin-only queue routes
|
|
adminQueue := queue.Group("", handlers.AdminMiddleware)
|
|
adminQueue.GET("/items", queueHandler.ListAllQueueItems)
|
|
|
|
// WebSocket endpoint for real-time sync
|
|
e.GET("/ws/sync", wsHandler.HandleWebSocket)
|
|
|
|
// OPDS routes (public - device authentication optional)
|
|
opds := e.Group("/opds/devices")
|
|
opds.GET("/:deviceId/catalog", opdsHandler.GetDeviceCatalog)
|
|
opds.GET("/:deviceId/search", opdsHandler.SearchDeviceCatalog)
|
|
opds.GET("/:deviceId/nav", opdsHandler.GetDeviceNavigation)
|
|
opds.GET("/:deviceId/download/:bookId", opdsHandler.DownloadBook)
|
|
opds.GET("/:deviceId/cover/:bookId", opdsHandler.GetCoverImage)
|
|
opds.GET("/:deviceId/formats/:bookId", opdsHandler.ListFormats)
|
|
|
|
// Static files
|
|
e.Static("/static", "web/static")
|
|
|
|
// Start scheduler for auto-scanning
|
|
go h.StartScheduler()
|
|
defer h.StopScheduler()
|
|
|
|
// Start watch mode for all libraries (background)
|
|
go func() {
|
|
time.Sleep(2 * time.Second) // Wait a bit for server to be ready
|
|
if err := h.StartWatchModeForAllLibraries(context.Background()); err != nil {
|
|
log.Printf("Warning: failed to start watch mode for libraries: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Bookshelf route (protected) - new default for logged-in users
|
|
protected.GET("/bookshelf", func(c echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
// Fetch libraries server-side for SSR
|
|
librariesData, err := libraryHandler.GetUserVisibleLibrariesData(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
|
|
}
|
|
|
|
// Convert to template format
|
|
libraries := make([]templates.LibraryData, len(librariesData))
|
|
for i, lib := range librariesData {
|
|
libUUID := uuid.UUID(lib.ID.Bytes)
|
|
description := ""
|
|
if lib.Description.Valid {
|
|
description = lib.Description.String
|
|
}
|
|
libraries[i] = templates.LibraryData{
|
|
ID: libUUID.String(),
|
|
Name: lib.Name,
|
|
Description: description,
|
|
TypeName: lib.TypeName,
|
|
}
|
|
}
|
|
|
|
userTemplate := templates.User{
|
|
ID: userUUID.String(),
|
|
Email: user.Email,
|
|
Username: user.Username,
|
|
Role: user.Role,
|
|
}
|
|
|
|
// Render template WITH libraries data (SSR)
|
|
var buf bytes.Buffer
|
|
err = templates.BookShelf(userTemplate, libraries).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Analytics route (protected) - SSR version
|
|
protected.GET("/analytics", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, queries)
|
|
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())
|
|
})
|
|
|
|
// Queue Management route (protected) - SSR version
|
|
// Progress visualization route (protected) - SSR version
|
|
protected.GET("/progress", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, queries)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading user")
|
|
}
|
|
|
|
progressData, err := h.GetAllProgressData(c)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading progress")
|
|
}
|
|
|
|
progressItems := make([]templates.ProgressItemData, len(progressData))
|
|
for i, p := range progressData {
|
|
deviceIcon := ""
|
|
deviceType := ""
|
|
switch p.LastSyncDevice {
|
|
case "koreader":
|
|
deviceIcon = "📖"
|
|
deviceType = "KOReader"
|
|
case "kobo":
|
|
deviceIcon = "📚"
|
|
deviceType = "Kobo"
|
|
case "web":
|
|
deviceIcon = "🌐"
|
|
deviceType = "Web"
|
|
case "mobile":
|
|
deviceIcon = "📱"
|
|
deviceType = "Mobile"
|
|
}
|
|
|
|
progressItems[i] = templates.ProgressItemData{
|
|
MediaItemID: uuid.UUID(p.MediaItemID).String(),
|
|
Title: p.Title,
|
|
Author: p.Author,
|
|
CoverImagePath: p.CoverImagePath,
|
|
CurrentPage: p.CurrentPage,
|
|
TotalPages: p.TotalPages,
|
|
ProgressPercentage: p.Percentage,
|
|
LastUpdated: p.LastReadAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
DeviceName: p.LastSyncDevice,
|
|
DeviceType: deviceType,
|
|
DeviceIcon: deviceIcon,
|
|
EpubCFI: p.Epubcfi,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Progress(user, progressItems).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Queue Management route (protected) - SSR version
|
|
protected.GET("/queue", func(c echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userEmail := c.Get("user_email").(string)
|
|
userUsername := c.Get("user_username").(string)
|
|
userRole := c.Get("user_role").(string)
|
|
|
|
user := templates.User{
|
|
ID: userID,
|
|
Email: userEmail,
|
|
Username: userUsername,
|
|
Role: userRole,
|
|
}
|
|
|
|
// Fetch queue items for SSR (uses existing handler method)
|
|
queueItems, err := queueHandler.GetQueueData(c)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading queue")
|
|
}
|
|
|
|
// Calculate stats from items
|
|
stats := handlers.QueueStatsResponse{
|
|
PendingCount: 0,
|
|
ProcessingCount: 0,
|
|
FailedCount: 0,
|
|
CompletedCount: 0,
|
|
TotalCount: int64(len(queueItems)),
|
|
}
|
|
for _, item := range queueItems {
|
|
switch item.Status {
|
|
case "pending":
|
|
stats.PendingCount++
|
|
case "processing":
|
|
stats.ProcessingCount++
|
|
case "failed":
|
|
stats.FailedCount++
|
|
case "completed":
|
|
stats.CompletedCount++
|
|
}
|
|
}
|
|
|
|
// Render template WITH data (SSR)
|
|
var buf bytes.Buffer
|
|
err = templates.Queue(user, queueItems, stats).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collections management route (protected) - SSR version
|
|
collectionHandler := handlers.NewCollectionHandler(queries, connManager)
|
|
collections := protected.Group("/collections")
|
|
collections.POST("/bulk-add-books", collectionHandler.HandleBulkAddBooks)
|
|
collections.GET("", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, queries)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading user")
|
|
}
|
|
|
|
// Fetch collections for SSR
|
|
collectionData, err := collectionHandler.GetCollectionsData(c)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading collections")
|
|
}
|
|
|
|
// Convert to template format
|
|
collectionsList := make([]templates.CollectionData, len(collectionData))
|
|
for i, col := range collectionData {
|
|
description := ""
|
|
if col.Description.Valid {
|
|
description = col.Description.String
|
|
}
|
|
color := ""
|
|
if col.Color.Valid {
|
|
color = col.Color.String
|
|
}
|
|
icon := ""
|
|
if col.Icon.Valid {
|
|
icon = col.Icon.String
|
|
}
|
|
|
|
collectionsList[i] = templates.CollectionData{
|
|
ID: uuid.UUID(col.ID.Bytes).String(),
|
|
Name: col.Name,
|
|
Description: description,
|
|
Color: color,
|
|
Icon: icon,
|
|
}
|
|
}
|
|
|
|
// Render template WITH data (SSR)
|
|
var buf bytes.Buffer
|
|
err = templates.Collection(user, collectionsList).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
collections.GET("/:id", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, queries)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading user")
|
|
}
|
|
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, "Invalid collection ID")
|
|
}
|
|
|
|
// Fetch collection for SSR
|
|
collectionDB, err := collectionHandler.GetCollectionData(c, collectionID)
|
|
if err != nil {
|
|
return c.HTML(http.StatusNotFound, "Collection not found")
|
|
}
|
|
|
|
// Fetch books for SSR
|
|
booksData, err := collectionHandler.GetCollectionBooksData(c, collectionID)
|
|
if err != nil {
|
|
return c.HTML(http.StatusInternalServerError, "Error loading books")
|
|
}
|
|
|
|
// Convert to template format
|
|
description := ""
|
|
if collectionDB.Description.Valid {
|
|
description = collectionDB.Description.String
|
|
}
|
|
color := ""
|
|
if collectionDB.Color.Valid {
|
|
color = collectionDB.Color.String
|
|
}
|
|
icon := ""
|
|
if collectionDB.Icon.Valid {
|
|
icon = collectionDB.Icon.String
|
|
}
|
|
|
|
collectionDetail := templates.CollectionDetailData{
|
|
ID: uuid.UUID(collectionDB.ID.Bytes).String(),
|
|
Name: collectionDB.Name,
|
|
Description: description,
|
|
Color: color,
|
|
Icon: icon,
|
|
}
|
|
|
|
books := make([]templates.BookData, len(booksData))
|
|
for i, book := range booksData {
|
|
author := ""
|
|
if book.Author.Valid {
|
|
author = book.Author.String
|
|
}
|
|
coverPath := ""
|
|
if book.CoverImagePath.Valid {
|
|
coverPath = book.CoverImagePath.String
|
|
}
|
|
books[i] = templates.BookData{
|
|
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
|
Title: book.Title,
|
|
Author: author,
|
|
CoverImagePath: coverPath,
|
|
}
|
|
}
|
|
|
|
// Render template WITH data (SSR)
|
|
var buf bytes.Buffer
|
|
err = templates.CollectionDetail(user, collectionDetail, books).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Documentation routes (no authentication required)
|
|
docsHandler := docs.NewHTTPHandler("docs")
|
|
e.GET("/docs", docsHandler.DocsHome)
|
|
e.GET("/docs/*", docsHandler.ShowDocumentation)
|
|
e.GET("/docs/api/search", docsHandler.Search)
|
|
e.GET("/docs/search-index.json", docsHandler.ServeSearchIndex)
|
|
|
|
// Start server
|
|
log.Printf("Starting server on port %s", cfg.ServerPort)
|
|
e.Logger.Fatal(e.Start(":" + cfg.ServerPort))
|
|
}
|