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("library", "h-5 w-5") - -
-

Library

-

Manage your ebook collection

-
-
- - @Icon("arrow-right", "h-4 w-4") - View Library - -
-
-
- - @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("library", "h-5 w-5") - Manage Libraries - - Add or remove libraries and scan directories - +
+
+ @Icon("book", "h-4 w-4") + Books
+

{ stats.MediaCount }

- -
-
+ +
+

Quick Actions

+
+ + + + @Icon("library", "h-5 w-5") + Manage Libraries + + Add or remove libraries and folders + +
+
+ + +
+
} 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("arrow-left", "h-4 w-4") - Back to Dashboard - - -
-
- - @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("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

-
- -
-
- -
+
-
-
- +
+ @LibraryList(user, libraries, users) +
+
+
+ ") + 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

") + 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 = 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 = 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 + } + 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 + } + } + 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 + } + 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 = 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
") 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" { - - } -
+
+ if issue.Severity == "warning" || issue.Severity == "info" { + + } +
}
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, 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

+
+
+
+
+
+ +

Watch libraries for file changes on startup

+
+ +
+
+ + +

How often to poll libraries for changes (1–3600 seconds). Default: 60.

+
+
+ +
+
+
+
+} + +// 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) { +
+
+ + if !e.IsDefault { +

{ e.Key } — modified from default

+ } else { +

{ e.Key }

+ } +
+
+ + if e.Type == "bool" { + + } else if e.Type == "int" { + + } else { + + } + +
+ +
+} 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

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

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

Watch libraries for file changes on startup

How often to poll libraries for changes (1–3600 seconds). Default: 60.

") + 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 + } + 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, " ") + 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 } 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 - } - 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

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

Quick Actions

") + 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, 17, "Scan Complete!
") 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 {
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
Admin
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"text-xs mt-1\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 96, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Edit ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if user.Role == "admin" && adminCount == 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/analytics.templ b/templates/analytics.templ index 105049c..c707669 100644 --- a/templates/analytics.templ +++ b/templates/analytics.templ @@ -9,6 +9,7 @@ templ Analytics(user User) { Reading Analytics - Bookhoard + @Header(user, "/analytics") diff --git a/templates/analytics_templ.go b/templates/analytics_templ.go index 5a44334..198eda9 100644 --- a/templates/analytics_templ.go +++ b/templates/analytics_templ.go @@ -29,7 +29,7 @@ func Analytics(user User) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Analytics - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Analytics - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/book_detail.templ b/templates/book_detail.templ index 27986c0..9989cf3 100644 --- a/templates/book_detail.templ +++ b/templates/book_detail.templ @@ -18,6 +18,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { { book.Title } - Bookhoard + Library - Bookhoard + Library - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Library - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -97,7 +97,7 @@ func BookShelf( var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 92, Col: 135} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -110,7 +110,7 @@ func BookShelf( var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 94, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -173,7 +173,7 @@ func BookShelf( var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 146, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 147, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -191,7 +191,7 @@ func BookShelf( var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 148, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 149, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -211,7 +211,7 @@ func BookShelf( var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -224,7 +224,7 @@ func BookShelf( var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -237,7 +237,7 @@ func BookShelf( var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -255,7 +255,7 @@ func BookShelf( var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -268,7 +268,7 @@ func BookShelf( var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -281,7 +281,7 @@ func BookShelf( var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -310,7 +310,7 @@ func BookShelf( var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 268, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -333,7 +333,7 @@ func BookShelf( var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 277, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 278, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { @@ -364,7 +364,7 @@ func BookShelf( var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 286, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 287, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -377,7 +377,7 @@ func BookShelf( var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 291, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 292, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { diff --git a/templates/browse_detail.templ b/templates/browse_detail.templ index 5b299a3..378c444 100644 --- a/templates/browse_detail.templ +++ b/templates/browse_detail.templ @@ -13,6 +13,7 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, { pageTitle } - Bookhoard + @Header(user, backUrl) diff --git a/templates/browse_detail_templ.go b/templates/browse_detail_templ.go index 47995ba..ee2ca39 100644 --- a/templates/browse_detail_templ.go +++ b/templates/browse_detail_templ.go @@ -47,7 +47,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -62,7 +62,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 21, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -83,7 +83,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 23, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -96,7 +96,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -109,7 +109,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -122,7 +122,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 32, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -135,7 +135,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 125} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -168,7 +168,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 43, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -181,7 +181,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 45, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { diff --git a/templates/collection_rules.templ b/templates/collection_rules.templ index fa08c61..0ca4a21 100644 --- a/templates/collection_rules.templ +++ b/templates/collection_rules.templ @@ -9,6 +9,7 @@ templ CollectionRules(user User, collection CollectionData) { Collection Rules - { collection.Name } - Bookhoard + @Header(user, "/collections") diff --git a/templates/collection_rules_templ.go b/templates/collection_rules_templ.go index 2415673..c6a3d2f 100644 --- a/templates/collection_rules_templ.go +++ b/templates/collection_rules_templ.go @@ -42,7 +42,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -65,7 +65,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 176} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 176} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -78,7 +78,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -99,7 +99,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 44, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 45, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { diff --git a/templates/collections.templ b/templates/collections.templ index aac3a23..c7382ad 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -11,6 +11,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) { Collections - Bookhoard + @Header(user, "/collections") @@ -116,6 +117,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo { collection.Name } - Bookhoard + @Header(user, "/collections") diff --git a/templates/collections_templ.go b/templates/collections_templ.go index 68ef4b3..8eee24f 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -31,7 +31,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Collections - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Collections - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -97,7 +97,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 70, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -110,7 +110,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 73, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -123,7 +123,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 76, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -136,7 +136,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 79, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -157,7 +157,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 88, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -178,7 +178,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 98, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -191,7 +191,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -246,13 +246,13 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 116, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27} } _, 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, 22, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -279,7 +279,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 130, Col: 166} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 166} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -292,7 +292,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -305,7 +305,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 133, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -355,7 +355,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -368,7 +368,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -381,7 +381,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 147} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 147} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -404,7 +404,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var17 templ.SafeURL templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -417,7 +417,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -435,7 +435,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -453,7 +453,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var20 templ.SafeURL templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 204, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -471,7 +471,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 207, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 209, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) if templ_7745c5c3_Err != nil { @@ -551,7 +551,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 383, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { @@ -564,7 +564,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 384, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 386, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) if templ_7745c5c3_Err != nil { @@ -577,7 +577,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) if templ_7745c5c3_Err != nil { @@ -620,7 +620,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 395, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 397, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) if templ_7745c5c3_Err != nil { @@ -638,7 +638,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 399, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) if templ_7745c5c3_Err != nil { @@ -651,7 +651,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 399, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) if templ_7745c5c3_Err != nil { @@ -669,7 +669,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 403, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) if templ_7745c5c3_Err != nil { @@ -687,7 +687,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 404, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 406, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) if templ_7745c5c3_Err != nil { @@ -700,7 +700,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 409, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 411, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) if templ_7745c5c3_Err != nil { @@ -713,7 +713,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var32 string templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 416, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 418, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { diff --git a/templates/conflicts.templ b/templates/conflicts.templ index a9b87fe..8e50234 100644 --- a/templates/conflicts.templ +++ b/templates/conflicts.templ @@ -11,6 +11,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in Sync Conflicts - Bookhoard + @Header(user, "/conflicts") diff --git a/templates/conflicts_templ.go b/templates/conflicts_templ.go index 4a714c5..20ef339 100644 --- a/templates/conflicts_templ.go +++ b/templates/conflicts_templ.go @@ -31,7 +31,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sync Conflicts - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sync Conflicts - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -54,7 +54,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 44, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 45, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -67,7 +67,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 48, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -80,7 +80,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 52, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 53, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -128,7 +128,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 110, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 111, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -141,7 +141,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 116, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 117, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -162,7 +162,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 124, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -175,7 +175,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 126, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -188,7 +188,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 132, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 133, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -206,7 +206,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 134, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 135, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { diff --git a/templates/custom_section.templ b/templates/custom_section.templ index 341d88c..f8ad18a 100644 --- a/templates/custom_section.templ +++ b/templates/custom_section.templ @@ -9,6 +9,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri Create Custom Section - Bookhoard + @Header(user, "/custom-section") diff --git a/templates/custom_section_templ.go b/templates/custom_section_templ.go index 1c90ad8..e4bfc0a 100644 --- a/templates/custom_section_templ.go +++ b/templates/custom_section_templ.go @@ -29,7 +29,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Create Custom Section - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Create Custom Section - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -65,7 +65,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -78,7 +78,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { diff --git a/templates/dashboard.templ b/templates/dashboard.templ index 90906da..799f17b 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -14,6 +14,7 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle Dashboard - Bookhoard + @Header(user, "/dashboard") diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go index 5948b96..82f7450 100644 --- a/templates/dashboard_templ.go +++ b/templates/dashboard_templ.go @@ -34,7 +34,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Dashboard - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Dashboard - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -104,7 +104,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 39, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -117,7 +117,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 41, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -130,7 +130,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 44, Col: 130} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 130} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -143,7 +143,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 46, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -161,7 +161,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -184,7 +184,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var8 templ.SafeURL templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -210,7 +210,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 67, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -231,7 +231,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 75, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -276,7 +276,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 94, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -326,7 +326,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var13 templ.SafeURL templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 107, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -344,7 +344,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -357,7 +357,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 116, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -375,7 +375,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -393,7 +393,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 129, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { @@ -406,7 +406,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -424,7 +424,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 133, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) if templ_7745c5c3_Err != nil { @@ -437,7 +437,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 19} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -460,7 +460,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var21 templ.SafeURL templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 142, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -473,7 +473,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 144, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { @@ -499,7 +499,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var23 templ.SafeURL templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 151, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 152, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -512,7 +512,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 153, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 154, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) if templ_7745c5c3_Err != nil { @@ -580,7 +580,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 183, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) if templ_7745c5c3_Err != nil { @@ -593,7 +593,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) if templ_7745c5c3_Err != nil { @@ -614,7 +614,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 189, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -627,7 +627,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 191, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 192, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -655,7 +655,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 201, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) if templ_7745c5c3_Err != nil { @@ -688,7 +688,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 227, Col: 128} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 128} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -701,7 +701,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var32 string templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 234, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) if templ_7745c5c3_Err != nil { diff --git a/templates/devices.templ b/templates/devices.templ index d6592ac..21d8bce 100644 --- a/templates/devices.templ +++ b/templates/devices.templ @@ -11,6 +11,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P Device Management - Bookhoard + @Header(user, "/devices") diff --git a/templates/devices_templ.go b/templates/devices_templ.go index e6a7e53..2edbb64 100644 --- a/templates/devices_templ.go +++ b/templates/devices_templ.go @@ -31,7 +31,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Device Management - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Device Management - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -111,7 +111,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 63, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 64, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -132,7 +132,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 66, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -153,7 +153,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 71, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -166,7 +166,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -199,7 +199,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 85, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -227,7 +227,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 93, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -255,7 +255,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 109, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 110, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { @@ -282,7 +282,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 131, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 132, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -308,7 +308,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 146, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 147, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -349,7 +349,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 167, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 168, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -362,7 +362,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 169, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -375,7 +375,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 169, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -388,7 +388,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 173, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 174, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -401,7 +401,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 176, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 177, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -440,7 +440,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 241, Col: 148} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 242, Col: 148} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -453,7 +453,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 243, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 244, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { diff --git a/templates/docs.templ b/templates/docs.templ index 9ef039c..08f3731 100644 --- a/templates/docs.templ +++ b/templates/docs.templ @@ -10,6 +10,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) { { doc.Title } - Bookhoard Documentation + diff --git a/templates/docs_templ.go b/templates/docs_templ.go index cc93637..d166186 100644 --- a/templates/docs_templ.go +++ b/templates/docs_templ.go @@ -44,7 +44,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -102,7 +102,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 49, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -133,7 +133,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var6 templ.SafeURL templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 55, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -146,7 +146,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -159,7 +159,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -187,7 +187,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var9 templ.SafeURL templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -200,7 +200,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -213,7 +213,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -257,7 +257,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var12 templ.SafeURL templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -270,7 +270,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 129} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -293,7 +293,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 85, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -324,7 +324,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var15 templ.SafeURL templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 95, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -337,7 +337,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 97, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -350,7 +350,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 99, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { diff --git a/templates/error.templ b/templates/error.templ index 494851b..e575c56 100644 --- a/templates/error.templ +++ b/templates/error.templ @@ -7,6 +7,7 @@ templ ErrorPage(message string, errorType string) { Error - Bookhoard +
⚠️

Something went wrong

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Error - Bookhoard

⚠️

Something went wrong

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 83, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 89, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { diff --git a/templates/header.templ b/templates/header.templ index 92802b4..e0293d8 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -20,7 +20,9 @@ templ Header(user User, currentPath string) { class="flex items-center gap-2.5 px-5 h-16 shrink-0 border-b" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);" > - 📚 + + @Icon("book-open", "h-6 w-6") + Bookhoard { opt.Label } - if user.Theme == opt.Name { - @Icon("check", "h-4 w-4 shrink-0") - } + + @Icon("check", "h-4 w-4") + }

@@ -122,6 +125,44 @@ templ Header(user User, currentPath string) { }
+ + if user.Role == "admin" { + + } @@ -173,12 +214,6 @@ templ SidebarUserMenu(user User) { @Icon("user", "h-4 w-4") Profile - if user.Role == "admin" { - - @Icon("settings", "h-4 w-4") - Admin Panel - - }
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, opt := range ThemeOptions { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

Bookshelf

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, w := range WoodOptions { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">") + 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, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if user.Role == "admin" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 = []any{activeClass(currentPath, "/admin")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + 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, 42, "Dashboard ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 = []any{activeClass(currentPath, "/admin/library")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...) + 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 = 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, 45, "Libraries ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 = []any{activeClass(currentPath, "/admin/users")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + 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, 48, "Users ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 = []any{activeClass(currentPath, "/admin/settings")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var29...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("gear", "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, 51, "Settings
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -364,7 +543,7 @@ func Header(user User, currentPath string) templ.Component { 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, 54, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -389,12 +568,12 @@ func SidebarUserMenu(user User) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var20 := templ.GetChildren(ctx) - if templ_7745c5c3_Var20 == nil { - templ_7745c5c3_Var20 = templ.NopComponent + templ_7745c5c3_Var31 := templ.GetChildren(ctx) + if templ_7745c5c3_Var31 == nil { + templ_7745c5c3_Var31 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -431,25 +610,7 @@ func SidebarUserMenu(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "Profile ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if user.Role == "admin" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("settings", "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, 42, "Admin Panel ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Logout
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -483,12 +644,12 @@ func SidebarSignIn(currentPath string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var22 := templ.GetChildren(ctx) - if templ_7745c5c3_Var22 == nil { - templ_7745c5c3_Var22 = templ.NopComponent + templ_7745c5c3_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
Sign in
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">
Create an account
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/index.templ b/templates/index.templ index 46d4045..bed39b3 100644 --- a/templates/index.templ +++ b/templates/index.templ @@ -10,6 +10,7 @@ templ Index(loggedIn bool) { +
diff --git a/templates/index_templ.go b/templates/index_templ.go index 681bb02..7852fff 100644 --- a/templates/index_templ.go +++ b/templates/index_templ.go @@ -29,7 +29,7 @@ func Index(loggedIn bool) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Bookhoard - Home
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -41,7 +41,7 @@ func Index(loggedIn bool) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 23, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -54,7 +54,7 @@ func Index(loggedIn bool) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 23, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { diff --git a/templates/login.templ b/templates/login.templ index 5b64550..3d239ca 100644 --- a/templates/login.templ +++ b/templates/login.templ @@ -10,6 +10,7 @@ templ Login(sessionExpired bool, deleted bool) { +
diff --git a/templates/login_templ.go b/templates/login_templ.go index 6bb4c79..624147e 100644 --- a/templates/login_templ.go +++ b/templates/login_templ.go @@ -29,7 +29,7 @@ func Login(sessionExpired bool, deleted bool) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Login - Bookhoard
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -41,7 +41,7 @@ func Login(sessionExpired bool, deleted bool) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 23, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -54,7 +54,7 @@ func Login(sessionExpired bool, deleted bool) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 23, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { diff --git a/templates/profile.templ b/templates/profile.templ index b4adc4d..cdd1fb6 100644 --- a/templates/profile.templ +++ b/templates/profile.templ @@ -8,6 +8,7 @@ templ Profile(user User) { Profile Settings - Bookhoard + @Header(user, "/profile") diff --git a/templates/profile_templ.go b/templates/profile_templ.go index b2b4401..b28e57e 100644 --- a/templates/profile_templ.go +++ b/templates/profile_templ.go @@ -29,7 +29,7 @@ func Profile(user User) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Profile Settings - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Profile Settings - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/progress.templ b/templates/progress.templ index 85b9131..62fa10d 100644 --- a/templates/progress.templ +++ b/templates/progress.templ @@ -12,6 +12,7 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag Reading Progress - Bookhoard + @Header(user, "/progress") diff --git a/templates/progress_templ.go b/templates/progress_templ.go index cc4b862..9d332d2 100644 --- a/templates/progress_templ.go +++ b/templates/progress_templ.go @@ -32,7 +32,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Progress - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Progress - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -74,7 +74,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 43, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 44, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -87,7 +87,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 53, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 54, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -100,7 +100,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 57, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 58, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -118,7 +118,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 60, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 61, Col: 91} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -136,7 +136,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", item.ProgressPercentage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 63, Col: 127} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 64, Col: 127} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -149,7 +149,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 67, Col: 162} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 68, Col: 162} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -164,7 +164,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 76, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 77, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -174,7 +174,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 78, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 79, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -185,7 +185,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 81, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 82, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -199,7 +199,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 87, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 88, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -212,7 +212,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 92, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 93, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -225,7 +225,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 93, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 94, Col: 98} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -251,7 +251,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 100, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 101, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -264,7 +264,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 101, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 102, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -291,7 +291,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 107, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 108, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { diff --git a/templates/queue.templ b/templates/queue.templ index 7438190..5f5ca16 100644 --- a/templates/queue.templ +++ b/templates/queue.templ @@ -11,6 +11,7 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q Sync Queue - Bookhoard + @Header(user, "/queue") diff --git a/templates/queue_templ.go b/templates/queue_templ.go index 1172028..e43687d 100644 --- a/templates/queue_templ.go +++ b/templates/queue_templ.go @@ -31,7 +31,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sync Queue - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sync Queue - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -62,7 +62,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.PendingCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 40, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 41, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -75,7 +75,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ProcessingCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 44, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 45, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -88,7 +88,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.CompletedCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 48, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 49, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -101,7 +101,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.FailedCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 52, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 53, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -149,7 +149,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 129, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 130, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -162,7 +162,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.SyncType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 130, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 131, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -175,7 +175,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Priority) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 132, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 133, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -188,7 +188,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 135, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 136, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -206,7 +206,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(*item.MediaTitle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 137, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 138, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -220,7 +220,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Attempts) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 141, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -233,7 +233,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.MaxAttempts) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 141, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -246,7 +246,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.CreatedAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 143, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -272,7 +272,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(*item.ErrorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 147, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 148, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { diff --git a/templates/reader.templ b/templates/reader.templ index 75c8dc1..2b3dec4 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -43,6 +43,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm + +
diff --git a/templates/register_templ.go b/templates/register_templ.go index 05a9795..14bd4a3 100644 --- a/templates/register_templ.go +++ b/templates/register_templ.go @@ -29,7 +29,7 @@ func Register() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Register - Bookhoard
📚 Bookhoard

Create your account

Password Requirements:

  • At least 8 characters
  • One uppercase letter
  • One lowercase letter
  • One number
  • One special character
  • Passwords match

Already have an account? Login

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Register - Bookhoard
📚 Bookhoard

Create your account

Password Requirements:

  • At least 8 characters
  • One uppercase letter
  • One lowercase letter
  • One number
  • One special character
  • Passwords match

Already have an account? Login

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/series.templ b/templates/series.templ index afcf288..9b0849d 100644 --- a/templates/series.templ +++ b/templates/series.templ @@ -13,6 +13,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr Series - Bookhoard + @Header(user, "/series") diff --git a/templates/series_templ.go b/templates/series_templ.go index 6f1ff39..ba0ea46 100644 --- a/templates/series_templ.go +++ b/templates/series_templ.go @@ -34,7 +34,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Series - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Series - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -99,7 +99,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre var templ_7745c5c3_Var2 templ.SafeURL templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 49, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 50, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -125,7 +125,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 57, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -138,7 +138,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 57, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -156,7 +156,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre var templ_7745c5c3_Var5 templ.SafeURL templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 61, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 62, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -224,7 +224,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var7 templ.SafeURL templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 78, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 79, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -269,7 +269,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 83, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 84, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -295,7 +295,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 85, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 86, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { @@ -327,7 +327,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 97, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 98, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -340,7 +340,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 99, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -353,7 +353,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 99, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { diff --git a/templates/setup.templ b/templates/setup.templ index da6a4b3..d137862 100644 --- a/templates/setup.templ +++ b/templates/setup.templ @@ -10,6 +10,7 @@ templ Setup() { +
diff --git a/templates/setup_templ.go b/templates/setup_templ.go index b51d506..3d8d677 100644 --- a/templates/setup_templ.go +++ b/templates/setup_templ.go @@ -29,7 +29,7 @@ func Setup() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Setup - Bookhoard
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Setup - Bookhoard
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 84268d7..9bde5a7 100644 --- a/templates/types.go +++ b/templates/types.go @@ -38,7 +38,59 @@ type LibraryData struct { Name string Description string TypeName string + TypeValue string MediaCount int64 + FolderCount int +} + +type FolderData struct { + FolderPath string +} + +type DirEntry struct { + Name string + Path string +} + +type UserVisibilityData struct { + UserID string + Username string + Email string + IsVisible bool +} + +type AdminStats struct { + LibraryCount int + MediaCount int + UserCount int + DeviceCount int +} + +type ScanSettingsData struct { + AutoScanEnabled bool + ScanPollIntervalSeconds int +} + +// SettingEntry mirrors database.SettingEntry for the admin UI. Kept as a +// template-local type so the templates package does not import database. +type SettingEntry struct { + Key string + Value string + Type string + Min string + Max string + RequiresRestart bool + Category string + Group string + Description string + IsDefault bool +} + +// SettingGroup is a labeled cluster of related settings rendered as a +// sub-section within a tunable-settings card. +type SettingGroup struct { + Name string + Entries []SettingEntry } type SeriesCardData struct { diff --git a/templates/unlinked_books.templ b/templates/unlinked_books.templ index 974debb..6d07d84 100644 --- a/templates/unlinked_books.templ +++ b/templates/unlinked_books.templ @@ -9,6 +9,7 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) { Unlinked Books - Bookhoard + @Header(user, "/unlinked") diff --git a/templates/unlinked_books_templ.go b/templates/unlinked_books_templ.go index 77f0fa3..eb5aca0 100644 --- a/templates/unlinked_books_templ.go +++ b/templates/unlinked_books_templ.go @@ -29,7 +29,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Unlinked Books - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Unlinked Books - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -105,7 +105,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 62, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 63, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -118,7 +118,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 62, Col: 136} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 63, Col: 136} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -151,7 +151,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 76, Col: 111} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 77, Col: 111} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -164,7 +164,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 78, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 79, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -177,7 +177,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 78, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 79, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -194,7 +194,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 87, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 88, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -212,7 +212,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 93, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 94, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { @@ -225,7 +225,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 93, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 94, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -243,7 +243,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 98, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 99, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -261,7 +261,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 104, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 105, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { diff --git a/templates/utils.go b/templates/utils.go index 917f973..88c9add 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -16,7 +16,7 @@ import ( func activeClass(current, target string) string { base := "flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors " - if current == target { + if strings.TrimRight(current, "/") == strings.TrimRight(target, "/") { return base + "bg-brand/15 text-brand" } return base + "text-content-muted hover:bg-surface-hover hover:text-content" @@ -31,6 +31,17 @@ func navItemClass(current, target string) string { return base + "text-content-muted hover:text-content hover:bg-surface-hover" } +// themeCheckClass returns the class suffix for a theme option's checkmark +// wrapper: empty for the active theme (visible), " hidden" otherwise. The +// check element is always rendered so updateThemeIndicators() can move it +// client-side when the user picks a different theme. +func themeCheckClass(name, current string) string { + if name == current { + return "" + } + return " hidden" +} + func ContainsString(slice []string, item string) bool { for _, s := range slice { if s == item { @@ -40,6 +51,15 @@ func ContainsString(slice []string, item string) bool { return false } +func isUserVisible(userID string, visibility []UserVisibilityData) bool { + for _, v := range visibility { + if v.UserID == userID { + return v.IsVisible + } + } + return false +} + // TotalMediaCount sums the MediaCount across the given libraries, // used to display the total next to the "All Libraries" option. func TotalMediaCount(libs []LibraryData) int64 { @@ -294,3 +314,22 @@ func formatDateForInput(d pgtype.Date) string { } return d.Time.Format("2006-01-02") } + +// GroupTunableSettings splits a flat, group-sorted entry list into labeled +// sub-section groups, separated into "live" (applies immediately) and +// "restart required" buckets. Entries keep their original order so groups stay +// coherent. +func GroupTunableSettings(entries []SettingEntry) (live, restart []SettingGroup) { + var liveGroups, restartGroups []SettingGroup + for _, e := range entries { + target := &liveGroups + if e.RequiresRestart { + target = &restartGroups + } + if len(*target) == 0 || (*target)[len(*target)-1].Name != e.Group { + *target = append(*target, SettingGroup{Name: e.Group}) + } + (*target)[len(*target)-1].Entries = append((*target)[len(*target)-1].Entries, e) + } + return liveGroups, restartGroups +} diff --git a/templates/utils_test.go b/templates/utils_test.go new file mode 100644 index 0000000..474b3bb --- /dev/null +++ b/templates/utils_test.go @@ -0,0 +1,56 @@ +package templates + +import "testing" + +func TestGroupTunableSettings(t *testing.T) { + entries := []SettingEntry{ + {Key: "session_duration_seconds", Group: "Session", RequiresRestart: false}, + {Key: "password_min_length", Group: "Password Quality", RequiresRestart: false}, + {Key: "password_require_upper", Group: "Password Quality", RequiresRestart: false}, + {Key: "opds_default_page_size", Group: "OPDS Catalog", RequiresRestart: false}, + {Key: "auth_rate_limit_per_min", Group: "Auth Rate Limiting", RequiresRestart: true}, + {Key: "login_max_attempts", Group: "Login Lockout", RequiresRestart: true}, + {Key: "login_lockout_minutes", Group: "Login Lockout", RequiresRestart: true}, + } + + live, restart := GroupTunableSettings(entries) + + if len(live) != 3 { + t.Fatalf("expected 3 live groups, got %d", len(live)) + } + if live[0].Name != "Session" || len(live[0].Entries) != 1 { + t.Errorf("live[0] = %+v", live[0]) + } + if live[1].Name != "Password Quality" || len(live[1].Entries) != 2 { + t.Errorf("live[1] = %+v", live[1]) + } + if live[2].Name != "OPDS Catalog" || len(live[2].Entries) != 1 { + t.Errorf("live[2] = %+v", live[2]) + } + + if len(restart) != 2 { + t.Fatalf("expected 2 restart groups, got %d", len(restart)) + } + if restart[0].Name != "Auth Rate Limiting" || len(restart[0].Entries) != 1 { + t.Errorf("restart[0] = %+v", restart[0]) + } + if restart[1].Name != "Login Lockout" || len(restart[1].Entries) != 2 { + t.Errorf("restart[1] = %+v", restart[1]) + } +} + +func TestGroupTunableSettingsEmpty(t *testing.T) { + live, restart := GroupTunableSettings(nil) + if len(live) != 0 || len(restart) != 0 { + t.Errorf("expected empty groups, got live=%d restart=%d", len(live), len(restart)) + } +} + +func TestThemeCheckClass(t *testing.T) { + if got := themeCheckClass("dracula", "dracula"); got != "" { + t.Errorf("active theme should be visible, got %q", got) + } + if got := themeCheckClass("nord", "dracula"); got != " hidden" { + t.Errorf("inactive theme should be hidden, got %q", got) + } +} diff --git a/web/src/admin.ts b/web/src/admin.ts index e9cd903..7464756 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -1,134 +1,5 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; -import { createWebSocket } from "./websocket"; - -function initializeScanWebSocket(): void { - createWebSocket({ - onMessage: (message) => { - switch (message.type) { - case "scan_progress": - updateScanProgress(message.data); - break; - case "scan_complete": - showScanComplete(message.data); - break; - case "scan_error": - showScanError(message.data); - break; - } - }, - enableReconnect: true, - reconnectDelay: 5000, - }); -} - -function updateScanProgress(data: { progress: number; files_scanned: number; new_items: number }): void { - const progressBar = document.getElementById("scan-progress-bar"); - if (progressBar) { - progressBar.style.width = (data.progress * 100) + "%"; - } - - const progressText = document.getElementById("scan-progress-text"); - if (progressText) { - progressText.textContent = `${data.files_scanned} files scanned (${data.new_items} new)`; - } -} - -function showScanComplete(_data: unknown): void { - console.log("Scan complete:", _data); -} - -function showScanError(_data: unknown): void { - console.error("Scan error:", _data); -} - -async function triggerLibraryScan(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch("/api/libraries/scan", { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - showToast("Library scan started", "success"); - } else { - const error = await response.json(); - showToast(error.error || "Failed to start scan", "error"); - } - } catch (error) { - console.error("Scan error:", error); - showToast("Failed to start library scan", "error"); - } -} - -async function triggerQuickScan(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch("/api/libraries/quick-scan", { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - showToast("Quick scan started", "success"); - } else { - const error = await response.json(); - showToast(error.error || "Failed to start quick scan", "error"); - } - } catch (error) { - console.error("Quick scan error:", error); - showToast("Failed to start quick scan", "error"); - } -} - -async function loadSystemStats(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch("/api/admin/stats", { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - const stats = await response.json(); - renderSystemStats(stats); - } - } catch (error) { - console.error("Failed to load stats:", error); - } -} - -function renderSystemStats(stats: Record): void { - const container = document.getElementById("system-stats"); - if (!container) return; - - container.innerHTML = ` -
-
-

${stats.total_books || 0}

-

Total Books

-
-
-

${stats.total_users || 0}

-

Users

-
-
-

${stats.total_devices || 0}

-

Devices

-
-
-

${stats.total_libraries || 0}

-

Libraries

-
-
- `; -} async function scanAllLibraries(): Promise { const token = localStorage.getItem("token"); @@ -227,6 +98,8 @@ function showScanProgress( pollScanProgress(jobIds, libraryNames); } +let scanPollInterval: ReturnType | undefined = undefined; + function pollScanProgress( jobIds: string[], _libraryNames: Record, @@ -283,7 +156,7 @@ function pollScanProgress( } if (allComplete) { - clearInterval(scanPollInterval); + clearInterval(scanPollInterval!); showScanResults( jobIds.length, totalFiles, @@ -369,8 +242,6 @@ async function loadWatchStatus(): Promise { } } -let scanPollInterval: ReturnType | undefined = undefined; - function stopScanStatusPolling(): void { if (scanPollInterval !== undefined) { clearInterval(scanPollInterval); @@ -380,22 +251,14 @@ function stopScanStatusPolling(): void { export { hideScanProgress, - initializeScanWebSocket, - loadSystemStats, loadWatchStatus, scanAllLibraries, stopScanStatusPolling, - triggerLibraryScan, - triggerQuickScan, }; Alpine.data("admin", () => ({ hideScanProgress, - initializeScanWebSocket, - loadSystemStats, loadWatchStatus, scanAllLibraries, stopScanStatusPolling, - triggerLibraryScan, - triggerQuickScan, })); diff --git a/web/src/library.ts b/web/src/library.ts deleted file mode 100644 index 608228a..0000000 --- a/web/src/library.ts +++ /dev/null @@ -1,695 +0,0 @@ -// Library management functionality for admin/library page -// Procedural/imperative style t -// nt -// OOP) - -import { Alpine } from "./alpine"; -import { - apiPut, - apiPost, - apiGet, - handleResponse, - handleError, - handleVoidResponse, - apiDelete, -} from "./api"; -import { showToast } from "./toast"; - -interface Library { - id: string; - name: string; - description: string | null; - library_type_id: string; - type_name: string; - created_at: string; - updated_at: string; -} - -interface LibraryFolder { - id: string; - library_id: string; - folder_path: string; - created_at: string; -} - -interface LibrariesResponse { - data: Library[]; -} - -// State (already SSR'd, used for updates) -let libraries: Library[] = []; - -// Reload libraries from API (called after create/delete/update) -async function reloadLibraries(): Promise { - try { - const response = await apiGet("/libraries"); - const result = (await handleResponse( - response, - )) as unknown as LibrariesResponse; - libraries = result.data; - renderLibraries(); - } catch (error) { - handleError(error, "Failed to load libraries"); - } -} - -// Render libraries list (replaces SSR content after updates) -function renderLibraries(): void { - const container = document.getElementById("libraries-list"); - if (!container) return; - if (libraries.length === 0) { - container.innerHTML = - '

No libraries yet. Create your first library to get started.

'; - return; - } - - container.innerHTML = libraries - .map( - (library) => - '
' + - '
' + - "
" + - `

${escapeHtmlLocal(library.name)}

` + - (library.description - ? `

${escapeHtmlLocal(library.description)}

` - : "") + - `${escapeHtmlLocal(library.type_name)}` + - "
" + - '
' + - `` + - `` + - `` + - "
" + - "
" + - `` + - "
", - ) - .join(""); -} - -// Load user's visible libraries for visibility management -async function loadUserVisibility(): Promise { - const select = document.getElementById("user-select") as HTMLSelectElement; - const userId = select?.value; - if (!userId) { - const container = document.getElementById("user-libraries"); - if (container) { - container.innerHTML = - '

Please select a user

'; - } - return; - } - - try { - const response = await apiGet("/libraries/visible"); - const visibleLibraries = (await handleResponse( - response, - )) as unknown as Library[]; - const container = document.getElementById("user-libraries"); - if (!container) return; - - const visibleIds = new Set(visibleLibraries.map((lib: Library) => lib.id)); - - container.innerHTML = libraries - .map((library) => { - const isVisible = visibleIds.has(library.id); - return ( - '" - ); - }) - .join(""); - } catch (error) { - handleError(error, "Failed to load user libraries"); - } -} - -// Set library visibility for a user -async function setLibraryVisibility( - userId: string, - libraryId: string, - isVisible: boolean, -): Promise { - // userId is used in the HTML onchange handler but the API gets user from JWT context - console.debug( - "Setting visibility for user:", - userId, - "library:", - libraryId, - "visible:", - isVisible, - ); - - try { - const response = await apiPost("/libraries/visibility", { - library_id: libraryId, - is_visible: isVisible, - }); - await handleVoidResponse(response); - - showToast("Library visibility updated", "success"); - - // Refresh visibility controls - void loadUserVisibility(); - } catch (error) { - handleError(error, "Failed to update library visibility"); - } -} - -// Create library form handler -async function handleCreateLibrarySubmit(event: Event): Promise { - event.preventDefault(); - - const form = event.target as HTMLFormElement; - const formData = new FormData(form); - - const libraryId = (document.getElementById("library-id") as HTMLInputElement) - ?.value; - const isEdit = !!libraryId; - - const libraryData = { - name: formData.get("name") as string, - description: formData.get("description") as string, - type: formData.get("type") as string, - }; - - try { - const url = isEdit ? `/libraries/${libraryId}` : "/libraries"; - const response = isEdit - ? await apiPut(url, libraryData) - : await apiPost(url, libraryData); - - if (isEdit) { - await handleVoidResponse(response); - } else { - (await handleResponse(response)) as unknown as { data: Library }; - } - - showToast( - isEdit ? "Library updated successfully" : "Library created successfully", - "success", - ); - - hideCreateLibraryModal(); - form.reset(); - - const libraryIdInput = document.getElementById( - "library-id", - ) as HTMLInputElement; - if (libraryIdInput) { - libraryIdInput.value = ""; - } - - void reloadLibraries(); - } catch (error) { - handleError( - error, - isEdit ? "Failed to update library" : "Failed to create library", - ); - } -} - -// Delete library -async function deleteLibrary(libraryId: string): Promise { - const library = libraries.find((l) => l.id === libraryId); - if (!library) return; - - showDeleteModal(library); -} - -// Show library folders -async function showLibraryFolders(libraryId: string): Promise { - const container = document.getElementById(`library-folders-${libraryId}`); - if (!container) return; - - try { - const response = await apiGet(`/libraries/${libraryId}/folders`); - const folders = (await handleResponse( - response, - )) as unknown as LibraryFolder[]; - - container.innerHTML = folders - .map( - (folder: LibraryFolder) => - '
' + - `${escapeHtmlLocal(folder.folder_path)}` + - `' + - "
", - ) - .join(""); - - container.innerHTML += - '
' + - `' + - `' + - `' + - "
"; - - container.classList.remove("hidden"); - } catch (error) { - handleError(error, "Failed to load folders"); - } -} - -// Add library folder -async function addLibraryFolder(libraryId: string): Promise { - const input = document.getElementById( - `folder-path-${libraryId}`, - ) as HTMLInputElement; - const folderPath = input?.value.trim(); - - if (!folderPath) { - return; - } - - try { - const response = await apiPost(`/libraries/${libraryId}/folders`, { - folder_path: folderPath, - }); - await handleVoidResponse(response); - - showToast("Folder added successfully", "success"); - - if (input) { - input.value = ""; - } - void showLibraryFolders(libraryId); // Refresh - } catch (error) { - handleError(error, "Failed to add folder"); - } -} - -// Remove library folder -async function removeLibraryFolder( - libraryId: string, - folderPath: string, -): Promise { - if (!confirm(`Remove folder "${folderPath}" from the library?`)) { - return; - } - - try { - const response = await apiDelete(`/libraries/${libraryId}/folders`, { - folder_path: folderPath, - }); - await handleVoidResponse(response); - - showToast("Folder removed successfully", "success"); - - void showLibraryFolders(libraryId); // Refresh - } catch (error) { - handleError(error, "Failed to remove folder"); - } -} - -// Edit library (placeholder - opens modal or navigates to edit page) -function editLibrary(libraryId: string): void { - const library = libraries.find((l) => l.id === libraryId); - if (!library) { - showToast("Library not found", "error"); - return; - } - - const form = document.getElementById( - "create-library-form", - ) as HTMLFormElement; - if (form) { - const nameInput = form.querySelector('[name="name"]') as HTMLInputElement; - const descInput = form.querySelector( - '[name="description"]', - ) as HTMLTextAreaElement; - const typeInput = form.querySelector('[name="type"]') as HTMLSelectElement; - - if (nameInput) nameInput.value = library.name; - if (descInput) descInput.value = library.description || ""; - if (typeInput) typeInput.value = library.type_name; - } - - const modalTitle = document.querySelector("#create-library-modal h2"); - if (modalTitle) { - modalTitle.textContent = "Edit Library"; - } - - const libraryIdInput = document.getElementById( - "library-id", - ) as HTMLInputElement; - if (libraryIdInput) { - libraryIdInput.value = libraryId; - } - - showCreateLibraryModal(); -} - -// Modal controls -function showCreateLibraryModal(): void { - const modal = document.getElementById("create-library-modal") as HTMLElement; - if (modal) { - modal.classList.remove("hidden"); - - const modalTitle = document.querySelector("#create-library-modal h2"); - if (modalTitle) { - modalTitle.textContent = "Create Library"; - } - } -} - -function hideCreateLibraryModal(): void { - const modal = document.getElementById("create-library-modal") as HTMLElement; - if (modal) { - modal.classList.add("hidden"); - } -} - -// Delete modal state -let libraryToDelete: Library | null = null; - -function showDeleteModal(library: Library): void { - libraryToDelete = library; - - const modal = document.getElementById("delete-library-modal") as HTMLElement; - const content = document.getElementById( - "delete-modal-content", - ) as HTMLElement; - - if (modal && content) { - const message = `Are you sure you want to delete "${escapeHtmlLocal(library.name)}"? - -This will remove: -• Library metadata from the database -• All folder references -• All book records from the database - -⚠️ Book files on disk will NOT be deleted. - -This action cannot be undone.`; - - content.innerHTML = message.replace(/\n/g, "
"); - modal.classList.remove("hidden"); - } -} - -function hideDeleteModal(): void { - const modal = document.getElementById("delete-library-modal") as HTMLElement; - if (modal) { - modal.classList.add("hidden"); - } - libraryToDelete = null; -} - -async function confirmDeleteLibrary(): Promise { - if (!libraryToDelete) return; - - const libraryId = libraryToDelete.id; - hideDeleteModal(); - - try { - const response = await apiDelete(`/libraries/${libraryId}`); - await handleVoidResponse(response); - - showToast("Library deleted successfully", "success"); - - await reloadLibraries(); - - const libraryIdInput = document.getElementById( - "library-id", - ) as HTMLInputElement | null; - if (libraryIdInput) { - libraryIdInput.value = ""; - } - } catch (error) { - handleError(error, "Failed to delete library"); - } -} - -// Local escape HTML helper -function escapeHtmlLocal(text: string): string { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; -} - -// Event delegation for handling dynamic button clicks -function handleLibraryListClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest("button") as HTMLElement; - if (!button) return; - - const action = button.dataset.action; - const libraryId = button.dataset.libraryId; - - switch (action) { - case "show-folders": - if (libraryId) showLibraryFolders(libraryId); - break; - case "delete": - if (libraryId) deleteLibrary(libraryId); - break; - case "edit": - if (libraryId) editLibrary(libraryId); - break; - case "add-folder": - if (libraryId) addLibraryFolder(libraryId); - break; - case "remove-folder": - if (libraryId && button.dataset.folderPath) { - removeLibraryFolder(libraryId, button.dataset.folderPath); - } - break; - case "browse-folder": - if (button.dataset.inputId) showFolderBrowser(button.dataset.inputId); - break; - } -} - -function handleFolderBrowserClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest("button") as HTMLElement; - const div = target.closest("div[data-action]") as HTMLElement; - - if (button) { - const action = button.dataset.action; - const path = button.dataset.path; - - switch (action) { - case "browse-parent": - if (path) navigateFolderBrowser(path); - break; - case "browse-cancel": - hideFolderBrowser(); - break; - case "browse-select": - if (path) selectBrowseFolder(path); - break; - } - } - - if (div && div.dataset.action === "browse-navigate") { - const path = div.dataset.path; - if (path) navigateFolderBrowser(path); - } -} - -function handleGlobalClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest("button") as HTMLElement; - if (!button) return; - - const action = button.dataset.action; - - switch (action) { - case "show-create-modal": - showCreateLibraryModal(); - break; - case "hide-create-modal": - hideCreateLibraryModal(); - break; - case "hide-delete-modal": - hideDeleteModal(); - break; - case "confirm-delete": - void confirmDeleteLibrary(); - break; - } -} - -// Folder browser state -let currentBrowsePath = ""; -let currentBrowseInputId = ""; - -// Show folder browser modal -function showFolderBrowser(inputId: string): void { - currentBrowseInputId = inputId; - currentBrowsePath = "/"; - - const modal = document.getElementById("folder-browser-modal") as HTMLElement; - if (modal) { - modal.classList.remove("hidden"); - void loadBrowseDirectories(currentBrowsePath); - } -} - -// Load directories for browsing -async function loadBrowseDirectories(path: string): Promise { - try { - const response = await apiGet( - `/libraries/browse?path=${encodeURIComponent(path)}`, - ); - const data = (await handleResponse(response)) as unknown as { - current_path: string; - parent_path: string; - directories: string[]; - }; - - currentBrowsePath = data.current_path; - renderBrowseDirectories(data); - } catch (error) { - handleError(error, "Failed to load directories"); - } -} - -// Render browse directories (uses event delegation via data-action attributes) -function renderBrowseDirectories(data: { - current_path: string; - parent_path: string; - directories: string[]; -}): void { - const container = document.getElementById("folder-browser-content"); - if (!container) return; - - let html = ` -
- ${ - data.parent_path - ? `` - : "" - } - ${escapeHtmlLocal(data.current_path)} -
-
- `; - - if (data.directories.length === 0) { - html += - '

No subdirectories

'; - } else { - data.directories.forEach((dir) => { - const fullPath = - data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`; - html += ` -
- 📁 ${escapeHtmlLocal(dir)} -
- `; - }); - } - - html += ` -
-
- - -
- `; - - container.innerHTML = html; -} - -// Navigate to subdirectory -function navigateFolderBrowser(path: string): void { - void loadBrowseDirectories(path); -} - -// Select folder and close browser -function selectBrowseFolder(path: string): void { - const input = document.getElementById( - currentBrowseInputId, - ) as HTMLInputElement; - if (input) { - input.value = path; - } - hideFolderBrowser(); -} - -// Hide folder browser modal -function hideFolderBrowser(): void { - const modal = document.getElementById("folder-browser-modal") as HTMLElement; - if (modal) { - modal.classList.add("hidden"); - } -} - -// Initialize page -function initializeLibraryAdmin(): void { - // Setup event listeners - const librariesList = document.getElementById("libraries-list"); - if (librariesList) { - librariesList.addEventListener("click", handleLibraryListClick); - } - - const folderBrowserModal = document.getElementById("folder-browser-modal"); - if (folderBrowserModal) { - folderBrowserModal.addEventListener("click", handleFolderBrowserClick); - } - - document.addEventListener("click", handleGlobalClick); - - // Setup form submission - const createLibraryForm = document.getElementById("create-library-form"); - if (createLibraryForm) { - createLibraryForm.addEventListener("submit", handleCreateLibrarySubmit); - } - - // SSR provides initial library list - no need to fetch on page load - // reloadLibraries() is called after create/delete/update operations -} - -// Export functions for global access -export { - addLibraryFolder, - confirmDeleteLibrary, - deleteLibrary, - editLibrary, - handleCreateLibrarySubmit, - hideDeleteModal, - hideFolderBrowser, - initializeLibraryAdmin, - loadUserVisibility, - navigateFolderBrowser, - removeLibraryFolder, - selectBrowseFolder, - setLibraryVisibility, - showDeleteModal, - showFolderBrowser, - showLibraryFolders, -}; - -Alpine.data("library", () => ({ - addLibraryFolder, - confirmDeleteLibrary, - deleteLibrary, - editLibrary, - handleCreateLibrarySubmit, - hideDeleteModal, - hideFolderBrowser, - initializeLibraryAdmin, - loadUserVisibility, - navigateFolderBrowser, - removeLibraryFolder, - selectBrowseFolder, - setLibraryVisibility, - showDeleteModal, - showFolderBrowser, - showLibraryFolders, -})); diff --git a/web/src/main.ts b/web/src/main.ts index 5af70b9..4253739 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -20,7 +20,6 @@ import "./dom"; import "./events"; import "./header"; import "./index"; -import "./library"; import "./library-switcher"; import "./linking"; import "./login"; diff --git a/web/src/theme.ts b/web/src/theme.ts index 968e637..dc20078 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -41,6 +41,7 @@ const loadTheme = (): void => { // Change: Remove DOM element lookup, accept theme as parameter const changeTheme = async (theme: string): Promise => { applyTheme(theme); + updateThemeIndicators(); // Save to server if logged in const token = localStorage.getItem(TOKEN_STORAGE_KEY); @@ -78,6 +79,7 @@ const loadUserTheme = async (): Promise => { const data = await response.json(); if (data.theme) { applyTheme(data.theme as string); + updateThemeIndicators(); } } } catch { @@ -85,10 +87,26 @@ const loadUserTheme = async (): Promise => { } }; +// Update the active-theme checkmark in the Appearance menu to follow the +// theme currently applied to . Each theme button carries its theme name +// in data-theme and a .theme-check element that is shown for the active theme +// and hidden for the rest. +const updateThemeIndicators = (): void => { + const match = document.body.className.match(/theme-([\w-]+)/); + const current = match ? match[1] : DEFAULT_THEME; + document.querySelectorAll(".theme-btn").forEach((btn) => { + const check = btn.querySelector(".theme-check"); + if (!check) return; + const theme = btn.getAttribute("data-theme"); + check.classList.toggle("hidden", theme !== current); + }); +}; + // Initialize theme system const initializeTheme = (): void => { loadTheme(); loadUserTheme(); + updateThemeIndicators(); // Set theme select value to current theme const themeSelect = document.getElementById( @@ -210,6 +228,7 @@ Alpine.data("theme", () => ({ loadTheme, loadUserTheme, loadWoodPaneling, + updateThemeIndicators, updateWoodPanelingIndicators, })); @@ -221,6 +240,7 @@ export { loadTheme, loadUserTheme, loadWoodPaneling, + updateThemeIndicators, updateWoodPanelingIndicators, }; export type { ThemeType }; diff --git a/web/static/favicon.svg b/web/static/favicon.svg new file mode 100644 index 0000000..ed12135 --- /dev/null +++ b/web/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/static/style.css b/web/static/style.css index c2ef621..f29d824 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-catppuccin-latte{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.dockable-panel{background-color:var(--bg-secondary);border-bottom:1px solid var(--border);transition:max-height .2s ease-out;overflow:hidden}.dockable-panel.panel-collapsed .panel-content{display:none}.panel-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.panel-header:hover{background-color:var(--surface-hover)}.panel-container{background-color:var(--bg-secondary);border-right:1px solid var(--border);width:320px;max-height:calc(100vh - 8rem);overflow-y:auto}.panel-container[data-side=right]{border-right:none;border-left:1px solid var(--border)}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-24{bottom:6rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[calc\(100vh-4rem\)\]{height:calc(100vh - 4rem)}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[200px\]{min-width:200px}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.scale-100,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.scale-95,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-90{--tw-bg-opacity:0.9}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:opacity-40:disabled{opacity:.4}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:max-w-none{max-width:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-3{padding-top:.75rem;padding-bottom:.75rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none} \ No newline at end of file +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-catppuccin-latte{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.dockable-panel{background-color:var(--bg-secondary);border-bottom:1px solid var(--border);transition:max-height .2s ease-out;overflow:hidden}.dockable-panel.panel-collapsed .panel-content{display:none}.panel-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.panel-header:hover{background-color:var(--surface-hover)}.panel-container{background-color:var(--bg-secondary);border-right:1px solid var(--border);width:320px;max-height:calc(100vh - 8rem);overflow-y:auto}.panel-container[data-side=right]{border-right:none;border-left:1px solid var(--border)}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-24{bottom:6rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[200px\]{min-width:200px}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.rotate-180,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-100,.scale-95{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-90{--tw-bg-opacity:0.9}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:opacity-40:disabled{opacity:.4}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:translate-x-5{--tw-translate-x:1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:bg-brand{background-color:var(--accent)}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:max-w-none{max-width:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-3{padding-top:.75rem;padding-bottom:.75rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none} \ No newline at end of file