Compare commits

2 Commits

Author SHA1 Message Date
296201eee5 fix: improve UI elements in recipe editor
All checks were successful
CI / update (push) Successful in 1m14s
- Center isBaseRecipe toggle by changing display to inline-flex
- Fix note field editing by adding textarea with bindable value
- Clear instruction step input after submission instead of restoring placeholder
- Style note textarea with transparent background and lighter placeholder text

The instruction field now properly clears on submission, while ingredient fields retain their previous values.
2026-01-13 19:14:26 +01:00
b43b45dac2 feat: add base multiplier support for recipe references
Add optional baseMultiplier field to ingredient and instruction references, allowing base recipes to be included at scaled amounts (e.g., 0.5 for half the recipe).

- Add baseMultiplier field to Recipe schema with default value of 1
- Update TypeScript types to include baseMultiplier
- Add multiplier input field to BaseRecipeSelector modal
- Apply baseMultiplier to ingredient amounts during flattening
- Combine baseMultiplier with recipe multiplier in links
- Display and allow editing baseMultiplier in recipe editor

The multiplier cascades through nested references and works alongside the standard recipe multiplier for compound scaling.
2026-01-13 19:14:10 +01:00
9 changed files with 116 additions and 18 deletions

View File

@@ -23,7 +23,8 @@ let options = $state({
includeIngredients: false, includeIngredients: false,
includeInstructions: false, includeInstructions: false,
showLabel: true, showLabel: true,
labelOverride: '' labelOverride: '',
baseMultiplier: 1
}); });
// Reset options whenever type or modal state changes // Reset options whenever type or modal state changes
@@ -46,6 +47,7 @@ function handleInsert() {
selectedRecipe = null; selectedRecipe = null;
options.labelOverride = ''; options.labelOverride = '';
options.showLabel = true; options.showLabel = true;
options.baseMultiplier = 1;
closeModal(); closeModal();
} }
} }
@@ -134,7 +136,8 @@ dialog h2 {
} }
.selector-content select, .selector-content select,
.selector-content input[type="text"] { .selector-content input[type="text"],
.selector-content input[type="number"] {
width: 100%; width: 100%;
padding: 0.5em 1em; padding: 0.5em 1em;
margin-top: 0.5em; margin-top: 0.5em;
@@ -149,7 +152,9 @@ dialog h2 {
.selector-content select:hover, .selector-content select:hover,
.selector-content select:focus, .selector-content select:focus,
.selector-content input[type="text"]:hover, .selector-content input[type="text"]:hover,
.selector-content input[type="text"]:focus { .selector-content input[type="text"]:focus,
.selector-content input[type="number"]:hover,
.selector-content input[type="number"]:focus {
border-color: var(--nord9); border-color: var(--nord9);
transform: scale(1.02, 1.02); transform: scale(1.02, 1.02);
} }
@@ -245,6 +250,18 @@ dialog h2 {
</label> </label>
{/if} {/if}
<label>
Mengenfaktor (Multiplikator):
<input
type="number"
bind:value={options.baseMultiplier}
min="0.1"
step="0.1"
placeholder="1"
onkeydown={(event) => do_on_key(event, 'Enter', false, handleInsert)}
/>
</label>
<div class="button-group"> <div class="button-group">
<button class="button-insert" onclick={handleInsert} disabled={!selectedRecipe}> <button class="button-insert" onclick={handleInsert} disabled={!selectedRecipe}>
Einfügen Einfügen

View File

@@ -128,6 +128,7 @@ function handleSelect(recipe: any, options: any) {
includeIngredients: options.includeIngredients, includeIngredients: options.includeIngredients,
showLabel: options.showLabel, showLabel: options.showLabel,
labelOverride: options.labelOverride || '', labelOverride: options.labelOverride || '',
baseMultiplier: options.baseMultiplier || 1,
itemsBefore: [], itemsBefore: [],
itemsAfter: [] itemsAfter: []
}; };
@@ -746,6 +747,18 @@ h3{
</div> </div>
<div class="reference-badge"> <div class="reference-badge">
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed} 📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
<div style="margin-top: 0.5em;">
<label style="font-size: 0.9em; display: flex; align-items: center; gap: 0.5em;">
{t[lang].baseMultiplier || 'Mengenfaktor'}:
<input
type="number"
bind:value={list.baseMultiplier}
min="0.1"
step="0.1"
style="width: 5em; padding: 0.25em 0.5em; border-radius: 5px; border: 1px solid var(--nord4);"
/>
</label>
</div>
</div> </div>
<div class="mod_icons"> <div class="mod_icons">
<button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}> <button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>

View File

@@ -125,6 +125,7 @@ function handleSelect(recipe: any, options: any) {
includeInstructions: options.includeInstructions, includeInstructions: options.includeInstructions,
showLabel: options.showLabel, showLabel: options.showLabel,
labelOverride: options.labelOverride || '', labelOverride: options.labelOverride || '',
baseMultiplier: options.baseMultiplier || 1,
stepsBefore: [], stepsBefore: [],
stepsAfter: [] stepsAfter: []
}; };
@@ -246,7 +247,8 @@ export function add_new_step(){
instructions[list_index].steps.push(new_step.step) instructions[list_index].steps.push(new_step.step)
} }
const el = document.querySelector("#step") const el = document.querySelector("#step")
el.innerHTML = step_placeholder el.innerHTML = ""
new_step.step = ""
instructions = instructions //tells svelte to update dom instructions = instructions //tells svelte to update dom
} }
@@ -797,6 +799,18 @@ h3{
</div> </div>
<div class="reference-badge"> <div class="reference-badge">
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed} 📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
<div style="margin-top: 0.5em;">
<label style="font-size: 0.9em; display: flex; align-items: center; gap: 0.5em;">
{t[lang].baseMultiplier || 'Mengenfaktor'}:
<input
type="number"
bind:value={list.baseMultiplier}
min="0.1"
step="0.1"
style="width: 5em; padding: 0.25em 0.5em; border-radius: 5px; border: 1px solid var(--nord4);"
/>
</label>
</div>
</div> </div>
<div class="mod_icons"> <div class="mod_icons">
<button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}> <button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
let { note = $bindable("") } = $props<{ note?: string }>();
</script> </script>
<style> <style>
div{ div{
@@ -14,9 +15,25 @@ div{
h3{ h3{
margin-block: 0; margin-block: 0;
} }
textarea {
width: 100%;
min-height: 80px;
padding: 0.5em;
border-radius: 5px;
border: none;
color: white;
font-size: 1rem;
resize: vertical;
margin-top: 0.5em;
font-family: sans-serif;
background-color: transparent;
}
textarea::placeholder {
color: rgba(255, 255, 255, 0.6);
}
</style> </style>
<div> <div>
<h3>Notiz:</h3> <h3>Notiz:</h3>
<slot></slot> <textarea bind:value={note} placeholder="Füge eine Notiz für dieses Rezept hinzu..."></textarea>
</div> </div>

View File

@@ -7,8 +7,20 @@ import HefeSwapper from './HefeSwapper.svelte';
let { data } = $props(); let { data } = $props();
// Helper function to multiply numbers in ingredient amounts
function multiplyIngredientAmount(amount, multiplier) {
if (!amount || multiplier === 1) return amount;
return amount.replace(/(\d+(?:[\.,]\d+)?)/g, match => {
const number = match.includes(',') ? match.replace(/\./g, '').replace(',', '.') : match;
const multiplied = (parseFloat(number) * multiplier).toString();
const rounded = parseFloat(multiplied).toFixed(3);
const trimmed = parseFloat(rounded).toString();
return match.includes(',') ? trimmed.replace('.', ',') : trimmed;
});
}
// Recursively flatten nested ingredient references // Recursively flatten nested ingredient references
function flattenIngredientReferences(items, lang, visited = new Set()) { function flattenIngredientReferences(items, lang, visited = new Set(), baseMultiplier = 1) {
const result = []; const result = [];
for (const item of items) { for (const item of items) {
@@ -29,18 +41,22 @@ function flattenIngredientReferences(items, lang, visited = new Set()) {
? item.resolvedRecipe.translations.en.ingredients ? item.resolvedRecipe.translations.en.ingredients
: item.resolvedRecipe.ingredients || []; : item.resolvedRecipe.ingredients || [];
// Recursively flatten nested references // Calculate combined multiplier for this reference
const flattenedNested = flattenIngredientReferences(ingredientsToUse, lang, newVisited); const itemBaseMultiplier = item.baseMultiplier || 1;
const combinedMultiplier = baseMultiplier * itemBaseMultiplier;
// Recursively flatten nested references with the combined multiplier
const flattenedNested = flattenIngredientReferences(ingredientsToUse, lang, newVisited, combinedMultiplier);
// Combine all items into one list // Combine all items into one list
const combinedList = []; const combinedList = [];
// Add items before // Add items before (not affected by baseMultiplier)
if (item.itemsBefore && item.itemsBefore.length > 0) { if (item.itemsBefore && item.itemsBefore.length > 0) {
combinedList.push(...item.itemsBefore); combinedList.push(...item.itemsBefore);
} }
// Add base recipe ingredients (now recursively flattened) // Add base recipe ingredients (now recursively flattened with multiplier applied)
if (item.includeIngredients) { if (item.includeIngredients) {
flattenedNested.forEach(section => { flattenedNested.forEach(section => {
if (section.list) { if (section.list) {
@@ -49,7 +65,7 @@ function flattenIngredientReferences(items, lang, visited = new Set()) {
}); });
} }
// Add items after // Add items after (not affected by baseMultiplier)
if (item.itemsAfter && item.itemsAfter.length > 0) { if (item.itemsAfter && item.itemsAfter.length > 0) {
combinedList.push(...item.itemsAfter); combinedList.push(...item.itemsAfter);
} }
@@ -69,12 +85,24 @@ function flattenIngredientReferences(items, lang, visited = new Set()) {
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '', name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
list: combinedList, list: combinedList,
isReference: item.showLabel, isReference: item.showLabel,
short_name: baseRecipeShortName short_name: baseRecipeShortName,
baseMultiplier: itemBaseMultiplier
}); });
} }
} else if (item.type === 'section' || !item.type) { } else if (item.type === 'section' || !item.type) {
// Regular section - pass through // Regular section - pass through with multiplier applied to amounts
result.push(item); if (baseMultiplier !== 1 && item.list) {
const adjustedList = item.list.map(ingredient => ({
...ingredient,
amount: multiplyIngredientAmount(ingredient.amount, baseMultiplier)
}));
result.push({
...item,
list: adjustedList
});
} else {
result.push(item);
}
} }
} }
@@ -488,7 +516,7 @@ h3 a:hover {
{#each flattenedIngredients as list, listIndex} {#each flattenedIngredients as list, listIndex}
{#if list.name} {#if list.name}
{#if list.isReference} {#if list.isReference}
<h3><a href="{list.short_name}?multiplier={multiplier}">{@html list.name}</a></h3> <h3><a href="{list.short_name}?multiplier={multiplier * (list.baseMultiplier || 1)}">{@html list.name}</a></h3>
{:else} {:else}
<h3>{@html list.name}</h3> <h3>{@html list.name}</h3>
{/if} {/if}

View File

@@ -60,12 +60,15 @@ function flattenInstructionReferences(items, lang, visited = new Set()) {
? item.resolvedRecipe.translations.en.short_name ? item.resolvedRecipe.translations.en.short_name
: item.resolvedRecipe.short_name; : item.resolvedRecipe.short_name;
const itemBaseMultiplier = item.baseMultiplier || 1;
result.push({ result.push({
type: 'section', type: 'section',
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '', name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
steps: combinedSteps, steps: combinedSteps,
isReference: item.showLabel, isReference: item.showLabel,
short_name: baseRecipeShortName short_name: baseRecipeShortName,
baseMultiplier: itemBaseMultiplier
}); });
} }
} else if (item.type === 'section' || !item.type) { } else if (item.type === 'section' || !item.type) {
@@ -211,7 +214,7 @@ h3 a:hover {
{#each flattenedInstructions as list} {#each flattenedInstructions as list}
{#if list.name} {#if list.name}
{#if list.isReference} {#if list.isReference}
<h3><a href="{list.short_name}?multiplier={multiplier}">{@html list.name}</a></h3> <h3><a href="{list.short_name}?multiplier={multiplier * (list.baseMultiplier || 1)}">{@html list.name}</a></h3>
{:else} {:else}
<h3>{@html list.name}</h3> <h3>{@html list.name}</h3>
{/if} {/if}

View File

@@ -4,7 +4,7 @@
<style> <style>
.toggle-wrapper { .toggle-wrapper {
display: flex; display: inline-flex;
} }
.toggle-wrapper label { .toggle-wrapper label {

View File

@@ -46,6 +46,7 @@ const RecipeSchema = new mongoose.Schema(
includeIngredients: { type: Boolean, default: true }, includeIngredients: { type: Boolean, default: true },
showLabel: { type: Boolean, default: true }, showLabel: { type: Boolean, default: true },
labelOverride: { type: String, default: "" }, labelOverride: { type: String, default: "" },
baseMultiplier: { type: Number, default: 1 },
itemsBefore: [{ itemsBefore: [{
name: { type: String, default: "" }, name: { type: String, default: "" },
unit: String, unit: String,
@@ -70,6 +71,7 @@ const RecipeSchema = new mongoose.Schema(
includeInstructions: { type: Boolean, default: true }, includeInstructions: { type: Boolean, default: true },
showLabel: { type: Boolean, default: true }, showLabel: { type: Boolean, default: true },
labelOverride: { type: String, default: "" }, labelOverride: { type: String, default: "" },
baseMultiplier: { type: Number, default: 1 },
stepsBefore: [String], stepsBefore: [String],
stepsAfter: [String], stepsAfter: [String],
}], }],
@@ -115,6 +117,7 @@ const RecipeSchema = new mongoose.Schema(
includeIngredients: { type: Boolean, default: true }, includeIngredients: { type: Boolean, default: true },
showLabel: { type: Boolean, default: true }, showLabel: { type: Boolean, default: true },
labelOverride: { type: String, default: "" }, labelOverride: { type: String, default: "" },
baseMultiplier: { type: Number, default: 1 },
itemsBefore: [{ itemsBefore: [{
name: { type: String, default: "" }, name: { type: String, default: "" },
unit: String, unit: String,
@@ -134,6 +137,7 @@ const RecipeSchema = new mongoose.Schema(
includeInstructions: { type: Boolean, default: true }, includeInstructions: { type: Boolean, default: true },
showLabel: { type: Boolean, default: true }, showLabel: { type: Boolean, default: true },
labelOverride: { type: String, default: "" }, labelOverride: { type: String, default: "" },
baseMultiplier: { type: Number, default: 1 },
stepsBefore: [String], stepsBefore: [String],
stepsAfter: [String], stepsAfter: [String],
}], }],

View File

@@ -25,6 +25,7 @@ export type IngredientReference = {
includeIngredients: boolean; includeIngredients: boolean;
showLabel: boolean; showLabel: boolean;
labelOverride?: string; labelOverride?: string;
baseMultiplier?: number;
itemsBefore?: [{ itemsBefore?: [{
name: string; name: string;
unit: string; unit: string;
@@ -61,6 +62,7 @@ export type InstructionReference = {
includeInstructions: boolean; includeInstructions: boolean;
showLabel: boolean; showLabel: boolean;
labelOverride?: string; labelOverride?: string;
baseMultiplier?: number;
stepsBefore?: [string]; stepsBefore?: [string];
stepsAfter?: [string]; stepsAfter?: [string];
// Populated after server-side resolution // Populated after server-side resolution