diff --git a/cmd/server/main.go b/cmd/server/main.go
index b470e10..829173b 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -50,6 +50,16 @@ func main() {
}
log.Println("✅ Database schema initialized and verified, starting server...")
+ // Load tunable settings from the DB into the registry. All values fall back
+ // to compiled defaults if a row is missing, so this never blocks startup.
+ registry := database.NewSettingsRegistry(queries)
+ if err := registry.Load(ctx); err != nil {
+ log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
+ }
+ // Wire the registry into the package-level password validator so live
+ // rule changes apply to the echo struct-tag validator and ValidatePassword.
+ middleware.SetDefaultPasswordSettings(registry)
+
// 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 != "" {
@@ -79,15 +89,20 @@ func main() {
}
}
- // Create login attempt tracker: 5 failed attempts = 15 minute lockout
- loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
+ // Create login attempt tracker from configured (or default) lockout policy.
+ loginMaxAttempts, loginLockout := registry.LoginLockout()
+ loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute)
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
+ authHandler.SetSettings(registry)
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
+ systemSettingsHandler.SetSettings(registry)
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
+ sidecarHandler.SetSettings(registry)
libraryHandler := handlers.NewLibraryHandler(queries)
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
+ deviceAuthMiddleware.SetSettings(registry)
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
// Create WebSocket connection manager
@@ -95,10 +110,11 @@ func main() {
progressService := sync.NewProgressService(queries, connManager)
annotationService := sync.NewAnnotationService(queries, connManager)
- tombstonePurgerCancel := annotationService.StartTombstonePurger()
- defer tombstonePurgerCancel()
+ annotationService.SetSettings(registry)
+ maintenanceCancel := annotationService.StartDailyMaintenance()
+ defer maintenanceCancel()
- queueProcessor := sync.NewSyncQueueProcessor(queries)
+ queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize)
queueProcessor.SetProgressService(progressService)
queueProcessor.SetAnnotationService(annotationService)
@@ -109,7 +125,8 @@ func main() {
libraryService.SyncAllowedExtensions(context.Background())
// Create worker for background tasks
- worker := services.NewWorker(3, connManager)
+ workerCfg := registry.WorkerPoolConfig()
+ worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
services.WorkerInstance = worker
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
@@ -122,7 +139,9 @@ func main() {
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
+ conversionService.SetSettings(registry)
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
+ opdsHandler.SetSettings(registry)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries)
@@ -175,6 +194,7 @@ func main() {
Echo: e,
Queries: queries,
Cfg: cfg,
+ Settings: registry,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
diff --git a/database/schema/schema.sql b/database/schema/schema.sql
index 0629cf6..af2f44f 100644
--- a/database/schema/schema.sql
+++ b/database/schema/schema.sql
@@ -45,11 +45,47 @@ CREATE TABLE IF NOT EXISTS system_settings (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
--- Insert default system settings
-INSERT INTO system_settings (setting_key, setting_value, description) VALUES
-('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
-('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
-('default_timezone', 'UTC', 'System default timezone')
+-- Extend system_settings with typed metadata so it can back the admin UI's
+-- configurable tunables. All columns are nullable for backward compatibility
+-- with the original three rows and any pre-existing data.
+ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
+ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT;
+ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT;
+ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE;
+ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
+
+-- Insert default system settings (original scan/timezone rows + tunables).
+-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade.
+-- ON CONFLICT DO NOTHING preserves any admin-modified values.
+INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
+('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
+('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
+('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
+-- security / auth (live)
+('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
+('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'),
+('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'),
+('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
+('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
+('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
+-- security / auth (restart required)
+('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
+('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
+('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'),
+-- api (live)
+('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'),
+('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'),
+('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'),
+('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'),
+('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'),
+-- sync / performance (live)
+('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'),
+('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'),
+-- sync / performance (restart required)
+('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
+('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
+('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
+('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table
diff --git a/internal/database/db.go b/internal/database/db.go
index bdf4241..486aa36 100644
--- a/internal/database/db.go
+++ b/internal/database/db.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.30.0
+// sqlc v1.31.1
package database
diff --git a/internal/database/models.go b/internal/database/models.go
index fa7d6a0..714627a 100644
--- a/internal/database/models.go
+++ b/internal/database/models.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.30.0
+// sqlc v1.31.1
package database
@@ -484,11 +484,16 @@ type SystemConfig struct {
}
type SystemSettings struct {
- ID pgtype.UUID `db:"id" json:"id"`
- SettingKey string `db:"setting_key" json:"setting_key"`
- SettingValue string `db:"setting_value" json:"setting_value"`
- Description pgtype.Text `db:"description" json:"description"`
- UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
+ ID pgtype.UUID `db:"id" json:"id"`
+ SettingKey string `db:"setting_key" json:"setting_key"`
+ SettingValue string `db:"setting_value" json:"setting_value"`
+ Description pgtype.Text `db:"description" json:"description"`
+ UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
+ SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
+ MinValue pgtype.Text `db:"min_value" json:"min_value"`
+ MaxValue pgtype.Text `db:"max_value" json:"max_value"`
+ RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
+ Category pgtype.Text `db:"category" json:"category"`
}
type UnlinkedBooks struct {
@@ -532,3 +537,4 @@ type Users struct {
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
+
diff --git a/internal/database/querier.go b/internal/database/querier.go
index 5d8fbc2..28c4b45 100644
--- a/internal/database/querier.go
+++ b/internal/database/querier.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.30.0
+// sqlc v1.31.1
package database
@@ -26,7 +26,7 @@ type Querier interface {
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
// Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error
- CleanupExpiredRefreshTokens(ctx context.Context) error
+ CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
@@ -137,6 +137,7 @@ type Querier interface {
// Get all system config
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
+ GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
// Get collection
@@ -278,6 +279,7 @@ type Querier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
// System Settings queries
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
+ GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
GetSystemTimezone(ctx context.Context) (string, error)
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
// Get universal progress for a book
@@ -428,6 +430,7 @@ type Querier interface {
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
+ UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
}
var _ Querier = (*Queries)(nil)
diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go
index 91961c5..076afdd 100644
--- a/internal/database/queries.sql.go
+++ b/internal/database/queries.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.30.0
+// sqlc v1.31.1
// source: queries.sql
package database
@@ -186,11 +186,11 @@ func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error {
}
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec
-DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')
+DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision))
`
-func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error {
- _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens)
+func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error {
+ _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens, dollar_1)
return err
}
@@ -2258,6 +2258,41 @@ func (q *Queries) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSetti
return items, nil
}
+const GetAllSystemSettingsFull = `-- name: GetAllSystemSettingsFull :many
+SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings ORDER BY category, setting_key
+`
+
+func (q *Queries) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) {
+ rows, err := q.db.Query(ctx, GetAllSystemSettingsFull)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []SystemSettings{}
+ for rows.Next() {
+ var i SystemSettings
+ if err := rows.Scan(
+ &i.ID,
+ &i.SettingKey,
+ &i.SettingValue,
+ &i.Description,
+ &i.UpdatedAt,
+ &i.SettingType,
+ &i.MinValue,
+ &i.MaxValue,
+ &i.RequiresRestart,
+ &i.Category,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many
SELECT
mh.id,
@@ -6587,6 +6622,28 @@ func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (stri
return setting_value, err
}
+const GetSystemSettingFull = `-- name: GetSystemSettingFull :one
+SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings WHERE setting_key = $1
+`
+
+func (q *Queries) GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) {
+ row := q.db.QueryRow(ctx, GetSystemSettingFull, settingKey)
+ var i SystemSettings
+ err := row.Scan(
+ &i.ID,
+ &i.SettingKey,
+ &i.SettingValue,
+ &i.Description,
+ &i.UpdatedAt,
+ &i.SettingType,
+ &i.MinValue,
+ &i.MaxValue,
+ &i.RequiresRestart,
+ &i.Category,
+ )
+ return i, err
+}
+
const GetSystemTimezone = `-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'
`
@@ -12280,3 +12337,56 @@ func (q *Queries) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSett
)
return i, err
}
+
+const UpsertSystemSetting = `-- name: UpsertSystemSetting :one
+INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ON CONFLICT (setting_key) DO UPDATE
+SET setting_value = EXCLUDED.setting_value,
+ description = EXCLUDED.description,
+ setting_type = EXCLUDED.setting_type,
+ min_value = EXCLUDED.min_value,
+ max_value = EXCLUDED.max_value,
+ requires_restart = EXCLUDED.requires_restart,
+ category = EXCLUDED.category,
+ updated_at = NOW()
+RETURNING id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category
+`
+
+type UpsertSystemSettingParams struct {
+ SettingKey string `db:"setting_key" json:"setting_key"`
+ SettingValue string `db:"setting_value" json:"setting_value"`
+ Description pgtype.Text `db:"description" json:"description"`
+ SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
+ MinValue pgtype.Text `db:"min_value" json:"min_value"`
+ MaxValue pgtype.Text `db:"max_value" json:"max_value"`
+ RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
+ Category pgtype.Text `db:"category" json:"category"`
+}
+
+func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) {
+ row := q.db.QueryRow(ctx, UpsertSystemSetting,
+ arg.SettingKey,
+ arg.SettingValue,
+ arg.Description,
+ arg.SettingType,
+ arg.MinValue,
+ arg.MaxValue,
+ arg.RequiresRestart,
+ arg.Category,
+ )
+ var i SystemSettings
+ err := row.Scan(
+ &i.ID,
+ &i.SettingKey,
+ &i.SettingValue,
+ &i.Description,
+ &i.UpdatedAt,
+ &i.SettingType,
+ &i.MinValue,
+ &i.MaxValue,
+ &i.RequiresRestart,
+ &i.Category,
+ )
+ return i, err
+}
diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql
index eb1220a..fb8e871 100644
--- a/internal/database/queries/queries.sql
+++ b/internal/database/queries/queries.sql
@@ -373,9 +373,29 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1;
-- name: UpdateSystemSetting :exec
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
+-- name: UpsertSystemSetting :one
+INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ON CONFLICT (setting_key) DO UPDATE
+SET setting_value = EXCLUDED.setting_value,
+ description = EXCLUDED.description,
+ setting_type = EXCLUDED.setting_type,
+ min_value = EXCLUDED.min_value,
+ max_value = EXCLUDED.max_value,
+ requires_restart = EXCLUDED.requires_restart,
+ category = EXCLUDED.category,
+ updated_at = NOW()
+RETURNING *;
+
+-- name: GetSystemSettingFull :one
+SELECT * FROM system_settings WHERE setting_key = $1;
+
-- name: GetAllSystemSettings :many
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
+-- name: GetAllSystemSettingsFull :many
+SELECT * FROM system_settings ORDER BY category, setting_key;
+
-- name: CreateMediaRating :one
INSERT INTO media_ratings (media_item_id, user_id, rating)
VALUES ($1, $2, $3)
@@ -1007,7 +1027,7 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
-- name: CleanupExpiredRefreshTokens :exec
-DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
+DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision));
-- ============================================
-- FORMAT DETECTION & PROGRESS
diff --git a/internal/database/settings_registry.go b/internal/database/settings_registry.go
new file mode 100644
index 0000000..3f6e905
--- /dev/null
+++ b/internal/database/settings_registry.go
@@ -0,0 +1,342 @@
+package database
+
+// SettingsRegistry provides a typed, cached view over the system_settings table.
+// It is the single source of truth for tunable runtime values that used to be
+// hardcoded as Go literals.
+//
+// Consumers call the domain-specific getters (SessionDuration, OpdsPageSize,
+// etc.) which read from an in-memory cache. The cache is populated by Load at
+// startup and refreshed by Reload whenever a setting is written. Getters always
+// fall back to a compiled-in default if the DB value is missing or unparsable,
+// so a corrupt or deleted row can never break the app.
+//
+// SettingsRegistry lives in the database package (rather than its own package)
+// so that every consumer already imports database and does not need to take on
+// a new package import.
+
+import (
+ "context"
+ "log"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// SettingType enumerates the value types stored in system_settings.setting_type.
+const (
+ SettingTypeInt = "int"
+ SettingTypeBool = "bool"
+ SettingTypeString = "string"
+ SettingTypeStringList = "string_list"
+)
+
+// SecondsPerDay / SecondsPerHour are conversion helpers used by defaults.
+const (
+ SecondsPerMinute = 60
+ SecondsPerHour = 3600
+ SecondsPerDay = 86400
+)
+
+// SettingDefault holds the fallback value for a key. These mirror the literals that
+// were previously hardcoded in the source so an empty/corrupt DB row preserves
+// prior behavior exactly.
+type SettingDefault struct {
+ Key string
+ Value string
+ Type string
+ Min string
+ Max string
+ RequiresRestart bool
+ Category string
+ Group string
+ Description string
+}
+
+// SettingDefaults is the source of truth for fallback values and metadata. New keys
+// must be added here AND seeded in database/schema/schema.sql. Entries are ordered
+// by (RequiresRestart, Group) so the admin UI renders coherent sub-sections.
+var SettingDefaults = []SettingDefault{
+ {Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
+ {Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
+ {Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
+ {Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
+ {Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
+ {Key: "password_require_upper", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one uppercase letter (A-Z)"},
+ {Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
+ {Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
+ {Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
+ {Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
+ {Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
+ {Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
+ {Key: "device_rate_progress_per_min", Value: "120", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device progress requests per minute"},
+ {Key: "device_rate_metadata_per_min", Value: "30", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device metadata requests per minute"},
+ {Key: "annotation_tombstone_ttl_days", Value: "30", Type: SettingTypeInt, Min: "1", Max: "3650", Category: "sync", Group: "Annotation Retention", Description: "How long deleted annotations are kept before purge"},
+ {Key: "conversion_cache_ttl_hours", Value: "24", Type: SettingTypeInt, Min: "1", Max: "720", Category: "performance", Group: "Conversion Cache", Description: "How long converted (kepub) files are cached"},
+ {Key: "auth_rate_limit_per_min", Value: "10", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "security", Group: "Auth Rate Limiting", Description: "Global auth API rate limit (requests per minute)"},
+ {Key: "login_max_attempts", Value: "5", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Failed login attempts before lockout"},
+ {Key: "login_lockout_minutes", Value: "15", Type: SettingTypeInt, Min: "1", Max: "10080", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Lockout duration after too many failed logins"},
+ {Key: "sync_queue_interval_seconds", Value: "5", Type: SettingTypeInt, Min: "1", Max: "3600", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "How often the sync queue flushes"},
+ {Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
+ {Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
+ {Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
+}
+
+// defaultBy indexes SettingDefaults by key for O(1) lookup.
+var defaultBy = func() map[string]SettingDefault {
+ m := make(map[string]SettingDefault, len(SettingDefaults))
+ for _, d := range SettingDefaults {
+ m[d.Key] = d
+ }
+ return m
+}()
+
+// Registry caches system_settings values in memory. The zero value is not
+// usable; construct with New.
+type SettingsRegistry struct {
+ q *Queries
+
+ mu sync.RWMutex
+ values map[string]string
+ loadedAt time.Time
+}
+
+// New returns a Registry backed by the given queries. The cache is empty
+// until Load is called.
+func NewSettingsRegistry(q *Queries) *SettingsRegistry {
+ return &SettingsRegistry{q: q, values: make(map[string]string)}
+}
+
+// Load populates the cache from the database. Missing rows fall back to the
+// compiled defaults. Safe to call multiple times.
+func (r *SettingsRegistry) Load(ctx context.Context) error {
+ rows, err := r.q.GetAllSystemSettings(ctx)
+ if err != nil {
+ return err
+ }
+ fresh := make(map[string]string, len(SettingDefaults))
+ for _, d := range SettingDefaults {
+ fresh[d.Key] = d.Value
+ }
+ for _, row := range rows {
+ if _, ok := fresh[row.SettingKey]; ok {
+ fresh[row.SettingKey] = row.SettingValue
+ }
+ }
+ r.mu.Lock()
+ r.values = fresh
+ r.loadedAt = time.Now()
+ r.mu.Unlock()
+ return nil
+}
+
+// Reload refreshes the cache from the database. Should be called after any
+// setting write. On error the cache is left untouched and the error is logged.
+func (r *SettingsRegistry) Reload(ctx context.Context) {
+ if err := r.Load(ctx); err != nil {
+ log.Printf("settings: reload failed: %v", err)
+ }
+}
+
+// raw returns the cached string value for a key (or the default), clamped to
+// [min, max] for int-typed keys.
+func (r *SettingsRegistry) raw(key string) string {
+ r.mu.RLock()
+ v, ok := r.values[key]
+ r.mu.RUnlock()
+ if !ok || v == "" {
+ v = defaultBy[key].Value
+ }
+ return v
+}
+
+func (r *SettingsRegistry) getInt(key string) int {
+ d := defaultBy[key]
+ v := r.raw(key)
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ n, _ = strconv.Atoi(d.Value)
+ }
+ if d.Min != "" {
+ if mn, err := strconv.Atoi(d.Min); err == nil && n < mn {
+ n = mn
+ }
+ }
+ if d.Max != "" {
+ if mx, err := strconv.Atoi(d.Max); err == nil && n > mx {
+ n = mx
+ }
+ }
+ return n
+}
+
+func (r *SettingsRegistry) getBool(key string) bool {
+ v := r.raw(key)
+ b, err := strconv.ParseBool(v)
+ if err != nil {
+ b, _ = strconv.ParseBool(defaultBy[key].Value)
+ }
+ return b
+}
+
+// ---- Domain-specific getters (call sites use these) ----
+
+// ScanPollInterval is how often the scanner polls, as a duration.
+func (r *SettingsRegistry) ScanPollInterval() time.Duration {
+ return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
+}
+
+// AutoScanEnabled reports whether auto-scanning is on.
+func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
+
+// DefaultTimezone returns the configured default timezone name.
+func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
+
+// SessionDuration is how long a login session / refresh token stays valid.
+func (r *SettingsRegistry) SessionDuration() time.Duration {
+ return time.Duration(r.getInt("session_duration_seconds")) * time.Second
+}
+
+// PasswordMinLength is the minimum password length.
+func (r *SettingsRegistry) PasswordMinLength() int { return r.getInt("password_min_length") }
+
+// PasswordRules bundles the active complexity requirements.
+type PasswordRules struct {
+ MinLength int
+ Upper bool
+ Lower bool
+ Number bool
+ Special bool
+}
+
+// PasswordRules returns the active password complexity configuration.
+func (r *SettingsRegistry) PasswordRules() PasswordRules {
+ return PasswordRules{
+ MinLength: r.PasswordMinLength(),
+ Upper: r.getBool("password_require_upper"),
+ Lower: r.getBool("password_require_lower"),
+ Number: r.getBool("password_require_number"),
+ Special: r.getBool("password_require_special"),
+ }
+}
+
+// AuthRateLimit is the global auth endpoint rate limit (requests/minute). Read
+// once at startup.
+func (r *SettingsRegistry) AuthRateLimit() int { return r.getInt("auth_rate_limit_per_min") }
+
+// LoginLockout returns (max attempts, lockout duration). Read once at startup.
+func (r *SettingsRegistry) LoginLockout() (int, time.Duration) {
+ return r.getInt("login_max_attempts"), time.Duration(r.getInt("login_lockout_minutes")) * time.Minute
+}
+
+// OpdsDefaultPageSize is the default OPDS items-per-page.
+func (r *SettingsRegistry) OpdsDefaultPageSize() int { return r.getInt("opds_default_page_size") }
+
+// OpdsMaxPageSize is the maximum items-per-page a client may request.
+func (r *SettingsRegistry) OpdsMaxPageSize() int { return r.getInt("opds_max_page_size") }
+
+// DeviceRateLimits bundles the per-route device rate limits (requests/minute).
+type DeviceRateLimits struct {
+ Sync int
+ Progress int
+ Metadata int
+}
+
+// DeviceRateLimits returns the active device rate limits.
+func (r *SettingsRegistry) DeviceRateLimits() DeviceRateLimits {
+ return DeviceRateLimits{
+ Sync: r.getInt("device_rate_sync_per_min"),
+ Progress: r.getInt("device_rate_progress_per_min"),
+ Metadata: r.getInt("device_rate_metadata_per_min"),
+ }
+}
+
+// TombstoneTTL is how long deleted annotations are retained before purge.
+func (r *SettingsRegistry) TombstoneTTL() time.Duration {
+ return time.Duration(r.getInt("annotation_tombstone_ttl_days")) * 24 * time.Hour
+}
+
+// ConversionCacheTTL is how long converted (kepub) files are served from cache.
+func (r *SettingsRegistry) ConversionCacheTTL() time.Duration {
+ return time.Duration(r.getInt("conversion_cache_ttl_hours")) * time.Hour
+}
+
+// SyncQueueConfig bundles the sync queue interval and batch size. Read at
+// startup; changes require a restart.
+type SyncQueueConfig struct {
+ Interval time.Duration
+ BatchSize int
+}
+
+// SyncQueueConfig returns the active sync queue configuration.
+func (r *SettingsRegistry) SyncQueueConfig() SyncQueueConfig {
+ return SyncQueueConfig{
+ Interval: time.Duration(r.getInt("sync_queue_interval_seconds")) * time.Second,
+ BatchSize: r.getInt("sync_queue_batch_size"),
+ }
+}
+
+// WorkerPoolConfig bundles worker count and queue capacity. Read at startup;
+// changes require a restart.
+type WorkerPoolConfig struct {
+ Size int
+ QueueCap int
+}
+
+// WorkerPoolConfig returns the active worker pool configuration.
+func (r *SettingsRegistry) WorkerPoolConfig() WorkerPoolConfig {
+ return WorkerPoolConfig{
+ Size: r.getInt("worker_pool_size"),
+ QueueCap: r.getInt("worker_queue_cap"),
+ }
+}
+
+// SettingEntry exposes one setting's metadata + current value, for the admin UI/API.
+type SettingEntry struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+ Type string `json:"type"`
+ Min string `json:"min,omitempty"`
+ Max string `json:"max,omitempty"`
+ RequiresRestart bool `json:"requires_restart"`
+ Category string `json:"category"`
+ Group string `json:"group"`
+ Description string `json:"description"`
+ IsDefault bool `json:"is_default"`
+}
+
+// All returns metadata + current values for every known setting, grouped by
+// the in-memory cache (which reflects the DB after Load/Reload).
+func (r *SettingsRegistry) All() []SettingEntry {
+ r.mu.RLock()
+ vals := make(map[string]string, len(r.values))
+ for k, v := range r.values {
+ vals[k] = v
+ }
+ r.mu.RUnlock()
+ out := make([]SettingEntry, 0, len(SettingDefaults))
+ for _, d := range SettingDefaults {
+ v, ok := vals[d.Key]
+ if !ok {
+ v = d.Value
+ }
+ out = append(out, SettingEntry{
+ Key: d.Key,
+ Value: v,
+ Type: d.Type,
+ Min: d.Min,
+ Max: d.Max,
+ RequiresRestart: d.RequiresRestart,
+ Category: d.Category,
+ Group: d.Group,
+ Description: d.Description,
+ IsDefault: v == d.Value,
+ })
+ }
+ return out
+}
+
+// LookupDefault returns the compiled-in SettingDefault for a key (ok=false if unknown).
+func LookupDefault(key string) (SettingDefault, bool) {
+ d, ok := defaultBy[key]
+ return d, ok
+}
diff --git a/internal/database/settings_registry_test.go b/internal/database/settings_registry_test.go
new file mode 100644
index 0000000..07a455d
--- /dev/null
+++ b/internal/database/settings_registry_test.go
@@ -0,0 +1,97 @@
+package database
+
+import (
+ "strconv"
+ "testing"
+)
+
+// TestSettingDefaults ensures every seeded setting has a compiled default with
+// a valid value for its declared type. This guards against typos that would
+// silently fall back at runtime.
+func TestSettingDefaults(t *testing.T) {
+ if len(SettingDefaults) == 0 {
+ t.Fatal("SettingDefaults is empty")
+ }
+ for _, d := range SettingDefaults {
+ if d.Key == "" {
+ t.Errorf("default has empty key: %+v", d)
+ continue
+ }
+ switch d.Type {
+ case SettingTypeInt:
+ if _, err := strconv.Atoi(d.Value); err != nil {
+ t.Errorf("int setting %s default %q is not an int: %v", d.Key, d.Value, err)
+ }
+ if d.Min != "" {
+ if _, err := strconv.Atoi(d.Min); err != nil {
+ t.Errorf("int setting %s min %q is not an int", d.Key, d.Min)
+ }
+ }
+ if d.Max != "" {
+ if _, err := strconv.Atoi(d.Max); err != nil {
+ t.Errorf("int setting %s max %q is not an int", d.Key, d.Max)
+ }
+ }
+ case SettingTypeBool:
+ if _, err := strconv.ParseBool(d.Value); err != nil {
+ t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
+ }
+ case SettingTypeString:
+ if d.Value == "" {
+ t.Errorf("string setting %s has empty default", d.Key)
+ }
+ default:
+ t.Errorf("setting %s has unknown type %q", d.Key, d.Type)
+ }
+ }
+}
+
+// TestSettingsRegistryGetIntClamping verifies that out-of-range DB values are
+// clamped to the declared min/max, and that garbage falls back to the default.
+func TestSettingsRegistryGetIntClamping(t *testing.T) {
+ r := &SettingsRegistry{values: map[string]string{}, q: nil}
+
+ // Seed with an over-max value; expect clamping to the max (3600).
+ r.values["scan_poll_interval_seconds"] = "999999"
+ if got := r.ScanPollInterval(); got.Seconds() != 3600 {
+ t.Errorf("expected clamp to 3600, got %v", got)
+ }
+
+ // Seed with an under-min value; expect clamp to min (1).
+ r.values["scan_poll_interval_seconds"] = "0"
+ if got := r.ScanPollInterval(); got.Seconds() != 1 {
+ t.Errorf("expected clamp to 1, got %v", got)
+ }
+
+ // Seed with garbage; expect fallback to default (60).
+ r.values["scan_poll_interval_seconds"] = "not-a-number"
+ if got := r.ScanPollInterval(); got.Seconds() != 60 {
+ t.Errorf("expected fallback default 60, got %v", got)
+ }
+}
+
+// TestSettingsRegistryGetBoolFallback verifies bool parsing and fallback.
+func TestSettingsRegistryGetBoolFallback(t *testing.T) {
+ r := &SettingsRegistry{values: map[string]string{}, q: nil}
+
+ r.values["auto_scan_enabled"] = "true"
+ if !r.AutoScanEnabled() {
+ t.Error("expected true")
+ }
+
+ r.values["auto_scan_enabled"] = "garbage"
+ // garbage falls back to default ("true")
+ if !r.AutoScanEnabled() {
+ t.Error("expected fallback to default true")
+ }
+}
+
+// TestLookupDefaultUnknownKey verifies unknown keys return ok=false.
+func TestLookupDefaultUnknownKey(t *testing.T) {
+ if _, ok := LookupDefault("does_not_exist"); ok {
+ t.Error("expected ok=false for unknown key")
+ }
+ if _, ok := LookupDefault("session_duration_seconds"); !ok {
+ t.Error("expected ok=true for known key")
+ }
+}
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index 8710290..8a6471c 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -26,14 +26,34 @@ import (
)
const (
- // Session duration constants
- // Follows same pattern as refresh_token.go
- SessionDuration = 7 * 24 * time.Hour // 7 days
+ // DefaultSessionDuration is the fallback session duration used when no
+ // settings registry is wired (matches the historical 7-day value).
+ DefaultSessionDuration = 7 * 24 * time.Hour
)
-// SessionDurationSec is the session duration in seconds for use in cookies and API responses
-// Note: This is computed from SessionDuration to avoid magic numbers
-var SessionDurationSec = int(SessionDuration.Seconds())
+// SessionDurationSec is retained for backward compatibility; new code uses the
+// registry via AuthHandler.sessionDuration().
+var SessionDurationSec = int(DefaultSessionDuration.Seconds())
+
+// SetSettings wires the tunable settings registry (optional).
+func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
+
+// sessionDuration returns the active session duration from the registry.
+func (h *AuthHandler) sessionDuration() time.Duration {
+ if h.settings != nil {
+ return h.settings.SessionDuration()
+ }
+ return DefaultSessionDuration
+}
+
+// refreshTokenTTL returns the active refresh-token lifetime (shared with the
+// session duration), with a compiled-default fallback.
+func (h *AuthHandler) refreshTokenTTL() time.Duration {
+ if h.settings != nil {
+ return h.settings.SessionDuration()
+ }
+ return DefaultSessionDuration
+}
var secure = os.Getenv("COOKIE_SECURE")
@@ -41,6 +61,7 @@ type AuthHandler struct {
db *database.Queries
jwtKey []byte
loginAttemptTracker *middleware.LoginAttemptTracker
+ settings *database.SettingsRegistry
}
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
@@ -265,7 +286,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
HttpOnly: true,
Secure: secure == "true", // TODO: Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
- MaxAge: SessionDurationSec,
+ MaxAge: int(h.sessionDuration().Seconds()),
}
c.SetCookie(cookie)
@@ -298,7 +319,7 @@ window.location.href = '/dashboard';
Token: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
- ExpiresIn: SessionDurationSec,
+ ExpiresIn: int(h.sessionDuration().Seconds()),
User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
@@ -411,7 +432,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
HttpOnly: true,
Secure: secure == "true", // TODO: Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
- MaxAge: SessionDurationSec,
+ MaxAge: int(h.sessionDuration().Seconds()),
}
c.SetCookie(cookie)
@@ -450,7 +471,7 @@ window.location.href = '%s';
Token: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
- ExpiresIn: SessionDurationSec,
+ ExpiresIn: int(h.sessionDuration().Seconds()),
User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
@@ -1014,7 +1035,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
"user_role": userRole,
"user_email": userEmail,
"user_username": userUsername,
- "exp": time.Now().Add(SessionDuration).Unix(),
+ "exp": time.Now().Add(h.sessionDuration()).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go
index d36efd4..73ab1ee 100644
--- a/internal/handlers/kobo.go
+++ b/internal/handlers/kobo.go
@@ -623,7 +623,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
}
if h.annotationSvc != nil && len(processedBooks) > 0 {
- cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
+ cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
for mediaItemID, contentId := range processedBooks {
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: mediaItemID,
diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go
index a28b1d0..a19775e 100644
--- a/internal/handlers/koreader.go
+++ b/internal/handlers/koreader.go
@@ -854,7 +854,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
}
- cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
+ cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go
index 69287d6..7b5d286 100644
--- a/internal/handlers/opds.go
+++ b/internal/handlers/opds.go
@@ -26,6 +26,26 @@ type OPDSHandler struct {
conversionService interface {
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
}
+ settings *database.SettingsRegistry
+}
+
+// SetSettings wires the tunable settings registry (OPDS page size).
+func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
+
+// opdsDefaultPageSize returns the configured default page size (50 if unset).
+func (h *OPDSHandler) opdsDefaultPageSize() int {
+ if h.settings != nil {
+ return h.settings.OpdsDefaultPageSize()
+ }
+ return 50
+}
+
+// opdsMaxPageSize returns the configured maximum page size (200 if unset).
+func (h *OPDSHandler) opdsMaxPageSize() int {
+ if h.settings != nil {
+ return h.settings.OpdsMaxPageSize()
+ }
+ return 200
}
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
@@ -168,9 +188,10 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
}
}
- perPageNum := 50
+ perPageNum := h.opdsDefaultPageSize()
+ maxPerPage := h.opdsMaxPageSize()
if perPage != "" {
- if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 {
+ if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
perPageNum = num
}
}
diff --git a/internal/handlers/processing_issues.go b/internal/handlers/processing_issues.go
index cb5b03e..7d34c8f 100644
--- a/internal/handlers/processing_issues.go
+++ b/internal/handlers/processing_issues.go
@@ -2,6 +2,7 @@ package handlers
import (
"bookhoard/internal/database"
+ "context"
"net/http"
"time"
@@ -139,3 +140,8 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
"message": "Issue deleted",
})
}
+
+// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
+func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
+ return h.db.GetProcessingIssueStats(ctx, libraryID)
+}
diff --git a/internal/handlers/refresh_token.go b/internal/handlers/refresh_token.go
index 2fbb358..4863926 100644
--- a/internal/handlers/refresh_token.go
+++ b/internal/handlers/refresh_token.go
@@ -14,10 +14,6 @@ import (
"github.com/labstack/echo/v5"
)
-const (
- refreshTokenExpiration = 7 * 24 * time.Hour // 7 days
-)
-
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
@@ -72,7 +68,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
return c.JSON(http.StatusOK, RefreshTokenResponse{
AccessToken: accessToken,
TokenType: "Bearer",
- ExpiresIn: SessionDurationSec,
+ ExpiresIn: int(h.refreshTokenTTL().Seconds()),
})
}
@@ -101,7 +97,7 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
tokenUUID := uuid.New()
refreshToken := tokenUUID.String()
- expiresAt := time.Now().Add(refreshTokenExpiration)
+ expiresAt := time.Now().Add(h.refreshTokenTTL())
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
diff --git a/internal/handlers/sidecar.go b/internal/handlers/sidecar.go
index bf36b06..3bd0f77 100644
--- a/internal/handlers/sidecar.go
+++ b/internal/handlers/sidecar.go
@@ -15,14 +15,19 @@ import (
)
type SidecarHandler struct {
- db *database.Queries
- cfg *config.Config
+ db *database.Queries
+ cfg *config.Config
+ settings *database.SettingsRegistry
}
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
return &SidecarHandler{db: db, cfg: cfg}
}
+// SetSettings wires the tunable settings registry so the timezone write path
+// keeps the cache consistent.
+func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
+
type SidecarConfig struct {
Version string `json:"version"`
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
@@ -400,6 +405,9 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
"error": "failed to update default timezone",
})
}
+ if h.settings != nil {
+ h.settings.Reload(ctx)
+ }
continue
}
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
diff --git a/internal/handlers/system_settings.go b/internal/handlers/system_settings.go
index 7e3c1eb..762a84d 100644
--- a/internal/handlers/system_settings.go
+++ b/internal/handlers/system_settings.go
@@ -2,17 +2,21 @@ package handlers
import (
"bookhoard/internal/database"
+ "context"
"errors"
+ "fmt"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type SystemSettingsHandler struct {
- db *database.Queries
+ db *database.Queries
+ settings *database.SettingsRegistry
}
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
@@ -21,6 +25,154 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
}
}
+// SetSettings wires the tunable settings registry. Required for the unified
+// /api/system/settings endpoints and for cache invalidation after writes.
+func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) {
+ h.settings = s
+}
+
+// reload refreshes the in-memory cache after a write.
+func (h *SystemSettingsHandler) reload(c *echo.Context) {
+ if h.settings != nil {
+ h.settings.Reload(c.Request().Context())
+ }
+}
+
+// ---- Unified /api/system/settings endpoints ----
+
+// GetSettings handles GET /api/system/settings.
+func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error {
+ if h.settings == nil {
+ return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
+ }
+ return c.JSON(http.StatusOK, h.settings.All())
+}
+
+// UpdateSettingRequest is the body for PUT /api/system/settings.
+type UpdateSettingRequest struct {
+ Key string `json:"key" form:"key"`
+ Value string `json:"value" form:"value"`
+}
+
+// UpdateSettingResponse mirrors a settings entry plus a reload hint.
+type UpdateSettingResponse struct {
+ database.SettingEntry
+ ReloadRequired bool `json:"reload_required"`
+ Message string `json:"message,omitempty"`
+}
+
+// UpdateSetting handles PUT /api/system/settings.
+func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error {
+ if h.settings == nil {
+ return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
+ }
+ var req UpdateSettingRequest
+ if err := c.Bind(&req); err != nil {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
+ }
+ resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value)
+ if err != nil {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
+ }
+ return c.JSON(http.StatusOK, resp)
+}
+
+// ApplySetting validates, persists, and reloads a single setting. Shared by the
+// JSON API and the HTMX admin endpoint.
+func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) {
+ if h.settings == nil {
+ return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized")
+ }
+ if key == "" {
+ return UpdateSettingResponse{}, fmt.Errorf("key is required")
+ }
+ def, ok := database.LookupDefault(key)
+ if !ok {
+ return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key)
+ }
+ if err := validateSettingValue(def, value); err != nil {
+ return UpdateSettingResponse{}, err
+ }
+
+ desc := def.Description
+ rType := pgtype.Text{}
+ if def.Type != "" {
+ rType = pgtype.Text{String: def.Type, Valid: true}
+ }
+ var minP, maxP pgtype.Text
+ if def.Min != "" {
+ minP = pgtype.Text{String: def.Min, Valid: true}
+ }
+ if def.Max != "" {
+ maxP = pgtype.Text{String: def.Max, Valid: true}
+ }
+ if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{
+ SettingKey: key,
+ SettingValue: value,
+ Description: pgtype.Text{String: desc, Valid: desc != ""},
+ SettingType: rType,
+ MinValue: minP,
+ MaxValue: maxP,
+ RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true},
+ Category: pgtype.Text{String: def.Category, Valid: def.Category != ""},
+ }); err != nil {
+ return UpdateSettingResponse{}, err
+ }
+
+ h.settings.Reload(ctx)
+
+ resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart}
+ for _, e := range h.settings.All() {
+ if e.Key == key {
+ resp.SettingEntry = e
+ break
+ }
+ }
+ if def.RequiresRestart {
+ resp.Message = "Saved. Restart the server for this change to take full effect."
+ } else {
+ resp.Message = "Saved."
+ }
+ return resp, nil
+}
+
+// validateSettingValue checks a candidate value against the setting's type and bounds.
+func validateSettingValue(def database.SettingDefault, value string) error {
+ switch def.Type {
+ case database.SettingTypeInt:
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return fmt.Errorf("value must be an integer")
+ }
+ if def.Min != "" {
+ if mn, err := strconv.Atoi(def.Min); err == nil && n < mn {
+ return fmt.Errorf("value must be >= %s", def.Min)
+ }
+ }
+ if def.Max != "" {
+ if mx, err := strconv.Atoi(def.Max); err == nil && n > mx {
+ return fmt.Errorf("value must be <= %s", def.Max)
+ }
+ }
+ case database.SettingTypeBool:
+ if _, err := strconv.ParseBool(value); err != nil {
+ return fmt.Errorf("value must be true or false")
+ }
+ case database.SettingTypeString:
+ if value == "" {
+ return fmt.Errorf("value must not be empty")
+ }
+ if def.Key == "default_timezone" {
+ if _, err := time.LoadLocation(value); err != nil {
+ return fmt.Errorf("invalid timezone: %v", err)
+ }
+ }
+ }
+ return nil
+}
+
+// ---- Legacy scan-settings endpoints (retained for backward compatibility) ----
+
type UpdateScanSettingsRequest struct {
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
AutoScanEnabled bool `json:"auto_scan_enabled"`
@@ -51,6 +203,7 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
if err != nil {
return err
}
+ h.reload(c)
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
@@ -88,6 +241,8 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
+ h.reload(c)
+
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
AutoScanEnabled: req.AutoScanEnabled,
@@ -96,6 +251,15 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
}
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
+ // Prefer the registry (single source of truth after Load).
+ if h.settings != nil {
+ interval := int32(h.settings.ScanPollInterval().Seconds())
+ return c.JSON(http.StatusOK, ScanSettingsResponse{
+ ScanPollIntervalSeconds: interval,
+ AutoScanEnabled: h.settings.AutoScanEnabled(),
+ })
+ }
+
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
diff --git a/internal/middleware/device_auth.go b/internal/middleware/device_auth.go
index d06132c..19893d7 100644
--- a/internal/middleware/device_auth.go
+++ b/internal/middleware/device_auth.go
@@ -25,6 +25,7 @@ type DeviceContext struct {
type DeviceAuthMiddleware struct {
db *database.Queries
rateLimiter *DeviceRateLimiter
+ settings *database.SettingsRegistry
}
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
@@ -34,6 +35,41 @@ func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
}
}
+// SetSettings wires the tunable settings registry so device rate limits are
+// read live on each authenticated request.
+func (m *DeviceAuthMiddleware) SetSettings(s *database.SettingsRegistry) { m.settings = s }
+
+// rateLimitConfig returns the active device rate limits from the registry, or
+// the historical defaults when no registry is wired.
+func (m *DeviceAuthMiddleware) rateLimitConfig() DeviceRateLimitConfig {
+ if m.settings != nil {
+ dl := m.settings.DeviceRateLimits()
+ return DeviceRateLimitConfig{
+ SyncRequestsPerMinute: dl.Sync,
+ ProgressUpdatesPerMinute: dl.Progress,
+ MetadataRequestsPerMinute: dl.Metadata,
+ }
+ }
+ return DeviceRateLimitConfig{
+ SyncRequestsPerMinute: DefaultSyncRequestsPerMinute,
+ ProgressUpdatesPerMinute: DefaultProgressUpdatesPerMinute,
+ MetadataRequestsPerMinute: DefaultMetadataRequestsPerMinute,
+ }
+}
+
+// rateLimitForRequestType returns the configured per-minute limit for a given
+// request type, for use in X-RateLimit-* headers.
+func (m *DeviceAuthMiddleware) rateLimitForRequestType(requestType string, config DeviceRateLimitConfig) int {
+ switch requestType {
+ case "progress":
+ return config.ProgressUpdatesPerMinute
+ case "metadata":
+ return config.MetadataRequestsPerMinute
+ default: // "sync" and any unknown type
+ return config.SyncRequestsPerMinute
+ }
+}
+
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
var device database.Devices
@@ -115,15 +151,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
deviceUUID := uuid.UUID(device.ID.Bytes)
deviceID := deviceUUID.String()
- config := DeviceRateLimitConfig{
- SyncRequestsPerMinute: 60,
- ProgressUpdatesPerMinute: 120,
- MetadataRequestsPerMinute: 30,
- }
+ config := m.rateLimitConfig()
+ limitForType := m.rateLimitForRequestType(requestType, config)
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
- c.Response().Header().Set("X-RateLimit-Limit", "60")
+ c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
c.Response().Header().Set("X-RateLimit-Reset", "60")
return c.JSON(http.StatusTooManyRequests, map[string]string{
@@ -134,7 +167,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
}
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
- c.Response().Header().Set("X-RateLimit-Limit", "60")
+ c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
ctx := DeviceContext{
diff --git a/internal/middleware/password_validator.go b/internal/middleware/password_validator.go
index 159c3aa..e4e18ae 100644
--- a/internal/middleware/password_validator.go
+++ b/internal/middleware/password_validator.go
@@ -1,96 +1,146 @@
package middleware
import (
+ "bookhoard/internal/database"
"fmt"
"regexp"
+ "sync"
"github.com/go-playground/validator/v10"
)
-// PasswordValidator validates password complexity requirements
-type PasswordValidator struct{}
+// specialCharRegex matches the historical "special character" set used by the
+// password complexity rules.
+const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`
-// Validate checks if a password meets complexity requirements:
-// - Minimum 8 characters
-// - At least one uppercase letter
-// - At least one lowercase letter
-// - At least one number
-// - At least one special character
+// PasswordValidator validates password complexity against the configured rules.
+// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the
+// regex set is recompiled under a mutex on each validation. Without a registry
+// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply.
+type PasswordValidator struct {
+ settings *database.SettingsRegistry
+}
+
+// SetSettings wires the tunable settings registry.
+func (v *PasswordValidator) SetSettings(s *database.SettingsRegistry) { v.settings = s }
+
+// compileSpecialRegex isolates the regexp compile (which is safe to call
+// concurrently, but we keep it behind a cached var for the no-registry path).
+var (
+ specialOnce sync.Once
+ specialRe *regexp.Regexp
+)
+
+func specialRegex() *regexp.Regexp {
+ specialOnce.Do(func() {
+ specialRe = regexp.MustCompile(specialCharRegex)
+ })
+ return specialRe
+}
+
+func (v *PasswordValidator) rules() database.PasswordRules {
+ if v.settings != nil {
+ return v.settings.PasswordRules()
+ }
+ return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true}
+}
+
+// Validate checks if a password meets the configured complexity requirements.
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
- password := fl.Field().String()
+ return v.CheckPassword(fl.Field().String())
+}
- // Check minimum length
- if len(password) < 8 {
+// CheckPassword applies the active rules to a single password.
+func (v *PasswordValidator) CheckPassword(password string) bool {
+ r := v.rules()
+ if len(password) < r.MinLength {
return false
}
-
- // Check for uppercase
- hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
- if !hasUpper {
+ if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
return false
}
-
- // Check for lowercase
- hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
- if !hasLower {
+ if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
return false
}
-
- // Check for number
- hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
- if !hasNumber {
+ if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
return false
}
-
- // Check for special character
- hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
- if !hasSpecial {
+ if r.Special && !specialRegex().MatchString(password) {
return false
}
-
return true
}
-// GetPasswordRequirements returns a human-readable list of password requirements
+// GetPasswordRequirements returns a human-readable list of the active password
+// requirements, driven by the configured rules when a registry is wired.
func GetPasswordRequirements() []string {
- return []string{
- "At least 8 characters long",
- "At least one uppercase letter (A-Z)",
- "At least one lowercase letter (a-z)",
- "At least one number (0-9)",
- "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
- }
+ return defaultPasswordValidator.Requirements()
}
-// ValidatePassword checks a password and returns an error if it doesn't meet requirements
-func ValidatePassword(password string) error {
- if len(password) < 8 {
- return fmt.Errorf("password must be at least 8 characters long")
+// Requirements returns the human-readable list for the receiver's active rules.
+func (v *PasswordValidator) Requirements() []string {
+ r := v.rules()
+ var out []string
+ out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength))
+ if r.Upper {
+ out = append(out, "At least one uppercase letter (A-Z)")
}
+ if r.Lower {
+ out = append(out, "At least one lowercase letter (a-z)")
+ }
+ if r.Number {
+ out = append(out, "At least one number (0-9)")
+ }
+ if r.Special {
+ out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)")
+ }
+ return out
+}
- if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
+// ValidatePassword checks a password against the default (hardcoded) rules and
+// returns an error describing the first unmet requirement. Retained for callers
+// that don't have access to a configured PasswordValidator instance.
+func ValidatePassword(password string) error {
+ v := defaultPasswordValidator
+ r := v.rules()
+ if len(password) < r.MinLength {
+ return fmt.Errorf("password must be at least %d characters long", r.MinLength)
+ }
+ if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one uppercase letter")
}
-
- if !regexp.MustCompile(`[a-z]`).MatchString(password) {
+ if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one lowercase letter")
}
-
- if !regexp.MustCompile(`[0-9]`).MatchString(password) {
+ if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
return fmt.Errorf("password must contain at least one number")
}
-
- if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
+ if r.Special && !specialRegex().MatchString(password) {
return fmt.Errorf("password must contain at least one special character")
}
-
return nil
}
-// RegisterPasswordValidation registers the password validator with the validator instance
+// defaultPasswordValidator is used by the package-level helpers
+// (GetPasswordRequirements, ValidatePassword) and as the fallback inside
+// RegisterPasswordValidation when no registry has been wired. Callers that want
+// live rule updates should construct their own PasswordValidator and call
+// SetSettings.
+var defaultPasswordValidator = &PasswordValidator{}
+
+// RegisterPasswordValidation registers the password validator with the
+// validator instance. The registered func re-evaluates rules on every call, so
+// changes to the wired registry take effect immediately.
func RegisterPasswordValidation(v *validator.Validate) error {
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
- pv := &PasswordValidator{}
- return pv.Validate(fl)
+ return defaultPasswordValidator.CheckPassword(fl.Field().String())
})
}
+
+// SetDefaultPasswordSettings wires the settings registry into the package-level
+// default validator so that the struct-tag validator (used by echo's
+// CustomValidator) and ValidatePassword follow live configuration. Intended to
+// be called once at startup.
+func SetDefaultPasswordSettings(s *database.SettingsRegistry) {
+ defaultPasswordValidator.SetSettings(s)
+}
diff --git a/internal/router/admin_library.go b/internal/router/admin_library.go
new file mode 100644
index 0000000..4d4fe9e
--- /dev/null
+++ b/internal/router/admin_library.go
@@ -0,0 +1,447 @@
+package router
+
+import (
+ "bookhoard/internal/database"
+ "bookhoard/internal/handlers"
+ "bookhoard/templates"
+ "bytes"
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/labstack/echo/v5"
+)
+
+func registerAdminLibraryRoutes(cfg *Config, frontendProtected *echo.Group) {
+ g := frontendProtected.Group("", handlers.AdminMiddleware)
+
+ // HTMX: Create library
+ g.POST("/admin/library/create", func(c *echo.Context) error {
+ user := c.Get("user").(database.Users)
+
+ name := c.FormValue("name")
+ desc := c.FormValue("description")
+ libType := c.FormValue("type")
+ if name == "" || libType == "" {
+ return c.HTML(http.StatusBadRequest, `
Name and type are required
`)
+ }
+
+ _, err := cfg.LibraryService.CreateLibrary(
+ c.Request().Context(),
+ name,
+ desc,
+ libType,
+ user.ID,
+ )
+ if err != nil {
+ return c.HTML(http.StatusInternalServerError, `Failed to create library
`)
+ }
+
+ return renderLibraryList(c, cfg)
+ })
+
+ // HTMX: Update library
+ g.PUT("/admin/library/:id", func(c *echo.Context) error {
+ libraryID, err := parseAdminUUID(c.Param("id"))
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, `Invalid library ID
`)
+ }
+
+ name := c.FormValue("name")
+ desc := c.FormValue("description")
+ if name == "" {
+ return c.HTML(http.StatusBadRequest, `Name is required
`)
+ }
+
+ _, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc)
+ if err != nil {
+ return c.HTML(http.StatusInternalServerError, `Failed to update library
`)
+ }
+
+ return renderLibraryList(c, cfg)
+ })
+
+ // HTMX: Delete library
+ g.DELETE("/admin/library/:id", func(c *echo.Context) error {
+ libraryID, err := parseAdminUUID(c.Param("id"))
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, `Invalid library ID
`)
+ }
+
+ err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID)
+ if err != nil {
+ return c.HTML(http.StatusInternalServerError, `Failed to delete library
`)
+ }
+
+ return renderLibraryList(c, cfg)
+ })
+
+ // HTMX: Library expanded panel
+ g.GET("/admin/library/:id/panel", func(c *echo.Context) error {
+ libraryID, err := parseAdminUUID(c.Param("id"))
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, `Invalid library ID
`)
+ }
+
+ return renderLibraryPanel(c, cfg, libraryID)
+ })
+
+ // HTMX: Add folder
+ g.POST("/admin/library/:id/folders", func(c *echo.Context) error {
+ libraryID, err := parseAdminUUID(c.Param("id"))
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, `Invalid library ID
`)
+ }
+
+ folderPath := c.FormValue("folder_path")
+ if folderPath == "" {
+ return renderLibraryPanel(c, cfg, libraryID)
+ }
+
+ if strings.Contains(folderPath, "..") {
+ return renderLibraryPanelWithError(c, cfg, libraryID, "Path traversal not allowed")
+ }
+
+ cleanPath := filepath.Clean(folderPath)
+ fileInfo, err := os.Stat(cleanPath)
+ if err != nil {
+ return renderLibraryPanelWithError(c, cfg, libraryID, "Folder path does not exist")
+ }
+ if !fileInfo.IsDir() {
+ return renderLibraryPanelWithError(c, cfg, libraryID, "Path must be a directory")
+ }
+
+ _, err = cfg.LibraryService.AddLibraryFolder(c.Request().Context(), libraryID, cleanPath)
+ if err != nil {
+ return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to add folder: "+err.Error())
+ }
+
+ return renderLibraryPanel(c, cfg, libraryID)
+ })
+
+ // HTMX: Remove folder
+ g.DELETE("/admin/library/:id/folders", func(c *echo.Context) error {
+ libraryID, err := parseAdminUUID(c.Param("id"))
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, `Invalid library ID
`)
+ }
+
+ folderPath := c.FormValue("folder_path")
+ if folderPath == "" {
+ return renderLibraryPanel(c, cfg, libraryID)
+ }
+
+ err = cfg.LibraryService.DeleteLibraryFolder(c.Request().Context(), libraryID, folderPath)
+ if err != nil {
+ return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to remove folder")
+ }
+
+ return renderLibraryPanel(c, cfg, libraryID)
+ })
+
+ // HTMX: Folder browser
+ g.GET("/admin/library/browse", func(c *echo.Context) error {
+ path := c.QueryParam("path")
+ if path == "" {
+ path = "/"
+ }
+ targetInput := c.QueryParam("target_input")
+ libraryID := c.QueryParam("library_id")
+
+ dirs, currentPath, parentPath, err := cfg.LibraryService.BrowseDirectories(c.Request().Context(), path)
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, `Cannot browse: `+err.Error()+`
`)
+ }
+
+ entries := make([]templates.DirEntry, len(dirs))
+ for i, d := range dirs {
+ fullPath := filepath.Join(currentPath, d)
+ entries[i] = templates.DirEntry{Name: d, Path: fullPath}
+ }
+
+ var buf bytes.Buffer
+ err = templates.FolderBrowserContent(currentPath, parentPath, entries, targetInput, libraryID).Render(c.Request().Context(), &buf)
+ if err != nil {
+ return err
+ }
+ return c.HTML(http.StatusOK, buf.String())
+ })
+
+ // HTMX: Set user visibility for library
+ g.POST("/admin/library/:id/visibility", func(c *echo.Context) error {
+ libraryID, err := parseAdminUUID(c.Param("id"))
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, `Invalid library ID
`)
+ }
+
+ userIDStr := c.FormValue("user_id")
+ isVisible := c.FormValue("is_visible") == "true"
+
+ userID, err := parseAdminUUID(userIDStr)
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, `Invalid user ID
`)
+ }
+
+ _, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible)
+ if err != nil {
+ return c.HTML(http.StatusInternalServerError, `Failed to update visibility
`)
+ }
+
+ return renderLibraryPanel(c, cfg, libraryID)
+ })
+}
+
+// renderLibraryList fetches all libraries + users and renders the LibraryList partial.
+func renderLibraryList(c *echo.Context, cfg *Config) error {
+ libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
+ if err != nil {
+ return c.HTML(http.StatusInternalServerError, `Failed to load libraries
`)
+ }
+
+ libData := make([]templates.LibraryData, len(libraries))
+ for i, lib := range libraries {
+ libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
+ folderCount := getFolderCount(c.Request().Context(), cfg, lib.ID)
+ libData[i] = templates.LibraryData{
+ ID: libUUID.String(),
+ Name: lib.Name,
+ Description: getText(lib.Description),
+ TypeName: lib.TypeName,
+ TypeValue: lib.TypeName,
+ FolderCount: folderCount,
+ }
+ }
+
+ users, err := cfg.Queries.ListUsers(c.Request().Context())
+ if err != nil {
+ log.Printf("ListUsers failed: %v", err)
+ users = []database.ListUsersRow{}
+ }
+ userData := make([]templates.User, len(users))
+ for i, u := range users {
+ userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
+ userData[i] = templates.User{
+ ID: userUUID.String(),
+ Username: u.Username,
+ Email: u.Email,
+ Role: u.Role,
+ }
+ }
+
+ var buf bytes.Buffer
+ err = templates.LibraryList(templates.User{}, libData, userData).Render(c.Request().Context(), &buf)
+ if err != nil {
+ return err
+ }
+ return c.HTML(http.StatusOK, buf.String())
+}
+
+// renderLibraryPanel fetches library details and renders the LibraryPanel partial.
+func renderLibraryPanel(c *echo.Context, cfg *Config, libraryID pgtype.UUID) error {
+ return renderLibraryPanelWithError(c, cfg, libraryID, "")
+}
+
+func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.UUID, errMsg string) error {
+ ctx := c.Request().Context()
+ libraryIDStr := uuid.UUID(libraryID.Bytes).String()
+
+ // Get library details
+ lib, err := cfg.LibraryService.GetLibrary(ctx, libraryID)
+ if err != nil {
+ return c.HTML(http.StatusNotFound, `Library not found
`)
+ }
+
+ libData := templates.LibraryData{
+ ID: libraryIDStr,
+ Name: lib.Name,
+ Description: getText(lib.Description),
+ TypeName: lib.TypeName,
+ TypeValue: lib.TypeName,
+ }
+
+ // Get folders
+ dbFolders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
+ if err != nil {
+ log.Printf("GetLibraryFolders failed: %v", err)
+ }
+ folders := make([]templates.FolderData, len(dbFolders))
+ for i, f := range dbFolders {
+ folders[i] = templates.FolderData{FolderPath: f.FolderPath}
+ }
+ libData.FolderCount = len(folders)
+
+ // Get users
+ dbUsers, err := cfg.Queries.ListUsers(ctx)
+ if err != nil {
+ log.Printf("ListUsers failed: %v", err)
+ dbUsers = []database.ListUsersRow{}
+ }
+ userData := make([]templates.User, len(dbUsers))
+ for i, u := range dbUsers {
+ userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
+ userData[i] = templates.User{
+ ID: userUUID.String(),
+ Username: u.Username,
+ Email: u.Email,
+ }
+ }
+
+ // Get visibility for all users
+ visibility := make([]templates.UserVisibilityData, len(userData))
+ for i, u := range userData {
+ userUUID, _ := parseAdminUUID(u.ID)
+ visibleLibs, err := cfg.LibraryService.GetUserVisibleLibraries(ctx, userUUID)
+ if err != nil {
+ log.Printf("GetUserVisibleLibraries failed: %v", err)
+ }
+ isVisible := false
+ for _, vl := range visibleLibs {
+ if vl.ID.Bytes == libraryID.Bytes {
+ isVisible = true
+ break
+ }
+ }
+ visibility[i] = templates.UserVisibilityData{
+ UserID: u.ID,
+ Username: u.Username,
+ Email: u.Email,
+ IsVisible: isVisible,
+ }
+ }
+
+ // Get issue count
+ issueStats, err := cfg.ProcessingIssuesHandler.GetProcessingIssueStatsData(ctx, libraryID)
+ if err != nil {
+ log.Printf("GetProcessingIssueStats failed: %v", err)
+ }
+ issueCount := issueStats.ErrorCount + issueStats.WarningCount + issueStats.InfoCount
+
+ // Get current user for template
+ tmplUser := templates.User{}
+ if u, ok := c.Get("user").(database.Users); ok {
+ userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
+ tmplUser = templates.User{
+ ID: userUUID.String(),
+ Username: u.Username,
+ Role: u.Role,
+ }
+ }
+
+ var buf bytes.Buffer
+ err = templates.LibraryPanel(tmplUser, libraryIDStr, libData, folders, userData, visibility, int(issueCount)).Render(ctx, &buf)
+ if err != nil {
+ return err
+ }
+
+ html := buf.String()
+ if errMsg != "" {
+ html = `` + errMsg + `
` + html
+ }
+ return c.HTML(http.StatusOK, html)
+}
+
+func getFolderCount(ctx context.Context, cfg *Config, libraryID pgtype.UUID) int {
+ folders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
+ if err != nil {
+ return 0
+ }
+ return len(folders)
+}
+
+func parseAdminUUID(s string) (pgtype.UUID, error) {
+ parsed, err := uuid.Parse(s)
+ if err != nil {
+ return pgtype.UUID{}, err
+ }
+ return pgtype.UUID{Bytes: parsed, Valid: true}, nil
+}
+
+func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats {
+ stats := templates.AdminStats{}
+
+ libs, _ := cfg.LibraryHandler.ListLibrariesData(ctx)
+ stats.LibraryCount = len(libs)
+
+ users, _ := cfg.Queries.ListUsers(ctx)
+ stats.UserCount = len(users)
+
+ if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
+ _ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM media_items").Scan(&stats.MediaCount)
+ _ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM devices").Scan(&stats.DeviceCount)
+ }
+
+ return stats
+}
+
+func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) {
+ g := frontendProtected.Group("", handlers.AdminMiddleware)
+
+ g.PUT("/admin/settings/scan", func(c *echo.Context) error {
+ ctx := c.Request().Context()
+
+ autoScan := c.FormValue("auto_scan_enabled") == "true"
+ intervalStr := c.FormValue("scan_poll_interval_seconds")
+ interval, err := strconv.Atoi(intervalStr)
+ if err != nil || interval < 1 || interval > 3600 {
+ return c.HTML(http.StatusBadRequest, `Interval must be between 1 and 3600 seconds
`)
+ }
+
+ autoScanStr := "false"
+ if autoScan {
+ autoScanStr = "true"
+ }
+ _ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
+ SettingKey: "auto_scan_enabled",
+ SettingValue: autoScanStr,
+ })
+ _ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
+ SettingKey: "scan_poll_interval_seconds",
+ SettingValue: strconv.Itoa(interval),
+ })
+
+ // Refresh the registry cache so the change is visible immediately.
+ if cfg.Settings != nil {
+ cfg.Settings.Reload(ctx)
+ }
+
+ scanSettings := templates.ScanSettingsData{
+ AutoScanEnabled: autoScan,
+ ScanPollIntervalSeconds: interval,
+ }
+ var buf bytes.Buffer
+ _ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf)
+ return c.HTML(http.StatusOK, buf.String())
+ })
+
+ // HTMX endpoint for saving a single tunable setting. Returns a small HTML
+ // status snippet rendered into the row's status span.
+ g.PUT("/admin/settings/tunable", func(c *echo.Context) error {
+ ctx := c.Request().Context()
+ key := c.FormValue("key")
+ value := c.FormValue("value")
+
+ if cfg.SystemSettingsHandler == nil {
+ return c.HTML(http.StatusServiceUnavailable, `settings unavailable `)
+ }
+ resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value)
+ if err != nil {
+ return c.HTML(http.StatusBadRequest, fmt.Sprintf(`%s `, err.Error()))
+ }
+
+ color := "var(--status-success)"
+ msg := "Saved"
+ if resp.ReloadRequired {
+ color = "var(--status-warning)"
+ msg = "Saved — restart required"
+ }
+ return c.HTML(http.StatusOK, fmt.Sprintf(`%s `, color, msg))
+ })
+}
diff --git a/internal/router/frontend.go b/internal/router/frontend.go
index 1ee0774..d8741d6 100644
--- a/internal/router/frontend.go
+++ b/internal/router/frontend.go
@@ -810,8 +810,9 @@ func registerFrontendRoutes(cfg *Config) {
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).Render(c.Request().Context(), &buf)
+ err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -823,8 +824,9 @@ func registerFrontendRoutes(cfg *Config) {
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).Render(c.Request().Context(), &buf)
+ err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -848,11 +850,14 @@ func registerFrontendRoutes(cfg *Config) {
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
+ folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
+ TypeValue: lib.TypeName,
+ FolderCount: len(folders),
}
}
@@ -990,21 +995,67 @@ func registerFrontendRoutes(cfg *Config) {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
+ ctx := c.Request().Context()
+
// Fetch current system configuration - just base_url
- baseURL := cfg.getBaseURL(c.Request().Context())
+ baseURL := cfg.getBaseURL(ctx)
systemConfig := map[string]string{
"base_url": baseURL,
"default_timezone": "UTC",
}
- defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
+ 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, "").Render(c.Request().Context(), &buf)
+ err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf)
if err != nil {
return err
}
@@ -1046,6 +1097,11 @@ func registerFrontendRoutes(cfg *Config) {
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)
// ============================================================================
diff --git a/internal/router/library.go b/internal/router/library.go
index f2582c6..b86cb4a 100644
--- a/internal/router/library.go
+++ b/internal/router/library.go
@@ -38,6 +38,8 @@ func registerLibraryRoutes(cfg *Config) {
adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats)
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
+ adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
+ adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
libraryID := c.Param("id")
scanReq := map[string]interface{}{
diff --git a/internal/router/router.go b/internal/router/router.go
index 4c956ab..318abfa 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -39,6 +39,7 @@ type Config struct {
Echo *echo.Echo
Queries *database.Queries
Cfg *config.Config
+ Settings *database.SettingsRegistry
DBPool interface{} // pgxpool.Pool interface
AuthHandler *handlers.AuthHandler
LibraryHandler *handlers.LibraryHandler
@@ -211,10 +212,12 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
// Setup redirect middleware - must run before all routes
e.Pre(setupRedirectMiddleware(cfg))
- // Rate limiter
+ // Rate limiter. The per-minute value comes from the settings registry (DB);
+ // the enabled flag stays env-driven since disabling rate limiting is a
+ // deployment-time decision, not a runtime tunable.
rateLimiterConfig := ratelimit.RateLimiterConfig{
Enabled: cfg.Cfg.RateLimitEnabled,
- RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
+ RequestsPerMinute: cfg.Settings.AuthRateLimit(),
CleanupInterval: 5 * time.Minute,
}
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
diff --git a/internal/router/system.go b/internal/router/system.go
index 4c76f8a..aa2c8f7 100644
--- a/internal/router/system.go
+++ b/internal/router/system.go
@@ -17,4 +17,10 @@ func registerSystemRoutes(cfg *Config) {
// System configuration routes (admin-only)
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration)
+
+ // Unified tunable settings (admin-only). These back the admin UI's
+ // editable System Settings sections and supersede the legacy
+ // /api/libraries/scan-settings JSON routes.
+ system.GET("/settings", cfg.SystemSettingsHandler.GetSettings)
+ system.PUT("/settings", cfg.SystemSettingsHandler.UpdateSetting)
}
diff --git a/internal/services/conversion_service.go b/internal/services/conversion_service.go
index 1a9e2ff..e478795 100644
--- a/internal/services/conversion_service.go
+++ b/internal/services/conversion_service.go
@@ -16,6 +16,10 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
+// defaultConversionCacheTTL is the fallback kepub cache lifetime when no
+// settings registry is wired. Matches the historical hardcoded 24h.
+const defaultConversionCacheTTL = 24 * time.Hour
+
type ConvertedKEPUB struct {
Path string
SHA256 string
@@ -27,6 +31,7 @@ type ConversionService struct {
cacheDir string
conversionTool string
conversionCacheTTL time.Duration
+ settings *database.SettingsRegistry
}
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
@@ -34,17 +39,32 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ
db: db,
cacheDir: cacheDir,
conversionTool: "/usr/bin/kepubify",
- conversionCacheTTL: 24 * time.Hour,
+ conversionCacheTTL: defaultConversionCacheTTL,
}
}
+// SetSettings wires the tunable settings registry. When wired, the cache TTL
+// is read live on each conversion request.
+func (s *ConversionService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
+
+// cacheTTL returns the active conversion cache TTL.
+func (s *ConversionService) cacheTTL() time.Duration {
+ if s.settings != nil {
+ return s.settings.ConversionCacheTTL()
+ }
+ if s.conversionCacheTTL > 0 {
+ return s.conversionCacheTTL
+ }
+ return defaultConversionCacheTTL
+}
+
func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) {
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
MediaItemID: mediaItemID,
FormatType: "kepub",
})
if err == nil && existing.FilePath.Valid {
- if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL {
+ if time.Since(existing.CreatedAt.Time) < s.cacheTTL() {
return &ConvertedKEPUB{
Path: existing.FilePath.String,
SHA256: existing.FileSha256.String,
diff --git a/internal/services/conversion_service_test.go b/internal/services/conversion_service_test.go
index f974c6d..cbcb625 100644
--- a/internal/services/conversion_service_test.go
+++ b/internal/services/conversion_service_test.go
@@ -65,5 +65,7 @@ func TestConversionServiceDefaults(t *testing.T) {
assert.NotNil(t, service)
assert.Equal(t, cacheDir, service.cacheDir)
assert.Equal(t, "/usr/bin/kepubify", service.conversionTool)
- assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours")
+ assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL field should be 24 hours")
+ // cacheTTL() must reflect the same default when no registry is wired.
+ assert.Equal(t, int64(24*3600*1000000000), service.cacheTTL().Nanoseconds(), "Default TTL accessor should return 24 hours")
}
diff --git a/internal/services/worker.go b/internal/services/worker.go
index 8f509d2..59b7b47 100644
--- a/internal/services/worker.go
+++ b/internal/services/worker.go
@@ -176,10 +176,17 @@ func (w *Worker) GetActiveJobCount() int {
return count
}
func NewWorker(numWorkers int, connManager *wsync.ConnectionManager) *Worker {
+ return NewWorkerWithConfig(numWorkers, 100, connManager)
+}
+
+// NewWorkerWithConfig constructs a worker pool with the given worker count and
+// job-queue capacity. Used at startup to source values from the settings
+// registry.
+func NewWorkerWithConfig(numWorkers, queueCap int, connManager *wsync.ConnectionManager) *Worker {
ctx, cancel := context.WithCancel(context.Background())
w := &Worker{
- jobQueue: make(chan *Job, 100),
+ jobQueue: make(chan *Job, queueCap),
results: make(map[string]*JobResult),
ctx: ctx,
cancel: cancel,
@@ -995,3 +1002,4 @@ func (w *Worker) Shutdown() {
close(w.jobQueue)
w.wg.Wait()
}
+
diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go
index d047479..f30b353 100644
--- a/internal/sync/annotations.go
+++ b/internal/sync/annotations.go
@@ -18,8 +18,38 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
+// TombstoneTTL is the fallback retention for soft-deleted annotations when no
+// settings registry is wired (e.g. in tests). It matches the historical value.
const TombstoneTTL = 30 * 24 * time.Hour
+type AnnotationService struct {
+ db *database.Queries
+ connMgr *ConnectionManager
+ settings *database.SettingsRegistry
+}
+
+func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
+ return &AnnotationService{db: db, connMgr: connMgr}
+}
+
+// SetSettings wires the tunable settings registry. When wired, the tombstone
+// TTL is read live from the DB; otherwise the package const TombstoneTTL is
+// used.
+func (s *AnnotationService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
+
+// tombstoneTTL returns the active tombstone retention window.
+func (s *AnnotationService) tombstoneTTL() time.Duration {
+ if s.settings != nil {
+ return s.settings.TombstoneTTL()
+ }
+ return TombstoneTTL
+}
+
+// ActiveTombstoneTTL exposes the configured tombstone retention window for
+// callers outside the sync package (e.g. kobo/koreader handlers) that need to
+// compute cutoffs consistently with the service.
+func (s *AnnotationService) ActiveTombstoneTTL() time.Duration { return s.tombstoneTTL() }
+
type SaveOutcome string
const (
@@ -29,15 +59,6 @@ const (
SaveOutcomeDeleted SaveOutcome = "deleted"
)
-type AnnotationService struct {
- db *database.Queries
- connMgr *ConnectionManager
-}
-
-func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
- return &AnnotationService{db: db, connMgr: connMgr}
-}
-
type SaveHighlightRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
@@ -79,7 +100,7 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight
}
if existing.Deleted.Bool {
- if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
+ if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
}
return s.createHighlight(ctx, req, dedupKey)
@@ -238,7 +259,7 @@ func (s *AnnotationService) TombstoneHighlightByID(
}
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
- cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true}
+ cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true}
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
return fmt.Errorf("purge highlight tombstones: %w", err)
}
@@ -251,7 +272,13 @@ func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
return nil
}
-func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
+// StartDailyMaintenance launches a single background goroutine that runs all
+// periodic cleanup tasks once every 24 hours: expired annotation tombstones,
+// expired/revoked refresh tokens (retention follows the configured session
+// duration), and expired OPDS tokens. Each task is independent; a failure in
+// one is logged and does not skip the others. The returned CancelFunc stops the
+// goroutine and the underlying ticker; it must be invoked on shutdown.
+func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc {
ticker := time.NewTicker(24 * time.Hour)
ctx, cancel := context.WithCancel(context.Background())
@@ -262,9 +289,7 @@ func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
ticker.Stop()
return
case <-ticker.C:
- if err := s.PurgeExpiredTombstones(ctx); err != nil {
- log.Printf("AnnotationService: tombstone purge failed: %v", err)
- }
+ s.runDailyMaintenance(ctx)
}
}
}()
@@ -272,6 +297,28 @@ func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
return cancel
}
+// runDailyMaintenance executes every periodic cleanup task. Tasks run
+// sequentially under the single daily-tick goroutine so there is no added
+// concurrency. All three queries only delete rows that are already unusable
+// (expired or revoked), so this never logs out active sessions.
+func (s *AnnotationService) runDailyMaintenance(ctx context.Context) {
+ if err := s.PurgeExpiredTombstones(ctx); err != nil {
+ log.Printf("maintenance: tombstone purge failed: %v", err)
+ }
+ if err := s.db.CleanupExpiredOpdsTokens(ctx); err != nil {
+ log.Printf("maintenance: OPDS token purge failed: %v", err)
+ }
+ // Refresh-token retention follows the configured session duration; re-read
+ // on every tick so live settings changes are honored. Guarded so unwired
+ // test paths simply skip cleanup (production always wires the registry).
+ if s.settings != nil {
+ retention := s.settings.SessionDuration().Seconds()
+ if err := s.db.CleanupExpiredRefreshTokens(ctx, retention); err != nil {
+ log.Printf("maintenance: refresh token purge failed: %v", err)
+ }
+ }
+}
+
type SaveNoteRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
@@ -457,7 +504,7 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
}
if existing.Deleted.Bool {
- if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
+ if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
}
return s.createBookmark(ctx, req, dedupKey)
diff --git a/internal/sync/queue.go b/internal/sync/queue.go
index b7822e0..4a579b3 100644
--- a/internal/sync/queue.go
+++ b/internal/sync/queue.go
@@ -74,11 +74,18 @@ type SyncQueueItem struct {
}
func NewSyncQueueProcessor(db *database.Queries) *SyncQueueProcessor {
+ return NewSyncQueueProcessorWithConfig(db, 5*time.Second, 50)
+}
+
+// NewSyncQueueProcessorWithConfig constructs a processor with the given flush
+// interval and batch size. Used at startup to source values from the settings
+// registry.
+func NewSyncQueueProcessorWithConfig(db *database.Queries, interval time.Duration, batchSize int) *SyncQueueProcessor {
return &SyncQueueProcessor{
db: db,
progressChan: make(chan *ProgressUpdate, 100),
- interval: 5 * time.Second,
- batchSize: 50,
+ interval: interval,
+ batchSize: batchSize,
}
}
diff --git a/templates/admin.templ b/templates/admin.templ
index fb1d8a7..6951568 100644
--- a/templates/admin.templ
+++ b/templates/admin.templ
@@ -1,140 +1,136 @@
package templates
-templ Admin(user User) {
+templ Admin(user User, stats AdminStats) {
Admin Dashboard - Bookhoard
-
+
-
+
@Header(user, "/admin")
-
- @AdminSidebar(user, "/admin")
-
-
-
-
-
- @Icon("grid", "h-5 w-5")
-
-
Dashboard
-
-
Overview of your Bookhoard library and settings
+
+
+
+
+
+ @Icon("grid", "h-5 w-5")
+
+
Dashboard
-
-
-
-
-
- @Icon("sync", "h-5 w-5")
-
-
-
Scan Watch Status
-
Auto-detecting new files
-
-
-
-
- Watching 0 libraries
-
+
Overview of your Bookhoard instance
+
+
+
+
+
+ @Icon("library", "h-4 w-4")
+ Libraries
+
{ stats.LibraryCount }
-
-
Quick Actions
-
-
-
- @Icon("refresh", "h-5 w-5")
- Rescan Library
-
- Re-scan existing files and fix metadata
-
-
-
- @Icon("library", "h-5 w-5")
- Manage Libraries
-
- Add or remove libraries and scan directories
-
+
+
+ @Icon("book", "h-4 w-4")
+ Books
+
{ stats.MediaCount }
-
-
-
-
- @Icon("refresh", "h-5 w-5")
- Scanning Libraries
-
-
- @Icon("close", "h-5 w-5")
-
+
+
+ @Icon("users", "h-4 w-4")
+ Users
-
-
-
- Overall Progress
- 0%
-
-
-
- Starting scan...
-
+
{ stats.UserCount }
+
+
+
+ @Icon("device", "h-4 w-4")
+ Devices
-
-
-
+
{ stats.DeviceCount }
+
+
+
+
+
+
+ @Icon("sync", "h-5 w-5")
+
+
+
File Watcher
+
Auto-detects new files in library folders
-
-
-
- @Icon("check-circle", "h-5 w-5")
- Scan Complete!
-
-
-
-
-
-
- Refresh to View Books
-
-
- Dismiss
-
-
+
+
+ Watching 0 libraries
-
-
+
+
+
+
+
+
+ @Icon("refresh", "h-5 w-5")
+ Scanning Libraries
+
+
+ @Icon("close", "h-5 w-5")
+
+
+
+
+ Overall Progress
+ 0%
+
+
+
+ Starting scan...
+
+
+
+
+
+ @Icon("check-circle", "h-5 w-5")
+ Scan Complete!
+
+
+
+ Refresh to View Books
+ Dismiss
+
+
+
+
+
}
diff --git a/templates/admin_library.templ b/templates/admin_library.templ
index 6c570f4..033ad59 100644
--- a/templates/admin_library.templ
+++ b/templates/admin_library.templ
@@ -6,126 +6,54 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
Library Management - Bookhoard
+
+
-
+
@Header(user, "/admin/library")
-
- @AdminSidebar(user, "/admin/library")
-
-
-
-
-
-
- @Icon("library", "h-5 w-5")
-
-
Library Management
-
-
Manage libraries and configure media scanning
-
-
-
-
-
- @Icon("library", "h-5 w-5 shrink-0")
-
Libraries
-
-
Manage media libraries and their folders
-
- if len(libraries) == 0 {
-
- No libraries yet. Create your first library to get started.
-
- } else {
- for _, library := range libraries {
-
-
-
-
{ library.Name }
- if library.Description != "" {
-
{ library.Description }
- }
-
- @Icon("tag", "h-3 w-3")
- { library.TypeName }
-
-
-
-
- @Icon("folder", "h-4 w-4")
- Folders
-
-
- @Icon("edit", "h-4 w-4")
- Edit
-
-
- @Icon("trash", "h-4 w-4")
- Delete
-
-
-
-
-
- }
- }
+
+
+
+
+
+
+
+ @Icon("library", "h-5 w-5")
+
+
Libraries
+
Manage media libraries, folders, and scanning
-
-
-
- @Icon("check-circle", "h-5 w-5 shrink-0")
-
Library Visibility
-
-
Control which libraries are visible to users
-
-
-
-
-
-
-
-
- @Icon("users", "h-5 w-5 shrink-0")
-
User Library Access
-
-
Manage individual user access to specific libraries
-
-
- Select a user...
- for _, user := range users {
- { user.Username } ({ user.Email })
- }
-
-
-
-
-
+
+ @Icon("plus", "h-4 w-4")
+ Create Library
+
-
-
-
+
+ @LibraryList(user, libraries, users)
+
+
+
+
-
+
Create Library
-
+
@Icon("close", "h-5 w-5")
-
-
-
-
-
-
Browse Folders
-
+
+
+
+
+
Edit Library
+
@Icon("close", "h-5 w-5")
-
-
-
+
-
+
+
+
+
+
Browse Folders
+
+ @Icon("close", "h-5 w-5")
+
+
+
+
+
+
Delete Library
-
+
@Icon("close", "h-5 w-5")
-
-
-
+
+ Are you sure you want to delete ?
+ This will remove the library and all its folder mappings. Media files will not be deleted.
+
- Cancel
-
+ Cancel
+
@Icon("trash", "h-4 w-4")
Delete
-
+
}
+
+templ LibraryList(user User, libraries []LibraryData, users []User) {
+ if len(libraries) == 0 {
+
+
+ @Icon("library", "h-7 w-7")
+
+
No Libraries Yet
+
Create your first library to get started
+
Create Your First Library
+
+ } else {
+
+ for _, library := range libraries {
+
+
+
+
+ @Icon("library", "h-5 w-5")
+
+
+
{ library.Name }
+ if library.Description != "" {
+
{ library.Description }
+ }
+
+
+ @Icon("tag", "h-3 w-3")
+ { library.TypeName }
+
+ if library.FolderCount > 0 {
+
+ @Icon("folder", "h-3 w-3")
+ { library.FolderCount } folders
+
+ }
+
+
+
+ @Icon("refresh", "h-4 w-4")
+ Scan
+
+
+ @Icon("chevron-down", "h-4 w-4")
+ Manage
+
+
+
+
+
+ }
+
+ }
+
+}
+
+templ LibraryPanel(user User, libraryID string, library LibraryData, folders []FolderData, users []User, visibility []UserVisibilityData, issueCount int) {
+
+
+
+
+ @Icon("folder", "h-4 w-4 shrink-0")
+
Folders
+
+ if len(folders) == 0 {
+
No folders configured. Add a folder to enable scanning.
+ } else {
+
+ for _, folder := range folders {
+
+ { folder.FolderPath }
+
+ @Icon("trash", "h-3.5 w-3.5")
+
+
+ }
+
+ }
+
+
+
+ @Icon("folder", "h-4 w-4")
+ Browse
+
+
+ @Icon("plus", "h-4 w-4")
+ Add
+
+
+
+
+ if len(users) > 0 {
+
+
+ @Icon("users", "h-4 w-4 shrink-0")
+
User Access
+
+
+ for _, u := range users {
+
+
+ { u.Username }
+ { u.Email }
+
+ }
+
+
+ }
+
+ if issueCount > 0 {
+
+ }
+
+
+
+ @Icon("edit", "h-4 w-4")
+ Edit Details
+
+
+ @Icon("trash", "h-4 w-4")
+ Delete Library
+
+
+
+}
+
+templ FolderBrowserContent(currentPath string, parentPath string, entries []DirEntry, targetInput string, libraryID string) {
+
+
+ @Icon("folder", "h-4 w-4 shrink-0")
+ { currentPath }
+
+ if parentPath != "" {
+
+ @Icon("arrow-left", "h-4 w-4")
+ ..
+
+ }
+
+ for _, entry := range entries {
+
+ @Icon("folder", "h-4 w-4 shrink-0")
+ { entry.Name }
+
+ }
+
+
+
+ @Icon("check", "h-4 w-4")
+ Select This Folder
+
+
+
+}
diff --git a/templates/admin_library_templ.go b/templates/admin_library_templ.go
index e1ee1c3..bc03b1e 100644
--- a/templates/admin_library_templ.go
+++ b/templates/admin_library_templ.go
@@ -29,7 +29,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Library Management - Bookhoard ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Library Management - Bookhoard ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -37,31 +37,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = AdminSidebar(user, "/admin/library").Render(ctx, templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -69,234 +45,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Library Management Manage libraries and configure media scanning
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("library", "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, 7, "
Libraries Manage media libraries and their folders
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- if len(libraries) == 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
No libraries yet. Create your first library to get started.
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else {
- for _, library := range libraries {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var2 string
- templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 89}
- }
- _, 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, 10, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- if library.Description != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var3 string
- templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 56, Col: 92}
- }
- _, 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, 12, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("tag", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var4 string
- templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 60, Col: 33}
- }
- _, 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, 14, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("folder", "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, 16, "Folders ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("edit", "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, 18, "Edit ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("trash", "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, 20, "Delete
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("check-circle", "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, 23, "
Library Visibility Control which libraries are visible to users
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("users", "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, 24, "
User Library Access Manage individual user access to specific libraries
Select a user... ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- for _, user := range users {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var10 string
- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 107, Col: 51}
- }
- _, 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, 27, " (")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var11 string
- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 107, Col: 67}
- }
- _, 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, 28, ") ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Create Library ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("close", "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, 30, " Library Name
Description
Library Type Ebooks Comics Manga
Cancel ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Libraries
Manage media libraries, folders, and scanning
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -304,7 +53,15 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Create
Browse Folders ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Create Library ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = LibraryList(user, libraries, users).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
Create Library ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -312,7 +69,15 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
Delete Library ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " Library Name
Description
Library Type Ebooks Comics Manga
Cancel ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("plus", "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, 7, "Create
Edit Library ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -320,7 +85,31 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
Cancel ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
Library Name
Description
Cancel ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("save", "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, 9, "Save
Browse Folders ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("close", "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, "
Delete Library ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("close", "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, 11, " Are you sure you want to delete ? This will remove the library and all its folder mappings. Media files will not be deleted.
Cancel ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -328,7 +117,813 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Delete
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Delete
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func LibraryList(user User, libraries []LibraryData, users []User) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var2 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var2 == nil {
+ templ_7745c5c3_Var2 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if len(libraries) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("library", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " No Libraries Yet Create your first library to get started
Create Your First Library ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, library := range libraries {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("library", "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, 17, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 207, Col: 92}
+ }
+ _, 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, 18, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if library.Description != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 209, Col: 95}
+ }
+ _, 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, 20, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("tag", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 214, Col: 26}
+ }
+ _, 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, 22, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if library.FolderCount > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("folder", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 219, Col: 30}
+ }
+ _, 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, 24, " folders ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("refresh", "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, 27, "Scan ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("chevron-down", "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, 29, "Manage
")
+ 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, 32, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func LibraryPanel(user User, libraryID string, library LibraryData, folders []FolderData, users []User, visibility []UserVisibilityData, issueCount int) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var10 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var10 == nil {
+ templ_7745c5c3_Var10 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
Folders ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(folders) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
No folders configured. Add a folder to enable scanning.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, folder := range folders {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 266, Col: 99}
+ }
+ _, 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, 38, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("trash", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("folder", "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, 50, "Browse ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("plus", "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, 51, "Add ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(users) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("users", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
User Access ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, u := range users {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 76}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 329, Col: 75}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if issueCount > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("alert", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if issueCount == 1 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "1 processing issue ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var26 string
+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 343, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " processing issues ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = Icon("chevron-right", "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, 69, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("edit", "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, 74, "Edit Details ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("trash", "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, 77, "Delete Library
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func FolderBrowserContent(currentPath string, parentPath string, entries []DirEntry, targetInput string, libraryID string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var32 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var32 == nil {
+ templ_7745c5c3_Var32 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var33 string
+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 378, Col: 89}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if parentPath != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
")
+ 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, 83, ".. ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, entry := range entries {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var36 string
+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 402, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("check", "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, 92, "Select This Folder
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/templates/admin_processing_issues.templ b/templates/admin_processing_issues.templ
index 9c58178..ad2cdf2 100644
--- a/templates/admin_processing_issues.templ
+++ b/templates/admin_processing_issues.templ
@@ -7,28 +7,23 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
Processing Issues - Bookhoard
+
-
- @Header(user, "/admin/libraries/"+libraryID)
-
-
-
-
-
-
-
- @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
-
+
+ @Header(user, "/admin/library")
+
+
+
+
+
+
+ @Icon("alert", "h-5 w-5")
+
+
Processing Issues
+
Items that couldn't be processed in this library
+
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
@@ -72,7 +67,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
for _, issue := range issues {
-
+
{ issue.Title }
@@ -94,17 +89,20 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
}
-
- if issue.Severity == "warning" || issue.Severity == "info" {
-
- @Icon("close", "h-4 w-4")
- Dismiss
-
- }
-
+
+ if issue.Severity == "warning" || issue.Severity == "info" {
+
+ @Icon("close", "h-4 w-4")
+ Dismiss
+
+ }
+
}
diff --git a/templates/admin_processing_issues_templ.go b/templates/admin_processing_issues_templ.go
index 0b56c9a..3ecfb4c 100644
--- a/templates/admin_processing_issues_templ.go
+++ b/templates/admin_processing_issues_templ.go
@@ -29,15 +29,15 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Processing Issues - Bookhoard ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Processing Issues - Bookhoard ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = Header(user, "/admin/libraries/"+libraryID).Render(ctx, templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = Header(user, "/admin/library").Render(ctx, templ_7745c5c3_Buffer)
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,25 +45,17 @@ 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, 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 ")
+ 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
}
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if stats.ErrorCount > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -71,26 +63,26 @@ 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, 7, "
Errors ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
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: 41, Col: 92}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, 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, 8, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if stats.WarningCount > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -98,26 +90,26 @@ 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, 10, "
Warnings ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
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: 50, Col: 94}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, 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, 11, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if stats.InfoCount > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -125,31 +117,31 @@ 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, 13, "
Info ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
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: 59, Col: 91}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 54, 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, 14, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(issues) == 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -157,26 +149,39 @@ 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, 17, " No processing issues found for this library.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " 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, 18, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, issue := range issues {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, 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: 73, Col: 98}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -184,12 +189,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var6 string
- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, 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: 79, Col: 96}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -197,12 +202,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var7 string
- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, 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: 81, Col: 140}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -210,12 +215,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var8 string
- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, 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: 82, Col: 144}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -223,12 +228,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var9 string
- templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, 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: 83, Col: 139}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -236,12 +241,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var10 string
- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, 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: 84, Col: 149}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -254,12 +259,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var11 string
- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
+ 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: 89, Col: 62}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 62}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -272,12 +277,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var12 string
- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
+ 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: 91, Col: 63}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 86, Col: 63}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -290,12 +295,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var13 string
- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, 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}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 88, Col: 66}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -309,7 +314,33 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
return templ_7745c5c3_Err
}
if issue.Severity == "warning" || issue.Severity == "info" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -317,22 +348,22 @@ 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, 34, "Dismiss ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Dismiss ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ
index d0e2a1e..750da4c 100644
--- a/templates/admin_settings.templ
+++ b/templates/admin_settings.templ
@@ -1,6 +1,8 @@
package templates
-templ AdminSettings(user User, systemConfig map[string]string, errorMessage string) {
+import "fmt"
+
+templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
@@ -8,28 +10,21 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
System Settings - Bookhoard
+
-
- @Header(user, "/admin/settings")
-
- @AdminSidebar(user, "/admin/settings")
-
-
-
-
-
-
- @Icon("settings", "h-5 w-5")
-
-
System Settings
-
-
Configure your Bookhoard instance
-
+
+ @Header(user, "/admin/settings")
+
+
+
+
+
+ @Icon("gear", "h-5 w-5")
+
+
System Settings
+
+
Configure your Bookhoard instance
+
if errorMessage != "" {
@Icon("alert", "h-5 w-5 shrink-0 mt-0.5")
@@ -113,9 +108,150 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
Device Sync: { systemConfig["base_url"] }/api/sync
-
-
+ @ScanSettingsSection(scanSettings)
+ @TunableSettingsSection(liveGroups, false)
+ @TunableSettingsSection(restartGroups, true)
-
+
+
}
+
+templ ScanSettingsSection(scanSettings ScanSettingsData) {
+
+
+ @Icon("refresh", "h-5 w-5 shrink-0")
+
Scanning
+
+
+
+
+
+
Auto-Scan
+
Watch libraries for file changes on startup
+
+
+
+
+
+
+
+
+
Scan Interval (seconds)
+
+
How often to poll libraries for changes (1–3600 seconds). Default: 60.
+
+
+
+ @Icon("save", "h-4 w-4")
+ Save Scan Settings
+
+
+
+
+
+}
+
+// TunableSettingsSection renders the editable tunables for a given bucket
+// (live vs restart-required). Within the card, settings are clustered into
+// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits").
+templ TunableSettingsSection(groups []SettingGroup, restartRequired bool) {
+
+
+ if restartRequired {
+ @Icon("alert", "h-5 w-5 shrink-0")
+
Tunable Settings — Restart Required
+ } else {
+ @Icon("settings", "h-5 w-5 shrink-0")
+ Tunable Settings — Live
+ }
+
+ if restartRequired {
+
Changes are saved immediately but only take effect after the server restarts.
+ } else {
+
Changes apply immediately — no restart needed.
+ }
+ for _, g := range groups {
+
+
{ g.Name }
+
+ for _, e := range g.Entries {
+ @TunableSettingRow(e)
+ }
+
+
+ }
+
+}
+
+// TunableSettingRow renders a single editable setting as an inline HTMX form.
+templ TunableSettingRow(e SettingEntry) {
+
+
+
{ e.Description }
+ if !e.IsDefault {
+
{ e.Key } — modified from default
+ } else {
+
{ e.Key }
+ }
+
+
+
+ if e.Type == "bool" {
+
+ Yes
+ No
+
+ } else if e.Type == "int" {
+
+ } else {
+
+ }
+
+ @Icon("save", "h-3.5 w-3.5")
+ Save
+
+
+
+
+}
diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go
index ec93dcc..ffa9b5d 100644
--- a/templates/admin_settings_templ.go
+++ b/templates/admin_settings_templ.go
@@ -8,7 +8,9 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
-func AdminSettings(user User, systemConfig map[string]string, errorMessage string) templ.Component {
+import "fmt"
+
+func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -29,7 +31,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
System Settings - Bookhoard ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
System Settings - Bookhoard ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -37,36 +39,20 @@ 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
}
- templ_7745c5c3_Err = AdminSidebar(user, "/admin/settings").Render(ctx, templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = Icon("gear", "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, "")
- 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
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
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, 6, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -74,25 +60,25 @@ 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, 7, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "")
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: 36, Col: 28}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 31, 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, 8, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
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, 7, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -100,20 +86,20 @@ 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, 10, "
Base URL Base URL Base URL
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" placeholder=\"https://books.example.com\" class=\"input\" required>The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -121,7 +107,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, 12, "Save Settings
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Save Settings
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -129,247 +115,247 @@ 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, 13, "
System Defaults Default Timezone System Defaults
Default Timezone UTC (UTC+0) UTC (UTC+0) Hawaii (UTC-10) Hawaii (UTC-10) Alaska (UTC-9/-8) Alaska (UTC-9/-8) Pacific (UTC-8/-7) Pacific (UTC-8/-7) Mountain (UTC-7/-6) Mountain (UTC-7/-6) Mountain - no DST (UTC-7) Mountain - no DST (UTC-7) Central (UTC-6/-5) Central (UTC-6/-5) Eastern (UTC-5/-4) Eastern (UTC-5/-4) Brasilia (UTC-3/-2) Brasilia (UTC-3/-2) British (UTC+0/+1) British (UTC+0/+1) Central European (UTC+1/+2) Central European (UTC+1/+2) Eastern European (UTC+2/+3) Eastern European (UTC+2/+3) Moscow (UTC+3) Moscow (UTC+3) Iran (UTC+3:30) Iran (UTC+3:30) Gulf (UTC+4) Gulf (UTC+4) Pakistan (UTC+5) Pakistan (UTC+5) India (UTC+5:30) India (UTC+5:30) Bangladesh (UTC+6) Bangladesh (UTC+6) Indochina (UTC+7) Indochina (UTC+7) China (UTC+8) China (UTC+8) Japan/Korea (UTC+9) Japan/Korea (UTC+9) Australian Central (UTC+9:30) Australian Central (UTC+9:30) Australian Eastern (UTC+10/+11) Australian Eastern (UTC+10/+11) New Zealand (UTC+12/+13) Default timezone for users who haven't set their own.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">New Zealand (UTC+12/+13)
Default timezone for users who haven't set their own.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -377,46 +363,468 @@ 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, 62, "
URL Paths OPDS: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
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: 111, Col: 145}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, 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, 63, "/opds
API: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "/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: 112, Col: 144}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, 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, 64, "/api
Device Sync: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "/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: 113, Col: 152}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 108, 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, 65, "/api/sync
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/api/sync ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = ScanSettingsSection(scanSettings).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = TunableSettingsSection(liveGroups, false).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = TunableSettingsSection(restartGroups, true).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var7 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var7 == nil {
+ templ_7745c5c3_Var7 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("refresh", "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, 66, "
Scanning Auto-Scan Watch libraries for file changes on startup
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("save", "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, 70, "Save Scan Settings
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// TunableSettingsSection renders the editable tunables for a given bucket
+// (live vs restart-required). Within the card, settings are clustered into
+// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits").
+func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var9 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var9 == nil {
+ templ_7745c5c3_Var9 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if restartRequired {
+ templ_7745c5c3_Err = Icon("alert", "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, 72, "
Tunable Settings — Restart Required ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = Icon("settings", "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, 73, " Tunable Settings — Live ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if restartRequired {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
Changes are saved immediately but only take effect after the server restarts.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
Changes apply immediately — no restart needed.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ for _, g := range groups {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 194, Col: 112}
+ }
+ _, 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, 78, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, e := range g.Entries {
+ templ_7745c5c3_Err = TunableSettingRow(e).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// TunableSettingRow renders a single editable setting as an inline HTMX form.
+func TunableSettingRow(e SettingEntry) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var11 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var11 == nil {
+ templ_7745c5c3_Var11 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 209, Col: 94}
+ }
+ _, 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, 82, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if !e.IsDefault {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 211, Col: 74}
+ }
+ _, 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, 84, " — modified from default
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 74}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if e.Type == "bool" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "Yes No ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if e.Type == "int" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("save", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Save ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/templates/admin_sidebar.templ b/templates/admin_sidebar.templ
deleted file mode 100644
index f213594..0000000
--- a/templates/admin_sidebar.templ
+++ /dev/null
@@ -1,32 +0,0 @@
-package templates
-
-templ AdminSidebar(user User, currentPath string) {
-
-}
diff --git a/templates/admin_sidebar_templ.go b/templates/admin_sidebar_templ.go
deleted file mode 100644
index dfa9407..0000000
--- a/templates/admin_sidebar_templ.go
+++ /dev/null
@@ -1,168 +0,0 @@
-// 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.
-
-import "github.com/a-h/templ"
-import templruntime "github.com/a-h/templ/runtime"
-
-func AdminSidebar(user User, currentPath string) templ.Component {
- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
- return templ_7745c5c3_CtxErr
- }
- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
- if !templ_7745c5c3_IsBuffer {
- defer func() {
- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err == nil {
- templ_7745c5c3_Err = templ_7745c5c3_BufErr
- }
- }()
- }
- ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var1 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var1 == nil {
- templ_7745c5c3_Var1 = templ.NopComponent
- }
- ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("shield", "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, 2, "
Admin Panel ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var2 = []any{activeClass(currentPath, "/admin")}
- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("grid", "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, 5, "Dashboard ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var4 = []any{activeClass(currentPath, "/admin/users")}
- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("users", "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, 8, "Users ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var6 = []any{activeClass(currentPath, "/admin/library")}
- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("library", "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, 11, "Library ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/admin/settings")}
- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = Icon("settings", "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, 14, "Settings ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- return nil
- })
-}
-
-var _ = templruntime.GeneratedTemplate
diff --git a/templates/admin_templ.go b/templates/admin_templ.go
index 5351e6b..3d54462 100644
--- a/templates/admin_templ.go
+++ b/templates/admin_templ.go
@@ -8,7 +8,7 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
-func Admin(user User) templ.Component {
+func Admin(user User, stats AdminStats) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -29,7 +29,7 @@ func Admin(user User) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Admin Dashboard - Bookhoard ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Admin Dashboard - Bookhoard ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -37,15 +37,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = AdminSidebar(user, "/admin").Render(ctx, templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -53,23 +45,91 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
Dashboard Overview of your Bookhoard library and settings
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Dashboard Overview of your Bookhoard instance
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = Icon("library", "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, 5, "
Library Manage your ebook collection
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Libraries ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = Icon("arrow-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, 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, 6, "View Library
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("book", "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, 6, "Books
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90}
+ }
+ _, 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, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("users", "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, 8, "Users
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89}
+ }
+ _, 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, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Icon("device", "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, 10, "Devices
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91}
+ }
+ _, 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, 11, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -77,7 +137,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " Scan Watch Status Auto-detecting new files
Watching 0 libraries
Quick Actions ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " File Watcher Auto-detects new files in library folders
Watching 0 libraries
Quick Actions ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -85,7 +145,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Rescan Library Re-scan existing files and fix metadata ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Scan All Libraries Re-scan existing files and detect new items ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -93,7 +153,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Manage Libraries Add or remove libraries and scan directories
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Manage Libraries Add or remove libraries and folders
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -101,7 +161,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Scanning Libraries ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Scanning Libraries")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -109,7 +169,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " Overall Progress 0%
Starting scan...
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " Overall Progress 0%
Starting scan...
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -117,7 +177,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Scan Complete!
Refresh to View Books Dismiss
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scan Complete!
Refresh to View Books Dismiss
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/templates/admin_users.templ b/templates/admin_users.templ
index 878cb1f..3cfa182 100644
--- a/templates/admin_users.templ
+++ b/templates/admin_users.templ
@@ -8,16 +8,15 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
Users - Bookhoard Admin
+
- @Header(currentUser, "/admin/users")
-
-
-
- @AdminSidebar(currentUser, "/admin/users")
-
-
-
+ @Header(currentUser, "/admin/users")
+
+
+
+
+
@Icon("users", "h-5 w-5")
@@ -76,14 +75,11 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
} else {
User
Admin
@@ -119,7 +115,6 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
} else {
-
-
-
+
+