@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+5
-1
@@ -13,7 +13,11 @@
|
|||||||
<p class="eyebrow">Kitchen Mirror</p>
|
<p class="eyebrow">Kitchen Mirror</p>
|
||||||
<h1>Recipes</h1>
|
<h1>Recipes</h1>
|
||||||
</div>
|
</div>
|
||||||
<button id="exit" class="secondary" type="button">Exit recipe mode</button>
|
<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>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user