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; });