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 2696f09653
commit b67e2434b5
14 changed files with 1499 additions and 114 deletions

View File

@@ -0,0 +1,249 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { do_on_key } from '$lib/components/do_on_key.js'
import Check from '$lib/assets/icons/Check.svelte'
export let type: 'ingredients' | 'instructions' = 'ingredients';
export let onSelect: (recipe: any, options: any) => void;
export let open = false;
// Unique dialog ID based on type to prevent conflicts when both are on the same page
const dialogId = `base-recipe-selector-modal-${type}`;
let baseRecipes: any[] = [];
let selectedRecipe: any = null;
let options = {
includeIngredients: false,
includeInstructions: false,
showLabel: true,
labelOverride: ''
};
// Reset options whenever type or modal state changes
$: {
if (open || type) {
options.includeIngredients = type === 'ingredients';
options.includeInstructions = type === 'instructions';
}
}
onMount(async () => {
const res = await fetch('/api/rezepte/base-recipes');
baseRecipes = await res.json();
});
function handleInsert() {
if (selectedRecipe) {
onSelect(selectedRecipe, options);
// Reset modal
selectedRecipe = null;
options.labelOverride = '';
options.showLabel = true;
closeModal();
}
}
function closeModal() {
open = false;
if (browser) {
const modal = document.querySelector(`#${dialogId}`) as HTMLDialogElement;
if (modal) {
modal.close();
}
}
}
function openModal() {
if (browser) {
const modal = document.querySelector(`#${dialogId}`) as HTMLDialogElement;
if (modal) {
modal.showModal();
}
}
}
$: if (browser) {
if (open) {
setTimeout(openModal, 0);
} else {
closeModal();
}
}
</script>
<style>
dialog {
box-sizing: content-box;
width: 100%;
height: 100%;
background-color: transparent;
border: unset;
margin: 0;
transition: 500ms;
}
dialog[open]::backdrop {
animation: show 200ms ease forwards;
}
@keyframes show {
from {
backdrop-filter: blur(0px);
}
to {
backdrop-filter: blur(10px);
}
}
dialog h2 {
font-size: 3rem;
font-family: sans-serif;
color: white;
text-align: center;
margin-top: 30vh;
margin-top: 30dvh;
filter: drop-shadow(0 0 0.4em black)
drop-shadow(0 0 1em black);
}
.selector-content {
box-sizing: border-box;
margin-inline: auto;
margin-top: 2rem;
max-width: 600px;
padding: 2rem;
border-radius: 20px;
background-color: var(--blue);
color: white;
box-shadow: 0 0 1em 0.2em rgba(0,0,0,0.3);
}
.selector-content label {
display: block;
margin-block: 1rem;
font-size: 1.1rem;
}
.selector-content select,
.selector-content input[type="text"] {
width: 100%;
padding: 0.5em 1em;
margin-top: 0.5em;
border-radius: 1000px;
border: 2px solid var(--nord4);
background-color: white;
color: var(--nord0);
font-size: 1rem;
transition: 100ms;
}
.selector-content select:hover,
.selector-content select:focus,
.selector-content input[type="text"]:hover,
.selector-content input[type="text"]:focus {
border-color: var(--nord9);
transform: scale(1.02, 1.02);
}
.selector-content input[type="checkbox"] {
width: 1.2em;
height: 1.2em;
margin-right: 0.5em;
vertical-align: middle;
}
.button-group {
display: flex;
gap: 1rem;
margin-top: 2rem;
justify-content: center;
}
.button-group button {
padding: 0.75em 2em;
font-size: 1.1rem;
border-radius: 1000px;
border: none;
cursor: pointer;
transition: 200ms;
font-weight: bold;
}
.button-insert {
background-color: var(--nord14);
color: var(--nord0);
}
.button-cancel {
background-color: var(--nord3);
color: white;
}
.button-group button:hover {
transform: scale(1.1, 1.1);
box-shadow: 0 0 1em 0.3em rgba(0,0,0,0.3);
}
@media (prefers-color-scheme: dark) {
.selector-content {
background-color: var(--nord1);
}
}
</style>
<dialog id={dialogId}>
<h2>Basisrezept einfügen</h2>
<div class="selector-content">
<label>
Basisrezept auswählen:
<select bind:value={selectedRecipe}>
<option value={null}>-- Auswählen --</option>
{#each baseRecipes as recipe}
<option value={recipe}>{recipe.icon} {recipe.name}</option>
{/each}
</select>
</label>
{#if type === 'ingredients'}
<label>
<input type="checkbox" bind:checked={options.includeIngredients} />
Zutaten einbeziehen
</label>
{/if}
{#if type === 'instructions'}
<label>
<input type="checkbox" bind:checked={options.includeInstructions} />
Zubereitungsschritte einbeziehen
</label>
{/if}
<label>
<input type="checkbox" bind:checked={options.showLabel} />
Rezeptname als Überschrift anzeigen
</label>
{#if options.showLabel}
<label>
Eigene Überschrift (optional):
<input
type="text"
bind:value={options.labelOverride}
placeholder={selectedRecipe?.name || 'Überschrift eingeben...'}
on:keydown={(event) => do_on_key(event, 'Enter', false, handleInsert)}
/>
</label>
{/if}
<div class="button-group">
<button class="button-insert" on:click={handleInsert} disabled={!selectedRecipe}>
Einfügen
</button>
<button class="button-cancel" on:click={closeModal}>
Abbrechen
</button>
</div>
</div>
</dialog>