diff --git a/api/openapi.yaml b/api/openapi.yaml index 4e34c8b..a9523bb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -531,6 +531,10 @@ components: language: $ref: '#/components/schemas/Language' description: Preferred UI and AI-content language as an ISO 639-1 code + theme: + type: string + enum: [LIGHT, DARK, AUTO] + description: Preferred UI colour theme; AUTO follows the OS preference diet: type: array items: diff --git a/services/spring-api/src/main/kotlin/org/openapitools/api/UsersApiController.kt b/services/spring-api/src/main/kotlin/org/openapitools/api/UsersApiController.kt index 473b4e0..70fd653 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/api/UsersApiController.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/api/UsersApiController.kt @@ -23,6 +23,7 @@ import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import tools.jackson.databind.ObjectMapper +import tools.jackson.databind.node.ObjectNode @RestController @Validated @@ -97,7 +98,12 @@ class UsersApiController( @Valid userProfileUpdate: UserProfileUpdate, ): ResponseEntity { val user = userRepository.findByUsername(currentUsername()).orElseThrow() - userProfileUpdate.preferences?.let { user.preferences = objectMapper.writeValueAsString(it) } + userProfileUpdate.preferences?.let { incoming -> + // merge so a partial update keeps other preferences + val merged = (user.preferences?.let { objectMapper.readTree(it) } as? ObjectNode) ?: objectMapper.createObjectNode() + merged.setAll(objectMapper.valueToTree(incoming)) + user.preferences = objectMapper.writeValueAsString(merged) + } userProfileUpdate.username?.let { newUsername -> if (newUsername != user.username && userRepository.existsByUsername(newUsername)) { throw ConflictException("Username already taken") diff --git a/services/spring-api/src/main/kotlin/org/openapitools/model/UserPreferences.kt b/services/spring-api/src/main/kotlin/org/openapitools/model/UserPreferences.kt index 0be8a36..f496e3b 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/model/UserPreferences.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/model/UserPreferences.kt @@ -19,6 +19,7 @@ import java.util.Objects /** * * @param language Preferred UI and AI-content language as an ISO 639-1 code + * @param theme Preferred UI colour theme; AUTO follows the OS preference * @param diet Dietary restriction or style (e.g. vegan, keto) * @param allergies List of ingredients the user is allergic to * @param aboutMe Free-form user context provided to the AI @@ -27,10 +28,33 @@ data class UserPreferences( @field:Valid @Schema(example = "null", description = "Preferred UI and AI-content language as an ISO 639-1 code") @get:JsonProperty("language") val language: Language? = null, + @Schema(example = "null", description = "Preferred UI colour theme; AUTO follows the OS preference") + @get:JsonProperty("theme") val theme: UserPreferences.Theme? = null, @Schema(example = "null", description = "Dietary restriction or style (e.g. vegan, keto)") @get:JsonProperty("diet") val diet: kotlin.collections.List? = null, @Schema(example = "null", description = "List of ingredients the user is allergic to") @get:JsonProperty("allergies") val allergies: kotlin.collections.List? = null, @Schema(example = "null", description = "Free-form user context provided to the AI") @get:JsonProperty("aboutMe") val aboutMe: kotlin.collections.List? = null, -) +) { + /** + * Preferred UI colour theme; AUTO follows the OS preference + * Values: LIGHT,DARK,AUTO + */ + enum class Theme( + @get:JsonValue val value: kotlin.String, + ) { + LIGHT("LIGHT"), + DARK("DARK"), + AUTO("AUTO"), + ; + + companion object { + @JvmStatic + @JsonCreator + fun forValue(value: kotlin.String): Theme = + values().firstOrNull { it -> it.value == value } + ?: throw IllegalArgumentException("Unexpected value '$value' for enum 'Theme'") + } + } +} diff --git a/services/spring-api/src/test/kotlin/org/openapitools/api/UsersApiTest.kt b/services/spring-api/src/test/kotlin/org/openapitools/api/UsersApiTest.kt index 7a2955b..31e95e7 100644 --- a/services/spring-api/src/test/kotlin/org/openapitools/api/UsersApiTest.kt +++ b/services/spring-api/src/test/kotlin/org/openapitools/api/UsersApiTest.kt @@ -204,6 +204,77 @@ class UsersApiTest : ApiTestBase() { ).andExpect(status().isOk) } + @Test + fun `profile put - stores and returns the theme preference`() { + val token = register() + mockMvc + .perform( + put("/api/v1/users/profile") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"preferences":{"theme":"DARK"}}"""), + ).andExpect(status().isOk) + + mockMvc + .perform(get("/api/v1/users/profile").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.preferences.theme").value("DARK")) + } + + @Test + fun `profile put - a theme-only update preserves the other preferences`() { + val token = register() + mockMvc + .perform( + put("/api/v1/users/profile") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"preferences":{"language":"DE","diet":["vegan"],"allergies":["nuts"]}}"""), + ).andExpect(status().isOk) + + mockMvc + .perform( + put("/api/v1/users/profile") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"preferences":{"theme":"DARK"}}"""), + ).andExpect(status().isOk) + + mockMvc + .perform(get("/api/v1/users/profile").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.preferences.theme").value("DARK")) + .andExpect(jsonPath("$.preferences.language").value("DE")) + .andExpect(jsonPath("$.preferences.diet[0]").value("vegan")) + .andExpect(jsonPath("$.preferences.allergies[0]").value("nuts")) + } + + @Test + fun `profile put - updating other preferences preserves a previously saved theme`() { + val token = register() + mockMvc + .perform( + put("/api/v1/users/profile") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"preferences":{"theme":"DARK"}}"""), + ).andExpect(status().isOk) + + mockMvc + .perform( + put("/api/v1/users/profile") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"preferences":{"diet":["keto"]}}"""), + ).andExpect(status().isOk) + + mockMvc + .perform(get("/api/v1/users/profile").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.preferences.theme").value("DARK")) + .andExpect(jsonPath("$.preferences.diet[0]").value("keto")) + } + @Test fun `profile put - username conflict returns 409`() { register("taken", "testpass1234") diff --git a/web-client/index.html b/web-client/index.html index 4786d61..f8f8a38 100644 --- a/web-client/index.html +++ b/web-client/index.html @@ -5,6 +5,16 @@ Cooking Assistant +
diff --git a/web-client/src/api.ts b/web-client/src/api.ts index a6f1a1c..8bc0d89 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -738,6 +738,11 @@ export interface components { UserPreferences: { /** @description Preferred UI and AI-content language as an ISO 639-1 code */ language?: components["schemas"]["Language"]; + /** + * @description Preferred UI colour theme; AUTO follows the OS preference + * @enum {string} + */ + theme?: "LIGHT" | "DARK" | "AUTO"; /** @description Dietary restriction or style (e.g. vegan, keto) */ diet?: string[]; /** @description List of ingredients the user is allergic to */ diff --git a/web-client/src/auth.tsx b/web-client/src/auth.tsx index 052df2e..5134ffe 100644 --- a/web-client/src/auth.tsx +++ b/web-client/src/auth.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useState } from 'react' import type { ReactNode } from 'react' import { errorMessage } from './apiError' -const TOKEN_KEY = 'auth_token' +export const TOKEN_KEY = 'auth_token' const USERNAME_KEY = 'auth_username' const API_BASE = import.meta.env.VITE_API_BASE ?? '' diff --git a/web-client/src/components/AppLayout.tsx b/web-client/src/components/AppLayout.tsx index 2c08a7e..1da66a9 100644 --- a/web-client/src/components/AppLayout.tsx +++ b/web-client/src/components/AppLayout.tsx @@ -1,7 +1,8 @@ import { Outlet, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Nav } from './Nav' -import { useUserLanguage } from '../useUserLanguage' +import { ThemeIconToggle } from './ThemeToggle' +import { useUserPreferences } from '../useUserPreferences' const titleKeys = { generate: 'layout.generate', @@ -12,7 +13,7 @@ const titleKeys = { export function AppLayout() { const { t } = useTranslation() const { pathname } = useLocation() - useUserLanguage() + useUserPreferences() const section = pathname.split('/')[1] const titleKey = titleKeys[section as keyof typeof titleKeys] ?? 'layout.generate' const title = t(titleKey) @@ -23,8 +24,9 @@ export function AppLayout() {