diff --git a/bruno/NewDB.sh b/bruno/NewDB.sh index 03cc26f..18dd9ec 100755 --- a/bruno/NewDB.sh +++ b/bruno/NewDB.sh @@ -1,3 +1,3 @@ #!/bin/bash -bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml" +bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml" diff --git a/bruno/NewDevDBSetup/SetBaseUrl.yml b/bruno/NewDevDBSetup/SetBaseUrl.yml new file mode 100644 index 0000000..27ba77d --- /dev/null +++ b/bruno/NewDevDBSetup/SetBaseUrl.yml @@ -0,0 +1,38 @@ +info: + name: SetBaseUrl + type: http + seq: 3 + +http: + method: PUT + url: '{{base_url}}/api/system/config' + auth: inherit + body: + type: json + jsonBody: |- + { + "base_url": "http://localhost:8765" + } + headers: + - key: Authorization + value: Bearer {{token}} + - key: Content-Type + value: application/json + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Set Base URL + + Configures the server's base_url during initial dev database setup. + + Must be run after RegisterUser (which provides the auth token) and before + any library/device creation (which require setup to be complete). + + **Method:** PUT + **Endpoint:** /api/system/config + **Auth:** Bearer token (from RegisterUser) diff --git a/cmd/server/main.go b/cmd/server/main.go index 6ccd173..b470e10 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,6 +14,8 @@ import ( "log" "time" + _ "time/tzdata" + "github.com/go-playground/validator/v10" "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v5" @@ -48,6 +50,35 @@ func main() { } log.Println("✅ Database schema initialized and verified, starting server...") + // Seed base_url from env var if not already configured. Uses conditional + // UPDATE so admin-set values are never overwritten on restart. + if cfg.BaseURL != "" { + _, err = dbPool.Exec(ctx, ` + INSERT INTO system_config (key, value) + VALUES ('base_url', $1) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value + WHERE system_config.value = '' + `, cfg.BaseURL) + if err != nil { + log.Printf("⚠️ Could not seed base_url: %v", err) + } else { + // Also seed derived URLs + for key, suffix := range map[string]string{ + "opds_base_url": "/opds", + "api_base_url": "/api", + } { + _, _ = dbPool.Exec(ctx, ` + INSERT INTO system_config (key, value) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value + WHERE system_config.value = '' + `, key, cfg.BaseURL+suffix) + } + } + } + // Create login attempt tracker: 5 failed attempts = 15 minute lockout loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 9d72688..0629cf6 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -1149,12 +1149,14 @@ CREATE TABLE IF NOT EXISTS system_config ( updated_by UUID REFERENCES users(id) ); --- Pre-seeded values -INSERT INTO system_config (key, value) VALUES -('base_url', 'https://bookhoard.example.com'), -('opds_base_url', 'https://bookhoard.example.com/opds'), -('api_base_url', 'https://bookhoard.example.com/api') -ON CONFLICT (key) DO NOTHING; +-- One-time cleanup: clear the old placeholder seed so the startup logic +-- can re-seed from the BASE_URL env var (or the setup wizard can set it). +UPDATE system_config SET value = '' +WHERE key = 'base_url' AND value = 'https://bookhoard.example.com'; +UPDATE system_config SET value = '' +WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds'; +UPDATE system_config SET value = '' +WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api'; -- Create opds_tokens table (device-specific OPDS access tokens) CREATE TABLE IF NOT EXISTS opds_tokens ( diff --git a/internal/config/config.go b/internal/config/config.go index a68adf9..137334f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,30 +45,19 @@ func (c *Config) DatabaseURL() string { c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName) } -// GetBaseURL returns the base URL from system configuration database with fallback to config/env var -func GetBaseURL(ctx context.Context, db interface{}) string { - // Try to get from database first - type SystemConfigQuerier interface { - GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error) - } +// SystemConfigGetter returns the value for a system config key, or an error. +type SystemConfigGetter func(ctx context.Context, key string) (string, error) - if querier, ok := db.(SystemConfigQuerier); ok { - config, err := querier.GetSystemConfig(ctx, "base_url") - if err == nil && config.Value != "" { - return config.Value - } +// GetBaseURL returns the base URL from system configuration database, or empty +// string if not set. The getter abstraction avoids importing the database package. +func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string { + val, err := getter(ctx, "base_url") + if err == nil && val != "" { + return val } - - // Fallback: return empty string - caller should use their own fallback return "" } -// SystemConfigRow represents a system configuration row -type SystemConfigRow struct { - Key string - Value string -} - func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index ae548ef..a0fb939 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -73,6 +73,7 @@ type BookInfo struct { Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` + HasConflict bool `json:"has_conflict"` } type SectionData struct { diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index e6fcc47..c163d33 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -4,6 +4,7 @@ import ( "bookhoard/internal/database" "bookhoard/internal/services" "bookhoard/internal/utils" + "context" "log" "net/http" "strconv" @@ -64,6 +65,7 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error { } sectionData := BuildSections(sections, libraryID) + sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData) return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData}) } @@ -188,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string return result } +// activeConflictSet returns the set of media item IDs (as strings) that have an +// active (unresolved) progress sync conflict for the given user. A single query +// is issued; resolved conflicts are filtered out in memory. +func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool { + conflicts, err := db.ListSyncConflictsByUser(ctx, userID) + if err != nil { + return nil + } + set := make(map[string]bool, len(conflicts)) + for _, c := range conflicts { + if c.ResolutionStatus.String == "unresolved" { + set[uuid.UUID(c.MediaItemID.Bytes).String()] = true + } + } + return set +} + +// MarkActiveConflicts stamps HasConflict on each book whose media item has an +// active progress sync conflict for the user. It performs a single query +// regardless of how many books are passed. +func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo { + if len(books) == 0 { + return books + } + set := activeConflictSet(ctx, db, userID) + for i := range books { + if set[books[i].MediaItemID] { + books[i].HasConflict = true + } + } + return books +} + +// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts, +// used by the dashboard which renders books grouped into sections. +func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData { + if len(sections) == 0 { + return sections + } + set := activeConflictSet(ctx, db, userID) + if len(set) == 0 { + return sections + } + for s := range sections { + for i := range sections[s].Items { + if set[sections[s].Items[i].MediaItemID] { + sections[s].Items[i].HasConflict = true + } + } + } + return sections +} + func getViewAllURL(collectionID string, libraryID string) string { if collectionID != "" { if libraryID != "" { diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index 1d23b74..69287d6 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -38,15 +38,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic } } -// Helper function to get base URL from system config +// Helper function to get base URL from system config with request-derived fallback func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) { - baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url") - if err != nil { - return "", "", fmt.Errorf("failed to get base_url from config: %w", err) + var dbBaseURL string + if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil { + dbBaseURL = config.Value } - - opdsBaseURL := baseURL.Value + "/opds" - return baseURL.Value, opdsBaseURL, nil + baseURL := deriveBaseURL(c, dbBaseURL) + opdsBaseURL := baseURL + "/opds" + return baseURL, opdsBaseURL, nil } func (h *OPDSHandler) getAuthToken(c *echo.Context) string { diff --git a/internal/handlers/progress.go b/internal/handlers/progress.go index 0af5ea9..f0de199 100644 --- a/internal/handlers/progress.go +++ b/internal/handlers/progress.go @@ -374,6 +374,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro deviceName = progress.LastSyncDevice.String } + lastUpdated := "" + if progress.LastReadAt.Valid { + lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM") + } + progressList = append(progressList, ProgressWithMedia{ MediaItemID: progress.MediaItemID.Bytes, Title: mediaItem.Title, @@ -386,6 +391,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro Epubcfi: epubcfi, LastSyncDevice: deviceName, ProgressPercentage: progress.Percentage.Float64 * 100, + EpubCFI: epubcfi, + LastUpdated: lastUpdated, + DeviceIcon: getDeviceIcon(deviceName), + DeviceName: deviceName, + DeviceType: deviceName, FormatGroup: mediaItem.FormatGroup, EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64), }) diff --git a/internal/handlers/sidecar.go b/internal/handlers/sidecar.go index 678397e..bf36b06 100644 --- a/internal/handlers/sidecar.go +++ b/internal/handlers/sidecar.go @@ -3,6 +3,7 @@ package handlers import ( "bookhoard/internal/config" "bookhoard/internal/database" + "bookhoard/internal/setupstatus" "encoding/json" "fmt" "net/http" @@ -81,15 +82,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error { userID := device.UserID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} - // Get base URL and compute paths - baseURL, _ := h.db.GetSystemConfig(ctx, "base_url") - if baseURL.Value == "" { - baseURL.Value = h.cfg.BaseURL - } + // Get base URL and compute paths (with request-derived fallback) + dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url") + baseURL := deriveBaseURL(c, dbBaseURL.Value) // Generate URLs - opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String()) - syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value) + opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String()) + syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL) // Get user's visible libraries with media items mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) @@ -176,8 +175,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error { Bookhoard: SidecarBookhoardConfig{ OPDSCatalog: opdsCatalogURL, SyncAPI: syncAPIURL, - OPDSBaseURL: baseURL.Value + "/opds", - APIBaseURL: baseURL.Value + "/api", + OPDSBaseURL: baseURL + "/opds", + APIBaseURL: baseURL + "/api", DeviceID: deviceID.String(), DeviceToken: device.AuthToken, }, @@ -219,15 +218,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error { userID := device.UserID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} - // Get base URL and compute paths - baseURL, _ := h.db.GetSystemConfig(ctx, "base_url") - if baseURL.Value == "" { - baseURL.Value = h.cfg.BaseURL - } + // Get base URL and compute paths (with request-derived fallback) + dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url") + baseURL := deriveBaseURL(c, dbBaseURL.Value) // Generate URLs - opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String()) - syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value) + opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String()) + syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL) // Get user's visible libraries with media items mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) @@ -309,8 +306,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error { Bookhoard: SidecarBookhoardConfig{ OPDSCatalog: opdsCatalogURL, SyncAPI: syncAPIURL, - OPDSBaseURL: baseURL.Value + "/opds", - APIBaseURL: baseURL.Value + "/api", + OPDSBaseURL: baseURL + "/opds", + APIBaseURL: baseURL + "/api", DeviceID: deviceID.String(), DeviceToken: device.AuthToken, }, @@ -434,6 +431,10 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error { }) } } + + // Invalidate setup status cache so the middleware picks up the new + // base_url immediately (setup is not complete until base_url is set). + setupstatus.Invalidate() } // Check for HTMX request diff --git a/internal/handlers/url.go b/internal/handlers/url.go new file mode 100644 index 0000000..eaaa21d --- /dev/null +++ b/internal/handlers/url.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "strings" + + "github.com/labstack/echo/v5" +) + +// deriveBaseURL returns the base URL to use for constructing self-referential +// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured +// base_url when available, and falls back to deriving the URL from the incoming +// HTTP request (Host header + scheme), which is always reachable by the client. +// +// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so +// that deployments behind TLS-terminating reverse proxies advertise the correct +// external URL. +func deriveBaseURL(c *echo.Context, dbBaseURL string) string { + if dbBaseURL != "" { + return strings.TrimRight(dbBaseURL, "/") + } + + scheme := "http" + if c.Request().TLS != nil { + scheme = "https" + } + if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + + host := c.Request().Host + if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" { + host = forwarded + } + + return scheme + "://" + host +} diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 1dd0b1e..1ee0774 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -9,7 +9,6 @@ import ( "strconv" "time" - "bookhoard/internal/config" "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" @@ -215,6 +214,9 @@ func registerFrontendRoutes(cfg *Config) { 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 { @@ -267,6 +269,9 @@ func registerFrontendRoutes(cfg *Config) { 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 { @@ -354,6 +359,7 @@ func registerFrontendRoutes(cfg *Config) { } 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 @@ -408,7 +414,8 @@ func registerFrontendRoutes(cfg *Config) { // Get only visible sections for the dashboard display visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections) - sectionData := handlers.BuildSections(visibleSections, libraryID) + 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 @@ -623,6 +630,7 @@ func registerFrontendRoutes(cfg *Config) { Description: collection.Description.String, Color: collection.Color.String, Icon: collection.Icon.String, + IsSystem: collection.IsSystemCollection.Bool, } var buf bytes.Buffer @@ -725,10 +733,7 @@ func registerFrontendRoutes(cfg *Config) { } // Get base URL from database config with fallback to config/env var - baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) - if baseURL == "" { - baseURL = cfg.Cfg.BaseURL - } + baseURL := cfg.getBaseURL(c.Request().Context()) var buf bytes.Buffer err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf) @@ -986,10 +991,7 @@ func registerFrontendRoutes(cfg *Config) { } // Fetch current system configuration - just base_url - baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) - if baseURL == "" { - baseURL = cfg.Cfg.BaseURL - } + baseURL := cfg.getBaseURL(c.Request().Context()) systemConfig := map[string]string{ "base_url": baseURL, @@ -1077,10 +1079,7 @@ func registerFrontendRoutes(cfg *Config) { } // Get base URL from database config with fallback to config/env var - baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) - if baseURL == "" { - baseURL = cfg.Cfg.BaseURL - } + baseURL := cfg.getBaseURL(c.Request().Context()) var buf bytes.Buffer err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf) diff --git a/internal/router/router.go b/internal/router/router.go index 9e2f20d..4c956ab 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -72,6 +72,24 @@ type Config struct { LibraryService *services.LibraryService } +// getBaseURL returns the configured base URL from the database, falling back to +// the env var / config default. Uses a closure to adapt the database query to +// config.SystemConfigGetter. +func (cfg *Config) getBaseURL(ctx context.Context) string { + getter := func(ctx context.Context, key string) (string, error) { + row, err := cfg.Queries.GetSystemConfig(ctx, key) + if err != nil { + return "", err + } + return row.Value, nil + } + baseURL := config.GetBaseURL(ctx, getter) + if baseURL == "" { + baseURL = cfg.Cfg.BaseURL + } + return baseURL +} + // createJWTMiddleware creates a JWT middleware with proper user context setup func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc { return echojwt.WithConfig(echojwt.Config{ diff --git a/internal/router/search.go b/internal/router/search.go index 8602df5..d3cd95e 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -112,9 +112,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error { CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath), } } - // Render using BooksGrid template + // Stamp active conflict flags so cards route the play action correctly + bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList) + // Render using BooksGrid template (or BookPickerGrid for collection picker) var buf bytes.Buffer - err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf) + if c.QueryParam("show_checkbox") == "true" { + err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf) + } else { + err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf) + } if err != nil { log.Printf("Template render error: %v", err) return c.HTML(http.StatusInternalServerError, `
Render error
`) diff --git a/internal/router/setup.go b/internal/router/setup.go index d951635..cd9f71a 100644 --- a/internal/router/setup.go +++ b/internal/router/setup.go @@ -14,7 +14,40 @@ import ( ) func isSetupComplete(cfg *Config) bool { - return setupstatus.IsSetupComplete(context.Background(), cfg.Queries) + getter := func(ctx context.Context) (string, error) { + row, err := cfg.Queries.GetSystemConfig(ctx, "base_url") + if err != nil { + return "", err + } + return row.Value, nil + } + return setupstatus.IsSetupComplete(context.Background(), cfg.Queries, getter) +} + +// setupAllowedAPIRoutes lists API endpoints that remain accessible before +// initial setup is complete so the server can be configured via API. +var setupAllowedAPIRoutes = []string{ + "/api/auth/register", + "/api/auth/login", + "/api/system/config", +} + +// isAllowedDuringSetup reports whether a request path should bypass the setup +// gate. This includes the setup page itself, static assets, health checks, and +// the minimal set of API routes needed to perform initial configuration. +func isAllowedDuringSetup(path string) bool { + if path == "/setup" || path == "/setup/" { + return true + } + if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" { + return true + } + for _, route := range setupAllowedAPIRoutes { + if path == route || strings.HasPrefix(path, route+"/") { + return true + } + } + return false } func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc { @@ -22,19 +55,16 @@ func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc { return func(c *echo.Context) error { path := c.Request().URL.Path - if path == "/setup" || path == "/setup/" { - return next(c) - } - - if strings.HasPrefix(path, "/api/") { - return next(c) - } - - if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" { + if isAllowedDuringSetup(path) { return next(c) } if !isSetupComplete(cfg) { + if strings.HasPrefix(path, "/api/") { + return c.JSON(http.StatusServiceUnavailable, map[string]string{ + "error": "Server setup is not complete. Configure an admin account and base_url via the setup wizard or API.", + }) + } return c.Redirect(http.StatusFound, "/setup") } diff --git a/internal/setupstatus/status.go b/internal/setupstatus/status.go index d95fc19..e91b803 100644 --- a/internal/setupstatus/status.go +++ b/internal/setupstatus/status.go @@ -1,8 +1,9 @@ // Package setupstatus reports whether the application's initial setup has been -// completed. Setup is considered complete as soon as at least one admin user -// exists, regardless of how that user was created (setup wizard, API, or a -// future CLI). This keeps the setup gate a derived property of real data -// rather than a manually-flipped flag that can drift out of sync. +// completed. Setup is considered complete when at least one admin user exists +// AND a non-empty base_url has been configured, regardless of how those were +// created (setup wizard, API, or a future CLI). This keeps the setup gate a +// derived property of real data rather than a manually-flipped flag that can +// drift out of sync. package setupstatus import ( @@ -18,6 +19,12 @@ type AdminCounter interface { CountAdmins(ctx context.Context) (int64, error) } +// BaseURLGetter returns the configured base_url value from the database, or an +// error if it cannot be read. Defined as a function type (not an interface) so +// it can be satisfied by a closure wrapping *database.Queries.GetSystemConfig +// without importing the database package. +type BaseURLGetter func(ctx context.Context) (string, error) + var ( cacheMu sync.RWMutex cacheComplete bool = true @@ -26,10 +33,11 @@ var ( ) // IsSetupComplete reports whether setup is complete. Setup is complete when at -// least one admin user exists. A short in-memory cache avoids hammering the -// database on every request. On a database error the function fails open -// (returns true) so a transient outage does not lock users out of the app. -func IsSetupComplete(ctx context.Context, q AdminCounter) bool { +// least one admin user exists AND base_url is configured. A short in-memory +// cache avoids hammering the database on every request. On a database error the +// function fails open (returns true) so a transient outage does not lock users +// out of the app. +func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLGetter) bool { cacheMu.RLock() if time.Now().Before(cacheExpiry) { complete := cacheComplete @@ -38,12 +46,20 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool { } cacheMu.RUnlock() - count, err := q.CountAdmins(ctx) complete := true + + count, err := q.CountAdmins(ctx) if err == nil { complete = count > 0 } + if complete && baseURLGetter != nil { + baseURL, err := baseURLGetter(ctx) + if err == nil { + complete = baseURL != "" + } + } + cacheMu.Lock() cacheComplete = complete cacheExpiry = time.Now().Add(cacheTTL) @@ -53,7 +69,8 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool { // Invalidate clears the cached setup status so the next call to IsSetupComplete // re-reads from the database. Call this after any write that could change the -// admin user count (user creation, role promotion/demotion, user deletion). +// admin user count (user creation, role promotion/demotion, user deletion) or +// the base_url configuration. func Invalidate() { cacheMu.Lock() cacheComplete = true diff --git a/tailwind.config.ts b/tailwind.config.ts index f00ad4f..ac89013 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -44,25 +44,43 @@ const config: Config = { ], }, colors: { - primary: { - DEFAULT: "#7aa2f7", - 50: "#f0f9ff", - 100: "#e0f2fe", - 200: "#bae6fd", - 300: "#7dd3fc", - 400: "#38bdf8", - 500: "#0ea5e9", - 600: "#0284c7", - 700: "#0369a1", - 800: "#075985", - 900: "#0c4a6e", + // Semantic surface tokens (page bg, cards, raised layers) + surface: { + DEFAULT: "var(--bg-primary)", + raised: "var(--bg-secondary)", + hover: "var(--surface-hover)", + overlay: "var(--surface-overlay)", }, - "bg-primary": "var(--bg-primary)", - "bg-secondary": "var(--bg-secondary)", - "text-primary": "var(--text-primary)", - "text-secondary": "var(--text-secondary)", - accent: "var(--accent)", - border: "var(--border)", + // Text tokens + content: { + DEFAULT: "var(--text-primary)", + muted: "var(--text-secondary)", + }, + // Accent / brand + brand: { + DEFAULT: "var(--accent)", + muted: "var(--accent-muted)", + }, + // Borders / hairlines + line: { + DEFAULT: "var(--border)", + strong: "var(--border-strong)", + }, + // Status colors (theme-aware via vars, fall back to fixed) + success: "var(--status-success)", + warning: "var(--status-warning)", + danger: "var(--status-danger)", + info: "var(--status-info)", + }, + borderRadius: { + xl: "0.875rem", + "2xl": "1.25rem", + }, + boxShadow: { + card: "var(--shadow-card)", + "card-hover": "var(--shadow-card-hover)", + pop: "var(--shadow-pop)", + bar: "var(--shadow-bar)", }, backgroundImage: { "wood-light": "url('/static/textures/wood-light.png')", diff --git a/templates/admin.templ b/templates/admin.templ index a5d1ad9..fb1d8a7 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -11,60 +11,79 @@ templ Admin(user User) { @Header(user, "/admin") -
+
@AdminSidebar(user, "/admin")
-

Dashboard

-

Overview of your Bookhoard library and settings

+
+ + @Icon("grid", "h-5 w-5") + +

Dashboard

+
+

Overview of your Bookhoard library and settings

-
-
-
-
📖
+
+
+
+ + @Icon("library", "h-5 w-5") +

Library

-

Manage your ebook collection

+

Manage your ebook collection

- View Library + + @Icon("arrow-right", "h-4 w-4") + View Library +
-
-
-
👁️
+
+
+ + @Icon("sync", "h-5 w-5") +

Scan Watch Status

-

Auto-detecting new files

+

Auto-detecting new files

- + Watching 0 libraries
-
-

Quick Actions

+
+

Quick Actions

- - -
Manage Libraries and Folders
-
Add or remove libraries and scan directories
+
+ + @Icon("library", "h-5 w-5") + Manage Libraries + + Add or remove libraries and scan directories
-

Create Library

Browse Folders

Delete Library

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_processing_issues.templ b/templates/admin_processing_issues.templ index a017738..9c58178 100644 --- a/templates/admin_processing_issues.templ +++ b/templates/admin_processing_issues.templ @@ -1,4 +1,3 @@ - package templates templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) { @@ -12,83 +11,105 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu @Header(user, "/admin/libraries/"+libraryID)
-
-
-
-

Processing Issues

-

Items that couldn't be processed in this library

+
+
+
+
+
+ + @Icon("alert", "h-5 w-5") + +

Processing Issues

+
+

Items that couldn't be processed in this library

+
+ + @Icon("arrow-left", "h-4 w-4") + Back to Library +
- - ← Back to Library -
-
- if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { - -
- if stats.ErrorCount > 0 { -
-

Errors

-

{ stats.ErrorCount }

-
- } - if stats.WarningCount > 0 { -
-

Warnings

-

{ stats.WarningCount }

-
- } - if stats.InfoCount > 0 { -
-

Info

-

{ stats.InfoCount }

-
- } -
- } - if len(issues) == 0 { -
-

No processing issues found for this library.

-
- } else { - -
- for _, issue := range issues { -
-
-
-

{ issue.Title }

-

{ issue.IssueDescription }

-
-

Type: { issue.IssueType }

-

Format: { issue.FormatGroup }

-

File: { issue.FilePath }

-

Library: { issue.LibraryTypeName }

+ if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { + +
+ if stats.ErrorCount > 0 { +
+
+ @Icon("x-circle", "h-5 w-5") +

Errors

+
+

{ stats.ErrorCount }

+
+ } + if stats.WarningCount > 0 { +
+
+ @Icon("alert", "h-5 w-5") +

Warnings

+
+

{ stats.WarningCount }

+
+ } + if stats.InfoCount > 0 { +
+
+ @Icon("info", "h-5 w-5") +

Info

+
+

{ stats.InfoCount }

+
+ } +
+ } + if len(issues) == 0 { +
+ + @Icon("check-circle", "h-6 w-6") + +

No processing issues found for this library.

+
+ } else { + +
+ for _, issue := range issues { +
+
+
+

{ issue.Title }

+

{ issue.IssueDescription }

+
+

Type: { issue.IssueType }

+

Format: { issue.FormatGroup }

+

File: { issue.FilePath }

+

Library: { issue.LibraryTypeName }

+
+
+
+ if issue.Severity == "error" { + { issue.Severity } + } else if issue.Severity == "warning" { + { issue.Severity } + } else { + { issue.Severity } + }
-
- - { issue.Severity } - +
+ if issue.Severity == "warning" || issue.Severity == "info" { + + }
-
- if issue.Severity == "warning" || issue.Severity == "info" { - - } -
-
- } -
- } + } +
+ } +
diff --git a/templates/admin_processing_issues_templ.go b/templates/admin_processing_issues_templ.go index 56cac26..0b56c9a 100644 --- a/templates/admin_processing_issues_templ.go +++ b/templates/admin_processing_issues_templ.go @@ -1,7 +1,6 @@ // Code generated by templ - DO NOT EDIT. // templ: version: v0.3.1020 - package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -38,200 +37,302 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Processing Issues

Items that couldn't be processed in this library

← Back to Library
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Processing Issues

Items that couldn't be processed in this library

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Library
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if stats.ErrorCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Errors

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("x-circle", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Errors

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 32, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 41, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if stats.WarningCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Warnings

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Warnings

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 38, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 50, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if stats.InfoCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Info

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Info

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 44, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 59, Col: 91} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(issues) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

No processing issues found for this library.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("check-circle", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

No processing issues found for this library.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, issue := range issues { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 60, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 98} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 61, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

Type: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

Type: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 63, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 81, Col: 140} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

Format: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

Format: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 64, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 82, Col: 144} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

File: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

File: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 65, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 83, Col: 139} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

Library: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

Library: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 66, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 149} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if issue.Severity == "warning" || issue.Severity == "info" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if issue.Severity == "error" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if issue.Severity == "warning" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 91, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 93, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if issue.Severity == "warning" || issue.Severity == "info" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index feacf7d..d0e2a1e 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -11,55 +11,67 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri @Header(user, "/admin/settings") -
+
@AdminSidebar(user, "/admin/settings")
-
- - ← Back to Dashboard + -

System Settings

-

Configure your Bookhoard instance

+
+ + @Icon("settings", "h-5 w-5") + +

System Settings

+
+

Configure your Bookhoard instance

if errorMessage != "" { -
- { errorMessage } +
+ @Icon("alert", "h-5 w-5 shrink-0 mt-0.5") + { errorMessage }
}
-
-

Base URL

+
+
+ @Icon("globe", "h-5 w-5 shrink-0") +

Base URL

+
- + -

The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.

+

The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.

-
-
-

System Defaults

+
+
+ @Icon("clock", "h-5 w-5 shrink-0") +

System Defaults

+
- + -

Default timezone for users who haven't set their own.

+

Default timezone for users who haven't set their own.

-
-

URL Paths

+
+
+ @Icon("external", "h-5 w-5 shrink-0") +

URL Paths

+
-

OPDS: { systemConfig["base_url"] }/opds

-

API: { systemConfig["base_url"] }/api

-

Device Sync: { systemConfig["base_url"] }/api/sync

+

OPDS: { systemConfig["base_url"] }/opds

+

API: { systemConfig["base_url"] }/api

+

Device Sync: { systemConfig["base_url"] }/api/sync

diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go index 40b8b8f..ec93dcc 100644 --- a/templates/admin_settings_templ.go +++ b/templates/admin_settings_templ.go @@ -37,7 +37,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -45,322 +45,378 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

System Settings

Configure your Bookhoard instance

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("settings", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

System Settings

Configure your Bookhoard instance

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if errorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("alert", "h-5 w-5 shrink-0 mt-0.5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 29, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 36, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Base URL

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("globe", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Base URL

The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.

System Defaults

Default timezone for users who haven't set their own.

URL Paths

OPDS: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Japan/Korea (UTC+9)

Default timezone for users who haven't set their own.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("external", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "

URL Paths

OPDS: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 96, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 145} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "/opds

API: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/opds

API: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 97, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 112, Col: 144} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "/api

Device Sync: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "/api

Device Sync: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 98, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 113, Col: 152} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "/api/sync

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "/api/sync

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_sidebar.templ b/templates/admin_sidebar.templ index fff21e9..f213594 100644 --- a/templates/admin_sidebar.templ +++ b/templates/admin_sidebar.templ @@ -1,23 +1,32 @@ package templates templ AdminSidebar(user User, currentPath string) { -
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/analytics.templ b/templates/analytics.templ index 8cf3411..105049c 100644 --- a/templates/analytics.templ +++ b/templates/analytics.templ @@ -12,78 +12,75 @@ templ Analytics(user User) { @Header(user, "/analytics") -
+
-

📊 Reading Analytics

-

Track your reading habits and device usage

+
+ + @Icon("chart", "h-5 w-5") + +

Reading Analytics

+
+

Track your reading habits and device usage

- -
-
+
+
- +
- +
-
- +
+
- -
-
-

Books Read

-

-

+
+
+

Books Read

+

-

-
-

Pages Read

-

-

+
+

Pages Read

+

-

-
-

Reading Time

-

-

+
+

Reading Time

+

-

-
-

Completion Rate

-

-

+
+

Completion Rate

+

-

-
- -
-

Daily Reading Minutes

+
+

Daily Reading Minutes

- -
-

Device Usage

+
+

Device Usage

- -
-

Most Read Books

+
+

Most Read Books