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.
This commit is contained in:
@@ -23,7 +23,8 @@ let options = $state({
|
||||
includeIngredients: false,
|
||||
includeInstructions: false,
|
||||
showLabel: true,
|
||||
labelOverride: ''
|
||||
labelOverride: '',
|
||||
baseMultiplier: 1
|
||||
});
|
||||
|
||||
// Reset options whenever type or modal state changes
|
||||
@@ -46,6 +47,7 @@ function handleInsert() {
|
||||
selectedRecipe = null;
|
||||
options.labelOverride = '';
|
||||
options.showLabel = true;
|
||||
options.baseMultiplier = 1;
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
@@ -134,7 +136,8 @@ dialog h2 {
|
||||
}
|
||||
|
||||
.selector-content select,
|
||||
.selector-content input[type="text"] {
|
||||
.selector-content input[type="text"],
|
||||
.selector-content input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 0.5em 1em;
|
||||
margin-top: 0.5em;
|
||||
@@ -149,7 +152,9 @@ dialog h2 {
|
||||
.selector-content select:hover,
|
||||
.selector-content select:focus,
|
||||
.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);
|
||||
transform: scale(1.02, 1.02);
|
||||
}
|
||||
@@ -245,6 +250,18 @@ dialog h2 {
|
||||
</label>
|
||||
{/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">
|
||||
<button class="button-insert" onclick={handleInsert} disabled={!selectedRecipe}>
|
||||
Einfügen
|
||||
|
||||
@@ -128,6 +128,7 @@ function handleSelect(recipe: any, options: any) {
|
||||
includeIngredients: options.includeIngredients,
|
||||
showLabel: options.showLabel,
|
||||
labelOverride: options.labelOverride || '',
|
||||
baseMultiplier: options.baseMultiplier || 1,
|
||||
itemsBefore: [],
|
||||
itemsAfter: []
|
||||
};
|
||||
@@ -746,6 +747,18 @@ h3{
|
||||
</div>
|
||||
<div class="reference-badge">
|
||||
📋 {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 class="mod_icons">
|
||||
<button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
|
||||
|
||||
@@ -125,6 +125,7 @@ function handleSelect(recipe: any, options: any) {
|
||||
includeInstructions: options.includeInstructions,
|
||||
showLabel: options.showLabel,
|
||||
labelOverride: options.labelOverride || '',
|
||||
baseMultiplier: options.baseMultiplier || 1,
|
||||
stepsBefore: [],
|
||||
stepsAfter: []
|
||||
};
|
||||
@@ -797,6 +798,18 @@ h3{
|
||||
</div>
|
||||
<div class="reference-badge">
|
||||
📋 {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 class="mod_icons">
|
||||
<button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
|
||||
|
||||
@@ -7,8 +7,20 @@ import HefeSwapper from './HefeSwapper.svelte';
|
||||
|
||||
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
|
||||
function flattenIngredientReferences(items, lang, visited = new Set()) {
|
||||
function flattenIngredientReferences(items, lang, visited = new Set(), baseMultiplier = 1) {
|
||||
const result = [];
|
||||
|
||||
for (const item of items) {
|
||||
@@ -29,18 +41,22 @@ function flattenIngredientReferences(items, lang, visited = new Set()) {
|
||||
? item.resolvedRecipe.translations.en.ingredients
|
||||
: item.resolvedRecipe.ingredients || [];
|
||||
|
||||
// Recursively flatten nested references
|
||||
const flattenedNested = flattenIngredientReferences(ingredientsToUse, lang, newVisited);
|
||||
// Calculate combined multiplier for this reference
|
||||
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
|
||||
const combinedList = [];
|
||||
|
||||
// Add items before
|
||||
// Add items before (not affected by baseMultiplier)
|
||||
if (item.itemsBefore && item.itemsBefore.length > 0) {
|
||||
combinedList.push(...item.itemsBefore);
|
||||
}
|
||||
|
||||
// Add base recipe ingredients (now recursively flattened)
|
||||
// Add base recipe ingredients (now recursively flattened with multiplier applied)
|
||||
if (item.includeIngredients) {
|
||||
flattenedNested.forEach(section => {
|
||||
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) {
|
||||
combinedList.push(...item.itemsAfter);
|
||||
}
|
||||
@@ -69,14 +85,26 @@ function flattenIngredientReferences(items, lang, visited = new Set()) {
|
||||
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
|
||||
list: combinedList,
|
||||
isReference: item.showLabel,
|
||||
short_name: baseRecipeShortName
|
||||
short_name: baseRecipeShortName,
|
||||
baseMultiplier: itemBaseMultiplier
|
||||
});
|
||||
}
|
||||
} else if (item.type === 'section' || !item.type) {
|
||||
// Regular section - pass through
|
||||
// Regular section - pass through with multiplier applied to amounts
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -488,7 +516,7 @@ h3 a:hover {
|
||||
{#each flattenedIngredients as list, listIndex}
|
||||
{#if list.name}
|
||||
{#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}
|
||||
<h3>{@html list.name}</h3>
|
||||
{/if}
|
||||
|
||||
@@ -60,12 +60,15 @@ function flattenInstructionReferences(items, lang, visited = new Set()) {
|
||||
? item.resolvedRecipe.translations.en.short_name
|
||||
: item.resolvedRecipe.short_name;
|
||||
|
||||
const itemBaseMultiplier = item.baseMultiplier || 1;
|
||||
|
||||
result.push({
|
||||
type: 'section',
|
||||
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
|
||||
steps: combinedSteps,
|
||||
isReference: item.showLabel,
|
||||
short_name: baseRecipeShortName
|
||||
short_name: baseRecipeShortName,
|
||||
baseMultiplier: itemBaseMultiplier
|
||||
});
|
||||
}
|
||||
} else if (item.type === 'section' || !item.type) {
|
||||
@@ -211,7 +214,7 @@ h3 a:hover {
|
||||
{#each flattenedInstructions as list}
|
||||
{#if list.name}
|
||||
{#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}
|
||||
<h3>{@html list.name}</h3>
|
||||
{/if}
|
||||
|
||||
@@ -46,6 +46,7 @@ const RecipeSchema = new mongoose.Schema(
|
||||
includeIngredients: { type: Boolean, default: true },
|
||||
showLabel: { type: Boolean, default: true },
|
||||
labelOverride: { type: String, default: "" },
|
||||
baseMultiplier: { type: Number, default: 1 },
|
||||
itemsBefore: [{
|
||||
name: { type: String, default: "" },
|
||||
unit: String,
|
||||
@@ -70,6 +71,7 @@ const RecipeSchema = new mongoose.Schema(
|
||||
includeInstructions: { type: Boolean, default: true },
|
||||
showLabel: { type: Boolean, default: true },
|
||||
labelOverride: { type: String, default: "" },
|
||||
baseMultiplier: { type: Number, default: 1 },
|
||||
stepsBefore: [String],
|
||||
stepsAfter: [String],
|
||||
}],
|
||||
@@ -115,6 +117,7 @@ const RecipeSchema = new mongoose.Schema(
|
||||
includeIngredients: { type: Boolean, default: true },
|
||||
showLabel: { type: Boolean, default: true },
|
||||
labelOverride: { type: String, default: "" },
|
||||
baseMultiplier: { type: Number, default: 1 },
|
||||
itemsBefore: [{
|
||||
name: { type: String, default: "" },
|
||||
unit: String,
|
||||
@@ -134,6 +137,7 @@ const RecipeSchema = new mongoose.Schema(
|
||||
includeInstructions: { type: Boolean, default: true },
|
||||
showLabel: { type: Boolean, default: true },
|
||||
labelOverride: { type: String, default: "" },
|
||||
baseMultiplier: { type: Number, default: 1 },
|
||||
stepsBefore: [String],
|
||||
stepsAfter: [String],
|
||||
}],
|
||||
|
||||
@@ -25,6 +25,7 @@ export type IngredientReference = {
|
||||
includeIngredients: boolean;
|
||||
showLabel: boolean;
|
||||
labelOverride?: string;
|
||||
baseMultiplier?: number;
|
||||
itemsBefore?: [{
|
||||
name: string;
|
||||
unit: string;
|
||||
@@ -61,6 +62,7 @@ export type InstructionReference = {
|
||||
includeInstructions: boolean;
|
||||
showLabel: boolean;
|
||||
labelOverride?: string;
|
||||
baseMultiplier?: number;
|
||||
stepsBefore?: [string];
|
||||
stepsAfter?: [string];
|
||||
// Populated after server-side resolution
|
||||
|
||||
Reference in New Issue
Block a user