pizza_broodjeaap_net/app.js
David 11bb525143
Some checks failed
Build and push Docker image / build-and-push (push) Has been cancelled
updated recipes
2026-08-20 23:11:34 +02:00

255 lines
8.4 KiB
JavaScript

const RECIPES_URL = "recipes.json";
const state = {
recipes: [],
selectedId: null,
sliderValues: {}, // { [recipeId]: { hydration, doughBalls, doughBallSizeG } }
markdownCache: {}, // { [recipeId]: htmlString }
};
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function isSafeUrl(url) {
if (/["'<>]/.test(url)) return false;
return /^https?:\/\//i.test(url) || /^[\w./-]+$/.test(url);
}
function inlineMarkdown(text) {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) =>
isSafeUrl(url) ? `<img src="${url}" alt="${alt}">` : match
)
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label, url) =>
isSafeUrl(url) ? `<a href="${url}" target="_blank" rel="noopener">${label}</a>` : match
);
}
function markdownToHtml(md) {
const lines = md.split("\n");
let html = "";
let inList = false;
const closeList = () => {
if (inList) {
html += "</ol>";
inList = false;
}
};
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
closeList();
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
closeList();
const level = Math.min(headingMatch[1].length + 1, 6); // ## -> h3
html += `<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`;
continue;
}
const listMatch = line.match(/^\d+\.\s+(.*)$/);
if (listMatch) {
if (!inList) {
html += "<ol>";
inList = true;
}
html += `<li>${inlineMarkdown(listMatch[1])}</li>`;
continue;
}
closeList();
html += `<p>${inlineMarkdown(line)}</p>`;
}
closeList();
return html;
}
// Recipes can list more than one flour or water (a poolish has its own), so the
// hydration slider works on the totals and splits them back over the components.
function baseRoleTotal(recipe, role) {
return recipe.ingredients
.filter((i) => i.role === role)
.reduce((sum, i) => sum + i.baseAmount, 0);
}
// Yeast and honey are measured in fractions of a gram, so small amounts keep decimals.
function roundAmount(amount) {
if (amount >= 10) return Math.round(amount);
if (amount >= 1) return Math.round(amount * 10) / 10;
return Math.round(amount * 100) / 100;
}
function computeIngredients(recipe, values) {
const scaleFactor =
(values.doughBalls * values.doughBallSizeG) /
(recipe.baseDoughBalls * recipe.baseDoughBallSizeG);
const baseFlour = baseRoleTotal(recipe, "flour");
const baseWater = baseRoleTotal(recipe, "water");
const flourWaterSum = (baseFlour + baseWater) * scaleFactor;
const flour = flourWaterSum / (1 + values.hydration / 100);
const water = flourWaterSum - flour;
return recipe.ingredients.map((ing) => {
let amount;
if (ing.role === "flour") amount = flour * (ing.baseAmount / baseFlour);
else if (ing.role === "water") amount = water * (ing.baseAmount / baseWater);
else amount = ing.baseAmount * scaleFactor;
return { ...ing, amount: roundAmount(amount) };
});
}
function renderPicker() {
const nav = document.getElementById("recipe-picker");
nav.innerHTML = "";
for (const recipe of state.recipes) {
const btn = document.createElement("button");
btn.textContent = recipe.name;
btn.className = recipe.id === state.selectedId ? "active" : "";
btn.addEventListener("click", () => selectRecipe(recipe.id));
nav.appendChild(btn);
}
}
function baseHydration(recipe) {
const flour = baseRoleTotal(recipe, "flour");
const water = baseRoleTotal(recipe, "water");
return Math.round((water / flour) * 100);
}
async function getInstructionsHtml(recipe) {
if (state.markdownCache[recipe.id]) return state.markdownCache[recipe.id];
const res = await fetch(recipe.instructionsFile);
if (!res.ok) throw new Error(`Failed to load ${recipe.instructionsFile}`);
const md = await res.text();
const html = markdownToHtml(md);
state.markdownCache[recipe.id] = html;
return html;
}
function renderIngredients(recipe) {
const values = state.sliderValues[recipe.id];
const ingredients = computeIngredients(recipe, values);
const list = ingredients
.map(
(ing) =>
`<li><span>${escapeHtml(ing.name)}</span><span class="amount">${ing.amount}${ing.unit}</span></li>`
)
.join("");
const container = document.getElementById("ingredients-list");
if (container) container.innerHTML = list;
}
function onSliderChange(recipe) {
const panel = document.getElementById("recipe-panel");
const hydrationInput = panel.querySelector("#slider-hydration");
const ballsInput = panel.querySelector("#slider-balls");
const sizeInput = panel.querySelector("#slider-size");
const values = {
hydration: Number(hydrationInput.value),
doughBalls: Number(ballsInput.value),
doughBallSizeG: Number(sizeInput.value),
};
state.sliderValues[recipe.id] = values;
panel.querySelector("#value-hydration").textContent = `${values.hydration}%`;
panel.querySelector("#value-balls").textContent = values.doughBalls;
panel.querySelector("#value-size").textContent = `${values.doughBallSizeG}g`;
renderIngredients(recipe);
}
async function renderRecipePanel(recipe) {
const panel = document.getElementById("recipe-panel");
if (!state.sliderValues[recipe.id]) {
state.sliderValues[recipe.id] = {
hydration: baseHydration(recipe),
doughBalls: recipe.baseDoughBalls,
doughBallSizeG: recipe.baseDoughBallSizeG,
};
}
const values = state.sliderValues[recipe.id];
panel.innerHTML = `
<section class="recipe-panel">
<h2>${escapeHtml(recipe.name)}</h2>
<p class="recipe-desc">${escapeHtml(recipe.description)}</p>
<div class="recipe-meta">
<span>Difficulty: ${escapeHtml(recipe.difficulty)}</span>
<span>Total time: ${recipe.totalTimeHours}h</span>
</div>
<div class="sliders">
<div class="slider-field">
<label for="slider-hydration">Hydration <span class="value" id="value-hydration">${values.hydration}%</span></label>
<input type="range" id="slider-hydration" min="${recipe.hydrationRange.min}" max="${recipe.hydrationRange.max}" value="${values.hydration}">
</div>
<div class="slider-field">
<label for="slider-balls">Dough Balls <span class="value" id="value-balls">${values.doughBalls}</span></label>
<input type="range" id="slider-balls" min="${recipe.doughBallsRange.min}" max="${recipe.doughBallsRange.max}" value="${values.doughBalls}">
</div>
<div class="slider-field">
<label for="slider-size">Ball Size <span class="value" id="value-size">${values.doughBallSizeG}g</span></label>
<input type="range" id="slider-size" min="${recipe.doughBallSizeRange.min}" max="${recipe.doughBallSizeRange.max}" step="10" value="${values.doughBallSizeG}">
</div>
</div>
<div class="ingredients">
<h3>Ingredients</h3>
<ul id="ingredients-list"></ul>
</div>
<div class="instructions" id="instructions">
<p class="loading">Loading instructions…</p>
</div>
</section>
`;
["slider-hydration", "slider-balls", "slider-size"].forEach((id) => {
panel.querySelector(`#${id}`).addEventListener("input", () => onSliderChange(recipe));
});
renderIngredients(recipe);
try {
const html = await getInstructionsHtml(recipe);
const instructionsEl = document.getElementById("instructions");
if (instructionsEl) instructionsEl.innerHTML = html;
} catch (err) {
const instructionsEl = document.getElementById("instructions");
if (instructionsEl) instructionsEl.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
}
}
function selectRecipe(id) {
state.selectedId = id;
renderPicker();
const recipe = state.recipes.find((r) => r.id === id);
renderRecipePanel(recipe);
}
async function init() {
try {
const res = await fetch(RECIPES_URL);
if (!res.ok) throw new Error(`Failed to load ${RECIPES_URL}`);
state.recipes = await res.json();
if (state.recipes.length === 0) {
document.getElementById("recipe-panel").innerHTML = '<p class="error">No recipes found.</p>';
return;
}
selectRecipe(state.recipes[0].id);
} catch (err) {
document.getElementById("recipe-panel").innerHTML = `<p class="error">${escapeHtml(err.message)} — this app must be served over http(s), not opened directly as a file.</p>`;
}
}
init();