pizza_broodjeaap_net/app.js
David 07c17d1359
Some checks are pending
Build and push Docker image / build-and-push (push) Waiting to run
Initial copy
2026-08-20 21:26:16 +02:00

228 lines
7.5 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 inlineMarkdown(text) {
return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
}
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;
}
function computeIngredients(recipe, values) {
const scaleFactor =
(values.doughBalls * values.doughBallSizeG) /
(recipe.baseDoughBalls * recipe.baseDoughBallSizeG);
const baseFlour = recipe.ingredients.find((i) => i.role === "flour").baseAmount;
const baseWater = recipe.ingredients.find((i) => i.role === "water").baseAmount;
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;
else if (ing.role === "water") amount = water;
else amount = ing.baseAmount * scaleFactor;
return { ...ing, amount: Math.round(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 = recipe.ingredients.find((i) => i.role === "flour").baseAmount;
const water = recipe.ingredients.find((i) => i.role === "water").baseAmount;
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();