feat(ssr): add server-side routes for Phase 9 frontend features

Add SSR routes for collections, progress, and devices pages:

Progress Page (/api/progress):
- Fetches all user progress with device sync sources
- Maps device types to icons (Kobo, KOReader, Web, Mobile)
- Server-renders progress visualization with real data

Collections Pages (/api/collections):
- GET /collections: List all user collections with SSR
- GET /collections/🆔 Collection detail page with books SSR
- Fetches collection metadata and book listings
- Converts database models to template data structures

Helper Functions:
- getTemplateUserWithTheme: Fetches user with theme preference
- Proper error handling for missing data

All routes use JWT authentication and fetch data server-side
for better SEO and initial page load performance. Client-side
enhancements can be added via HTMX for interactive features.

These routes support the Phase 9 frontend implementation with
proper SSR rendering for improved performance and accessibility.
This commit is contained in:
2026-02-01 00:27:01 -05:00
parent ed31755a54
commit ff98c17531
+208 -1
View File
@@ -34,6 +34,36 @@ 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()
@@ -144,7 +174,6 @@ func main() {
protected = e.Group("/api", jwtMiddleware)
// Setup ebook handler routes first (so we can use it for library scan)
h = handlers.SetupRoutes(protected, queries, connManager)
protected.GET("/auth/profile", authHandler.GetProfile)
protected.PUT("/auth/profile", authHandler.UpdateProfile)
@@ -522,6 +551,63 @@ func main() {
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)
@@ -572,6 +658,127 @@ func main() {
return c.HTML(http.StatusOK, buf.String())
})
// Collections management route (protected) - SSR version
collectionHandler := handlers.NewCollectionHandler(queries)
collections := protected.Group("/collections")
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())
})
// Start server
log.Printf("Starting server on port %s", cfg.ServerPort)
e.Logger.Fatal(e.Start(":" + cfg.ServerPort))