package router import ( "bytes" "context" "encoding/json" "log" "net/http" "strconv" "time" "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 // 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, "
Invalid collection ID
") } // 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, "
Collection not found
") } // 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 err = templates.AdminLibrary(user, libData, userData).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 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 } } var buf bytes.Buffer err = templates.AdminSettings(user, systemConfig, scanSettings, "").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, "
Invalid user ID
") } // Fetch target user targetUser, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(parsedUUID)) if err != nil { return c.HTML(http.StatusNotFound, "
User not found
") } // 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} } // 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(), }, }) }