188 lines
6.6 KiB
JavaScript
188 lines
6.6 KiB
JavaScript
const apiBase = "/MMM-NextcloudCookbook/api";
|
|
const status = document.querySelector("#status");
|
|
const list = document.querySelector("#recipes");
|
|
const search = document.querySelector("#search");
|
|
const exit = document.querySelector("#exit");
|
|
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 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}`, {
|
|
headers: { "Content-Type": "application/json" },
|
|
...options
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => ({}));
|
|
throw new Error(body.error || `Request failed with HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function render() {
|
|
const query = search.value.trim().toLocaleLowerCase();
|
|
const matches = recipes.filter((recipe) => recipe.name.toLocaleLowerCase().includes(query));
|
|
list.replaceChildren(...matches.map((recipe) => {
|
|
const item = document.createElement("li");
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = recipe.id === activeRecipeId ? "recipe active" : "recipe";
|
|
const title = document.createElement("strong");
|
|
title.textContent = recipe.name;
|
|
button.appendChild(title);
|
|
if (recipe.description) {
|
|
const description = document.createElement("span");
|
|
description.textContent = recipe.description;
|
|
button.appendChild(description);
|
|
}
|
|
button.addEventListener("click", () => activate(recipe));
|
|
item.appendChild(button);
|
|
return item;
|
|
}));
|
|
if (!matches.length) status.textContent = "No matching recipes.";
|
|
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) {
|
|
status.textContent = `Opening ${recipe.name}…`;
|
|
try {
|
|
const state = await request("/activate", {
|
|
method: "POST",
|
|
body: JSON.stringify({ recipeId: recipe.id })
|
|
});
|
|
applyState(state);
|
|
activeRecipeLabel = recipe.name;
|
|
status.textContent = `${recipe.name} is now on the mirror.`;
|
|
render();
|
|
} catch (error) {
|
|
status.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
async function adjustPortions(delta) {
|
|
try {
|
|
const state = await request("/portions", {
|
|
method: "POST",
|
|
body: JSON.stringify({ delta })
|
|
});
|
|
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 {
|
|
const state = await request("/exit", { method: "POST", body: "{}" });
|
|
applyState(state);
|
|
status.textContent = "The mirror is back to its normal display.";
|
|
render();
|
|
} catch (error) {
|
|
status.textContent = error.message;
|
|
}
|
|
});
|
|
|
|
search.addEventListener("input", render);
|
|
|
|
Promise.all([request("/state"), request("/recipes")])
|
|
.then(([state, loadedRecipes]) => {
|
|
applyState(state);
|
|
recipes = loadedRecipes;
|
|
status.textContent = `${recipes.length} recipes available.`;
|
|
render();
|
|
})
|
|
.catch((error) => {
|
|
status.textContent = error.message;
|
|
});
|