+87
-14
@@ -7,10 +7,18 @@ const portionControls = document.querySelector("#portions");
|
||||
const portionCount = document.querySelector("#portion-count");
|
||||
const fewerPortions = document.querySelector("#fewer-portions");
|
||||
const morePortions = document.querySelector("#more-portions");
|
||||
const activeRecipe = document.querySelector("#active-recipe");
|
||||
const activeRecipeName = document.querySelector("#active-recipe-name");
|
||||
const activeRecipeYield = document.querySelector("#active-recipe-yield");
|
||||
const activeIngredients = document.querySelector("#active-ingredients");
|
||||
const activeInstructions = document.querySelector("#active-instructions");
|
||||
let recipes = [];
|
||||
let activeRecipeId = null;
|
||||
let activeRecipeName = null;
|
||||
let activeRecipeLabel = null;
|
||||
let displayedRecipe = null;
|
||||
let portions = null;
|
||||
let progress = { completedIngredients: [], completedSteps: [] };
|
||||
let recipeModeActive = false;
|
||||
|
||||
async function request(path, options) {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
@@ -45,9 +53,65 @@ function render() {
|
||||
return item;
|
||||
}));
|
||||
if (!matches.length) status.textContent = "No matching recipes.";
|
||||
portionControls.hidden = !activeRecipeId;
|
||||
portionControls.hidden = !recipeModeActive;
|
||||
portionCount.textContent = portions ?? "";
|
||||
fewerPortions.disabled = portions <= 1;
|
||||
renderActiveRecipe();
|
||||
}
|
||||
|
||||
function flattenInstructions(instructions) {
|
||||
return instructions.flatMap((instruction) => {
|
||||
if (typeof instruction === "string") return [instruction];
|
||||
if (Array.isArray(instruction?.itemListElement)) return flattenInstructions(instruction.itemListElement);
|
||||
return instruction?.text ? [instruction.text] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function renderChecklist(container, items, completed, kind, statuses = []) {
|
||||
container.replaceChildren(...items.map((item, index) => {
|
||||
const row = document.createElement("li");
|
||||
const label = document.createElement("label");
|
||||
const check = document.createElement("input");
|
||||
check.type = "checkbox";
|
||||
check.checked = completed.includes(index);
|
||||
check.addEventListener("change", () => updateProgress(kind, index, check.checked));
|
||||
const text = document.createElement("span");
|
||||
text.textContent = item;
|
||||
if (check.checked) row.classList.add("completed");
|
||||
if (statuses[index] === "unquantified") {
|
||||
row.classList.add("quantity-warning");
|
||||
text.prepend("⚠ Verify quantity: ");
|
||||
} else if (["unscalable", "approximate"].includes(statuses[index])) {
|
||||
row.classList.add("quantity-error");
|
||||
text.prepend("! Scaling warning: ");
|
||||
}
|
||||
label.append(check, text);
|
||||
row.appendChild(label);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function renderActiveRecipe() {
|
||||
const recipe = displayedRecipe;
|
||||
activeRecipe.hidden = !recipe;
|
||||
if (!recipe) return;
|
||||
activeRecipeName.textContent = recipe.name || "Untitled recipe";
|
||||
const original = recipe.originalRecipeYield;
|
||||
activeRecipeYield.textContent = original && original !== recipe.recipeYield
|
||||
? `Yield: ${recipe.recipeYield} (original: ${original})`
|
||||
: `Yield: ${recipe.recipeYield || "Not specified"}`;
|
||||
renderChecklist(activeIngredients, recipe.recipeIngredient || [], progress.completedIngredients, "ingredient",
|
||||
recipe.recipeIngredientStatus || []);
|
||||
renderChecklist(activeInstructions, flattenInstructions(recipe.recipeInstructions || []), progress.completedSteps, "step");
|
||||
}
|
||||
|
||||
function applyState(state) {
|
||||
recipeModeActive = Boolean(state.active);
|
||||
activeRecipeId = recipeModeActive ? state.recipeId : null;
|
||||
activeRecipeLabel = state.recipe?.name || activeRecipeLabel;
|
||||
displayedRecipe = recipeModeActive ? state.recipe : null;
|
||||
portions = state.portions;
|
||||
progress = state.progress || { completedIngredients: [], completedSteps: [] };
|
||||
}
|
||||
|
||||
async function activate(recipe) {
|
||||
@@ -57,9 +121,8 @@ async function activate(recipe) {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ recipeId: recipe.id })
|
||||
});
|
||||
activeRecipeId = state.recipeId;
|
||||
activeRecipeName = recipe.name;
|
||||
portions = state.portions;
|
||||
applyState(state);
|
||||
activeRecipeLabel = recipe.name;
|
||||
status.textContent = `${recipe.name} is now on the mirror.`;
|
||||
render();
|
||||
} catch (error) {
|
||||
@@ -73,24 +136,36 @@ async function adjustPortions(delta) {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ delta })
|
||||
});
|
||||
portions = state.portions;
|
||||
status.textContent = `${activeRecipeName} is now on the mirror.`;
|
||||
applyState(state);
|
||||
status.textContent = `${activeRecipeLabel} is now on the mirror.`;
|
||||
render();
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProgress(kind, index, completed) {
|
||||
try {
|
||||
const state = await request("/progress", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, index, completed })
|
||||
});
|
||||
applyState(state);
|
||||
render();
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
fewerPortions.addEventListener("click", () => adjustPortions(-1));
|
||||
morePortions.addEventListener("click", () => adjustPortions(1));
|
||||
|
||||
exit.addEventListener("click", async () => {
|
||||
status.textContent = "Leaving recipe mode…";
|
||||
try {
|
||||
await request("/exit", { method: "POST", body: "{}" });
|
||||
activeRecipeId = null;
|
||||
activeRecipeName = null;
|
||||
portions = null;
|
||||
const state = await request("/exit", { method: "POST", body: "{}" });
|
||||
applyState(state);
|
||||
status.textContent = "The mirror is back to its normal display.";
|
||||
render();
|
||||
} catch (error) {
|
||||
@@ -102,9 +177,7 @@ search.addEventListener("input", render);
|
||||
|
||||
Promise.all([request("/state"), request("/recipes")])
|
||||
.then(([state, loadedRecipes]) => {
|
||||
activeRecipeId = state.recipeId;
|
||||
activeRecipeName = state.recipe?.name || null;
|
||||
portions = state.portions;
|
||||
applyState(state);
|
||||
recipes = loadedRecipes;
|
||||
status.textContent = `${recipes.length} recipes available.`;
|
||||
render();
|
||||
|
||||
@@ -21,6 +21,18 @@
|
||||
<span><strong id="portion-count"></strong> portions</span>
|
||||
<button id="more-portions" type="button" aria-label="One more portion">+</button>
|
||||
</div>
|
||||
<article id="active-recipe" class="active-recipe" hidden>
|
||||
<h2 id="active-recipe-name"></h2>
|
||||
<p id="active-recipe-yield"></p>
|
||||
<section>
|
||||
<h3>Ingredients</h3>
|
||||
<ul id="active-ingredients" class="checklist"></ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Instructions</h3>
|
||||
<ol id="active-instructions" class="checklist"></ol>
|
||||
</section>
|
||||
</article>
|
||||
<label for="search">Find a recipe</label>
|
||||
<input id="search" type="search" autocomplete="off" placeholder="Search recipes">
|
||||
<ul id="recipes" aria-live="polite"></ul>
|
||||
|
||||
@@ -102,6 +102,30 @@ button {
|
||||
|
||||
.recipe strong { font-size: 1.12rem; }
|
||||
|
||||
.active-recipe {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid #d8c9ad;
|
||||
border-radius: 8px;
|
||||
background: #fffdf8;
|
||||
}
|
||||
|
||||
.active-recipe h2,
|
||||
.active-recipe h3 { margin-top: 0; }
|
||||
|
||||
.checklist {
|
||||
display: grid;
|
||||
gap: .7rem;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
|
||||
.checklist li { padding-left: .2rem; }
|
||||
.checklist label { display: flex; gap: .6rem; align-items: flex-start; }
|
||||
.checklist input { margin-top: .25rem; min-width: 1.1rem; min-height: 1.1rem; }
|
||||
.checklist .completed { color: #777; text-decoration: line-through; }
|
||||
.checklist .quantity-warning { color: #876500; }
|
||||
.checklist .quantity-error { color: #a2342b; }
|
||||
|
||||
.recipe span {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user