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 ­ 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:
249
src/lib/components/BaseRecipeSelector.svelte
Normal file
249
src/lib/components/BaseRecipeSelector.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user