feat: implement base recipe references with customizable ingredients and instructions

Add comprehensive base recipe system allowing recipes to reference other recipes dynamically. References can include custom items before/after the base recipe content and render as unified lists.

Features:
- Mark recipes as base recipes with isBaseRecipe flag
- Insert base recipe references at any position in ingredients/instructions
- Add custom items before/after referenced content (itemsBefore/itemsAfter, stepsBefore/stepsAfter)
- Combined rendering displays all items in single unified lists
- Full edit/remove functionality for additional items with modal reuse
- Empty item validation prevents accidental blank entries
- HTML rendering in section titles for proper <wbr> and &shy; support
- Reference links in section headings with multiplier preservation
- Subtle hover effects (2% scale) on add buttons
- Translation support for all reference fields
- Deletion handling expands references before removing base recipes
This commit is contained in:
2026-01-04 15:21:17 +01:00
parent f11bb1dcf5
commit 327aa6824b
14 changed files with 1499 additions and 114 deletions
@@ -0,0 +1,16 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../models/Recipe';
import { dbConnect } from '../../../../utils/db';
import { json } from '@sveltejs/kit';
// GET: List all base recipes for selector UI
export const GET: RequestHandler = async () => {
await dbConnect();
const baseRecipes = await Recipe.find({ isBaseRecipe: true })
.select('_id short_name name category icon')
.sort({ name: 1 })
.lean();
return json(baseRecipes);
};
@@ -0,0 +1,21 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../../models/Recipe';
import { dbConnect } from '../../../../../utils/db';
import { json } from '@sveltejs/kit';
// GET: Check which recipes reference this recipe
export const GET: RequestHandler = async ({ params }) => {
await dbConnect();
const referencingRecipes = await Recipe.find({
$or: [
{ 'ingredients.baseRecipeRef': params.id },
{ 'instructions.baseRecipeRef': params.id }
]
}).select('short_name name').lean();
return json({
isReferenced: referencingRecipes.length > 0,
references: referencingRecipes
});
};
+39
View File
@@ -21,6 +21,45 @@ export const POST: RequestHandler = async ({request, locals}) => {
throw error(404, "Recipe not found");
}
// Check if this recipe is referenced by others
const referencingRecipes = await Recipe.find({
$or: [
{ 'ingredients.baseRecipeRef': recipe._id },
{ 'instructions.baseRecipeRef': recipe._id }
]
});
// Expand all references into regular content before deletion
for (const depRecipe of referencingRecipes) {
// Expand ingredient references
if (depRecipe.ingredients) {
depRecipe.ingredients = depRecipe.ingredients.flatMap((item: any) => {
if (item.type === 'reference' && item.baseRecipeRef && item.baseRecipeRef.equals(recipe._id)) {
if (item.includeIngredients && recipe.ingredients) {
return recipe.ingredients.filter((i: any) => i.type === 'section' || !i.type);
}
return [];
}
return [item];
});
}
// Expand instruction references
if (depRecipe.instructions) {
depRecipe.instructions = depRecipe.instructions.flatMap((item: any) => {
if (item.type === 'reference' && item.baseRecipeRef && item.baseRecipeRef.equals(recipe._id)) {
if (item.includeInstructions && recipe.instructions) {
return recipe.instructions.filter((i: any) => i.type === 'section' || !i.type);
}
return [];
}
return [item];
});
}
await depRecipe.save();
}
// Remove this recipe from all users' favorites
await UserFavorites.updateMany(
{ favorites: recipe._id },
+30 -1
View File
@@ -6,11 +6,40 @@ import { error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
let recipe = (await Recipe.findOne({ short_name: params.name}).lean()) as RecipeModelType[];
let recipe = await Recipe.findOne({ short_name: params.name})
.populate({
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations'
})
.populate({
path: 'instructions.baseRecipeRef',
select: 'short_name name instructions translations'
})
.lean() as RecipeModelType[];
recipe = JSON.parse(JSON.stringify(recipe));
if(recipe == null){
throw error(404, "Recipe not found")
}
// Map populated refs to resolvedRecipe field
if (recipe?.ingredients) {
recipe.ingredients = recipe.ingredients.map((item: any) => {
if (item.type === 'reference' && item.baseRecipeRef) {
return { ...item, resolvedRecipe: item.baseRecipeRef };
}
return item;
});
}
if (recipe?.instructions) {
recipe.instructions = recipe.instructions.map((item: any) => {
if (item.type === 'reference' && item.baseRecipeRef) {
return { ...item, resolvedRecipe: item.baseRecipeRef };
}
return item;
});
}
return json(recipe);
};