From 327aa6824b87bc69817bfb4c8dab759e1440ee9c Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sun, 4 Jan 2026 15:21:17 +0100 Subject: [PATCH] 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 and ­ 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 --- src/lib/components/BaseRecipeSelector.svelte | 249 ++++++++++++ .../components/CreateIngredientList.svelte | 380 ++++++++++++++++-- src/lib/components/CreateStepList.svelte | 354 +++++++++++++++- src/lib/components/IngredientsPage.svelte | 82 +++- src/lib/components/InstructionsPage.svelte | 82 +++- src/models/Recipe.ts | 85 +++- .../[recipeLang=recipeLang]/add/+page.svelte | 9 + .../edit/[name]/+page.svelte | 58 ++- .../api/rezepte/base-recipes/+server.ts | 16 + .../rezepte/check-references/[id]/+server.ts | 21 + src/routes/api/rezepte/delete/+server.ts | 39 ++ .../api/rezepte/items/[name]/+server.ts | 31 +- src/types/types.ts | 97 +++-- src/utils/translation.ts | 110 ++++- 14 files changed, 1499 insertions(+), 114 deletions(-) create mode 100644 src/lib/components/BaseRecipeSelector.svelte create mode 100644 src/routes/api/rezepte/base-recipes/+server.ts create mode 100644 src/routes/api/rezepte/check-references/[id]/+server.ts diff --git a/src/lib/components/BaseRecipeSelector.svelte b/src/lib/components/BaseRecipeSelector.svelte new file mode 100644 index 00000000..18f971a6 --- /dev/null +++ b/src/lib/components/BaseRecipeSelector.svelte @@ -0,0 +1,249 @@ + + + + + +

Basisrezept einfügen

+ +
+ + + {#if type === 'ingredients'} + + {/if} + + {#if type === 'instructions'} + + {/if} + + + + {#if options.showLabel} + + {/if} + +
+ + +
+
+
diff --git a/src/lib/components/CreateIngredientList.svelte b/src/lib/components/CreateIngredientList.svelte index 1c863bce..02c560e7 100644 --- a/src/lib/components/CreateIngredientList.svelte +++ b/src/lib/components/CreateIngredientList.svelte @@ -10,6 +10,7 @@ import "$lib/css/action_button.css" import { do_on_key } from '$lib/components/do_on_key.js' import { portions } from '$lib/js/portions_store.js' +import BaseRecipeSelector from '$lib/components/BaseRecipeSelector.svelte' let portions_local portions.subscribe((p) => { @@ -43,6 +44,122 @@ let edit_heading = { list_index: "", } +// Base recipe selector state +let showSelector = false; +let insertPosition = 0; + +// State for adding items to references +let addingToReference = { + active: false, + list_index: -1, + position: 'before' as 'before' | 'after', + editing: false, + item_index: -1 +}; + +function openSelector(position: number) { + insertPosition = position; + showSelector = true; +} + +function handleSelect(recipe: any, options: any) { + const reference = { + type: 'reference', + name: options.labelOverride || (options.showLabel ? recipe.name : ''), + baseRecipeRef: recipe._id, + includeIngredients: options.includeIngredients, + showLabel: options.showLabel, + labelOverride: options.labelOverride || '', + itemsBefore: [], + itemsAfter: [] + }; + + ingredients.splice(insertPosition, 0, reference); + ingredients = ingredients; + showSelector = false; +} + +export function removeReference(list_index: number) { + const confirmed = confirm("Bist du dir sicher, dass du diese Referenz löschen möchtest?"); + if (confirmed) { + ingredients.splice(list_index, 1); + ingredients = ingredients; + } +} + +// Functions to manage items before/after base recipe in references +function addItemToReference(list_index: number, position: 'before' | 'after', item: any) { + if (!ingredients[list_index].itemsBefore) ingredients[list_index].itemsBefore = []; + if (!ingredients[list_index].itemsAfter) ingredients[list_index].itemsAfter = []; + + if (position === 'before') { + ingredients[list_index].itemsBefore.push(item); + } else { + ingredients[list_index].itemsAfter.push(item); + } + ingredients = ingredients; +} + +function removeItemFromReference(list_index: number, position: 'before' | 'after', item_index: number) { + if (position === 'before') { + ingredients[list_index].itemsBefore.splice(item_index, 1); + } else { + ingredients[list_index].itemsAfter.splice(item_index, 1); + } + ingredients = ingredients; +} + +function editItemFromReference(list_index: number, position: 'before' | 'after', item_index: number) { + const items = position === 'before' ? ingredients[list_index].itemsBefore : ingredients[list_index].itemsAfter; + const item = items[item_index]; + + // Set up edit state + addingToReference = { + active: true, + list_index, + position, + editing: true, + item_index + }; + + edit_ingredient = { + amount: item.amount || "", + unit: item.unit || "", + name: item.name || "", + sublist: "", + list_index: "", + ingredient_index: "", + }; + + const modal_el = document.querySelector("#edit_ingredient_modal") as HTMLDialogElement; + if (modal_el) { + modal_el.showModal(); + } +} + +function openAddToReferenceModal(list_index: number, position: 'before' | 'after') { + addingToReference = { + active: true, + list_index, + position, + editing: false, + item_index: -1 + }; + // Clear and open the edit ingredient modal for adding + edit_ingredient = { + amount: "", + unit: "", + name: "", + sublist: "", + list_index: "", + ingredient_index: "", + }; + const modal_el = document.querySelector("#edit_ingredient_modal") as HTMLDialogElement; + if (modal_el) { + modal_el.showModal(); + } +} + function get_sublist_index(sublist_name, list){ for(var i =0; i < list.length; i++){ if(list[i].name == sublist_name){ @@ -102,12 +219,55 @@ export function show_modal_edit_ingredient(list_index, ingredient_index){ modal_el.showModal(); } export function edit_ingredient_and_close_modal(){ - ingredients[edit_ingredient.list_index].list[edit_ingredient.ingredient_index] = { - amount: edit_ingredient.amount, - unit: edit_ingredient.unit, - name: edit_ingredient.name, + // Check if we're adding to or editing a reference + if (addingToReference.active) { + // Don't add empty ingredients + if (!edit_ingredient.name) { + addingToReference = { + active: false, + list_index: -1, + position: 'before', + editing: false, + item_index: -1 + }; + const modal_el = document.querySelector("#edit_ingredient_modal"); + modal_el.close(); + return; + } + + const item = { + amount: edit_ingredient.amount, + unit: edit_ingredient.unit, + name: edit_ingredient.name + }; + + if (addingToReference.editing) { + // Edit existing item in reference + const items = addingToReference.position === 'before' + ? ingredients[addingToReference.list_index].itemsBefore + : ingredients[addingToReference.list_index].itemsAfter; + items[addingToReference.item_index] = item; + ingredients = ingredients; + } else { + // Add new item to reference + addItemToReference(addingToReference.list_index, addingToReference.position, item); + } + addingToReference = { + active: false, + list_index: -1, + position: 'before', + editing: false, + item_index: -1 + }; + } else { + // Normal edit behavior + ingredients[edit_ingredient.list_index].list[edit_ingredient.ingredient_index] = { + amount: edit_ingredient.amount, + unit: edit_ingredient.unit, + name: edit_ingredient.name, + } + ingredients[edit_ingredient.list_index].name = edit_ingredient.sublist } - ingredients[edit_ingredient.list_index].name = edit_ingredient.sublist const modal_el = document.querySelector("#edit_ingredient_modal"); modal_el.close(); } @@ -430,6 +590,66 @@ h3{ width: 100%; text-align: left; } + +/* Base recipe reference styles */ +.reference-container { + margin-block: 1em; + padding: 1em; + background-color: var(--nord14); + border-radius: 10px; + border: 2px solid var(--nord9); + box-shadow: 0 0 0.5em 0.1em rgba(0,0,0,0.2); +} + +.reference-header { + display: flex; + align-items: center; + gap: 1em; + margin-bottom: 0.5em; +} + +.reference-badge { + flex-grow: 1; + font-weight: bold; + color: var(--nord0); + font-size: 1.1rem; +} + +@media (prefers-color-scheme: dark) { + .reference-container { + background-color: var(--nord1); + } + .reference-badge { + color: var(--nord6); + } +} + +.insert-base-recipe-button { + margin-block: 1rem; + padding: 1em 2em; + font-size: 1.1rem; + border-radius: 1000px; + background-color: var(--nord9); + color: white; + border: none; + cursor: pointer; + transition: 200ms; + box-shadow: 0 0 0.5em 0.1em rgba(0,0,0,0.2); +} + +.insert-base-recipe-button:hover { + transform: scale(1.05, 1.05); + box-shadow: 0 0 1em 0.2em rgba(0,0,0,0.3); +} + +.add-to-reference-button { + color: white; +} + +.add-to-reference-button:hover { + scale: 1.02 1.02 !important; + transform: scale(1.02) !important; +}
@@ -438,32 +658,118 @@ h3{

Zutaten

{#each ingredients as list, list_index} - -

-
- - -
+ {#if list.type === 'reference'} + +
+
+
+ + +
+
+ 📋 Basisrezept: {list.name || 'Unbenannt'} +
+
+ +
+
- + +
+ + +
+ {/each} +
+ {/if} + + + +
+ → Inhalt vom Basisrezept wird hier eingefügt ← +
+ + + + {#if list.itemsAfter && list.itemsAfter.length > 0} +

Zusätzliche Zutaten danach:

+
+ {#each list.itemsAfter as item, item_index} +
+ +
+ + +
+ + +
+ {/each} +
+ {/if} +
{:else} - Leer - {/if} - -
- - -
- -
+ +

+
+ + +
+ + +
+ + +
+

+
{#each list.list as ingredient, ingredient_index (ingredient_index)}
{/each} -
+
+ {/if} {/each} + + +
@@ -522,3 +835,10 @@ h3{
+ + + diff --git a/src/lib/components/CreateStepList.svelte b/src/lib/components/CreateStepList.svelte index d676ef55..2c4805c1 100644 --- a/src/lib/components/CreateStepList.svelte +++ b/src/lib/components/CreateStepList.svelte @@ -9,6 +9,7 @@ import '$lib/css/nordtheme.css' import "$lib/css/action_button.css" import { do_on_key } from '$lib/components/do_on_key.js' +import BaseRecipeSelector from '$lib/components/BaseRecipeSelector.svelte' const step_placeholder = "Kartoffeln schälen..." export let instructions @@ -24,6 +25,118 @@ let edit_heading = { list_index: "", } +// Base recipe selector state +let showSelector = false; +let insertPosition = 0; + +// State for adding steps to references +let addingToReference = { + active: false, + list_index: -1, + position: 'before' as 'before' | 'after', + editing: false, + step_index: -1 +}; + +function openSelector(position: number) { + insertPosition = position; + showSelector = true; +} + +function handleSelect(recipe: any, options: any) { + const reference = { + type: 'reference', + name: options.labelOverride || (options.showLabel ? recipe.name : ''), + baseRecipeRef: recipe._id, + includeInstructions: options.includeInstructions, + showLabel: options.showLabel, + labelOverride: options.labelOverride || '', + stepsBefore: [], + stepsAfter: [] + }; + + instructions.splice(insertPosition, 0, reference); + instructions = instructions; + showSelector = false; +} + +export function removeReference(list_index: number) { + const confirmed = confirm("Bist du dir sicher, dass du diese Referenz löschen möchtest?"); + if (confirmed) { + instructions.splice(list_index, 1); + instructions = instructions; + } +} + +// Functions to manage steps before/after base recipe in references +function addStepToReference(list_index: number, position: 'before' | 'after', step: string) { + if (!instructions[list_index].stepsBefore) instructions[list_index].stepsBefore = []; + if (!instructions[list_index].stepsAfter) instructions[list_index].stepsAfter = []; + + if (position === 'before') { + instructions[list_index].stepsBefore.push(step); + } else { + instructions[list_index].stepsAfter.push(step); + } + instructions = instructions; +} + +function removeStepFromReference(list_index: number, position: 'before' | 'after', step_index: number) { + if (position === 'before') { + instructions[list_index].stepsBefore.splice(step_index, 1); + } else { + instructions[list_index].stepsAfter.splice(step_index, 1); + } + instructions = instructions; +} + +function editStepFromReference(list_index: number, position: 'before' | 'after', step_index: number) { + const steps = position === 'before' ? instructions[list_index].stepsBefore : instructions[list_index].stepsAfter; + const step = steps[step_index]; + + // Set up edit state + addingToReference = { + active: true, + list_index, + position, + editing: true, + step_index + }; + + edit_step = { + step: step || "", + name: "", + list_index: 0, + step_index: 0, + }; + + const modal_el = document.querySelector("#edit_step_modal") as HTMLDialogElement; + if (modal_el) { + modal_el.showModal(); + } +} + +function openAddToReferenceModal(list_index: number, position: 'before' | 'after') { + addingToReference = { + active: true, + list_index, + position, + editing: false, + step_index: -1 + }; + // Clear and open the edit step modal for adding + edit_step = { + step: "", + name: "", + list_index: 0, + step_index: 0, + }; + const modal_el = document.querySelector("#edit_step_modal") as HTMLDialogElement; + if (modal_el) { + modal_el.showModal(); + } +} + function get_sublist_index(sublist_name, list){ for(var i =0; i < list.length; i++){ if(list[i].name == sublist_name){ @@ -86,7 +199,44 @@ export function show_modal_edit_step(list_index, step_index){ } export function edit_step_and_close_modal(){ - instructions[edit_step.list_index].steps[edit_step.step_index] = edit_step.step + // Check if we're adding to or editing a reference + if (addingToReference.active) { + // Don't add empty steps + if (!edit_step.step || edit_step.step.trim() === '') { + addingToReference = { + active: false, + list_index: -1, + position: 'before', + editing: false, + step_index: -1 + }; + const modal_el = document.querySelector("#edit_step_modal"); + modal_el.close(); + return; + } + + if (addingToReference.editing) { + // Edit existing step in reference + const steps = addingToReference.position === 'before' + ? instructions[addingToReference.list_index].stepsBefore + : instructions[addingToReference.list_index].stepsAfter; + steps[addingToReference.step_index] = edit_step.step; + instructions = instructions; + } else { + // Add new step to reference + addStepToReference(addingToReference.list_index, addingToReference.position, edit_step.step); + } + addingToReference = { + active: false, + list_index: -1, + position: 'before', + editing: false, + step_index: -1 + }; + } else { + // Normal edit behavior + instructions[edit_step.list_index].steps[edit_step.step_index] = edit_step.step + } const modal_el = document.querySelector("#edit_step_modal"); modal_el.close(); } @@ -451,6 +601,66 @@ h3{ width: 100%; text-align: left; } + +/* Base recipe reference styles */ +.reference-container { + margin-block: 1em; + padding: 1em; + background-color: var(--nord14); + border-radius: 10px; + border: 2px solid var(--nord9); + box-shadow: 0 0 0.5em 0.1em rgba(0,0,0,0.2); +} + +.reference-header { + display: flex; + align-items: center; + gap: 1em; + margin-bottom: 0.5em; +} + +.reference-badge { + flex-grow: 1; + font-weight: bold; + color: var(--nord0); + font-size: 1.1rem; +} + +@media (prefers-color-scheme: dark) { + .reference-container { + background-color: var(--nord1); + } + .reference-badge { + color: var(--nord6); + } +} + +.insert-base-recipe-button { + margin-block: 1rem; + padding: 1em 2em; + font-size: 1.1rem; + border-radius: 1000px; + background-color: var(--nord9); + color: white; + border: none; + cursor: pointer; + transition: 200ms; + box-shadow: 0 0 0.5em 0.1em rgba(0,0,0,0.2); +} + +.insert-base-recipe-button:hover { + transform: scale(1.05, 1.05); + box-shadow: 0 0 1em 0.2em rgba(0,0,0,0.3); +} + +.add-to-reference-button { + color: white; +} + +.add-to-reference-button:hover { + scale: 1.02 1.02 !important; + transform: scale(1.02) !important; +}
@@ -483,27 +693,115 @@ h3{

Zubereitung

{#each instructions as list, list_index} - -

-
- - -
- + +

+
+ 📋 Basisrezept: {list.name || 'Unbenannt'} +
+
+ +
+ + + + {#if list.stepsBefore && list.stepsBefore.length > 0} +

Zusätzliche Schritte davor:

+
    + {#each list.stepsBefore as step, step_index} +
  1. +
    +
    + +
    + +
    + + +
    +
    +
  2. + {/each} +
+ {/if} + + + +
+ → Inhalt vom Basisrezept wird hier eingefügt ← +
+ + + + {#if list.stepsAfter && list.stepsAfter.length > 0} +

Zusätzliche Schritte danach:

+
    + {#each list.stepsAfter as step, step_index} +
  1. +
    +
    + +
    + +
    + + +
    +
    +
  2. + {/each} +
+ {/if} + {:else} - Leer - {/if} - - - + + + + +
    @@ -532,7 +830,14 @@ h3{ {/each}
+ {/if} {/each} + + +
@@ -566,3 +871,10 @@ h3{
+ + + diff --git a/src/lib/components/IngredientsPage.svelte b/src/lib/components/IngredientsPage.svelte index 99c8bf80..051dc31e 100644 --- a/src/lib/components/IngredientsPage.svelte +++ b/src/lib/components/IngredientsPage.svelte @@ -6,6 +6,65 @@ import { page } from '$app/stores'; import HefeSwapper from './HefeSwapper.svelte'; let { data } = $props(); + +// Flatten ingredient references for display +const flattenedIngredients = $derived.by(() => { + if (!data.ingredients) return []; + + return data.ingredients.flatMap((item) => { + if (item.type === 'reference' && item.resolvedRecipe) { + const sections = []; + + // Get translated or original ingredients + const lang = data.lang || 'de'; + const ingredientsToUse = (lang === 'en' && + item.resolvedRecipe.translations?.en?.ingredients) + ? item.resolvedRecipe.translations.en.ingredients + : item.resolvedRecipe.ingredients || []; + + // Filter to only sections (not nested references) + const baseIngredients = item.includeIngredients + ? ingredientsToUse.filter(i => i.type === 'section' || !i.type) + : []; + + // Combine all items into one section + const combinedList = []; + + // Add items before + if (item.itemsBefore && item.itemsBefore.length > 0) { + combinedList.push(...item.itemsBefore); + } + + // Add base recipe ingredients + baseIngredients.forEach(section => { + if (section.list) { + combinedList.push(...section.list); + } + }); + + // Add items after + if (item.itemsAfter && item.itemsAfter.length > 0) { + combinedList.push(...item.itemsAfter); + } + + // Push as one section with optional label + if (combinedList.length > 0) { + sections.push({ + type: 'section', + name: item.showLabel ? (item.labelOverride || item.resolvedRecipe.name) : '', + list: combinedList, + isReference: item.showLabel, + short_name: item.resolvedRecipe.short_name + }); + } + + return sections; + } + + // Regular section - pass through + return [item]; + }); +}); let multiplier = $state(data.multiplier || 1); const isEnglish = $derived(data.lang === 'en'); @@ -324,6 +383,19 @@ span background-color: var(--orange); box-shadow: 0px 0px 0.5em 0.1em rgba(0,0,0, 0.3); } + +/* Base recipe reference link styling */ +h3 a { + color: var(--nord11); + text-decoration: underline; + text-decoration-color: var(--nord11); +} + +h3 a:hover { + color: var(--nord11); + text-decoration: underline; + text-decoration-color: var(--nord11); +} {#if data.ingredients}
@@ -400,10 +472,15 @@ span

{labels.ingredients}

-{#each data.ingredients as list, listIndex} +{#each flattenedIngredients as list, listIndex} {#if list.name} -

{list.name}

+ {#if list.isReference} +

{@html list.name}

+ {:else} +

{@html list.name}

+ {/if} {/if} +{#if list.list}
{#each list.list as item, ingredientIndex}
{@html adjust_amount(item.amount, multiplier)} {item.unit}
@@ -416,6 +493,7 @@ span
{/each} +{/if} {/each} {/if} diff --git a/src/lib/components/InstructionsPage.svelte b/src/lib/components/InstructionsPage.svelte index f29076ac..d03fc11e 100644 --- a/src/lib/components/InstructionsPage.svelte +++ b/src/lib/components/InstructionsPage.svelte @@ -1,6 +1,65 @@