import { useEffect, useId, useState } from 'react' import { ApiError } from '../api/index.ts' import { Button } from '../ui/Button.tsx' import { Card } from '../ui/Card.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Segmented } from '../ui/Segmented.tsx' import { Sheet } from '../ui/Sheet.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { TextInput } from '../ui/TextInput.tsx' import { maskedKey, recipeSearchProviderLabels, useRecipeSearchSettings, useSaveRecipeSearchSettings, useTestRecipeSearch, type RecipeSearchProvider, type RecipeSearchSettings, type RecipeSearchSettingsPatch, } from './api.ts' import styles from './AISettingsPage.module.css' const providerOptions = Object.entries(recipeSearchProviderLabels).map(([value, label]) => ({ value: value as RecipeSearchProvider, label, })) type RecipeSearchCardProps = { // Called with whether the card holds unsaved changes. onDirtyChange: (dirty: boolean) => void } // RecipeSearchCard is where recipes are searched for: a SearXNG instance or // Brave. The provider, SearXNG URL and a new Brave key are edited as a draft // and saved together; the client only ever sees the stored key's last four // characters. Test searches with the saved settings. export function RecipeSearchCard({ onDirtyChange }: RecipeSearchCardProps) { const settings = useRecipeSearchSettings() const titleId = useId() return (

Recipe search

Where MealQuest looks for real recipes behind each Side quest.

{settings.isPending && } {settings.isError && ( void settings.refetch()}> Recipe search settings could not load: {settings.error.message} )} {settings.isSuccess && }
) } type Draft = { provider: RecipeSearchProvider base_url: string // A new Brave key to store, null when the stored one stays. api_key: string | null } // draftOf starts a household that has not set up recipe search on SearXNG, // the sidecar every deployment runs. With no URL saved the field holds the // deployment's SearXNG URL, which saving stores. function draftOf(saved: RecipeSearchSettings): Draft { return { provider: saved.provider ?? 'searxng', base_url: saved.base_url ?? saved.default_base_url ?? '', api_key: null, } } // patchOf is the PATCH body for what draft changes. Only the chosen // provider's field is sent: the other provider's is left as it is. function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSettingsPatch { const patch: RecipeSearchSettingsPatch = {} if (draft.provider !== saved.provider) patch.provider = draft.provider if (draft.provider === 'searxng' && draft.base_url !== (saved.base_url ?? '')) patch.base_url = draft.base_url if (draft.provider === 'brave' && draft.api_key !== null) patch.api_key = draft.api_key return patch } function sameDraft(a: Draft, b: Draft): boolean { return a.provider === b.provider && a.base_url === b.base_url && a.api_key === b.api_key } function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) { const [draft, setDraft] = useState(() => draftOf(saved)) const save = useSaveRecipeSearchSettings() const test = useTestRecipeSearch() const patch = patchOf(saved, draft) const dirty = Object.keys(patch).length > 0 // Only the household's own edits are unsaved changes. Before recipe search // is set up the untouched draft already differs from what is saved — it // suggests SearXNG and the deployment's URL — but leaving it loses nothing. const edited = dirty && !sameDraft(draft, draftOf(saved)) useEffect(() => { onDirtyChange(edited) }, [edited, onDirtyChange]) useEffect(() => () => onDirtyChange(false), [onDirtyChange]) // An edit clears the last save and test results: they describe the saved // settings, not the draft. function edit(change: Partial) { save.reset() test.reset() setDraft((current) => ({ ...current, ...change })) } function onSave() { save.mutate(patch, { onSuccess: (settings) => { setDraft(draftOf(settings)) test.reset() }, }) } return (
edit({ provider })} /> {draft.provider === 'searxng' ? ( edit({ base_url: event.target.value })} /> ) : ( edit({ api_key })} /> )}
{save.isSuccess && !dirty && 'Recipe search saved.'} {test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`}
{save.isError && (

{saveErrorMessage(save.error)}

)} {test.isError && (

{testErrorMessage(test.error)}

)} {dirty && saved.provider !== null &&

Save your changes to test them.

}
) } type BraveKeyProps = { last4: string | null newKey: string | null onKey: (key: string | null) => void } // BraveKey shows the stored key masked, or the new one waiting to be saved, // and takes a new key in a sheet. The key is sent only when the card is // saved. function BraveKey({ last4, newKey, onKey }: BraveKeyProps) { const [replacing, setReplacing] = useState(false) const [key, setKey] = useState('') const labelId = useId() const sheetTitleId = useId() function close() { setReplacing(false) setKey('') } let shown = 'No key stored' if (newKey !== null) shown = maskedKey(newKey.slice(-4)) else if (last4 !== null) shown = maskedKey(last4) return (
Brave API key
{shown}
{newKey !== null && (

New key not saved yet

)}
{ event.preventDefault() onKey(key) close() }} >

{last4 === null ? 'Add Brave API key' : 'Replace Brave API key'}

The key is stored on the server when you save recipe search. This device only ever sees its last four characters.

setKey(event.target.value)} />
) } function saveErrorMessage(error: Error): string { if (error instanceof ApiError && error.code === 'invalid_settings') return `Not saved: ${error.message}.` return `Not saved: ${error.message}` } // testErrorMessage says why POST /settings/recipe-search/test failed, by // the error code the server names (internal/server/recipesearch.go). function testErrorMessage(error: Error): string { if (error instanceof ApiError && error.code === 'not_configured') { return 'Test failed: recipe search is not set up yet.' } return `Test failed: ${error.message}` }