Files
bookhoard/internal/router/frontend.go
T
John O'Keefe fe5ab9e5f8 feat(ui): hide missing books immediately and mark offline libraries
Two visibility fixes so the UI reflects the shelf's real state on the
next scan instead of only after the archive gate:

- Missing items disappear at once: the user-facing filters already hid
  archived rows; the same listings now also require missing_scan_count =
  0. A deleted or moved-then-not-yet-repointed file vanishes from the
  UI immediately, while purge timing stays gated on archived_at plus the
  retention window - grace protects data, not visibility. Restored
  automatically when the file returns.
- Steam Deck SD-card model for unmounted storage: resolveLibrary stats
  each library's folder roots and flags libraries with no live folder
  as Offline (LibraryData gains the field, computed at request time so
  mounts/unmounts react instantly). The bookshelf shows an empty shelf
  plus a 'storage is not connected' notice for an offline selected
  library, and both the shared LibrarySwitcher and the bookshelf's
  inline select label offline libraries with their true holding counts.
  Nothing is marked or purged while offline.
- TotalMediaCount skips offline libraries, so the 'All Books/Libraries'
  totals match what is actually visible.

Verified live: renaming uploads/Manga away produced the notice, an
empty shelf, and the offline dropdown label with a corrected total;
renaming it back restored all 38 cards with zero dirty rows.
2026-09-13 12:00:49 -04:00

1412 lines
45 KiB
Go

package router
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"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))
// Series browse page
frontendProtected.GET("/series", 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
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libData := libRes.Libraries
if libRes.IsAll {
errorMsg = ""
}
perSeriesPage := 24
page := 1
if p := c.QueryParam("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 {
page = v
}
}
offset := (page - 1) * perSeriesPage
var seriesCards []templates.SeriesCardData
totalPages := 1
if errorMsg == "" {
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
if err != nil {
log.Printf("GetSeriesCardsData failed: %v", err)
errorMsg = "Error loading series"
} else {
totalPages = (total + perSeriesPage - 1) / perSeriesPage
if totalPages < 1 {
totalPages = 1
}
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
for _, s := range seriesList {
covers := s.CoverPaths
if covers == nil {
covers = []string{}
}
seriesCards = append(seriesCards, templates.SeriesCardData{
Name: s.Name,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: covers,
})
}
}
}
if seriesCards == nil {
seriesCards = []templates.SeriesCardData{}
}
var buf bytes.Buffer
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Series detail page (books in a specific series)
frontendProtected.GET("/series/detail", 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
seriesName := c.QueryParam("name")
if seriesName == "" {
return renderErrorPage(c, "Series name required", "bad_request")
}
var bookInfoList []handlers.BookInfo
svc := services.NewSeriesService(cfg.Queries)
books, err := svc.GetSeriesBooks(c.Request().Context(), seriesName)
if err != nil {
log.Printf("GetSeriesBooks failed: %v", err)
errorMsg = "Error loading series books"
} else {
bookInfoList = make([]handlers.BookInfo, 0, len(books))
for _, item := range books {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookInfoList = append(bookInfoList, handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
})
}
}
if bookInfoList == nil {
bookInfoList = []handlers.BookInfo{}
}
seriesUserUUID, _ := uuid.Parse(user.ID)
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList)
var buf bytes.Buffer
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.GET("/tags/detail", 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
tagName := c.QueryParam("name")
if tagName == "" {
return renderErrorPage(c, "Tag name required", "bad_request")
}
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
var bookInfoList []handlers.BookInfo
if libraryID != "" && errorMsg == "" {
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
LibraryID: libRes.LibUUID,
Column2: tagName,
})
if err != nil {
log.Printf("GetBooksByTag failed: %v", err)
errorMsg = "Error loading tag books"
} else {
bookInfoList = make([]handlers.BookInfo, 0, len(books))
for _, item := range books {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookInfoList = append(bookInfoList, handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
})
}
}
}
if bookInfoList == nil {
bookInfoList = []handlers.BookInfo{}
}
tagUserUUID, _ := uuid.Parse(user.ID)
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: tagUserUUID, Valid: true}, bookInfoList)
var buf bytes.Buffer
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
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
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libData := libRes.Libraries
// Steam Deck behavior: an offline library (all folders missing, e.g.
// an unmounted external drive) shows an empty shelf with a notice
// instead of querying items. Nothing is marked or purged.
if !libRes.IsAll {
for _, l := range libData {
if l.ID == libraryID && l.Offline {
errorMsg = l.Name + " is currently unavailable - its storage is not connected. Your books are safe and will return when the drive is reattached."
break
}
}
}
// Fetch saved filters for SSR
userUUID, _ := uuid.Parse(user.ID)
var savedFilters []database.SavedFilters
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 errorMsg == "" {
// 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
}
}
params := services.SearchParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: libRes.LibUUID,
SearchQuery: "",
AuthorFilter: "",
SeriesFilter: "",
GenreFilter: "",
TagsFilter: "",
LanguageFilter: "",
YearMin: 0,
YearMax: 0,
HasCover: pgtype.Bool{Valid: false},
Sort: "created_at DESC",
Limit: limit,
Offset: offset,
}
var results []database.SearchMediaItemsUnifiedRow
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
if err != nil {
log.Printf("ExecuteSearch failed: %v", err)
} else {
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),
}
}
}
}
var buf bytes.Buffer
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
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
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
userUUID, _ := uuid.Parse(user.ID)
pgLibUUID := libRes.LibUUID
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, pgLibUUID)
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,
pgLibUUID,
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)
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
allSectionsData := handlers.BuildSections(allSections, libraryID)
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, 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
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
if collection.QueryType.Valid && collection.QueryType.String != "" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
if secErr != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
for _, section := range sections {
if section.CollectionID.String() == collectionID {
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 {
if libraryID != "" && !libRes.IsAll {
libUUID, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
}
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if collErr != nil {
books = []handlers.BookInfo{}
} else {
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 {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if collErr != nil {
books = []handlers.BookInfo{}
}
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,
IsSystem: collection.IsSystemCollection.Bool,
}
var buf bytes.Buffer
err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).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 := cfg.getBaseURL(c.Request().Context())
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")
}
stats := getAdminStats(c.Request().Context(), cfg)
var buf bytes.Buffer
err = templates.Admin(user, stats).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")
}
stats := getAdminStats(c.Request().Context(), cfg)
var buf bytes.Buffer
err = templates.Admin(user, stats).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])
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
TypeValue: lib.TypeName,
FolderCount: len(folders),
}
}
// 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
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
frontendProtected.GET("/admin/libraries/:id/issues", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
libraryID := c.Param("id")
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_id")
}
// Fetch stats
stats, err := cfg.Queries.GetProcessingIssueStats(
c.Request().Context(),
uuidToPGType(libUUID),
)
if err != nil {
return renderErrorPage(c, "Error loading stats", "stats_load_error")
}
// Fetch issues
dbIssues, err := cfg.Queries.ListProcessingIssuesByLibrary(
c.Request().Context(),
uuidToPGType(libUUID),
)
if err != nil {
return renderErrorPage(c, "Error loading issues", "issues_load_error")
}
// Convert to template data
issues := make([]templates.ProcessingIssueData, len(dbIssues))
for i, issue := range dbIssues {
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
issues[i] = templates.ProcessingIssueData{
ID: issueUUID.String(),
MediaItemID: uuid.UUID(issue.MediaItemID.Bytes).String(),
Title: issue.Title,
FilePath: issue.FilePath,
FormatGroup: issue.FormatGroup,
LibraryTypeName: issue.LibraryTypeName,
IssueType: issue.IssueType,
IssueDescription: issue.IssueDescription,
Severity: issue.Severity,
CreatedAt: issue.CreatedAt.Time,
}
}
statsData := templates.IssueStats{
ErrorCount: stats.ErrorCount,
WarningCount: stats.WarningCount,
InfoCount: stats.InfoCount,
}
var buf bytes.Buffer
err = templates.AdminProcessingIssues(user, libraryID, issues, statsData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Admin hash conflicts page: content-duplicate groups flagged during hash
// backfill or rescan, resolved by keeping all copies or merging into one.
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
pending, err := cfg.Queries.ListPendingHashConflicts(c.Request().Context())
if err != nil {
return renderErrorPage(c, "Error loading hash conflicts", "conflicts_load_error")
}
conflicts := make([]templates.HashConflictData, 0, len(pending))
for _, p := range pending {
conflict := templates.HashConflictData{
ID: uuid.UUID(p.ID.Bytes).String(),
LibraryName: p.LibraryName,
SHA256: p.FileSha256,
SHAShort: p.FileSha256[:16] + "…",
CreatedAt: p.CreatedAt.Time.Format("Jan 2, 2006"),
Items: []templates.HashConflictItemData{},
}
items, err := cfg.Queries.ListMediaItemsBySHA256AndLibrary(c.Request().Context(), database.ListMediaItemsBySHA256AndLibraryParams{
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
LibraryID: p.LibraryID,
})
if err != nil {
continue
}
for _, mi := range items {
counts, err := cfg.Queries.GetMediaItemUsageCounts(c.Request().Context(), mi.ID)
if err != nil {
counts = database.GetMediaItemUsageCountsRow{}
}
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
ID: uuid.UUID(mi.ID.Bytes).String(),
Title: mi.Title,
Author: mi.Author.String,
FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64,
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
HasReadingData: totalData > 0,
})
}
conflicts = append(conflicts, conflict)
}
var buf bytes.Buffer
err = templates.AdminHashConflicts(user, conflicts).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")
}
ctx := c.Request().Context()
// Fetch current system configuration - just base_url
baseURL := cfg.getBaseURL(ctx)
systemConfig := map[string]string{
"base_url": baseURL,
"default_timezone": "UTC",
}
defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx)
if err == nil && defaultTimezone != "" {
systemConfig["default_timezone"] = defaultTimezone
}
// Fetch scan settings
scanSettings := templates.ScanSettingsData{
AutoScanEnabled: true,
ScanPollIntervalSeconds: 60,
}
if val, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled"); err == nil {
scanSettings.AutoScanEnabled = val == "true"
}
if val, err := cfg.Queries.GetSystemSetting(ctx, "scan_poll_interval_seconds"); err == nil {
if n, err := strconv.Atoi(val); err == nil {
scanSettings.ScanPollIntervalSeconds = n
}
}
// Load tunable settings entries from the registry. Exclude keys that
// already have their own dedicated UI cards (timezone dropdown, scan
// settings) so they aren't listed twice.
dedicatedUI := map[string]bool{
"default_timezone": true,
"scan_poll_interval_seconds": true,
"auto_scan_enabled": true,
}
var tunableSettings []templates.SettingEntry
if cfg.Settings != nil {
for _, e := range cfg.Settings.All() {
if dedicatedUI[e.Key] {
continue
}
tunableSettings = append(tunableSettings, templates.SettingEntry{
Key: e.Key,
Value: e.Value,
Type: e.Type,
Min: e.Min,
Max: e.Max,
RequiresRestart: e.RequiresRestart,
Category: e.Category,
Group: e.Group,
Description: e.Description,
IsDefault: e.IsDefault,
})
}
}
liveGroups, restartGroups := templates.GroupTunableSettings(tunableSettings)
var buf bytes.Buffer
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &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())
}))
// Admin library HTMX endpoints
registerAdminLibraryRoutes(cfg, frontendProtected)
// Admin settings HTMX endpoints
registerAdminSettingsRoutes(cfg, frontendProtected)
// ============================================================================
// 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 := cfg.getBaseURL(c.Request().Context())
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}
}
// Resolve the on-disk location (library folder + relative path) so the
// detail page can show where the file lives, even for sparse metadata.
// Admin-only: everyday users never receive the absolute path.
fileLocation := ""
if user.Role == "admin" {
fileLocation = mediaItem.FilePath
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
if _, serr := os.Stat(candidate); serr == nil {
fileLocation = candidate
break
}
}
}
}
// 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
FileLocation: fileLocation,
Rating: rating,
Collections: collections,
ReadingProgress: progress,
ActiveConflict: activeConflict,
NotesCount: len(notes),
HighlightsCount: len(highlights),
DeletedAnnotations: handlers.DeletedAnnotationsForBook(c.Request().Context(), cfg.Queries, pgUserID, pgMediaUUID),
}
// 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(),
},
})
}