diff --git a/cmd/server/tests/user_test.go b/cmd/server/tests/user_test.go index 68855fb6..83c0fc78 100644 --- a/cmd/server/tests/user_test.go +++ b/cmd/server/tests/user_test.go @@ -667,3 +667,92 @@ func TestUpdatePasswordSelfChange(t *testing.T) { assert.Contains(t, rec.Body.String(), "current password required") }) } + +// TestProfileKeepScreenAwake pins the keep_screen_awake user preference +// end to end: schema default, the "true"/"false" form dropdown contract, +// JSON-client omission semantics ("" = untouched), and rejection of +// anything else. Uses a dedicated user — never the shared admin. +func TestProfileKeepScreenAwake(t *testing.T) { + // getProfile fetches the JSON profile and returns keep_screen_awake. + getProfile := func(t *testing.T, setup *TestServerSetup, token string) bool { + req := httptest.NewRequest("GET", "/api/auth/profile", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + var body struct { + KeepScreenAwake bool `json:"keep_screen_awake"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body.KeepScreenAwake + } + putProfile := func(t *testing.T, setup *TestServerSetup, token string, fields map[string]interface{}) *httptest.ResponseRecorder { + jsonData, _ := json.Marshal(fields) + req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + setup.Server.Config.Handler.ServeHTTP(rec, req) + return rec + } + + t.Run("fresh user defaults to true", func(t *testing.T) { + setup := setupTestServer(t) + selfUser := createRegularUserOnce(t, setup.DB) + token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password) + + row, err := setup.DB.GetUser(context.Background(), pgtype.UUID{Bytes: selfUser.ID, Valid: true}) + require.NoError(t, err) + assert.True(t, row.KeepScreenAwake, "schema default must keep the column TRUE") + assert.True(t, getProfile(t, setup, token), "profile API must report the default") + }) + + t.Run("false persists and reads back", func(t *testing.T) { + setup := setupTestServer(t) + selfUser := createRegularUserOnce(t, setup.DB) + token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password) + + rec := putProfile(t, setup, token, map[string]interface{}{"keep_screen_awake": "false"}) + require.Equal(t, http.StatusOK, rec.Code) + assert.False(t, getProfile(t, setup, token)) + + row, err := setup.DB.GetUser(context.Background(), pgtype.UUID{Bytes: selfUser.ID, Valid: true}) + require.NoError(t, err) + assert.False(t, row.KeepScreenAwake) + }) + + t.Run("flipping back to true persists", func(t *testing.T) { + setup := setupTestServer(t) + selfUser := createRegularUserOnce(t, setup.DB) + token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password) + + require.Equal(t, http.StatusOK, putProfile(t, setup, token, + map[string]interface{}{"keep_screen_awake": "false"}).Code) + require.Equal(t, http.StatusOK, putProfile(t, setup, token, + map[string]interface{}{"keep_screen_awake": "true"}).Code) + assert.True(t, getProfile(t, setup, token)) + }) + + t.Run("request omitting the field leaves it untouched", func(t *testing.T) { + setup := setupTestServer(t) + selfUser := createRegularUserOnce(t, setup.DB) + token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password) + + // JSON clients (the Android app) sync other profile fields + // without knowing this one — the default-on preference must + // survive such a save. + rec := putProfile(t, setup, token, map[string]interface{}{"theme": "nord"}) + require.Equal(t, http.StatusOK, rec.Code) + assert.True(t, getProfile(t, setup, token)) + }) + + t.Run("invalid value is rejected", func(t *testing.T) { + setup := setupTestServer(t) + selfUser := createRegularUserOnce(t, setup.DB) + token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password) + + rec := putProfile(t, setup, token, map[string]interface{}{"keep_screen_awake": "maybe"}) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.True(t, getProfile(t, setup, token), "rejected save must not change the preference") + }) +} diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 2cfe9f81..56d9c8f9 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -42,6 +42,11 @@ CREATE TABLE IF NOT EXISTS users ( -- mirrors the per-book table's column. ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0; +-- Keep-screen-awake preference: when true, the web reader and audiobook +-- player request a Screen Wake Lock so the display doesn't blank/lock +-- mid-session. On by default; the profile page's On/Off dropdown owns it. +ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE; + -- Create system_settings table CREATE TABLE IF NOT EXISTS system_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/internal/database/models.go b/internal/database/models.go index c7f4aedc..a8ba1b55 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -597,4 +597,5 @@ type Users struct { Timezone pgtype.Text `db:"timezone" json:"timezone"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"` + KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"` } diff --git a/internal/database/querier.go b/internal/database/querier.go index 112e3718..c7aa81ec 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -699,6 +699,7 @@ type Querier interface { // Update universal progress UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error + UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index da08a85a..e2e040f3 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -9942,6 +9942,7 @@ SELECT u.updated_at, u.timezone, u.default_audio_speed, + u.keep_screen_awake, (SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count FROM users u WHERE u.id = $1 @@ -9960,6 +9961,7 @@ type GetUserRow struct { UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` Timezone pgtype.Text `db:"timezone" json:"timezone"` DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"` + KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"` DeviceCount int64 `db:"device_count" json:"device_count"` } @@ -9979,6 +9981,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro &i.UpdatedAt, &i.Timezone, &i.DefaultAudioSpeed, + &i.KeepScreenAwake, &i.DeviceCount, ) return i, err @@ -17236,9 +17239,23 @@ func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUse return err } +const UpdateUserKeepScreenAwake = `-- name: UpdateUserKeepScreenAwake :exec +UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1 +` + +type UpdateUserKeepScreenAwakeParams struct { + ID pgtype.UUID `db:"id" json:"id"` + KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"` +} + +func (q *Queries) UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error { + _, err := q.db.Exec(ctx, UpdateUserKeepScreenAwake, arg.ID, arg.KeepScreenAwake) + return err +} + const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1 -RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed +RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed, keep_screen_awake ` type UpdateUserMaxDevicesParams struct { @@ -17263,6 +17280,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev &i.Timezone, &i.UpdatedAt, &i.DefaultAudioSpeed, + &i.KeepScreenAwake, ) return i, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index b93ee152..b92d0702 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -29,6 +29,7 @@ SELECT u.updated_at, u.timezone, u.default_audio_speed, + u.keep_screen_awake, (SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count FROM users u WHERE u.id = $1; @@ -515,6 +516,9 @@ UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1; -- name: UpdateUserDefaultAudioSpeed :exec UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1; +-- name: UpdateUserKeepScreenAwake :exec +UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1; + -- Per-book playback speed preference (player plan §8). -- name: GetAudioPlaybackSpeed :one diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index b3a6b05b..82f3fea5 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -122,6 +122,8 @@ type UserProfile struct { Theme string `json:"theme,omitempty"` // User-level default audiobook playback speed (plan §8); 0 omits. DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"` + // Whether the web reader/player should hold a Screen Wake Lock. + KeepScreenAwake bool `json:"keep_screen_awake"` } type ProfileUpdateRequest struct { @@ -132,6 +134,9 @@ type ProfileUpdateRequest struct { Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"` Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"` + // "true"/"false" — the profile dropdown always sends it; JSON clients + // that omit it decode as "" and leave the column untouched. + KeepScreenAwake string `json:"keep_screen_awake,omitempty" form:"keep_screen_awake" validate:"omitempty,oneof=true false"` // Per the SPEEDS list bounds; the profile select always sends it, // JSON clients omit it (0 = untouched). DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"` @@ -542,6 +547,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error { Role: user.Role, Theme: user.Theme.String, DefaultAudioSpeed: float64(user.DefaultAudioSpeed), + KeepScreenAwake: user.KeepScreenAwake, }) } @@ -706,6 +712,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error { } } + // Keep-screen-awake preference. The form dropdown always sends it + // ("true"/"false"); JSON clients that omit it decode as "" and leave + // the column untouched. + if req.KeepScreenAwake != "" { + if req.KeepScreenAwake != "true" && req.KeepScreenAwake != "false" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_screen_awake must be 'true' or 'false'"}) + } + err := h.db.UpdateUserKeepScreenAwake(c.Request().Context(), database.UpdateUserKeepScreenAwakeParams{ + ID: targetUserUUID, + KeepScreenAwake: req.KeepScreenAwake == "true", + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + } + // Browser path (htmx modal + profile page): refresh so every rendered // value (timezone select, table rows, header) reflects the save and the // modal closes. A bare JSON body swapped into #profile-result read as diff --git a/internal/router/helpers.go b/internal/router/helpers.go index ce518575..ede81d5f 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -67,6 +67,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err LastName: utils.PgTextString(userDB.LastName), CreatedAt: userDB.CreatedAt.Time, DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed), + KeepScreenAwake: userDB.KeepScreenAwake, }, nil } diff --git a/internal/router/reader.go b/internal/router/reader.go index 9d3577d5..7f899f27 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -100,6 +100,7 @@ func registerReaderRoutes(cfg *Config) { TotalCharacters: mediaItem.TotalCharacters.Int64, EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64), TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup), + KeepScreenAwake: user.KeepScreenAwake, } // Render template var buf bytes.Buffer diff --git a/templates/profile_form.templ b/templates/profile_form.templ index 582c1748..5633adca 100644 --- a/templates/profile_form.templ +++ b/templates/profile_form.templ @@ -87,6 +87,16 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show } +
+ + +
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">Australian Central (UTC+9:30) ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showRoleField { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, ">Admin") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -506,12 +526,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

Change Password

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

Change Password

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if requireCurrentPassword { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "Update Password
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

As an admin, you can change this user's password without knowing their current password.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Reset Password
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showCancelButton { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "Save Changes
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index d81431b4..6587c4c9 100644 --- a/templates/types.go +++ b/templates/types.go @@ -20,6 +20,8 @@ type User struct { // User-level default audiobook playback speed (plan §8); 0 means // "unset" and renders as the 1× option. DefaultAudioSpeed float64 + // Whether the web reader/player should hold a Screen Wake Lock. + KeepScreenAwake bool } // SpeedOption is one entry of the default-playback-speed select — the @@ -289,6 +291,9 @@ type ReaderMetadata struct { // TreatAsComic: fixed-layout item in a comics/manga library gets // comic reader treatment (bookmarks only — no highlights, no search). TreatAsComic bool `json:"treat_as_comic"` + // KeepScreenAwake: user preference — the reader holds a Screen Wake + // Lock while open when true. + KeepScreenAwake bool `json:"keep_screen_awake"` } type ReadingProgress struct {