Add recipe edit page

Recipe GET now returns effective AllowedUnitCategories per ingredient so
QuantityInput can filter units when editing. ProductTypeahead accepts an
optional initialSelectedName so it can detect when a pre-linked ingredient
name has been edited and clear the stale product link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-14 18:44:52 -05:00
parent ee98fc8134
commit 4e4d80410c
4 changed files with 322 additions and 3 deletions
+10 -2
View File
@@ -33,6 +33,10 @@
// or when they edit the input after a selection (with null) — the
// caller uses this to track / clear an associated product link.
onProductChange?: (product: ProductSuggestion | null) => void;
// When the input is pre-populated with a name linked to an existing
// product (e.g. editing a saved ingredient), pass that name here so we
// can fire onProductChange(null) the moment the user diverges from it.
initialSelectedName?: string | null;
}
let {
@@ -42,11 +46,15 @@
inputClass = '',
onsubmit,
onProductChange,
initialSelectedName = null,
}: Props = $props();
// Tracks the most recent picked product so we can detect when the user
// edits the input afterward (which invalidates the link).
let lastSelectedName: string | null = null;
// edits the input afterward (which invalidates the link). We intentionally
// only capture the initial value of the prop — later changes from the
// parent shouldn't re-seed it.
// svelte-ignore state_referenced_locally
let lastSelectedName: string | null = initialSelectedName;
let suggestions = $state<ProductSuggestion[]>([]);
let showDropdown = $state(false);
@@ -15,6 +15,9 @@
familyUnitOfMeasureId: number | null;
isApproximate: boolean;
quantityNote: string | null;
productId: number | null;
familyProductId: number | null;
allowedUnitCategories: number;
}
interface Recipe {
@@ -98,6 +101,12 @@
>
Add to list
</button>
<button
onclick={() => goto(`/recipes/${recipeId}/edit`)}
class="rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-700"
>
Edit
</button>
<button
onclick={deleteRecipe}
class="rounded-lg border border-danger px-4 py-2.5 text-sm text-danger"
@@ -0,0 +1,270 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
interface IngredientForm {
name: string;
initialName: string | null;
quantity: QuantityValue;
isApproximate: boolean;
quantityNote: string;
productId: number | null;
familyProductId: number | null;
allowedUnitCategories: number;
}
interface IngredientResponse {
id: number;
name: string;
sortOrder: number;
quantity: number | null;
unitOfMeasureId: number | null;
familyUnitOfMeasureId: number | null;
isApproximate: boolean;
quantityNote: string | null;
productId: number | null;
familyProductId: number | null;
allowedUnitCategories: number;
}
interface RecipeResponse {
id: number;
title: string;
description: string | null;
instructions: string | null;
servings: number | null;
sourceUrl: string | null;
ingredients: IngredientResponse[];
}
let title = $state('');
let description = $state('');
let instructions = $state('');
let servings = $state<number | undefined>();
let ingredients = $state<IngredientForm[]>([]);
let loading = $state(true);
let saving = $state(false);
const recipeId = $derived(Number(page.params.id));
onMount(async () => {
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
title = recipe.title;
description = recipe.description ?? '';
instructions = recipe.instructions ?? '';
servings = recipe.servings ?? undefined;
ingredients = recipe.ingredients.length === 0
? [emptyIngredient()]
: recipe.ingredients.map((i) => ({
name: i.name,
initialName: i.productId !== null || i.familyProductId !== null ? i.name : null,
quantity: {
quantity: i.quantity,
unitOfMeasureId: i.unitOfMeasureId,
familyUnitOfMeasureId: i.familyUnitOfMeasureId,
},
isApproximate: i.isApproximate,
quantityNote: i.quantityNote ?? '',
productId: i.productId,
familyProductId: i.familyProductId,
allowedUnitCategories: i.allowedUnitCategories,
}));
loading = false;
});
function emptyIngredient(): IngredientForm {
return {
name: '',
initialName: null,
quantity: { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null },
isApproximate: false,
quantityNote: '',
productId: null,
familyProductId: null,
allowedUnitCategories: 0,
};
}
function addIngredient() {
ingredients = [...ingredients, emptyIngredient()];
}
function removeIngredient(idx: number) {
ingredients = ingredients.filter((_, i) => i !== idx);
}
function toggleApproximate(idx: number) {
const ing = ingredients[idx];
ing.isApproximate = !ing.isApproximate;
if (ing.isApproximate) {
ing.quantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
} else {
ing.quantityNote = '';
}
}
function onIngredientProductChange(idx: number, product: ProductSuggestion | null) {
const next = ingredients[idx];
if (product === null) {
next.productId = null;
next.familyProductId = null;
next.allowedUnitCategories = 0;
} else if (product.kind === 'Global') {
next.productId = product.id;
next.familyProductId = null;
next.allowedUnitCategories = product.allowedUnitCategories;
} else {
next.productId = null;
next.familyProductId = product.id;
next.allowedUnitCategories = product.allowedUnitCategories;
}
}
async function save() {
if (!title.trim()) return;
saving = true;
try {
await api(`/api/recipes/${recipeId}`, {
method: 'PUT',
body: JSON.stringify({
title,
description: description || null,
instructions: instructions || null,
servings: servings || null,
sourceUrl: null,
ingredients: ingredients
.filter((i) => i.name.trim())
.map((i, idx) => ({
name: i.name,
sortOrder: idx,
quantity: i.isApproximate ? null : i.quantity.quantity,
unitOfMeasureId: i.isApproximate ? null : i.quantity.unitOfMeasureId,
familyUnitOfMeasureId: i.isApproximate ? null : i.quantity.familyUnitOfMeasureId,
isApproximate: i.isApproximate,
quantityNote: i.isApproximate ? (i.quantityNote || null) : null,
productId: i.productId,
familyProductId: i.familyProductId
}))
})
});
goto(`/recipes/${recipeId}`);
} finally {
saving = false;
}
}
</script>
{#if loading}
<p class="py-8 text-center text-gray-400">Loading...</p>
{:else}
<div>
<button onclick={() => goto(`/recipes/${recipeId}`)} class="mb-2 text-sm text-gray-500">&larr; Back</button>
<h2 class="mb-4 text-2xl font-bold">Edit Recipe</h2>
<form onsubmit={e => { e.preventDefault(); save(); }} class="space-y-4">
<input
type="text"
bind:value={title}
placeholder="Recipe title"
required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none"
/>
<textarea
bind:value={description}
placeholder="Short description (optional)"
rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
></textarea>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600">
Servings
<input
type="number"
bind:value={servings}
min={1}
placeholder="e.g. 4"
class="mt-1 block w-24 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
/>
</label>
</div>
<div>
<span class="mb-2 block text-sm font-medium text-gray-600">Ingredients</span>
{#each ingredients as ingredient, idx}
<div class="mb-3 space-y-1 rounded-lg border border-gray-100 p-2">
<div class="flex gap-2">
{#if ingredient.isApproximate}
<input
type="text"
bind:value={ingredient.quantityNote}
placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
{:else}
<QuantityInput
bind:value={ingredient.quantity}
allowedUnitCategories={ingredient.allowedUnitCategories}
/>
{/if}
<div class="flex-1">
<ProductTypeahead
bind:value={ingredient.name}
initialSelectedName={ingredient.initialName}
placeholder="Ingredient name"
ariaLabel="Ingredient name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
onProductChange={(p) => onIngredientProductChange(idx, p)}
/>
</div>
{#if ingredients.length > 1}
<button
type="button"
onclick={() => removeIngredient(idx)}
class="px-2 text-gray-300 active:text-danger"
aria-label="Remove ingredient"
>
</button>
{/if}
</div>
<button
type="button"
onclick={() => toggleApproximate(idx)}
class="text-xs text-gray-500 hover:text-primary"
>
{ingredient.isApproximate ? '↩ use measured amount' : '~ approximate (e.g. to taste)'}
</button>
</div>
{/each}
<button
type="button"
onclick={addIngredient}
class="mt-1 text-sm font-medium text-primary"
>
+ Add ingredient
</button>
</div>
<textarea
bind:value={instructions}
placeholder="Instructions (optional)"
rows={6}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
></textarea>
<button
type="submit"
disabled={saving}
class="w-full rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</form>
</div>
{/if}