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:
@@ -81,6 +81,37 @@ public static class RecipeEndpoints
|
|||||||
|
|
||||||
if (recipe is null) return Results.NotFound();
|
if (recipe is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var globalProductIds = recipe.Ingredients
|
||||||
|
.Where(i => i.ProductId.HasValue).Select(i => i.ProductId!.Value).Distinct().ToList();
|
||||||
|
var familyProductIds = recipe.Ingredients
|
||||||
|
.Where(i => i.FamilyProductId.HasValue).Select(i => i.FamilyProductId!.Value).Distinct().ToList();
|
||||||
|
|
||||||
|
var globalCats = globalProductIds.Count == 0
|
||||||
|
? new Dictionary<int, (UnitCategoryFlags Base, UnitCategoryFlags? Override)>()
|
||||||
|
: await db.Products
|
||||||
|
.Where(p => globalProductIds.Contains(p.Id))
|
||||||
|
.GroupJoin(
|
||||||
|
db.FamilyProductOverrides.Where(o => o.FamilyId == familyId),
|
||||||
|
p => p.Id,
|
||||||
|
o => o.ProductId,
|
||||||
|
(p, os) => new { p.Id, Base = p.AllowedUnitCategories, Override = os.Select(o => (UnitCategoryFlags?)o.AllowedUnitCategories).FirstOrDefault() })
|
||||||
|
.ToDictionaryAsync(x => x.Id, x => (x.Base, x.Override));
|
||||||
|
|
||||||
|
var familyCats = familyProductIds.Count == 0
|
||||||
|
? new Dictionary<int, UnitCategoryFlags>()
|
||||||
|
: await db.FamilyProducts
|
||||||
|
.Where(p => p.FamilyId == familyId && familyProductIds.Contains(p.Id))
|
||||||
|
.ToDictionaryAsync(p => p.Id, p => p.AllowedUnitCategories);
|
||||||
|
|
||||||
|
UnitCategoryFlags EffectiveCats(int? globalId, int? familyId)
|
||||||
|
{
|
||||||
|
if (globalId.HasValue && globalCats.TryGetValue(globalId.Value, out var g))
|
||||||
|
return g.Override ?? g.Base;
|
||||||
|
if (familyId.HasValue && familyCats.TryGetValue(familyId.Value, out var f))
|
||||||
|
return f;
|
||||||
|
return UnitCategoryFlags.None;
|
||||||
|
}
|
||||||
|
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
recipe.Id,
|
recipe.Id,
|
||||||
@@ -102,7 +133,8 @@ public static class RecipeEndpoints
|
|||||||
i.IsApproximate,
|
i.IsApproximate,
|
||||||
i.QuantityNote,
|
i.QuantityNote,
|
||||||
i.ProductId,
|
i.ProductId,
|
||||||
i.FamilyProductId
|
i.FamilyProductId,
|
||||||
|
AllowedUnitCategories = (int)EffectiveCats(i.ProductId, i.FamilyProductId)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
// or when they edit the input after a selection (with null) — the
|
// or when they edit the input after a selection (with null) — the
|
||||||
// caller uses this to track / clear an associated product link.
|
// caller uses this to track / clear an associated product link.
|
||||||
onProductChange?: (product: ProductSuggestion | null) => void;
|
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 {
|
let {
|
||||||
@@ -42,11 +46,15 @@
|
|||||||
inputClass = '',
|
inputClass = '',
|
||||||
onsubmit,
|
onsubmit,
|
||||||
onProductChange,
|
onProductChange,
|
||||||
|
initialSelectedName = null,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Tracks the most recent picked product so we can detect when the user
|
// Tracks the most recent picked product so we can detect when the user
|
||||||
// edits the input afterward (which invalidates the link).
|
// edits the input afterward (which invalidates the link). We intentionally
|
||||||
let lastSelectedName: string | null = null;
|
// 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 suggestions = $state<ProductSuggestion[]>([]);
|
||||||
let showDropdown = $state(false);
|
let showDropdown = $state(false);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
familyUnitOfMeasureId: number | null;
|
familyUnitOfMeasureId: number | null;
|
||||||
isApproximate: boolean;
|
isApproximate: boolean;
|
||||||
quantityNote: string | null;
|
quantityNote: string | null;
|
||||||
|
productId: number | null;
|
||||||
|
familyProductId: number | null;
|
||||||
|
allowedUnitCategories: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Recipe {
|
interface Recipe {
|
||||||
@@ -98,6 +101,12 @@
|
|||||||
>
|
>
|
||||||
Add to list
|
Add to list
|
||||||
</button>
|
</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
|
<button
|
||||||
onclick={deleteRecipe}
|
onclick={deleteRecipe}
|
||||||
class="rounded-lg border border-danger px-4 py-2.5 text-sm text-danger"
|
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">← 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}
|
||||||
Reference in New Issue
Block a user