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")
-