Some checks failed
Build and push Docker image / build-and-push (push) Has been cancelled
255 lines
8.4 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
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();
|