Skip to content
Merged
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
5 changes: 4 additions & 1 deletion web-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -24,7 +25,9 @@ export default function App() {
<Route
element={
<RequireAuth>
<AppLayout />
<RecipeGenerationProvider>
<AppLayout />
</RecipeGenerationProvider>
</RequireAuth>
}
>
Expand Down
82 changes: 6 additions & 76 deletions web-client/src/pages/GeneratePage.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<string>>
selectedTags: string[]
setSelectedTags: Dispatch<SetStateAction<string[]>>
recipes: Recipe[]
setRecipes: Dispatch<SetStateAction<Recipe[]>>
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

Expand All @@ -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<string[]>(() => {
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<Recipe[]>(() => {
const stored = sessionStorage.getItem('generated_recipes')
return stored ? (JSON.parse(stored) as Recipe[]) : []
})
const [status, setStatus] = useState<string | null>(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)
Expand All @@ -103,16 +37,12 @@ export function GenerateFlow() {
setPrevView(view)
}

const context: RecipeGenerationContext = {
prompt, setPrompt, selectedTags, setSelectedTags, recipes, setRecipes, status, loading, generate,
}

return (
<div
key={view}
className={`flex flex-col gap-4 ${slideDirectionBack ? 'animate-slide-from-left' : 'animate-slide-from-right'}`}
>
<Outlet context={context}/>
<Outlet/>
</div>
)
}
Expand All @@ -128,7 +58,7 @@ export function GeneratePage() {
recipes,
loading,
generate
} = useOutletContext<RecipeGenerationContext>()
} = useRecipeGeneration()
const [generateBtnRef, pulseGenerate] = usePressPulse<HTMLButtonElement>()

return (
Expand Down Expand Up @@ -198,7 +128,7 @@ export function GeneratePage() {
export function GenerateResultsPage() {
const {t, i18n} = useTranslation()
const navigate = useNavigate()
const {recipes, status, setRecipes} = useOutletContext<RecipeGenerationContext>()
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') ?? ''
Expand Down
8 changes: 4 additions & 4 deletions web-client/src/pages/RecipePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -37,13 +37,13 @@ export function RecipePage() {
return pathname.startsWith('/library/') ? <LibraryRecipePage key={pathname} /> : <GeneratedRecipePage />
}

// 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<RecipeGenerationContext>()
const { recipes, setRecipes } = useRecipeGeneration()
const [helpAnswers, setHelpAnswers] = useState<Record<number, HelpEntry[]>>({})
const recipe = recipes[index]

Expand Down
98 changes: 98 additions & 0 deletions web-client/src/recipeGeneration.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<string>>;
selectedTags: string[];
setSelectedTags: Dispatch<SetStateAction<string[]>>;
recipes: Recipe[];
setRecipes: Dispatch<SetStateAction<Recipe[]>>;
status: string | null;
loading: boolean;
generate: () => void;
}

const RecipeGenerationContext = createContext<RecipeGenerationContextValue | null>(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<string[]>(() => safeGetArrayFromStorage('recipe_tags'));
const [recipes, setRecipes] = useState<Recipe[]>(() => safeGetArrayFromStorage('generated_recipes'));
const [status, setStatus] = useState<string | null>(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 <RecipeGenerationContext.Provider value={value}>{children}</RecipeGenerationContext.Provider>;
}

// 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};
16 changes: 10 additions & 6 deletions web-client/tests/pages/GeneratePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -28,16 +29,19 @@ beforeEach(() => {
afterEach(() => {
fetchMock.mockReset()
vi.unstubAllGlobals()
sessionStorage.clear()
})

function render(route = '/generate') {
renderWithProviders(
<Routes>
<Route path="/generate" element={<GenerateFlow />}>
<Route index element={<GeneratePage />} />
<Route path="results" element={<GenerateResultsPage />} />
</Route>
</Routes>,
<RecipeGenerationProvider>
<Routes>
<Route path="/generate" element={<GenerateFlow />}>
<Route index element={<GeneratePage />} />
<Route path="results" element={<GenerateResultsPage />} />
</Route>
</Routes>
</RecipeGenerationProvider>,
{ route, token: { value: 'tkn', username: 'alice' } },
)
}
Expand Down
22 changes: 9 additions & 13 deletions web-client/tests/pages/RecipePage.test.tsx
Original file line number Diff line number Diff line change
@@ -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']
Expand All @@ -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 <Outlet context={{recipes: list, setRecipes: setList}}/>
}

function renderGeneratedRecipe(index = 0, recipes: Recipe[] = [a, b]) {
sessionStorage.setItem('generated_recipes', JSON.stringify(recipes))
renderWithProviders(
<Routes>
<Route path="/generate" element={<GenerateContext recipes={recipes}/>}>
<Route path="recipe" element={<RecipePage/>}/>
</Route>
</Routes>,
<RecipeGenerationProvider>
<Routes>
<Route path="/generate/recipe" element={<RecipePage/>}/>
</Routes>
</RecipeGenerationProvider>,
{
route: {pathname: '/generate/recipe', state: {index}},
token: {value: 'tkn', username: 'alice'},
Expand Down
Loading