/* global Module, MM, NCCFormatDuration */ Module.register("MMM-NextcloudCookbook", { defaults: { controlPath: "/MMM-NextcloudCookbook/control", controlUrl: "", animationSpeed: 400, layout: "stacked", theme: "dark", shareUrl: "", sharePassword: "" }, start() { this.state = { active: false, recipe: null, error: null }; this.hiddenModules = false; window.addEventListener("resize", () => this.scheduleRecipeFit()); this.sendSocketNotification("CONFIG", this.config); }, getStyles() { return ["MMM-NextcloudCookbook.css"]; }, getScripts() { return [this.file("lib/format-duration.js")]; }, socketNotificationReceived(notification, payload) { if (notification !== "STATE") return; const previous = this.state; this.state = payload; this.setRecipeMode(Boolean(payload.active)); const requiresDomUpdate = previous.active !== payload.active || previous.recipeId !== payload.recipeId || previous.portions !== payload.portions || previous.theme !== payload.theme || previous.error !== payload.error || previous.recipe?.recipeYield !== payload.recipe?.recipeYield; if (requiresDomUpdate) this.updateDom(this.config.animationSpeed); else this.updateProgressDom(); }, notificationReceived(notification) { if (notification === "MODULE_DOM_UPDATED") this.scheduleRecipeFit(); }, scheduleRecipeFit() { window.requestAnimationFrame(() => this.fitRecipe()); }, fitRecipe() { const root = document.getElementById(this.identifier)?.querySelector(".ncc-active"); const recipe = root?.querySelector(".ncc-recipe"); if (!root || !recipe) return; const style = window.getComputedStyle(root); const availableHeight = root.clientHeight - Number.parseFloat(style.paddingTop) - Number.parseFloat(style.paddingBottom); const availableWidth = root.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight); let minimum = 0.45; let maximum = 2; let best = minimum; for (let attempt = 0; attempt < 12; attempt++) { const scale = (minimum + maximum) / 2; root.style.setProperty("--ncc-content-scale", scale); const fits = recipe.scrollHeight <= availableHeight && recipe.scrollWidth <= availableWidth; if (fits) { best = scale; minimum = scale; } else { maximum = scale; } } root.style.setProperty("--ncc-content-scale", best.toFixed(3)); }, setRecipeMode(active) { if (active && !this.hiddenModules) { MM.getModules().exceptModule(this).enumerate((module) => { module.hide(this.config.animationSpeed, { lockString: this.name }); }); this.hiddenModules = true; } else if (!active && this.hiddenModules) { MM.getModules().exceptModule(this).enumerate((module) => { module.show(this.config.animationSpeed, { lockString: this.name }); }); this.hiddenModules = false; } }, updateProgressDom() { const root = document.getElementById(this.identifier)?.querySelector(".ncc-active"); if (!root) return; root.querySelectorAll("[data-ncc-progress-kind]").forEach((item) => { const kind = item.dataset.nccProgressKind; const index = Number(item.dataset.nccProgressIndex); const completed = kind === "ingredient" ? this.state.progress?.completedIngredients?.includes(index) : this.state.progress?.completedSteps?.includes(index); item.classList.toggle("ncc-completed", completed); const checkbox = item.querySelector("input[type=checkbox]"); if (checkbox) { checkbox.checked = completed; checkbox.disabled = false; } }); }, getDom() { const root = document.createElement("section"); root.className = `ncc-root ${this.state.active ? "ncc-active" : "ncc-idle"} ncc-theme-${this.state.theme || this.config.theme}`; const qr = document.createElement("a"); qr.className = "ncc-qr"; qr.href = this.config.controlUrl || this.config.controlPath; qr.setAttribute("aria-label", "Open recipe controller"); const qrLabel = document.createElement("span"); qrLabel.textContent = "Recipe mode control:"; const qrImage = document.createElement("img"); const qrParameters = new URLSearchParams(); if (this.config.controlUrl) qrParameters.set("url", this.config.controlUrl); qrImage.src = `/MMM-NextcloudCookbook/qr.svg${qrParameters.size ? `?${qrParameters}` : ""}`; qrImage.alt = "Recipe controller QR code"; qr.append(qrLabel, qrImage); root.appendChild(qr); const themeToggle = document.createElement("button"); themeToggle.type = "button"; themeToggle.className = "ncc-theme-toggle"; const nextTheme = this.state.theme === "light" ? "dark" : "light"; themeToggle.textContent = `Use ${nextTheme} mode`; themeToggle.addEventListener("click", () => this.sendSocketNotification("SET_THEME", nextTheme)); root.appendChild(themeToggle); if (!this.state.active) return root; if (this.state.error) { const error = document.createElement("p"); error.className = "ncc-error"; error.textContent = this.state.error; root.appendChild(error); return root; } if (!this.state.recipe) { const loading = document.createElement("p"); loading.className = "ncc-loading"; loading.textContent = "Loading recipe…"; root.appendChild(loading); return root; } root.appendChild(this.renderRecipe(this.state.recipe, this.state.progress)); return root; }, renderRecipe(recipe, progress = {}) { const article = document.createElement("article"); article.className = "ncc-recipe"; const header = document.createElement("header"); const title = document.createElement("h1"); title.textContent = recipe.name || "Untitled recipe"; header.appendChild(title); const yieldValue = recipe.originalRecipeYield && recipe.originalRecipeYield !== recipe.recipeYield ? `${recipe.recipeYield} (originally ${recipe.originalRecipeYield})` : recipe.recipeYield; const metadata = [ ["Yield", yieldValue], ["Prep", NCCFormatDuration(recipe.prepTime)], ["Cook", NCCFormatDuration(recipe.cookTime)], ["Total", NCCFormatDuration(recipe.totalTime)] ].filter(([, value]) => value); if (metadata.length) { const list = document.createElement("dl"); list.className = "ncc-meta"; metadata.forEach(([label, value]) => { const pair = document.createElement("div"); pair.className = "ncc-meta-item"; const term = document.createElement("dt"); term.textContent = label; const detail = document.createElement("dd"); detail.textContent = value; pair.append(term, detail); list.appendChild(pair); }); header.appendChild(list); } article.appendChild(header); const columns = document.createElement("div"); const layout = this.config.layout === "side-by-side" ? "side-by-side" : "stacked"; columns.className = `ncc-columns ncc-layout-${layout}`; const ingredients = document.createElement("section"); const ingredientsTitle = document.createElement("h2"); ingredientsTitle.textContent = "Ingredients"; ingredients.appendChild(ingredientsTitle); const ingredientList = document.createElement("ul"); (recipe.recipeIngredient || []).forEach((ingredient, index) => { const item = document.createElement("li"); item.dataset.nccProgressKind = "ingredient"; item.dataset.nccProgressIndex = index; const status = recipe.recipeIngredientStatus?.[index]; if (status === "unquantified") { item.classList.add("ncc-quantity-warning"); item.setAttribute("aria-label", `Verify quantity: ${ingredient}`); } else if (status === "unscalable" || status === "approximate") { item.classList.add("ncc-quantity-error"); item.setAttribute("aria-label", `Scaling warning: ${ingredient}`); } const completed = progress.completedIngredients?.includes(index); if (completed) item.classList.add("ncc-completed"); const label = document.createElement("label"); label.className = "ncc-progress-control"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = completed; checkbox.setAttribute("aria-label", `Mark ingredient complete: ${ingredient}`); checkbox.addEventListener("change", () => { checkbox.disabled = true; this.sendSocketNotification("UPDATE_PROGRESS", { kind: "ingredient", index, completed: checkbox.checked }); }); const text = document.createElement("span"); text.textContent = ingredient; label.append(checkbox, text); item.appendChild(label); ingredientList.appendChild(item); }); ingredients.appendChild(ingredientList); const instructions = document.createElement("section"); const instructionsTitle = document.createElement("h2"); instructionsTitle.textContent = "Instructions"; instructions.appendChild(instructionsTitle); const instructionList = document.createElement("ol"); this.flattenInstructions(recipe.recipeInstructions || []).forEach((step, index) => { const item = document.createElement("li"); item.dataset.nccProgressKind = "step"; item.dataset.nccProgressIndex = index; const completed = progress.completedSteps?.includes(index); if (completed) item.classList.add("ncc-completed"); const label = document.createElement("label"); label.className = "ncc-progress-control"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = completed; checkbox.setAttribute("aria-label", `Mark step complete: ${step}`); checkbox.addEventListener("change", () => { checkbox.disabled = true; this.sendSocketNotification("UPDATE_PROGRESS", { kind: "step", index, completed: checkbox.checked }); }); const text = document.createElement("span"); text.textContent = step; label.append(checkbox, text); item.appendChild(label); instructionList.appendChild(item); }); instructions.appendChild(instructionList); columns.append(ingredients, instructions); article.appendChild(columns); return article; }, flattenInstructions(instructions) { return instructions.flatMap((instruction) => { if (typeof instruction === "string") return [instruction]; if (Array.isArray(instruction.itemListElement)) { return this.flattenInstructions(instruction.itemListElement); } return instruction.text ? [instruction.text] : []; }); } });