Implement SSR-first book detail page at /media/:uuid with complete book information, progress tracking, and interactivity. Features: - Cover image (256x384px) with responsive layout - Complete metadata: title, author, description, publisher, ISBN, language, edition, page count, genre, copyright year, format - External service links (Goodreads, Open Library, Google Books, Amazon) with smart URL fallback: ID → ISBN → Title+Author - Reading progress display with device sources (web/kobo/koreader) - Sync progress modal for conflict resolution - Collections display as clickable badges - Notes/highlights counter with placeholder modal - Rating display (1-10 scale with star rendering) - HTML sanitization for book descriptions using bluemonday Data Structure: - handlers.MediaDetail embeds database.MediaItems for zero duplication - Uses existing database queries (GetMediaItem, GetMediaRating, etc.) - Follows project pattern: no parallel type systems Frontend: - TypeScript modal triggers (book-detail.ts) - Alpine.js for modal interactions - TailwindCSS styling with theme variables - Responsive: cover-left layout, mobile stacks vertically Backend: - Route: GET /media/:uuid (protected) - Handler: inline function in frontend.go following existing pattern - Template: SSR-first with progressive enhancement - Returns HTML only (API uses separate /api/media-items/:id endpoint) Files created: - internal/handlers/media_detail.go - templates/book_detail.templ - templates/book_detail_modals.templ - web/src/book-detail.ts Files modified: - internal/router/frontend.go (add route) - web/src/main.ts (import module)
1138 lines
36 KiB
Go
1138 lines
36 KiB
Go
package router
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"bookhoard/internal/config"
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/internal/utils"
|
|
"bookhoard/templates"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func renderErrorPage(c *echo.Context, message string, errorType string) error {
|
|
var buf bytes.Buffer
|
|
err := templates.ErrorPage(message, errorType).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
log.Printf("renderErrorPage failed to render template: %v", err)
|
|
return c.HTML(http.StatusInternalServerError, "Internal server error")
|
|
}
|
|
return c.HTML(http.StatusInternalServerError, buf.String())
|
|
}
|
|
|
|
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"
|
|
deleted := c.QueryParam("deleted") == "true"
|
|
err := templates.Login(sessionExpired, deleted).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, ensureUserExistsMiddleware(cfg))
|
|
|
|
// Helper to extract text from pgtype.Text
|
|
getText := func(t pgtype.Text) string {
|
|
if t.Valid {
|
|
return t.String
|
|
}
|
|
return ""
|
|
}
|
|
|
|
frontendProtected.GET("/bookshelf", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
|
|
// Get library_id from query param or user's first library
|
|
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()
|
|
} else {
|
|
errorMsg = "No libraries available"
|
|
}
|
|
}
|
|
|
|
// Get libraries for dropdown
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
if errorMsg == "" {
|
|
errorMsg = "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,
|
|
}
|
|
}
|
|
|
|
// Fetch saved filters for SSR (using existing query)
|
|
var savedFilters []database.SavedFilters
|
|
if libraryID != "" && errorMsg == "" {
|
|
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
ResourceType: "media-items",
|
|
})
|
|
if err != nil {
|
|
log.Printf("GetSavedFilters failed: %v", err)
|
|
savedFilters = []database.SavedFilters{}
|
|
}
|
|
}
|
|
|
|
// Fetch first page of books for SSR
|
|
var bookInfoList []handlers.BookInfo
|
|
totalCount := 0
|
|
limit := 50
|
|
offset := 0
|
|
|
|
if libraryID != "" && errorMsg == "" {
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err == nil {
|
|
// Check URL params for pagination
|
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
|
limit = l
|
|
}
|
|
}
|
|
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
offset = o
|
|
}
|
|
}
|
|
// Convert user.ID (string) to pgtype.UUID for service layer
|
|
userUUID, err := uuid.Parse(user.ID)
|
|
if err != nil {
|
|
log.Printf("Failed to parse user ID: %v", err)
|
|
return renderErrorPage(c, "Error loading user", "user_id_error")
|
|
}
|
|
// Build search params (same as search.go:76-91)
|
|
params := services.SearchParams{
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
SearchQuery: "", // Empty for initial SSR load
|
|
AuthorFilter: "",
|
|
SeriesFilter: "",
|
|
GenreFilter: "",
|
|
TagsFilter: "",
|
|
LanguageFilter: "",
|
|
YearMin: 0,
|
|
YearMax: 0,
|
|
HasCover: pgtype.Bool{Valid: false},
|
|
Sort: "created_at DESC",
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
// Execute search using the same handler as API (search.go:93)
|
|
var results []database.SearchMediaItemsUnifiedRow
|
|
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
|
if err != nil {
|
|
log.Printf("ExecuteSearch failed: %v", err)
|
|
// Continue without books - will show empty state
|
|
} else {
|
|
// Convert to BookInfo (same as search.go:99-109)
|
|
bookInfoList = make([]handlers.BookInfo, len(results))
|
|
for i, book := range results {
|
|
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
|
|
bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16])
|
|
bookInfoList[i] = handlers.BookInfo{
|
|
MediaItemID: bookUUID.String(),
|
|
Title: book.Title,
|
|
Author: textToString(book.Author),
|
|
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
|
}
|
|
}
|
|
log.Printf("SSR: fetched %d books for library %s", len(bookInfoList), libraryID)
|
|
}
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Dashboard page
|
|
frontendProtected.GET("/dashboard", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
|
|
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, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
|
if err != nil {
|
|
log.Printf("GetDashboardPreferences failed: %v", err)
|
|
prefs = database.UserDashboardPreferences{
|
|
HiddenCollections: []string{},
|
|
CollectionOrder: []string{},
|
|
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
|
}
|
|
}
|
|
limit := 20
|
|
if prefs.ItemsPerSection.Int32 > 0 {
|
|
limit = int(prefs.ItemsPerSection.Int32)
|
|
}
|
|
|
|
// Get ALL sections (unfiltered) for the modal
|
|
allSections, err := cfg.DashboardService.GetDashboardSections(
|
|
c.Request().Context(),
|
|
userUUID,
|
|
libUUID,
|
|
limit,
|
|
prefs.CollectionOrder,
|
|
[]string{}, // No filtering - get all sections
|
|
)
|
|
if err != nil {
|
|
log.Printf("Dashboard sections query failed: %v", err)
|
|
allSections = []services.DashboardSection{}
|
|
errorMsg = "Error loading dashboard"
|
|
}
|
|
|
|
// Get only visible sections for the dashboard display
|
|
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
|
|
|
userUUID2, _ := uuid.Parse(user.ID)
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
if errorMsg == "" {
|
|
errorMsg = "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(visibleSections, libraryID)
|
|
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, errorMsg).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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var collections []database.Collections
|
|
|
|
collections, err = cfg.CollectionHandler.GetCollectionsData(c)
|
|
if err != nil {
|
|
log.Printf("GetCollectionsData failed: %v", err)
|
|
collections = []database.Collections{}
|
|
errorMsg = "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, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collection create modal
|
|
frontendProtected.GET("/collections/create-modal", func(c *echo.Context) error {
|
|
var buf bytes.Buffer
|
|
err := templates.CollectionModal(templates.CollectionData{}).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collection edit modal
|
|
frontendProtected.GET("/collections/:id/edit-modal", func(c *echo.Context) error {
|
|
// Parse collection ID
|
|
collectionID := c.Param("id")
|
|
collUUID, err := uuid.Parse(collectionID)
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
|
|
}
|
|
|
|
// Fetch collection data
|
|
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
|
if err != nil {
|
|
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
|
|
}
|
|
|
|
// Convert to template type
|
|
colData := templates.CollectionData{
|
|
ID: uuid.UUID(collection.ID.Bytes).String(),
|
|
Name: collection.Name,
|
|
Description: getText(collection.Description),
|
|
Color: getText(collection.Color),
|
|
Icon: getText(collection.Icon),
|
|
}
|
|
|
|
// Ensure default color if empty
|
|
if colData.Color == "" {
|
|
colData.Color = "blue"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.CollectionModal(colData).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Restore system collection modal
|
|
frontendProtected.GET("/collections/restore-modal", func(c *echo.Context) error {
|
|
var buf bytes.Buffer
|
|
err := templates.RestoreSystemCollectionModal().Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collection detail page (works for both system and user collections)
|
|
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
// Parse collection ID from URL
|
|
collectionID := c.Param("id")
|
|
collUUID, err := uuid.Parse(collectionID)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
|
|
}
|
|
// Fetch collection details
|
|
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
|
if err != nil {
|
|
if err.Error() == "no rows in result set" {
|
|
return renderErrorPage(c, "Collection not found", "not_found")
|
|
}
|
|
return renderErrorPage(c, "Error loading collection", "collection_load_error")
|
|
}
|
|
// Fetch books in collection
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
var books []handlers.BookInfo
|
|
|
|
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
|
// System collection - use query type
|
|
// System collection - need library_id for system collections
|
|
// Get library_id from query param or default to user's first library
|
|
libraryID := c.QueryParam("library_id")
|
|
if libraryID == "" {
|
|
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)
|
|
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
|
sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading books", "books_load_error")
|
|
}
|
|
|
|
// Find the matching section and convert items
|
|
for _, section := range sections {
|
|
if section.CollectionID.String() == collectionID {
|
|
// Convert []database.MediaItems to []handlers.BookInfo
|
|
bookCards := make([]handlers.BookInfo, len(section.Items))
|
|
for i, item := range section.Items {
|
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
|
bookCards[i] = handlers.BookInfo{
|
|
MediaItemID: itemUUID.String(),
|
|
Title: item.Title,
|
|
Author: getText(item.Author),
|
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
}
|
|
}
|
|
books = bookCards
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
// User collection - check if library_id filter is present
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
if libraryID != "" {
|
|
// Filter by library - reuse dashboard query
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
|
|
}
|
|
|
|
// Use GetCollectionItemsForDashboard for library-filtered results
|
|
collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
|
database.GetCollectionItemsForDashboardParams{
|
|
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
Limit: 1000,
|
|
})
|
|
if err != nil {
|
|
books = []handlers.BookInfo{}
|
|
} else {
|
|
// Convert to BookInfo format (non-excluded only)
|
|
var validItems []database.GetCollectionItemsForDashboardRow
|
|
for _, item := range collItems {
|
|
if !item.Excluded.Valid || !item.Excluded.Bool {
|
|
validItems = append(validItems, item)
|
|
}
|
|
}
|
|
|
|
bookCards := make([]handlers.BookInfo, len(validItems))
|
|
for i, item := range validItems {
|
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
|
bookCards[i] = handlers.BookInfo{
|
|
MediaItemID: itemUUID.String(),
|
|
Title: item.Title,
|
|
Author: getText(item.Author),
|
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
}
|
|
}
|
|
books = bookCards
|
|
}
|
|
} else {
|
|
// No library filter - show all books in collection
|
|
collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
|
if err != nil {
|
|
books = []handlers.BookInfo{}
|
|
}
|
|
|
|
// Convert to BookInfo format
|
|
bookCards := make([]handlers.BookInfo, len(collItems))
|
|
for i, item := range collItems {
|
|
itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
|
|
bookCards[i] = handlers.BookInfo{
|
|
MediaItemID: itemUUID.String(),
|
|
Title: item.Title,
|
|
Author: getText(item.Author),
|
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
}
|
|
}
|
|
books = bookCards
|
|
}
|
|
}
|
|
// Build collection data
|
|
colData := templates.CollectionData{
|
|
ID: collectionID,
|
|
Name: collection.Name,
|
|
Description: collection.Description.String,
|
|
Color: collection.Color.String,
|
|
Icon: collection.Icon.String,
|
|
}
|
|
|
|
// Get library_id from query params for template
|
|
libraryID := c.QueryParam("library_id")
|
|
// Render the CollectionDetail template
|
|
var buf bytes.Buffer
|
|
err = templates.CollectionDetail(user, colData, books, libraryID).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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var libraries []database.GetUserVisibleLibrariesRow
|
|
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
libraries, err = cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
errorMsg = "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, errorMsg).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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var progressData []handlers.ProgressWithMedia
|
|
|
|
progressData, err = cfg.ScannerHandler.GetAllProgressData(c)
|
|
if err != nil {
|
|
log.Printf("GetAllProgressData failed: %v", err)
|
|
progressData = []handlers.ProgressWithMedia{}
|
|
errorMsg = "Error loading progress"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Progress(user, progressData, errorMsg).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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var devices []handlers.DeviceInfo
|
|
var pendingList []templates.PendingRegistrationData
|
|
|
|
devices, err = cfg.DeviceHandler.GetDevicesData(c)
|
|
if err != nil {
|
|
log.Printf("GetDevicesData failed: %v", err)
|
|
devices = []handlers.DeviceInfo{}
|
|
errorMsg = "Error loading devices"
|
|
}
|
|
|
|
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
|
|
if err != nil {
|
|
log.Printf("GetPendingRegistrationsData failed: %v", err)
|
|
pendingList = []templates.PendingRegistrationData{}
|
|
if errorMsg == "" {
|
|
errorMsg = "Error loading pending registrations"
|
|
}
|
|
} else {
|
|
pendingList = convertPending(pendingMaps)
|
|
}
|
|
|
|
// Get base URL from database config with fallback to config/env var
|
|
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
|
if baseURL == "" {
|
|
baseURL = cfg.Cfg.BaseURL
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Devices(user, devices, pendingList, errorMsg, 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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var conflictsData []handlers.ConflictDetailResponse
|
|
var total, unresolved int
|
|
|
|
conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c)
|
|
if err != nil {
|
|
log.Printf("GetConflictsData failed: %v", err)
|
|
conflictsData = []handlers.ConflictDetailResponse{}
|
|
total = 0
|
|
unresolved = 0
|
|
errorMsg = "Error loading conflicts"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Conflicts(user, conflictsData, total, unresolved, errorMsg).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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
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())
|
|
})
|
|
|
|
// Profile page (all users)
|
|
frontendProtected.GET("/profile", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Profile(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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
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/library", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
// Fetch all libraries (admin-only)
|
|
libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
|
|
if err != nil {
|
|
log.Printf("ListLibrariesData failed: %v", err)
|
|
libraries = []database.ListLibrariesRow{}
|
|
}
|
|
|
|
// Convert to template LibraryData
|
|
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,
|
|
}
|
|
}
|
|
|
|
// Fetch all users for visibility management
|
|
users, err := cfg.Queries.ListUsers(c.Request().Context())
|
|
if err != nil {
|
|
log.Printf("ListUsers failed: %v", err)
|
|
users = []database.ListUsersRow{}
|
|
}
|
|
|
|
// Convert to template User types
|
|
userData := make([]templates.User, len(users))
|
|
for i, u := range users {
|
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
|
userData[i] = templates.User{
|
|
ID: userUUID.String(),
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
Role: u.Role,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
// Admin users page
|
|
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
// Fetch all users
|
|
users, err := cfg.Queries.ListUsers(c.Request().Context())
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading users", "users_load_error")
|
|
}
|
|
|
|
// Count admins for UI protection
|
|
adminCount := 0
|
|
for _, u := range users {
|
|
if u.Role == "admin" {
|
|
adminCount++
|
|
}
|
|
}
|
|
|
|
// Convert to template users
|
|
templateUsers := make([]templates.User, len(users))
|
|
for i, u := range users {
|
|
templateUsers[i] = templates.User{
|
|
ID: uuid.UUID(u.ID.Bytes).String(),
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
Role: u.Role,
|
|
Theme: getText(u.Theme),
|
|
FirstName: getText(u.FirstName),
|
|
LastName: getText(u.LastName),
|
|
CreatedAt: u.CreatedAt.Time,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.AdminUsers(user, templateUsers, adminCount).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
// Admin: Get profile modal for editing user
|
|
frontendProtected.GET("/admin/settings", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
// Fetch current system configuration - just base_url
|
|
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
|
if baseURL == "" {
|
|
baseURL = cfg.Cfg.BaseURL
|
|
}
|
|
|
|
systemConfig := map[string]string{
|
|
"base_url": baseURL,
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/users/:id/profile-modal", handlers.AdminMiddleware(func(c *echo.Context) error {
|
|
// Get target user ID from URL
|
|
targetUserID := c.Param("id")
|
|
parsedUUID, err := uuid.Parse(targetUserID)
|
|
if err != nil {
|
|
return c.HTML(http.StatusBadRequest, "<div>Invalid user ID</div>")
|
|
}
|
|
|
|
// Fetch target user
|
|
targetUser, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(parsedUUID))
|
|
if err != nil {
|
|
return c.HTML(http.StatusNotFound, "<div>User not found</div>")
|
|
}
|
|
|
|
// Convert to template user
|
|
templateUser := templates.User{
|
|
ID: uuid.UUID(targetUser.ID.Bytes).String(),
|
|
Username: targetUser.Username,
|
|
Email: targetUser.Email,
|
|
Role: targetUser.Role,
|
|
Theme: getText(targetUser.Theme),
|
|
FirstName: getText(targetUser.FirstName),
|
|
LastName: getText(targetUser.LastName),
|
|
CreatedAt: targetUser.CreatedAt.Time,
|
|
}
|
|
|
|
// Render modal
|
|
var buf bytes.Buffer
|
|
err = templates.ProfileModal(templateUser).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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
var errorMsg string
|
|
var devices []handlers.DeviceInfo
|
|
var pendingList []templates.PendingRegistrationData
|
|
|
|
devices, err = cfg.DeviceHandler.GetDevicesData(c)
|
|
if err != nil {
|
|
log.Printf("GetDevicesData failed: %v", err)
|
|
devices = []handlers.DeviceInfo{}
|
|
errorMsg = "Error loading devices"
|
|
}
|
|
|
|
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
|
|
if err != nil {
|
|
log.Printf("GetPendingRegistrationsData failed: %v", err)
|
|
pendingList = []templates.PendingRegistrationData{}
|
|
if errorMsg == "" {
|
|
errorMsg = "Error loading pending registrations"
|
|
}
|
|
} else {
|
|
pendingList = convertPending(pendingMaps)
|
|
}
|
|
|
|
// Get base URL from database config with fallback to config/env var
|
|
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
|
if baseURL == "" {
|
|
baseURL = cfg.Cfg.BaseURL
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Devices(user, devices, pendingList, errorMsg, 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 renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var conflictsData []handlers.ConflictDetailResponse
|
|
var total, unresolved int
|
|
|
|
conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c)
|
|
if err != nil {
|
|
log.Printf("GetConflictsData failed: %v", err)
|
|
conflictsData = []handlers.ConflictDetailResponse{}
|
|
total = 0
|
|
unresolved = 0
|
|
errorMsg = "Error loading conflicts"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Conflicts(user, conflictsData, total, unresolved, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Book detail page
|
|
frontendProtected.GET("/media/:uuid", func(c *echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
// Parse media UUID from URL
|
|
mediaUUID, err := uuid.Parse(c.Param("uuid"))
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid media ID", "invalid_id")
|
|
}
|
|
pgMediaUUID := uuidToPGType(mediaUUID)
|
|
|
|
// Get user UUID for queries
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
pgUserID := uuidToPGType(userUUID)
|
|
|
|
// Fetch media item (embeds ALL metadata)
|
|
mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgMediaUUID)
|
|
if err != nil {
|
|
if err.Error() == "no rows in result set" {
|
|
return renderErrorPage(c, "Book not found", "not_found")
|
|
}
|
|
return renderErrorPage(c, "Error loading book", "database_error")
|
|
}
|
|
|
|
// Resolve cover image path
|
|
if mediaItem.CoverImagePath.Valid && mediaItem.CoverImagePath.String != "" {
|
|
resolvedPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
|
|
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
|
|
}
|
|
|
|
// Fetch rating
|
|
var rating *database.MediaRatings
|
|
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
if err == nil {
|
|
rating = &userRating
|
|
}
|
|
|
|
// Fetch collections
|
|
collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID)
|
|
|
|
// Fetch reading progress
|
|
var progress *database.ReadingProgress
|
|
readingProgress, err := cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
if err == nil {
|
|
progress = &readingProgress
|
|
}
|
|
|
|
// Fetch active conflict (if any)
|
|
var activeConflict *handlers.ConflictDetailResponse
|
|
conflicts, err := cfg.Queries.ListSyncConflictsByMediaItem(c.Request().Context(),
|
|
database.ListSyncConflictsByMediaItemParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
if err == nil && len(conflicts) > 0 {
|
|
for _, conf := range conflicts {
|
|
if conf.ResolutionStatus.Valid && conf.ResolutionStatus.String == "unresolved" {
|
|
var conflictData map[string]handlers.ConflictSourceData
|
|
if err := json.Unmarshal(conf.ConflictData, &conflictData); err == nil {
|
|
activeConflict = &handlers.ConflictDetailResponse{
|
|
ID: uuid.UUID(conf.ID.Bytes).String(),
|
|
MediaItemID: uuid.UUID(conf.MediaItemID.Bytes).String(),
|
|
MediaItemTitle: mediaItem.Title,
|
|
ConflictType: conf.ConflictType,
|
|
ConflictData: conflictData,
|
|
ResolutionStatus: conf.ResolutionStatus.String,
|
|
CreatedAt: conf.CreatedAt.Time,
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count notes and highlights
|
|
notes, _ := cfg.Queries.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
highlights, _ := cfg.Queries.GetMediaHighlights(c.Request().Context(), database.GetMediaHighlightsParams{
|
|
MediaItemID: pgMediaUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
|
|
// Assemble response (no field duplication!)
|
|
detail := handlers.MediaDetail{
|
|
MediaItems: mediaItem, // Embedded - ALL fields available
|
|
Rating: rating,
|
|
Collections: collections,
|
|
ReadingProgress: progress,
|
|
ActiveConflict: activeConflict,
|
|
NotesCount: len(notes),
|
|
HighlightsCount: len(highlights),
|
|
}
|
|
|
|
// Render template
|
|
var buf bytes.Buffer
|
|
err = templates.BookDetail(user, detail, "").Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
e.GET("/health", cfg.GetHealth)
|
|
}
|
|
|
|
// ============================================================================
|
|
// HEALTH CHECK
|
|
// ============================================================================
|
|
|
|
// Health check
|
|
func (cfg *Config) GetHealth(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": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"status": "healthy",
|
|
"database": "connected",
|
|
"scan": map[string]interface{}{
|
|
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
|
"active_jobs": cfg.Worker.GetActiveJobCount(),
|
|
},
|
|
})
|
|
}
|