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'},