Files
MMM-NextcloudCookbook/MMM-NextcloudCookbook.js

287 lines
11 KiB
JavaScript

/* 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] : [];
});
}
});