feat: add recipe theme and resume controls

Fixes #4, #7
This commit is contained in:
2026-08-14 13:51:00 -05:00
parent 803d722836
commit 22daf703bb
7 changed files with 157 additions and 7 deletions
+31
View File
@@ -15,6 +15,15 @@
text-align: left; text-align: left;
} }
.MMM-NextcloudCookbook .ncc-active.ncc-theme-light {
color: #27231b;
background: #f8f6ef;
}
.MMM-NextcloudCookbook .ncc-theme-light .ncc-recipe h1 { color: #302817; }
.MMM-NextcloudCookbook .ncc-theme-light .ncc-meta dt,
.MMM-NextcloudCookbook .ncc-theme-light .ncc-columns h2 { color: #765900; }
.MMM-NextcloudCookbook .ncc-qr { .MMM-NextcloudCookbook .ncc-qr {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -37,6 +46,28 @@
top: 18px; top: 18px;
} }
.MMM-NextcloudCookbook .ncc-theme-toggle {
padding: 10px 14px;
border: 0;
border-radius: 6px;
color: #27231b;
background: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.MMM-NextcloudCookbook .ncc-active .ncc-theme-toggle {
position: absolute;
top: 190px;
right: 18px;
}
.MMM-NextcloudCookbook .ncc-theme-light .ncc-theme-toggle {
color: #f8f6ef;
background: #302817;
}
.MMM-NextcloudCookbook .ncc-qr img { .MMM-NextcloudCookbook .ncc-qr img {
width: 164px; width: 164px;
height: 164px; height: 164px;
+10 -3
View File
@@ -6,6 +6,7 @@ Module.register("MMM-NextcloudCookbook", {
controlUrl: "", controlUrl: "",
animationSpeed: 400, animationSpeed: 400,
layout: "stacked", layout: "stacked",
theme: "dark",
shareUrl: "", shareUrl: "",
sharePassword: "" sharePassword: ""
}, },
@@ -87,9 +88,7 @@ Module.register("MMM-NextcloudCookbook", {
getDom() { getDom() {
const root = document.createElement("section"); const root = document.createElement("section");
root.className = this.state.active root.className = `ncc-root ${this.state.active ? "ncc-active" : "ncc-idle"} ncc-theme-${this.state.theme || this.config.theme}`;
? "ncc-root ncc-active"
: "ncc-root ncc-idle";
const qr = document.createElement("a"); const qr = document.createElement("a");
qr.className = "ncc-qr"; qr.className = "ncc-qr";
@@ -105,6 +104,14 @@ Module.register("MMM-NextcloudCookbook", {
qr.append(qrLabel, qrImage); qr.append(qrLabel, qrImage);
root.appendChild(qr); 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.active) return root;
if (this.state.error) { if (this.state.error) {
+5
View File
@@ -33,6 +33,11 @@ the best use of the available screen while keeping all recipe content visible.
The recipe title, timing metadata, yield, and QR controller remain at fixed The recipe title, timing metadata, yield, and QR controller remain at fixed
sizes. sizes.
`theme` defaults to `"dark"`. Set it to `"light"` for a light starting theme;
the controller and recipe overlay also offer a toggle, and the chosen theme is
saved locally. After exiting recipe mode, use **Resume last recipe** in the
controller to restore its portions and completion marks.
When portions change, the display shows both the selected yield and the When portions change, the display shows both the selected yield and the
recipe's original yield. Ingredients without a leading quantity are marked in recipe's original yield. Ingredients without a leading quantity are marked in
yellow because they cannot be scaled; unsupported or imprecise numeric scaling yellow because they cannot be scaled; unsupported or imprecise numeric scaling
+32
View File
@@ -3,6 +3,8 @@ const status = document.querySelector("#status");
const list = document.querySelector("#recipes"); const list = document.querySelector("#recipes");
const search = document.querySelector("#search"); const search = document.querySelector("#search");
const exit = document.querySelector("#exit"); const exit = document.querySelector("#exit");
const resume = document.querySelector("#resume");
const themeToggle = document.querySelector("#theme-toggle");
const portionControls = document.querySelector("#portions"); const portionControls = document.querySelector("#portions");
const portionCount = document.querySelector("#portion-count"); const portionCount = document.querySelector("#portion-count");
const fewerPortions = document.querySelector("#fewer-portions"); const fewerPortions = document.querySelector("#fewer-portions");
@@ -19,6 +21,9 @@ let displayedRecipe = null;
let portions = null; let portions = null;
let progress = { completedIngredients: [], completedSteps: [] }; let progress = { completedIngredients: [], completedSteps: [] };
let recipeModeActive = false; let recipeModeActive = false;
let canResume = false;
let lastRecipeName = null;
let theme = "dark";
async function request(path, options) { async function request(path, options) {
const response = await fetch(`${apiBase}${path}`, { const response = await fetch(`${apiBase}${path}`, {
@@ -56,6 +61,9 @@ function render() {
portionControls.hidden = !recipeModeActive; portionControls.hidden = !recipeModeActive;
portionCount.textContent = portions ?? ""; portionCount.textContent = portions ?? "";
fewerPortions.disabled = portions <= 1; fewerPortions.disabled = portions <= 1;
resume.hidden = !canResume;
resume.textContent = lastRecipeName ? `Resume ${lastRecipeName}` : "Resume last recipe";
themeToggle.textContent = theme === "light" ? "Use dark mode" : "Use light mode";
renderActiveRecipe(); renderActiveRecipe();
} }
@@ -112,6 +120,9 @@ function applyState(state) {
displayedRecipe = recipeModeActive ? state.recipe : null; displayedRecipe = recipeModeActive ? state.recipe : null;
portions = state.portions; portions = state.portions;
progress = state.progress || { completedIngredients: [], completedSteps: [] }; progress = state.progress || { completedIngredients: [], completedSteps: [] };
canResume = Boolean(state.canResume);
lastRecipeName = state.lastRecipeName || null;
theme = state.theme === "light" ? "light" : "dark";
} }
async function activate(recipe) { async function activate(recipe) {
@@ -161,6 +172,27 @@ async function updateProgress(kind, index, completed) {
fewerPortions.addEventListener("click", () => adjustPortions(-1)); fewerPortions.addEventListener("click", () => adjustPortions(-1));
morePortions.addEventListener("click", () => adjustPortions(1)); morePortions.addEventListener("click", () => adjustPortions(1));
resume.addEventListener("click", async () => {
try {
applyState(await request("/resume", { method: "POST", body: "{}" }));
status.textContent = `${activeRecipeLabel} is back on the mirror.`;
render();
} catch (error) {
status.textContent = error.message;
}
});
themeToggle.addEventListener("click", async () => {
try {
applyState(await request("/theme", {
method: "POST", body: JSON.stringify({ theme: theme === "light" ? "dark" : "light" })
}));
render();
} catch (error) {
status.textContent = error.message;
}
});
exit.addEventListener("click", async () => { exit.addEventListener("click", async () => {
status.textContent = "Leaving recipe mode…"; status.textContent = "Leaving recipe mode…";
try { try {
+4
View File
@@ -13,7 +13,11 @@
<p class="eyebrow">Kitchen Mirror</p> <p class="eyebrow">Kitchen Mirror</p>
<h1>Recipes</h1> <h1>Recipes</h1>
</div> </div>
<div class="header-actions">
<button id="theme-toggle" class="secondary" type="button"></button>
<button id="resume" class="secondary" type="button" hidden>Resume last recipe</button>
<button id="exit" class="secondary" type="button">Exit recipe mode</button> <button id="exit" class="secondary" type="button">Exit recipe mode</button>
</div>
</header> </header>
<p id="status" role="status">Loading recipes…</p> <p id="status" role="status">Loading recipes…</p>
<div id="portions" class="portions" hidden> <div id="portions" class="portions" hidden>
+2
View File
@@ -23,6 +23,8 @@ header {
justify-content: space-between; justify-content: space-between;
} }
.header-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
h1 { margin: 0; font-size: 2.3rem; } h1 { margin: 0; font-size: 2.3rem; }
.eyebrow { .eyebrow {
+72 -3
View File
@@ -7,13 +7,18 @@ const NextcloudPublicShare = require("./lib/nextcloud-public-share");
const { parseYield, scaleRecipe } = require("./lib/recipe-scaler"); const { parseYield, scaleRecipe } = require("./lib/recipe-scaler");
const { normalizeProgress, updateProgress } = require("./lib/recipe-progress"); const { normalizeProgress, updateProgress } = require("./lib/recipe-progress");
function normalizeTheme(theme) {
return theme === "light" ? "light" : "dark";
}
module.exports = NodeHelper.create({ module.exports = NodeHelper.create({
start() { start() {
this.config = { controlPath: "/MMM-NextcloudCookbook/control" }; this.config = { controlPath: "/MMM-NextcloudCookbook/control", theme: "dark" };
this.state = { this.state = {
active: false, recipeId: null, recipe: null, portions: null, active: false, recipeId: null, recipe: null, portions: null,
progress: normalizeProgress(), error: null recipeName: null, progress: normalizeProgress(), theme: "dark", error: null
}; };
this.hasPersistedTheme = false;
this.recipeCache = { expires: 0, recipes: [] }; this.recipeCache = { expires: 0, recipes: [] };
this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json"); this.stateFile = path.join(process.cwd(), "config", "MMM-NextcloudCookbook-state.json");
this.configureClient(); this.configureClient();
@@ -26,6 +31,10 @@ module.exports = NodeHelper.create({
this.updateProgressFromMirror(payload); this.updateProgressFromMirror(payload);
return; return;
} }
if (notification === "SET_THEME") {
this.updateTheme(payload);
return;
}
if (notification !== "CONFIG") return; if (notification !== "CONFIG") return;
this.config = { ...this.config, ...payload }; this.config = { ...this.config, ...payload };
try { try {
@@ -35,6 +44,7 @@ module.exports = NodeHelper.create({
return; return;
} }
this.stateReady.then(async () => { this.stateReady.then(async () => {
if (!this.hasPersistedTheme) this.state.theme = normalizeTheme(this.config.theme);
this.sendSocketNotification("STATE", this.publicState()); this.sendSocketNotification("STATE", this.publicState());
await this.restoreRecipe(); await this.restoreRecipe();
}).catch((error) => this.setError(error)); }).catch((error) => this.setError(error));
@@ -97,6 +107,7 @@ module.exports = NodeHelper.create({
active: true, active: true,
recipeId, recipeId,
recipe, recipe,
recipeName: recipe.name || "Untitled recipe",
portions: isResumingRecipe portions: isResumingRecipe
? this.state.portions ? this.state.portions
: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)), : Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
@@ -110,6 +121,43 @@ module.exports = NodeHelper.create({
return next(error); return next(error);
} }
}); });
this.expressApp.post("/MMM-NextcloudCookbook/api/resume", async (_request, response, next) => {
try {
if (this.state.active) return response.json(this.publicState());
if (!this.state.recipeId) return response.status(409).json({ error: "No recent recipe to resume" });
const recipe = await this.getClient().getRecipe(this.state.recipeId);
this.state = {
...this.state,
active: true,
recipe,
recipeName: recipe.name || "Untitled recipe",
portions: Number.isInteger(this.state.portions) && this.state.portions > 0
? this.state.portions
: Math.max(1, Math.round(parseYield(recipe.recipeYield) || 1)),
progress: normalizeProgress(this.state.progress),
error: null
};
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
return response.json(this.publicState());
} catch (error) {
return next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/theme", async (request, response, next) => {
try {
const theme = request.body?.theme;
if (!["light", "dark"].includes(theme)) {
return response.status(400).json({ error: "theme must be light or dark" });
}
this.state.theme = theme;
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
return response.json(this.publicState());
} catch (error) {
return next(error);
}
});
this.expressApp.post("/MMM-NextcloudCookbook/api/portions", async (request, response, next) => { this.expressApp.post("/MMM-NextcloudCookbook/api/portions", async (request, response, next) => {
try { try {
const delta = request.body?.delta; const delta = request.body?.delta;
@@ -174,13 +222,17 @@ module.exports = NodeHelper.create({
async loadState() { async loadState() {
try { try {
const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8")); const persisted = JSON.parse(await fs.readFile(this.stateFile, "utf8"));
this.hasPersistedTheme = typeof persisted.theme === "string";
this.state.theme = normalizeTheme(persisted.theme);
if (!persisted.recipeId) return; if (!persisted.recipeId) return;
this.state = { this.state = {
active: Boolean(persisted.active), active: Boolean(persisted.active),
recipeId: persisted.recipeId, recipeId: persisted.recipeId,
recipe: null, recipe: null,
recipeName: typeof persisted.recipeName === "string" ? persisted.recipeName : null,
portions: Number.isInteger(persisted.portions) ? persisted.portions : null, portions: Number.isInteger(persisted.portions) ? persisted.portions : null,
progress: normalizeProgress(persisted.progress), progress: normalizeProgress(persisted.progress),
theme: this.state.theme,
error: null error: null
}; };
} catch (error) { } catch (error) {
@@ -205,8 +257,10 @@ module.exports = NodeHelper.create({
await fs.writeFile(temporary, `${JSON.stringify({ await fs.writeFile(temporary, `${JSON.stringify({
active: this.state.active, active: this.state.active,
recipeId: this.state.recipeId, recipeId: this.state.recipeId,
recipeName: this.state.recipeName,
portions: this.state.portions, portions: this.state.portions,
progress: this.state.progress progress: this.state.progress,
theme: this.state.theme
})}\n`, { })}\n`, {
mode: 0o600 mode: 0o600
}); });
@@ -227,6 +281,9 @@ module.exports = NodeHelper.create({
: null, : null,
portions: this.state.portions, portions: this.state.portions,
progress: this.state.progress, progress: this.state.progress,
theme: this.state.theme,
canResume: !this.state.active && Boolean(this.state.recipeId),
lastRecipeName: !this.state.active ? this.state.recipeName : null,
error: this.state.error error: this.state.error
}; };
}, },
@@ -241,5 +298,17 @@ module.exports = NodeHelper.create({
console.error(`[MMM-NextcloudCookbook] ${error.message}`); console.error(`[MMM-NextcloudCookbook] ${error.message}`);
this.sendSocketNotification("STATE", this.publicState()); this.sendSocketNotification("STATE", this.publicState());
} }
},
async updateTheme(theme) {
try {
if (!["light", "dark"].includes(theme)) throw new Error("theme must be light or dark");
this.state.theme = theme;
await this.saveState();
this.sendSocketNotification("STATE", this.publicState());
} catch (error) {
console.error(`[MMM-NextcloudCookbook] ${error.message}`);
this.sendSocketNotification("STATE", this.publicState());
}
} }
}); });