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.
1412 lines
45 KiB
Go
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(),
|
|
},
|
|
})
|
|
}
|