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();
|
||||
|
||||
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
|
||||
{
|
||||
recipe.Id,
|
||||
@@ -102,7 +133,8 @@ public static class RecipeEndpoints
|
||||
i.IsApproximate,
|
||||
i.QuantityNote,
|
||||
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
|
||||
// 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">← 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