diff --git a/web-client/src/App.tsx b/web-client/src/App.tsx
index c5df9cd..4c36d99 100644
--- a/web-client/src/App.tsx
+++ b/web-client/src/App.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './auth'
import { AppLayout } from './components/AppLayout'
+import { RecipeGenerationProvider } from './recipeGeneration'
import { GenerateFlow, GeneratePage, GenerateResultsPage } from './pages/GeneratePage'
import { LibraryPage } from './pages/LibraryPage'
import { LoginPage } from './pages/LoginPage'
@@ -24,7 +25,9 @@ export default function App() {
-
+
+
+
}
>
diff --git a/web-client/src/pages/GeneratePage.tsx b/web-client/src/pages/GeneratePage.tsx
index 8dc5376..c738dce 100644
--- a/web-client/src/pages/GeneratePage.tsx
+++ b/web-client/src/pages/GeneratePage.tsx
@@ -1,34 +1,15 @@
import {useEffect, useState} from 'react'
-import type {Dispatch, SetStateAction} from 'react'
-import {Outlet, useLocation, useNavigate, useOutletContext} from 'react-router-dom'
+import {Outlet, useLocation, useNavigate} from 'react-router-dom'
import {useTranslation} from 'react-i18next'
import {ChevronRightIcon, PencilSquareIcon, XMarkIcon} from '@heroicons/react/24/outline'
-import type {components} from '../api'
import {Button} from '../components/Button'
import {RecipeCard} from '../components/RecipeCard'
import {TagSelector} from '../components/TagSelector'
import {tagsById} from '../recipeFormat'
import {localizeTagLabel} from '../locales/recipeTagLabels'
import {usePressPulse} from '../usePressPulse'
-import {errorMessage} from '../apiError'
-import {SessionExpiredError, useApi} from '../useApi'
-import {currentLanguage} from '../i18n'
-
-// id is stored on the recipe once it is saved
-type Recipe = components['schemas']['RecipeInput'] & { id?: number }
-type RecipeRequest = components['schemas']['RecipeRequest']
-
-export interface RecipeGenerationContext {
- prompt: string
- setPrompt: Dispatch>
- selectedTags: string[]
- setSelectedTags: Dispatch>
- recipes: Recipe[]
- setRecipes: Dispatch>
- status: string | null
- loading: boolean
- generate: () => void
-}
+import {useApi} from '../useApi'
+import {useRecipeGeneration} from '../recipeGeneration'
const VIEW_ORDER = {options: 0, results: 1, recipe: 2} as const
@@ -39,62 +20,15 @@ function viewName(pathname: string): keyof typeof VIEW_ORDER {
}
export function GenerateFlow() {
- const {t} = useTranslation()
const apiFetch = useApi()
- const navigate = useNavigate()
const {pathname} = useLocation()
- const [prompt, setPrompt] = useState(() => sessionStorage.getItem('recipe_prompt') ?? '')
- const [selectedTags, setSelectedTags] = useState(() => {
- const stored = sessionStorage.getItem('recipe_tags')
- return stored ? (JSON.parse(stored) as string[]) : []
- })
- // keep the last results so the list is restored when returning from a recipe page
- const [recipes, setRecipes] = useState(() => {
- const stored = sessionStorage.getItem('generated_recipes')
- return stored ? (JSON.parse(stored) as Recipe[]) : []
- })
- const [status, setStatus] = useState(null)
- const [loading, setLoading] = useState(false)
-
// Confirm the session is still valid
useEffect(() => {
// on failure, this will automatically redirect to /login
apiFetch('/users/profile')
}, [apiFetch])
- useEffect(() => {
- sessionStorage.setItem('generated_recipes', JSON.stringify(recipes))
- }, [recipes])
-
- async function generate() {
- setLoading(true)
- setStatus(t('generate.generatingStatus'))
- setRecipes([])
- sessionStorage.setItem('recipe_prompt', prompt)
- sessionStorage.setItem('recipe_tags', JSON.stringify(selectedTags))
- navigate('/generate/results')
- try {
- const tagLabels = selectedTags.map((id) => tagsById.get(id)?.label).filter(Boolean)
- const fullPrompt = tagLabels.length > 0 ? `${prompt}\n\nPreferences: ${tagLabels.join(', ')}` : prompt
- const body: RecipeRequest = {prompt: fullPrompt, language: currentLanguage()}
- const response = await apiFetch('/ai/recipes', {
- method: 'POST',
- headers: {'content-type': 'application/json'},
- body: JSON.stringify(body),
- })
- if (!response.ok) throw new Error(await errorMessage(response))
- const data = (await response.json()) as Recipe[]
- setRecipes(data)
- setStatus(data.length === 0 ? t('generate.noRecipes') : null)
- } catch (e) {
- if (e instanceof SessionExpiredError) return
- setStatus(t('common.error', {message: e instanceof Error ? e.message : String(e)}))
- } finally {
- setLoading(false)
- }
- }
-
const view = viewName(pathname)
const [prevView, setPrevView] = useState(view)
const [slideDirectionBack, setSlideDirectionBack] = useState(false)
@@ -103,16 +37,12 @@ export function GenerateFlow() {
setPrevView(view)
}
- const context: RecipeGenerationContext = {
- prompt, setPrompt, selectedTags, setSelectedTags, recipes, setRecipes, status, loading, generate,
- }
-
return (
-
+
)
}
@@ -128,7 +58,7 @@ export function GeneratePage() {
recipes,
loading,
generate
- } = useOutletContext()
+ } = useRecipeGeneration()
const [generateBtnRef, pulseGenerate] = usePressPulse()
return (
@@ -198,7 +128,7 @@ export function GeneratePage() {
export function GenerateResultsPage() {
const {t, i18n} = useTranslation()
const navigate = useNavigate()
- const {recipes, status, setRecipes} = useOutletContext()
+ const {recipes, status, setRecipes} = useRecipeGeneration()
// read from sessionStorage instead of context so unsaved edits are not shown the results header
const lastPrompt = sessionStorage.getItem('recipe_prompt') ?? ''
diff --git a/web-client/src/pages/RecipePage.tsx b/web-client/src/pages/RecipePage.tsx
index 3c63cb3..4c6f86e 100644
--- a/web-client/src/pages/RecipePage.tsx
+++ b/web-client/src/pages/RecipePage.tsx
@@ -8,10 +8,10 @@ import {
PlusIcon,
} from '@heroicons/react/24/outline'
import Markdown from 'react-markdown'
-import { Link, Navigate, useLocation, useNavigate, useOutletContext, useParams } from 'react-router-dom'
+import { Link, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { components } from '../api'
-import type { RecipeGenerationContext } from './GeneratePage'
+import { useRecipeGeneration } from '../recipeGeneration'
import { RecipeSaveButton } from '../components/RecipeSaveButton.tsx'
import { formatQuantity } from '../recipeFormat'
import { usePressPulse } from '../usePressPulse'
@@ -37,13 +37,13 @@ export function RecipePage() {
return pathname.startsWith('/library/') ? :
}
-// Generated recipes are shared with the GenerateFlow layout (which persists them to sessionStorage)
+// Generated recipes are shared via the RecipeGeneration provider (which persists them to sessionStorage)
// and are paged through by list index in router state.
function GeneratedRecipePage() {
const location = useLocation()
const navigate = useNavigate()
const index = (location.state as { index?: number } | null)?.index ?? 0
- const { recipes, setRecipes } = useOutletContext()
+ const { recipes, setRecipes } = useRecipeGeneration()
const [helpAnswers, setHelpAnswers] = useState>({})
const recipe = recipes[index]
diff --git a/web-client/src/recipeGeneration.tsx b/web-client/src/recipeGeneration.tsx
new file mode 100644
index 0000000..e6c96bb
--- /dev/null
+++ b/web-client/src/recipeGeneration.tsx
@@ -0,0 +1,98 @@
+import type {Dispatch, ReactNode, SetStateAction} from 'react';
+import {createContext, useCallback, useContext, useEffect, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {useTranslation} from 'react-i18next';
+import type {components} from './api';
+import {tagsById} from './recipeFormat';
+import {errorMessage} from './apiError';
+import {SessionExpiredError, useApi} from './useApi';
+import {currentLanguage} from './i18n';
+
+// id is stored on the recipe once it is saved
+type Recipe = components['schemas']['RecipeInput'] & { id?: number }
+type RecipeRequest = components['schemas']['RecipeRequest']
+
+export interface RecipeGenerationContextValue {
+ prompt: string;
+ setPrompt: Dispatch>;
+ selectedTags: string[];
+ setSelectedTags: Dispatch>;
+ recipes: Recipe[];
+ setRecipes: Dispatch>;
+ status: string | null;
+ loading: boolean;
+ generate: () => void;
+}
+
+const RecipeGenerationContext = createContext(null);
+
+function safeGetArrayFromStorage(key: string) {
+ try {
+ const stored = sessionStorage.getItem(key);
+ return stored ? (JSON.parse(stored)) : [];
+ } catch {
+ return [];
+ }
+}
+
+// Lives above the tab layout so an ongoing generation survives navigating to another tab
+export function RecipeGenerationProvider({children}: { children: ReactNode }) {
+ const {t} = useTranslation();
+ const apiFetch = useApi();
+ const navigate = useNavigate();
+
+ const [prompt, setPrompt] = useState(() => sessionStorage.getItem('recipe_prompt') ?? '');
+ const [selectedTags, setSelectedTags] = useState(() => safeGetArrayFromStorage('recipe_tags'));
+ const [recipes, setRecipes] = useState(() => safeGetArrayFromStorage('generated_recipes'));
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ sessionStorage.setItem('generated_recipes', JSON.stringify(recipes));
+ }, [recipes]);
+
+ const generate = useCallback(() => {
+ setLoading(true);
+ setStatus(t('generate.generatingStatus'));
+ setRecipes([]);
+ sessionStorage.setItem('recipe_prompt', prompt);
+ sessionStorage.setItem('recipe_tags', JSON.stringify(selectedTags));
+ navigate('/generate/results');
+
+ void (async () => {
+ try {
+ const tagLabels = selectedTags.map((id) => tagsById.get(id)?.label).filter(Boolean);
+ const fullPrompt = tagLabels.length > 0 ? `${prompt}\n\nPreferences: ${tagLabels.join(', ')}` : prompt;
+ const body: RecipeRequest = {prompt: fullPrompt, language: currentLanguage()};
+ const response = await apiFetch('/ai/recipes', {
+ method: 'POST',
+ headers: {'content-type': 'application/json'},
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) throw new Error(await errorMessage(response));
+ const data = (await response.json()) as Recipe[];
+ setRecipes(data);
+ setStatus(data.length === 0 ? t('generate.noRecipes') : null);
+ } catch (e) {
+ if (e instanceof SessionExpiredError) return;
+ setStatus(t('common.error', {message: e instanceof Error ? e.message : String(e)}));
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, [apiFetch, navigate, prompt, selectedTags, t]);
+
+ const value: RecipeGenerationContextValue = {
+ prompt, setPrompt, selectedTags, setSelectedTags, recipes, setRecipes, status, loading, generate,
+ };
+ return {children};
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useRecipeGeneration(): RecipeGenerationContextValue {
+ const ctx = useContext(RecipeGenerationContext);
+ if (!ctx) throw new Error('useRecipeGeneration must be used within a RecipeGenerationProvider');
+ return ctx;
+}
+
+export type {Recipe};
diff --git a/web-client/tests/pages/GeneratePage.test.tsx b/web-client/tests/pages/GeneratePage.test.tsx
index 7e4ea7d..623e551 100644
--- a/web-client/tests/pages/GeneratePage.test.tsx
+++ b/web-client/tests/pages/GeneratePage.test.tsx
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, vi } from 'vitest'
import type { components } from '../../src/api'
import { GenerateFlow, GeneratePage, GenerateResultsPage } from '../../src/pages/GeneratePage'
+import { RecipeGenerationProvider } from '../../src/recipeGeneration'
import { jsonResponse, renderWithProviders } from '../utils'
type Recipe = components['schemas']['Recipe']
@@ -28,16 +29,19 @@ beforeEach(() => {
afterEach(() => {
fetchMock.mockReset()
vi.unstubAllGlobals()
+ sessionStorage.clear()
})
function render(route = '/generate') {
renderWithProviders(
-
- }>
- } />
- } />
-
- ,
+
+
+ }>
+ } />
+ } />
+
+
+ ,
{ route, token: { value: 'tkn', username: 'alice' } },
)
}
diff --git a/web-client/tests/pages/RecipePage.test.tsx b/web-client/tests/pages/RecipePage.test.tsx
index 4722909..58ef174 100644
--- a/web-client/tests/pages/RecipePage.test.tsx
+++ b/web-client/tests/pages/RecipePage.test.tsx
@@ -1,10 +1,10 @@
-import {useState} from 'react'
-import {Outlet, Route, Routes} from 'react-router-dom'
+import {Route, Routes} from 'react-router-dom'
import {screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {afterEach, beforeEach, vi} from 'vitest'
import type {components} from '../../src/api'
import {RecipePage} from '../../src/pages/RecipePage'
+import {RecipeGenerationProvider} from '../../src/recipeGeneration'
import {jsonResponse, renderWithProviders} from '../utils'
type Recipe = components['schemas']['Recipe']
@@ -29,21 +29,17 @@ beforeEach(() => {
afterEach(() => {
fetchMock.mockReset()
vi.unstubAllGlobals()
+ sessionStorage.clear()
})
-// mirrors the GenerateFlow layout that holds the shared recipe list
-function GenerateContext({recipes}: {recipes: Recipe[]}) {
- const [list, setList] = useState(recipes)
- return
-}
-
function renderGeneratedRecipe(index = 0, recipes: Recipe[] = [a, b]) {
+ sessionStorage.setItem('generated_recipes', JSON.stringify(recipes))
renderWithProviders(
-
- }>
- }/>
-
- ,
+
+
+ }/>
+
+ ,
{
route: {pathname: '/generate/recipe', state: {index}},
token: {value: 'tkn', username: 'alice'},