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/internal/version" "bookhoard/templates" "errors" "github.com/golang-jwt/jwt/v5" "github.com/jackc/pgx/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, templates.SignupVisible()).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 { // Registration can be disabled by admins. The first-user exception // keeps the page reachable on an unconfigured server (zero admins) โ€” // see templates.SignupVisible for the shared reasoning. Redirect // (not 404) so stale links and bookmarks land somewhere sensible. if !templates.SignupVisible() { return c.Redirect(http.StatusFound, "/login") } 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, templates.SignupVisible()).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) // 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 == "" { // Visible-library scoping: cards are grouped per library, and a // specific library selection only renders if it's in the set. visibleSet, visibleIDs, visErr := visibleLibraryIDsFor(c, cfg) if visErr != nil { log.Printf("visibleLibraryIDsFor failed: %v", visErr) errorMsg = "Error loading series" } else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] { errorMsg = "Library not accessible" } else { seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, 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, LibraryID: s.LibraryID, 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") } // Series are per-library now: the detail page must say WHICH library's // series it is showing, and the requester must be able to see it. libraryIDParam := c.QueryParam("library_id") if libraryIDParam == "" { return renderErrorPage(c, "Library required", "bad_request") } libUUIDParam, err := uuid.Parse(libraryIDParam) if err != nil { return renderErrorPage(c, "Invalid library", "bad_request") } visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg) if visErr != nil { return renderErrorPage(c, "Error checking access", "access_error") } if !visibleSet[libraryIDParam] { return renderErrorPage(c, "Access denied", "access_denied") } var bookInfoList []handlers.BookInfo svc := services.NewSeriesService(cfg.Queries) books, err := svc.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUIDParam, Valid: true}, seriesName) if err != nil { log.Printf("GetSeriesBooks failed: %v", err) errorMsg = "Error loading series books" } else { bookInfoList = handlers.BookCardsFromMediaItems(books, handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)) } 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 = handlers.BookCardsFromMediaItems(books, handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)) } } 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 = handlers.BookCardsFromUnified(results) } } 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) // Active library type drives the per-type wording (A2c): system // collection titles and stat labels swap on audiobooks scope; the // "All libraries" view keeps generic wording. activeLibType := "" if !libRes.IsAll { for _, ld := range libRes.Libraries { if ld.ID == libraryID { activeLibType = ld.TypeValue break } } } libTypes := map[string]string{} for _, ld := range libRes.Libraries { libTypes[ld.ID] = ld.TypeValue } userPgID := pgtype.UUID{Bytes: userUUID, Valid: true} sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID, activeLibType, libTypes)) allSectionsData := handlers.BuildSections(allSections, libraryID, activeLibType, libTypes) // "This week" widget: the user's rolling 7-day reading pulse for // the CURRENTLY SELECTED library (the same resolution the sections // use), from the same session seed the /stats page serves. // Suppressed when the service fails (dashboard always renders). var week *templates.WeekStatsData if cfg.StatisticsService != nil { weekScope := pgtype.UUID{} if !libRes.IsAll { weekScope = libRes.LibUUID } weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}, user.Timezone, weekScope) if weekErr == nil { wk := weekStatsData(weekSvc, libRes.LibraryID) week = &wk } else { log.Printf("ThisWeek widget failed: %v", weekErr) } } userUUID, userUUIDErr := uuid.Parse(user.ID) if userUUIDErr != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } unlinkedCount, countErr := cfg.Queries.CountUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) if countErr != nil { log.Printf("CountUnresolvedUnlinkedBooksForUser failed: %v", countErr) unlinkedCount = 0 } var buf bytes.Buffer err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg, int(unlinkedCount)).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" } // Live per-card counts: smart collections are rule-matched at // render against the requesting user's visible libraries; static // ones count their stored links. Library filter applies when a // specific library is selected. libRes := resolveLibrary(c, cfg, user.ID) var libScope pgtype.UUID if !libRes.IsAll { if parsed, parseErr := uuid.Parse(libRes.LibraryID); parseErr == nil { libScope = pgtype.UUID{Bytes: parsed, Valid: true} } } visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c) if visErr != nil { log.Printf("UserVisibleLibraryIDs failed: %v", visErr) visibleIDs = nil } colData := make([]templates.CollectionData, len(collections)) colService := cfg.CollectionHandler.CollectionService() for i, col := range collections { isSmart := colService.IsSmartCollection(col.AutoAssignRules) bookCount := 0 if isSmart { matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{ LibraryID: libScope, VisibleIDs: visibleIDs, }) if evalErr == nil { bookCount = len(matches) } } else { count, countErr := cfg.Queries.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{ CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true}, LibraryID: libScope, }) if countErr == nil { bookCount = int(count) } } colData[i] = templates.CollectionData{ ID: uuid.UUID(col.ID.Bytes).String(), Name: col.Name, Description: utils.PgTextString(col.Description), Color: utils.PgTextString(col.Color), Icon: utils.PgTextString(col.Icon), IsSystem: col.IsSystemCollection.Bool, IsSmart: isSmart, BookCount: bookCount, } } 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: utils.PgTextString(collection.Description), Color: utils.PgTextString(collection.Color), Icon: utils.PgTextString(collection.Icon), } // A2c ยง3.3: playlist toggles from view_settings. if len(collection.ViewSettings) > 0 { var vs map[string]interface{} if json.Unmarshal(collection.ViewSettings, &vs) == nil { if b, ok := vs["hide_finished"].(bool); ok { colData.HideFinished = b } if b, ok := vs["show_up_next"].(bool); ok { colData.ShowUpNext = b } } } // 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()) }) // Smart collection rules builder page (the smart editor). User // collections only: system sections keep their hardwired queries. frontendProtected.GET("/collections/:id/rules", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } collectionID := c.Param("id") collUUID, err := uuid.Parse(collectionID) if err != nil { return renderErrorPage(c, "Invalid collection ID", "invalid_id") } collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return renderErrorPage(c, "Collection not found", "not_found") } return renderErrorPage(c, "Error loading collection", "collection_load_error") } if collection.IsSystemCollection.Bool { return renderErrorPage(c, "System collections cannot have rules", "not_allowed") } colData := templates.CollectionData{ ID: collectionID, Name: collection.Name, Description: utils.PgTextString(collection.Description), Color: utils.PgTextString(collection.Color), Icon: utils.PgTextString(collection.Icon), IsSystem: collection.IsSystemCollection.Bool, IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules), } var buf bytes.Buffer err = templates.CollectionRules(user, colData).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) // Collection detail page. Book loading lives in loadCollectionBooks โ€” // shared verbatim with the books-grid partial below so the SSR page and // the partial can never drift (rendering-ownership rule). 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") } collectionID := c.Param("id") collUUID, err := uuid.Parse(collectionID) if err != nil { return renderErrorPage(c, "Invalid collection ID", "invalid_id") } 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") } books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID) if errCode != "" { msg := map[string]string{ "books_load_error": "Error loading books", "invalid_library_id": "Invalid library ID", "invalid_id": "Invalid collection ID", }[errCode] return renderErrorPage(c, msg, errCode) } colData := collectionDataFromRow(collection, collectionID, isSmart, len(books)) var buf bytes.Buffer err = templates.CollectionDetail(user, colData, books, libRes.LibraryID, libRes.Libraries).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) // Books-grid partial: the collection detail page's #books-container // content. The library switcher swaps this in (htmx.ajax) instead of // re-rendering the grid client-side from JSON. Family-lens scoped via // loadCollectionBooksLite โ€” the requested library must be in the // caller's visible set, without the page-only library resolution. frontendProtected.GET("/collections/:id/books-grid", func(c *echo.Context) error { user, ok := c.Get("user").(database.Users) if !ok { return c.HTML(http.StatusUnauthorized, "
Unauthorized
") } userUUID, uuidErr := uuid.Parse(user.ID.String()) if uuidErr != nil { return c.HTML(http.StatusUnauthorized, "
Unauthorized
") } collectionID := c.Param("id") collUUID, parseErr := uuid.Parse(collectionID) if parseErr != nil { return c.HTML(http.StatusBadRequest, "
Invalid collection ID
") } 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
") } books, isSmart, errCode := cfg.loadCollectionBooksLite(c, userUUID, collection, collectionID) if errCode == "library_forbidden" { return c.HTML(http.StatusForbidden, "
Library not available
") } if errCode != "" { return c.HTML(http.StatusBadRequest, "
Error loading books
") } colData := collectionDataFromRow(collection, collectionID, isSmart, len(books)) var buf bytes.Buffer if renderErr := templates.CollectionBooksGrid(colData, books).Render(c.Request().Context(), &buf); renderErr != nil { return renderErr } return c.HTML(http.StatusOK, buf.String()) }) // Week-widget fragment: the collapsible "This Week" strip re-fetches // itself here (htmx) whenever the library switcher dispatches // week-refresh, carrying the current #library-select value. Same // resolution rules as the dashboard page (family-lens safe). // Unlinked-books page: the identification queue for device files the // sync could not match (personal data โ€” the caller's own devices only). frontendProtected.GET("/unlinked-books", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } userUUID, userUUIDErr := uuid.Parse(user.ID) if userUUIDErr != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } rows, listErr := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) if listErr != nil { log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr) rows = []database.ListUnresolvedUnlinkedBooksForUserRow{} } books := make([]templates.UnlinkedBookData, 0, len(rows)) for _, r := range rows { books = append(books, templates.UnlinkedBookData{ ProgressID: uuid.UUID(r.ID.Bytes).String(), DeviceID: uuid.UUID(r.DeviceID.Bytes).String(), DeviceType: r.DeviceType, DeviceName: r.DeviceName, FilePath: utils.PgTextString(r.FilePath), SHA256: r.ContentID, TitleFromDevice: utils.PgTextString(r.Title), LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone), ConfidenceScore: r.ConfidenceScore.Float64, }) } var buf bytes.Buffer if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil { return renderErr } return c.HTML(http.StatusOK, buf.String()) }) // Unlinked-book match results partial: the match cascade for one device // file, rendered server-side and scoped to the requester's visible // libraries (absent-from-results) โ€” rendering-ownership: templ renders, // JS only swaps. NOTE: nothing routes to the unlinked-books page yet; // this endpoint is ready for whichever UI wires it up. frontendProtected.GET("/unlinked-books/matches", func(c *echo.Context) error { user, ok := c.Get("user").(database.Users) if !ok { return c.HTML(http.StatusUnauthorized, "
Unauthorized
") } userUUID, uuidErr := uuid.Parse(user.ID.String()) if uuidErr != nil { return c.HTML(http.StatusUnauthorized, "
Unauthorized
") } set, _, setErr := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) if setErr != nil { return c.HTML(http.StatusInternalServerError, "
Failed to resolve visible libraries
") } visibleIDs := make([]string, 0, len(set)) for id := range set { visibleIDs = append(visibleIDs, id) } req := &services.BookQueryRequest{ SHA256: c.QueryParam("sha256"), Title: c.QueryParam("title"), VisibleLibraryIDs: visibleIDs, } resp, matchErr := services.NewBookMatchingService(cfg.Queries).QueryBooks(c.Request().Context(), req) if matchErr != nil { return c.HTML(http.StatusInternalServerError, "
Failed to search
") } var buf bytes.Buffer if renderErr := templates.UnlinkedMatchCards(c.QueryParam("progress_id"), resp.Matches).Render(c.Request().Context(), &buf); renderErr != nil { return renderErr } return c.HTML(http.StatusOK, buf.String()) }) frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } if cfg.StatisticsService == nil { return echo.NewHTTPError(http.StatusServiceUnavailable, "stats unavailable") } libRes := resolveLibrary(c, cfg, user.ID) weekScope := pgtype.UUID{} if !libRes.IsAll { weekScope = libRes.LibUUID } userUUID, _ := uuid.Parse(user.ID) weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(), uuidToPGType(userUUID), user.Timezone, weekScope) if weekErr != nil { return echo.NewHTTPError(http.StatusInternalServerError, weekErr.Error()) } var buf bytes.Buffer widgetLibType := "" if !libRes.IsAll { for _, ld := range libRes.Libraries { if ld.ID == libRes.LibraryID { widgetLibType = ld.TypeValue break } } } err = templates.DashboardWeekWidget(weekStatsData(weekSvc, libRes.LibraryID), widgetLibType).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: utils.PgTextString(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()) }) // Conflicts list partial: fresh list plus the stats bar as an // out-of-band swap. The client refreshes through this instead of // re-rendering conflict cards from JSON (rendering-ownership rule: // templ renders, JS only swaps). frontendProtected.GET("/conflicts/list", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return c.HTML(http.StatusUnauthorized, "
Unauthorized
") } conflictsData, total, unresolved, dataErr := cfg.ConflictHandler.GetConflictsData(c) if dataErr != nil { log.Printf("GetConflictsData failed: %v", dataErr) conflictsData = []handlers.ConflictDetailResponse{} total = 0 unresolved = 0 } var buf bytes.Buffer if renderErr := templates.ConflictsRefreshFragment(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf); renderErr != nil { return renderErr } return c.HTML(http.StatusOK, buf.String()) }) // Analytics page โ†’ /stats (the rebuilt reading-statistics page; // nobody bookmarks orphan pages twice). frontendProtected.GET("/analytics", func(c *echo.Context) error { return c.Redirect(http.StatusMovedPermanently, "/stats") }) // Reading statistics page โ€” per-user by default, with library // breakdown; the per-book tier lives on the book detail page and the // dashboard carries the "this week" widget. frontendProtected.GET("/stats", 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.Stats(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/archived", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } rows, err := cfg.Queries.ListHiddenMediaItems(c.Request().Context()) if err != nil { log.Printf("ListHiddenMediaItems failed: %v", err) rows = []database.ListHiddenMediaItemsRow{} } retentionDays := config.ArchiveRetentionDays() items := make([]templates.ArchivedItem, 0, len(rows)) for _, row := range rows { itemUUID, _ := uuid.FromBytes(row.ID.Bytes[0:16]) item := templates.ArchivedItem{ ID: itemUUID.String(), Title: row.Title, Author: utils.PgTextString(row.Author), LibraryName: row.LibraryName, FilePath: row.FilePath, MissingScans: row.MissingScanCount, } if row.MatchID.Valid { item.MatchID = uuid.UUID(row.MatchID.Bytes).String() item.MatchTitle = row.MatchTitle item.MatchLibraryName = utils.PgTextString(row.MatchLibraryName) } if row.ArchivedAt.Valid { archived := row.ArchivedAt.Time item.ArchivedAt = &archived switch { case retentionDays <= 0: item.Status = "Archived - kept until manually purged (retention disabled)" default: purge := archived.AddDate(0, 0, retentionDays) item.PurgeAt = &purge item.Status = "Archived - permanently deleted " + purge.Format("Jan 2, 2006") } } else { item.Status = fmt.Sprintf("Missing from disk - archives on the next scan (%d/2)", row.MissingScanCount) } items = append(items, item) } var buf bytes.Buffer if err := templates.AdminArchived(user, items, retentionDays).Render(c.Request().Context(), &buf); 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: utils.PgTextString(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()) libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg) err = templates.AdminLibrary(user, libData, userData, archivedCount, libraryTypes).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") } // Family lens: issues leak media metadata (titles, file paths) โ€” // a library outside the requester's visible set renders nothing. visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg) if visErr != nil { return renderErrorPage(c, "Error checking library access", "visibility_error") } if !visibleSet[libraryID] { return renderErrorPage(c, "Library not accessible", "library_not_accessible") } // 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 (row-less import failures have no // media item: empty id, and the title falls back to the path). issues := make([]templates.ProcessingIssueData, len(dbIssues)) for i, issue := range dbIssues { issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16]) itemID := "" if issue.MediaItemID.Valid { itemID = uuid.UUID(issue.MediaItemID.Bytes).String() } title := issue.Title if title == "" { title = issue.FilePath } issues[i] = templates.ProcessingIssueData{ ID: issueUUID.String(), MediaItemID: itemID, Title: 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.In(utils.UserLocation(user.Timezone)).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, totalData := handlers.MediaItemUsageCountsTotal(c.Request().Context(), cfg.Queries, mi.ID) 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: utils.PgTextString(u.Theme), FirstName: utils.PgTextString(u.FirstName), LastName: utils.PgTextString(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, "
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: utils.PgTextString(targetUser.Theme), FirstName: utils.PgTextString(targetUser.FirstName), LastName: utils.PgTextString(targetUser.LastName), CreatedAt: targetUser.CreatedAt.Time, // Without this the timezone select rendered UTC selected for // EVERY user โ€” saves landed, the modal just never showed them. Timezone: utils.PgTextString(targetUser.Timezone), } // 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 the user's review (raw markdown; rendered client-side // through the notes pipeline). var review *database.MediaReviews userReview, err := cfg.Queries.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, }) if err == nil { review = &userReview } // 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 { detail := handlers.NewConflictDetail(conf.ID, conf.MediaItemID, mediaItem.Title, conf.ConflictType, conf.ResolutionStatus.String, conflictData, conf.CreatedAt, conf.ResolvedAt, conf.ResolvedBy, conf.ResolutionData) activeConflict = &detail } 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, }) // Per-book reading stats (the viewer's own history card) โ€” nil // keeps the card off the page for untouched books / other users. var bookStats *handlers.BookStatsData if cfg.StatisticsService != nil { if svcStats, serr := cfg.StatisticsService.BookStats(c.Request().Context(), pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 { bookStats = &handlers.BookStatsData{ TotalMinutes: svcStats.TotalMinutes, TotalSeconds: svcStats.TotalSeconds, TotalPages: svcStats.TotalPages, SessionCount: svcStats.SessionCount, MaxProgress: svcStats.MaxProgress, Finished: svcStats.Finished, FirstSession: svcStats.FirstSession, LastSession: svcStats.LastSession, } } } // Assemble response (no field duplication!) detail := handlers.MediaDetail{ MediaItems: mediaItem, // Embedded - ALL fields available FileLocation: fileLocation, Rating: rating, Review: review, Collections: collections, ReadingProgress: progress, BookStats: bookStats, ActiveConflict: activeConflict, NotesCount: len(notes), HighlightsCount: len(highlights), DeletedAnnotations: handlers.DeletedAnnotationsForBook(c.Request().Context(), cfg.Queries, pgUserID, pgMediaUUID), } // Render template var buf bytes.Buffer // The deleted-annotations modal states the retention in plain days // (the tunable's user-facing value), never as a settings name. tombstoneDays := cfg.Settings.TombstoneTTL() / (24 * time.Hour) err = templates.BookDetail(user, detail, "", int(tombstoneDays)).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", "version": version.Version, "database": "connected", "scan": map[string]interface{}{ "scan_in_progress": cfg.Worker.HasActiveScans(), "active_jobs": cfg.Worker.GetActiveJobCount(), }, }) } // weekStatsData maps the statistics weekly aggregate to the widget's // template shape (shared by the dashboard page and the HTMX // week-widget fragment). func weekStatsData(weekSvc services.WeekSummaryStats, libraryID string) templates.WeekStatsData { return templates.WeekStatsData{ WeekMinutes: weekSvc.WeekMinutes, WeekPages: weekSvc.WeekPages, WeekSessions: weekSvc.WeekSessions, WeekBooks: weekSvc.WeekBooks, PrevMinutes: weekSvc.PrevMinutes, ActiveToday: weekSvc.ActiveToday, CurrentStreak: weekSvc.CurrentStreak, LibraryID: libraryID, } }