Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,7 +98,12 @@ class UsersApiController(
@Valid userProfileUpdate: UserProfileUpdate,
): ResponseEntity<Unit> {
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<ObjectNode>(incoming))
user.preferences = objectMapper.writeValueAsString(merged)
}
userProfileUpdate.username?.let { newUsername ->
if (newUsername != user.username && userRepository.existsByUsername(newUsername)) {
throw ConflictException("Username already taken")
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions web-client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cooking Assistant</title>
<script>
// apply the saved theme before first paint to avoid a flash of the wrong theme
(function () {
try {
var mode = localStorage.getItem('theme') || 'AUTO'
var dark = mode === 'DARK' || (mode === 'AUTO' && matchMedia('(prefers-color-scheme: dark)').matches)
if (dark) document.documentElement.classList.add('dark')
} catch (e) {}
})()
</script>
</head>
<body>
<div id="root"></div>
Expand Down
5 changes: 5 additions & 0 deletions web-client/src/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web-client/src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ''

Expand Down
10 changes: 6 additions & 4 deletions web-client/src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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)
Expand All @@ -23,8 +24,9 @@ export function AppLayout() {
<div className="min-h-screen md:flex">
<Nav />
<div className="flex flex-1 flex-col">
<header className={`border-b border-gray-200 p-4 text-xl md:hidden ${bold ? 'font-bold' : ''}`}>
{title}
<header className="flex items-center justify-between gap-3 border-b border-gray-200 p-4 md:hidden dark:border-neutral-700">
<span className={`text-xl ${bold ? 'font-bold' : ''}`}>{title}</span>
<ThemeIconToggle className="-my-2 -mr-2" />
</header>
<main className={`mx-auto w-full p-6 pb-24 md:pb-6 ${pathname === '/library' ? 'max-w-6xl' : 'max-w-2xl'}`}>
<div key={section} className="flex flex-col gap-4 animate-fade-in">
Expand Down
2 changes: 1 addition & 1 deletion web-client/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ComponentProps } from 'react'
export function Button({ className = '', ...props }: ComponentProps<'button'>) {
return (
<button
className={`px-4 py-2 bg-orange-500 text-white rounded cursor-pointer transition-transform duration-100 hover:scale-98 disabled:opacity-50 ${className}`}
className={`px-4 py-2 bg-orange-500 dark:bg-orange-700 text-white rounded cursor-pointer transition-transform duration-100 hover:scale-98 disabled:opacity-50 ${className}`}
{...props}
/>
)
Expand Down
12 changes: 8 additions & 4 deletions web-client/src/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CSSProperties } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { BoltIcon, BookOpenIcon, UserIcon } from '@heroicons/react/24/solid'
import { ThemeSlider } from './ThemeToggle'

const items = [
{ to: '/generate', label: 'nav.generate', Icon: BoltIcon },
Expand Down Expand Up @@ -44,11 +45,11 @@ export function Nav() {
}, [activeIndex])

return (
<nav className="fixed inset-x-0 bottom-0 z-10 flex border-t border-gray-200 bg-white md:relative md:w-56 md:flex-col md:gap-1 md:border-t-0 md:border-r md:p-4">
<nav className="fixed inset-x-0 bottom-0 z-10 flex border-t border-gray-200 bg-white md:sticky md:top-0 md:h-screen md:w-56 md:flex-col md:gap-1 md:overflow-hidden md:border-t-0 md:border-r md:p-4 dark:border-neutral-700 dark:bg-neutral-900">
<span className="hidden px-3 pb-4 text-xl font-bold md:block">{t('layout.generate')}</span>
{pill && (
<span
className="pointer-events-none absolute rounded-lg bg-orange-50 transition-all duration-200 ease-out"
className="pointer-events-none absolute rounded-lg bg-orange-50 transition-all duration-200 ease-out dark:bg-orange-500/15"
style={pill}
/>
)}
Expand All @@ -62,15 +63,18 @@ export function Nav() {
className={({ isActive }) =>
`relative z-10 flex flex-1 flex-col items-center gap-1 py-2 text-xs md:flex-none md:flex-row md:gap-3 md:rounded-lg md:px-3 md:py-2 md:text-sm ${
isActive
? 'text-orange-600'
: 'text-gray-500 md:hover:bg-gray-100'
? 'text-orange-600 dark:text-orange-400'
: 'text-gray-500 md:hover:bg-gray-100 dark:text-neutral-400 md:dark:hover:bg-neutral-800'
}`
}
>
<Icon className="h-6 w-6" />
{t(label)}
</NavLink>
))}
<div className="mt-auto hidden md:block">
<ThemeSlider />
</div>
</nav>
)
}
2 changes: 1 addition & 1 deletion web-client/src/components/PasswordInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function PasswordInput({
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={toggleVisibility}
className="absolute inset-y-0 right-0 flex items-center px-2 text-gray-400 cursor-pointer transition-transform duration-100 hover:scale-98"
className="absolute inset-y-0 right-0 flex items-center px-2 text-gray-400 dark:text-neutral-500 cursor-pointer transition-transform duration-100 hover:scale-98"
aria-label={visible ? 'Hide password' : 'Show password'}
>
{visible ? (
Expand Down
6 changes: 3 additions & 3 deletions web-client/src/components/RecipeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ export function RecipeCard({
tabIndex={0}
onClick={onOpen}
onKeyDown={handleKeyDown}
className="relative w-full text-left rounded-lg border border-gray-200 bg-white p-6 shadow-sm flex flex-col gap-3 cursor-pointer transition-transform duration-100 hover:scale-99"
className="relative w-full text-left rounded-lg border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-6 shadow-sm flex flex-col gap-3 cursor-pointer transition-transform duration-100 hover:scale-99"
>
<header className="flex items-baseline justify-between gap-3">
<h2 className="text-lg font-bold">{recipe.title}</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 whitespace-nowrap">
<span className="text-sm text-gray-500 dark:text-neutral-400 whitespace-nowrap">
{recipe.portions} {recipe.portions === 1 ? t('common.portion') : t('common.portions')}
</span>
<RecipeSaveButton
Expand All @@ -68,7 +68,7 @@ export function RecipeCard({
</div>

{recipe.nutrients && (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-neutral-300">
{recipe.nutrients.calories != null && <span>{t('common.kcal', { value: recipe.nutrients.calories })}</span>}
{recipe.nutrients.protein != null && <span>{t('common.protein', { value: recipe.nutrients.protein })}</span>}
{recipe.nutrients.fat != null && <span>{t('common.fat', { value: recipe.nutrients.fat })}</span>}
Expand Down
8 changes: 4 additions & 4 deletions web-client/src/components/RecipeSaveButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function RecipeSaveButton({
>
<span className={`relative h-6 w-6 ${saving ? 'animate-pulse' : ''}`}>
<Outline
className={`absolute inset-0 h-6 w-6 transition-colors duration-300 ease-out group-hover:text-white ${failed || showTrash ? 'text-red-500' : 'text-gray-400'}`}
className={`absolute inset-0 h-6 w-6 transition-colors duration-300 ease-out group-hover:text-white ${failed || showTrash ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-neutral-500'}`}
/>
<Solid
className={`absolute inset-0 h-6 w-6 ${fillColor} [clip-path:inset(100%_0_0_0)] transition-[clip-path] duration-300 ease-out group-hover:[clip-path:inset(0_0_0_0)]`}
Expand All @@ -172,16 +172,16 @@ export function RecipeSaveButton({
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl"
className="w-full max-w-sm rounded-lg bg-white dark:bg-neutral-800 p-6 shadow-xl"
>
<h2 id="recipe-delete-title" className="text-lg font-medium text-gray-900">
<h2 id="recipe-delete-title" className="text-lg font-medium text-gray-900 dark:text-neutral-100">
{t('save.confirmTitle')}
</h2>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={cancelDelete}
className="px-4 py-2 rounded text-gray-700 cursor-pointer hover:bg-gray-100"
className="px-4 py-2 rounded text-gray-700 dark:text-neutral-200 cursor-pointer hover:bg-gray-100 dark:hover:bg-neutral-800"
>
{t('common.cancel')}
</button>
Expand Down
8 changes: 4 additions & 4 deletions web-client/src/components/SaveIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,22 @@ export function SaveIndicator({
{(status === 'saving' || status === 'resaving') && (
<span
data-testid="save-spinner"
className="absolute h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-orange-500"
className="absolute h-5 w-5 animate-spin rounded-full border-2 border-gray-300 dark:border-neutral-600 border-t-orange-500"
/>
)}
{status === 'resaving' && (
<CheckIcon className="relative h-3 w-3 text-green-600 stroke-[3]" />
<CheckIcon className="relative h-3 w-3 text-green-600 dark:text-green-400 stroke-[3]" />
)}
{status === 'saved' && (
<CheckIcon
data-testid="save-check"
className="h-5 w-5 text-green-600 stroke-[3] animate-fade-out"
className="h-5 w-5 text-green-600 dark:text-green-400 stroke-[3] animate-fade-out"
/>
)}
{status === 'error' && (
<ExclamationTriangleIcon
data-testid="save-error"
className="h-5 w-5 text-amber-500 stroke-[2.5] animate-fade-in"
className="h-5 w-5 text-amber-500 dark:text-amber-400 stroke-[2.5] animate-fade-in"
/>
)}
</span>
Expand Down
Loading
Loading