recipes: add cake form size scaling for ingredient multiplier
Allow recipes to specify a default pan shape (round, rectangular, gugelhupf) with dimensions. On the recipe page, users can enter their own pan size to auto-calculate an ingredient multiplier based on the 2D area ratio.
This commit is contained in:
@@ -120,9 +120,71 @@ const isEnglish = $derived(data.lang === 'en');
|
|||||||
const labels = $derived({
|
const labels = $derived({
|
||||||
portions: isEnglish ? 'Portions:' : 'Portionen:',
|
portions: isEnglish ? 'Portions:' : 'Portionen:',
|
||||||
adjustAmount: isEnglish ? 'Adjust Amount:' : 'Menge anpassen:',
|
adjustAmount: isEnglish ? 'Adjust Amount:' : 'Menge anpassen:',
|
||||||
ingredients: isEnglish ? 'Ingredients' : 'Zutaten'
|
ingredients: isEnglish ? 'Ingredients' : 'Zutaten',
|
||||||
|
cakeForm: isEnglish ? 'Cake form:' : 'Backform:',
|
||||||
|
round: isEnglish ? 'Round' : 'Rund',
|
||||||
|
rectangular: isEnglish ? 'Rectangular' : 'Rechteckig',
|
||||||
|
diameter: isEnglish ? 'Diameter' : 'Durchmesser',
|
||||||
|
width: isEnglish ? 'Width' : 'Breite',
|
||||||
|
length: isEnglish ? 'Length' : 'Länge',
|
||||||
|
factor: isEnglish ? 'Factor' : 'Faktor',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cake form scaling
|
||||||
|
const hasDefaultForm = $derived(!!data.defaultForm?.shape);
|
||||||
|
let userFormShape = $state(data.defaultForm?.shape || 'round');
|
||||||
|
let userFormDiameter = $state(data.defaultForm?.diameter || 26);
|
||||||
|
let userFormWidth = $state(data.defaultForm?.width || 20);
|
||||||
|
let userFormLength = $state(data.defaultForm?.length || 30);
|
||||||
|
let userFormInnerDiameter = $state(data.defaultForm?.innerDiameter || 8);
|
||||||
|
|
||||||
|
function calcArea(shape, diameter, width, length, innerDiameter) {
|
||||||
|
if (shape === 'round') return Math.PI * (diameter / 2) ** 2;
|
||||||
|
if (shape === 'gugelhupf') return Math.PI * ((diameter / 2) ** 2 - (innerDiameter / 2) ** 2);
|
||||||
|
return width * length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFormArea = $derived(
|
||||||
|
hasDefaultForm
|
||||||
|
? calcArea(data.defaultForm.shape, data.defaultForm.diameter, data.defaultForm.width, data.defaultForm.length, data.defaultForm.innerDiameter)
|
||||||
|
: 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const userFormArea = $derived(
|
||||||
|
calcArea(userFormShape, userFormDiameter, userFormWidth, userFormLength, userFormInnerDiameter)
|
||||||
|
);
|
||||||
|
|
||||||
|
const formMultiplier = $derived(
|
||||||
|
hasDefaultForm && defaultFormArea > 0 ? userFormArea / defaultFormArea : 1
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track whether multiplier is driven by form or manual buttons
|
||||||
|
let formDriven = $state(false);
|
||||||
|
|
||||||
|
function applyFormMultiplier() {
|
||||||
|
formDriven = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactively update multiplier when form dimensions change and form is driving
|
||||||
|
$effect(() => {
|
||||||
|
if (formDriven) {
|
||||||
|
multiplier = formMultiplier;
|
||||||
|
updateUrl(multiplier);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateUrl(value) {
|
||||||
|
if (browser) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
if (value === 1) {
|
||||||
|
url.searchParams.delete('multiplier');
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('multiplier', String(value));
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Multiplier button options
|
// Multiplier button options
|
||||||
const multiplierOptions = [
|
const multiplierOptions = [
|
||||||
{ value: 0.5, label: '<sup>1</sup>/<sub>2</sub>x' },
|
{ value: 0.5, label: '<sup>1</sup>/<sub>2</sub>x' },
|
||||||
@@ -176,15 +238,8 @@ function handleMultiplierClick(event, value) {
|
|||||||
if (browser) {
|
if (browser) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
multiplier = value;
|
multiplier = value;
|
||||||
|
formDriven = false;
|
||||||
// Update URL without reloading
|
updateUrl(value);
|
||||||
const url = new URL(window.location);
|
|
||||||
if (value === 1) {
|
|
||||||
url.searchParams.delete('multiplier');
|
|
||||||
} else {
|
|
||||||
url.searchParams.set('multiplier', value);
|
|
||||||
}
|
|
||||||
window.history.replaceState({}, '', url);
|
|
||||||
}
|
}
|
||||||
// If no JS, form will submit normally
|
// If no JS, form will submit normally
|
||||||
}
|
}
|
||||||
@@ -194,15 +249,8 @@ function handleCustomInput(event) {
|
|||||||
const value = parseFloat(event.target.value);
|
const value = parseFloat(event.target.value);
|
||||||
if (!isNaN(value) && value > 0) {
|
if (!isNaN(value) && value > 0) {
|
||||||
multiplier = value;
|
multiplier = value;
|
||||||
|
formDriven = false;
|
||||||
// Update URL without reloading
|
updateUrl(value);
|
||||||
const url = new URL(window.location);
|
|
||||||
if (value === 1) {
|
|
||||||
url.searchParams.delete('multiplier');
|
|
||||||
} else {
|
|
||||||
url.searchParams.set('multiplier', value);
|
|
||||||
}
|
|
||||||
window.history.replaceState({}, '', url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,6 +434,53 @@ function adjust_amount(string, multiplier){
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cake-form {
|
||||||
|
margin-block: 1rem;
|
||||||
|
}
|
||||||
|
.cake-form-shape {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.cake-form-shape label {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25em 0.6em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
.cake-form-shape input[type="radio"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.cake-form-selected {
|
||||||
|
background-color: var(--nord9);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.cake-form-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cake-form-num {
|
||||||
|
width: 3.5em;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border: 1px solid var(--nord4);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-size: inherit;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.cake-form-factor {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--nord10);
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{#if data.ingredients}
|
{#if data.ingredients}
|
||||||
<div class=ingredients>
|
<div class=ingredients>
|
||||||
@@ -419,6 +514,42 @@ function adjust_amount(string, multiplier){
|
|||||||
</span>
|
</span>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{#if hasDefaultForm}
|
||||||
|
<div class="cake-form">
|
||||||
|
<h3>{labels.cakeForm}</h3>
|
||||||
|
<div class="cake-form-shape">
|
||||||
|
<label class:cake-form-selected={userFormShape === 'round'}>
|
||||||
|
<input type="radio" name="userFormShape" value="round" bind:group={userFormShape} onchange={applyFormMultiplier} />
|
||||||
|
{labels.round}
|
||||||
|
</label>
|
||||||
|
<label class:cake-form-selected={userFormShape === 'rectangular'}>
|
||||||
|
<input type="radio" name="userFormShape" value="rectangular" bind:group={userFormShape} onchange={applyFormMultiplier} />
|
||||||
|
{labels.rectangular}
|
||||||
|
</label>
|
||||||
|
{#if data.defaultForm?.shape === 'gugelhupf'}
|
||||||
|
<label class:cake-form-selected={userFormShape === 'gugelhupf'}>
|
||||||
|
<input type="radio" name="userFormShape" value="gugelhupf" bind:group={userFormShape} onchange={applyFormMultiplier} />
|
||||||
|
Gugelhupf
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="cake-form-inputs">
|
||||||
|
{#if userFormShape === 'round'}
|
||||||
|
<label>{labels.diameter}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormDiameter} oninput={applyFormMultiplier} /> cm</label>
|
||||||
|
{:else if userFormShape === 'rectangular'}
|
||||||
|
<label>{labels.width}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormWidth} oninput={applyFormMultiplier} /> cm</label>
|
||||||
|
<label>{labels.length}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormLength} oninput={applyFormMultiplier} /> cm</label>
|
||||||
|
{:else if userFormShape === 'gugelhupf'}
|
||||||
|
<label>{isEnglish ? 'Outer Ø' : 'Außen-Ø'}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormDiameter} oninput={applyFormMultiplier} /> cm</label>
|
||||||
|
<label>{isEnglish ? 'Inner Ø' : 'Innen-Ø'}: <input type="number" min="1" step="1" class="cake-form-num" bind:value={userFormInnerDiameter} oninput={applyFormMultiplier} /> cm</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if formDriven}
|
||||||
|
<div class="cake-form-factor">→ {labels.factor}: {formMultiplier.toFixed(2)}x</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2>{labels.ingredients}</h2>
|
<h2>{labels.ingredients}</h2>
|
||||||
{#each flattenedIngredients as list, listIndex}
|
{#each flattenedIngredients as list, listIndex}
|
||||||
{#if list.name}
|
{#if list.name}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ const RecipeSchema = new mongoose.Schema(
|
|||||||
|
|
||||||
},
|
},
|
||||||
portions :{type:String, default: ""},
|
portions :{type:String, default: ""},
|
||||||
|
defaultForm: {
|
||||||
|
shape: { type: String, enum: ['round', 'rectangular', 'gugelhupf'] },
|
||||||
|
diameter: { type: Number },
|
||||||
|
width: { type: Number },
|
||||||
|
length: { type: Number },
|
||||||
|
innerDiameter: { type: Number },
|
||||||
|
},
|
||||||
cooking: {type:String, default: ""},
|
cooking: {type:String, default: ""},
|
||||||
total_time : {type:String, default: ""},
|
total_time : {type:String, default: ""},
|
||||||
ingredients: [{
|
ingredients: [{
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
let datecreated = $state(data.recipe.datecreated);
|
let datecreated = $state(data.recipe.datecreated);
|
||||||
let datemodified = $state(new Date());
|
let datemodified = $state(new Date());
|
||||||
let isBaseRecipe = $state(data.recipe.isBaseRecipe || false);
|
let isBaseRecipe = $state(data.recipe.isBaseRecipe || false);
|
||||||
|
let defaultForm = $state(data.recipe.defaultForm ? { ...data.recipe.defaultForm } : null);
|
||||||
let ingredients = $state(data.recipe.ingredients || []);
|
let ingredients = $state(data.recipe.ingredients || []);
|
||||||
let instructions = $state(data.recipe.instructions || []);
|
let instructions = $state(data.recipe.instructions || []);
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@
|
|||||||
preamble,
|
preamble,
|
||||||
note,
|
note,
|
||||||
isBaseRecipe,
|
isBaseRecipe,
|
||||||
|
defaultForm,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -343,6 +345,29 @@
|
|||||||
background-color: var(--nord6-dark);
|
background-color: var(--nord6-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.form-size-section {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 1rem auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.form-size-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.form-size-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.form-size-inputs input[type="number"] {
|
||||||
|
width: 4em;
|
||||||
|
display: inline;
|
||||||
|
margin: 0 0.3em;
|
||||||
|
}
|
||||||
.error-message {
|
.error-message {
|
||||||
background: var(--nord11);
|
background: var(--nord11);
|
||||||
color: var(--nord6);
|
color: var(--nord6);
|
||||||
@@ -422,6 +447,7 @@
|
|||||||
<input type="hidden" name="icon" value={card_data.icon} />
|
<input type="hidden" name="icon" value={card_data.icon} />
|
||||||
<input type="hidden" name="portions" value={portions_local} />
|
<input type="hidden" name="portions" value={portions_local} />
|
||||||
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
|
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
|
||||||
|
<input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} />
|
||||||
|
|
||||||
<div style="text-align: center; margin: 1rem;">
|
<div style="text-align: center; margin: 1rem;">
|
||||||
<Toggle
|
<Toggle
|
||||||
@@ -430,6 +456,44 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Default Form (Cake Pan) -->
|
||||||
|
<div class="form-size-section">
|
||||||
|
<h3>Backform (Standard):</h3>
|
||||||
|
<div class="form-size-controls">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="formShape" value="none" checked={!defaultForm} onchange={() => { defaultForm = null; }} />
|
||||||
|
Keine
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="formShape" value="round" checked={defaultForm?.shape === 'round'} onchange={() => { defaultForm = { shape: 'round', diameter: defaultForm?.diameter || 26 }; }} />
|
||||||
|
Rund
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="formShape" value="rectangular" checked={defaultForm?.shape === 'rectangular'} onchange={() => { defaultForm = { shape: 'rectangular', width: defaultForm?.width || 20, length: defaultForm?.length || 30 }; }} />
|
||||||
|
Rechteckig
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="formShape" value="gugelhupf" checked={defaultForm?.shape === 'gugelhupf'} onchange={() => { defaultForm = { shape: 'gugelhupf', diameter: defaultForm?.diameter || 24, innerDiameter: defaultForm?.innerDiameter || 8 }; }} />
|
||||||
|
Gugelhupf
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if defaultForm?.shape === 'round'}
|
||||||
|
<div class="form-size-inputs">
|
||||||
|
<label>Durchmesser: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
|
||||||
|
</div>
|
||||||
|
{:else if defaultForm?.shape === 'rectangular'}
|
||||||
|
<div class="form-size-inputs">
|
||||||
|
<label>Breite: <input type="number" min="1" step="1" bind:value={defaultForm.width} /> cm</label>
|
||||||
|
<label>Länge: <input type="number" min="1" step="1" bind:value={defaultForm.length} /> cm</label>
|
||||||
|
</div>
|
||||||
|
{:else if defaultForm?.shape === 'gugelhupf'}
|
||||||
|
<div class="form-size-inputs">
|
||||||
|
<label>Außen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
|
||||||
|
<label>Innen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.innerDiameter} /> cm</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recipe Note Component -->
|
<!-- Recipe Note Component -->
|
||||||
<EditRecipeNote bind:note />
|
<EditRecipeNote bind:note />
|
||||||
<input type="hidden" name="note" value={note} />
|
<input type="hidden" name="note" value={note} />
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ export type RecipeModelType = {
|
|||||||
final: string;
|
final: string;
|
||||||
}
|
}
|
||||||
portions?: string;
|
portions?: string;
|
||||||
|
defaultForm?: {
|
||||||
|
shape: 'round' | 'rectangular' | 'gugelhupf';
|
||||||
|
diameter?: number;
|
||||||
|
width?: number;
|
||||||
|
length?: number;
|
||||||
|
innerDiameter?: number;
|
||||||
|
};
|
||||||
cooking?: string;
|
cooking?: string;
|
||||||
total_time?: string;
|
total_time?: string;
|
||||||
ingredients?: IngredientItem[];
|
ingredients?: IngredientItem[];
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ export interface RecipeFormData {
|
|||||||
caption: string;
|
caption: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Cake form
|
||||||
|
defaultForm?: {
|
||||||
|
shape: 'round' | 'rectangular' | 'gugelhupf';
|
||||||
|
diameter?: number;
|
||||||
|
width?: number;
|
||||||
|
length?: number;
|
||||||
|
innerDiameter?: number;
|
||||||
|
};
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
isBaseRecipe?: boolean;
|
isBaseRecipe?: boolean;
|
||||||
datecreated?: Date;
|
datecreated?: Date;
|
||||||
@@ -144,6 +153,20 @@ export function extractRecipeFromFormData(formData: FormData): RecipeFormData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default form (cake pan size)
|
||||||
|
let defaultForm: RecipeFormData['defaultForm'] = undefined;
|
||||||
|
const defaultFormData = formData.get('defaultForm_json')?.toString();
|
||||||
|
if (defaultFormData) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(defaultFormData);
|
||||||
|
if (parsed && parsed.shape) {
|
||||||
|
defaultForm = parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse defaultForm:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
const isBaseRecipe = formData.get('isBaseRecipe') === 'true';
|
const isBaseRecipe = formData.get('isBaseRecipe') === 'true';
|
||||||
const datecreated = formData.get('datecreated')
|
const datecreated = formData.get('datecreated')
|
||||||
@@ -189,6 +212,7 @@ export function extractRecipeFromFormData(formData: FormData): RecipeFormData {
|
|||||||
instructions,
|
instructions,
|
||||||
add_info,
|
add_info,
|
||||||
images,
|
images,
|
||||||
|
defaultForm,
|
||||||
isBaseRecipe,
|
isBaseRecipe,
|
||||||
datecreated,
|
datecreated,
|
||||||
datemodified,
|
datemodified,
|
||||||
@@ -343,6 +367,7 @@ export function serializeRecipeForDatabase(data: RecipeFormData): any {
|
|||||||
if (data.preamble) recipe.preamble = data.preamble;
|
if (data.preamble) recipe.preamble = data.preamble;
|
||||||
if (data.addendum) recipe.addendum = data.addendum;
|
if (data.addendum) recipe.addendum = data.addendum;
|
||||||
if (data.note) recipe.note = data.note;
|
if (data.note) recipe.note = data.note;
|
||||||
|
if (data.defaultForm) recipe.defaultForm = data.defaultForm;
|
||||||
|
|
||||||
// Additional info
|
// Additional info
|
||||||
if (data.add_info && Object.keys(data.add_info).length > 0) {
|
if (data.add_info && Object.keys(data.add_info).length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user