Several improvements

This commit is contained in:
2026-08-03 16:04:17 -05:00
parent f94b08a4e6
commit a713af2fee
7 changed files with 102 additions and 31 deletions
+24 -16
View File
@@ -15,7 +15,7 @@
inset: 0; inset: 0;
z-index: 20; z-index: 20;
overflow: hidden; overflow: hidden;
padding: 50px 58px; padding: 42px 48px;
background: #11110f; background: #11110f;
} }
@@ -23,26 +23,34 @@
position: absolute; position: absolute;
right: 18px; right: 18px;
top: 18px; top: 18px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
padding: 6px; padding: 6px;
border-radius: 6px; border-radius: 6px;
color: #27231b;
background: #fff; background: #fff;
line-height: 0; font-size: 20px;
font-weight: 700;
line-height: 1.15;
text-decoration: none;
} }
.MMM-NextcloudCookbook .ncc-qr img { .MMM-NextcloudCookbook .ncc-qr img {
width: 82px; width: 164px;
height: 82px; height: 164px;
} }
.MMM-NextcloudCookbook .ncc-active .ncc-qr img { .MMM-NextcloudCookbook .ncc-active .ncc-qr img {
width: 68px; width: 144px;
height: 68px; height: 144px;
} }
.MMM-NextcloudCookbook .ncc-recipe h1 { .MMM-NextcloudCookbook .ncc-recipe h1 {
margin: 0 100px 24px 0; margin: 0 220px 26px 0;
color: #fff6d8; color: #fff6d8;
font-size: 68px; font-size: 82px;
line-height: 1.08; line-height: 1.08;
} }
@@ -50,8 +58,8 @@
display: flex; display: flex;
gap: 10px 22px; gap: 10px 22px;
align-items: baseline; align-items: baseline;
margin: 0 0 32px; margin: 0 0 36px;
font-size: 28px; font-size: 36px;
} }
.MMM-NextcloudCookbook .ncc-meta dt { .MMM-NextcloudCookbook .ncc-meta dt {
@@ -65,26 +73,26 @@
.MMM-NextcloudCookbook .ncc-columns { .MMM-NextcloudCookbook .ncc-columns {
display: grid; display: grid;
grid-template-columns: minmax(280px, 0.8fr) minmax(430px, 1.4fr); grid-template-columns: minmax(260px, 0.8fr) minmax(400px, 1.4fr);
gap: 52px; gap: 42px;
} }
.MMM-NextcloudCookbook .ncc-columns h2 { .MMM-NextcloudCookbook .ncc-columns h2 {
margin: 0 0 18px; margin: 0 0 18px;
color: #d6b96b; color: #d6b96b;
font-size: 42px; font-size: 56px;
} }
.MMM-NextcloudCookbook .ncc-columns ul, .MMM-NextcloudCookbook .ncc-columns ul,
.MMM-NextcloudCookbook .ncc-columns ol { .MMM-NextcloudCookbook .ncc-columns ol {
margin: 0; margin: 0;
padding-left: 1.25em; padding-left: 1.25em;
font-size: 34px; font-size: 50px;
line-height: 1.34; line-height: 1.36;
} }
.MMM-NextcloudCookbook .ncc-columns li { .MMM-NextcloudCookbook .ncc-columns li {
margin-bottom: 14px; margin-bottom: 18px;
} }
.MMM-NextcloudCookbook .ncc-error { .MMM-NextcloudCookbook .ncc-error {
+13 -6
View File
@@ -1,8 +1,9 @@
/* global Module, MM */ /* global Module, MM, NCCFormatDuration */
Module.register("MMM-NextcloudCookbook", { Module.register("MMM-NextcloudCookbook", {
defaults: { defaults: {
controlPath: "/MMM-NextcloudCookbook/control", controlPath: "/MMM-NextcloudCookbook/control",
controlUrl: "",
animationSpeed: 400, animationSpeed: 400,
shareUrl: "", shareUrl: "",
sharePassword: "" sharePassword: ""
@@ -18,6 +19,10 @@ Module.register("MMM-NextcloudCookbook", {
return ["MMM-NextcloudCookbook.css"]; return ["MMM-NextcloudCookbook.css"];
}, },
getScripts() {
return ["lib/format-duration.js"];
},
socketNotificationReceived(notification, payload) { socketNotificationReceived(notification, payload) {
if (notification !== "STATE") return; if (notification !== "STATE") return;
this.state = payload; this.state = payload;
@@ -47,12 +52,14 @@ Module.register("MMM-NextcloudCookbook", {
const qr = document.createElement("a"); const qr = document.createElement("a");
qr.className = "ncc-qr"; qr.className = "ncc-qr";
qr.href = this.config.controlPath; qr.href = this.config.controlUrl || this.config.controlPath;
qr.setAttribute("aria-label", "Open recipe controller"); qr.setAttribute("aria-label", "Open recipe controller");
const qrLabel = document.createElement("span");
qrLabel.textContent = "Recipe mode control:";
const qrImage = document.createElement("img"); const qrImage = document.createElement("img");
qrImage.src = "/MMM-NextcloudCookbook/qr.svg"; qrImage.src = "/MMM-NextcloudCookbook/qr.svg";
qrImage.alt = "Recipe controller QR code"; qrImage.alt = "Recipe controller QR code";
qr.appendChild(qrImage); qr.append(qrLabel, qrImage);
root.appendChild(qr); root.appendChild(qr);
if (!this.state.active) return root; if (!this.state.active) return root;
@@ -88,9 +95,9 @@ Module.register("MMM-NextcloudCookbook", {
const metadata = [ const metadata = [
["Yield", recipe.recipeYield], ["Yield", recipe.recipeYield],
["Prep", recipe.prepTime], ["Prep", NCCFormatDuration(recipe.prepTime)],
["Cook", recipe.cookTime], ["Cook", NCCFormatDuration(recipe.cookTime)],
["Total", recipe.totalTime] ["Total", NCCFormatDuration(recipe.totalTime)]
].filter(([, value]) => value); ].filter(([, value]) => value);
if (metadata.length) { if (metadata.length) {
const list = document.createElement("dl"); const list = document.createElement("dl");
+23 -7
View File
@@ -16,11 +16,25 @@ link; it should look like `https://cloud.example.test/s/SHARE_TOKEN`.
position: "fullscreen_above", position: "fullscreen_above",
config: { config: {
shareUrl: "https://cloud.example.test/s/SHARE_TOKEN", shareUrl: "https://cloud.example.test/s/SHARE_TOKEN",
controlPath: "/MMM-NextcloudCookbook/control" controlUrl: "http://mirror.example.test:8080/MMM-NextcloudCookbook/control"
} }
} }
``` ```
`controlUrl` is the complete address that should open when someone scans the QR
code. Use the MagicMirror host name or IP address and port that your phone can
reach. The module generates the QR image itself; it does not use an external QR
code service.
`controlUrl` is optional. When omitted, the module attempts to construct it
from the address used to load MagicMirror. Set it explicitly when MagicMirror
runs as an app or service using an address such as `localhost`, or when phones
reach it through a different host name, port, or reverse proxy.
When `controlUrl` is omitted, the module appends its default control path,
`/MMM-NextcloudCookbook/control`, to the detected MagicMirror address. Most
installations do not need to configure `controlPath` separately.
For a password-protected public share, add `sharePassword`: For a password-protected public share, add `sharePassword`:
```js ```js
@@ -30,16 +44,18 @@ config: {
} }
``` ```
MagicMirror sends module configuration to its browser clients. A public-share Do not treat values in MagicMirror's `config.js` as private: someone who can
URL is normally appropriate there because the token is already a scoped share load the MagicMirror display may be able to inspect them. A read-only public
credential, but do not put a Nextcloud account password or app password in this share URL is normally appropriate because its token is already limited to that
share. Do not put a Nextcloud account password or app password in this
configuration. configuration.
For deployments that keep even the share token out of browser-visible To keep even the share token out of the configuration delivered with the
configuration, set `SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL` and, if needed, MagicMirror display, set `SECRET_NEXTCLOUD_COOKBOOK_SHARE_URL` and, if needed,
`SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD` in the MagicMirror process `SECRET_NEXTCLOUD_COOKBOOK_SHARE_PASSWORD` in the MagicMirror process
environment and omit the corresponding config values. Environment values take environment and omit the corresponding config values. Environment values take
precedence over module configuration and are read only by `node_helper.js`. precedence over module configuration and are read only by `node_helper.js`. Read
more here: https://docs.magicmirror.builders/configuration/secrets.html
Open the controller at: Open the controller at:
+24
View File
@@ -0,0 +1,24 @@
(function exposeDurationFormatter(root, factory) {
const formatter = factory();
if (typeof module === "object" && module.exports) {
module.exports = formatter;
} else {
root.NCCFormatDuration = formatter;
}
}(globalThis, () => function formatDuration(value) {
if (typeof value !== "string") return value;
const match = value.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
if (!match || !match.slice(1).some((part) => part !== undefined)) return value;
const units = [
[match[1], "day"],
[match[2], "hr"],
[match[3], "min"],
[match[4], "sec"]
].filter(([amount]) => Number(amount) > 0);
if (!units.length) return "0 min";
return units.map(([amount, unit]) => (
`${Number(amount)} ${unit}${unit === "day" && Number(amount) !== 1 ? "s" : ""}`
)).join(" ");
}));
+2 -1
View File
@@ -57,7 +57,8 @@ module.exports = NodeHelper.create({
); );
this.expressApp.get("/MMM-NextcloudCookbook/qr.svg", async (request, response, next) => { this.expressApp.get("/MMM-NextcloudCookbook/qr.svg", async (request, response, next) => {
try { try {
const url = `${request.protocol}://${request.get("host")}${this.config.controlPath}`; const url = this.config.controlUrl
|| `${request.protocol}://${request.get("host")}${this.config.controlPath}`;
response.type("image/svg+xml").send(await QRCode.toString(url, { type: "svg", margin: 1 })); response.type("image/svg+xml").send(await QRCode.toString(url, { type: "svg", margin: 1 }));
} catch (error) { } catch (error) {
next(error); next(error);
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "Display recipes from a Nextcloud Cookbook share on MagicMirror", "description": "Display recipes from a Nextcloud Cookbook share on MagicMirror",
"main": "node_helper.js", "main": "node_helper.js",
"scripts": { "scripts": {
"check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/nextcloud-public-share.js && node --check lib/recipe-scaler.js && node --check control/app.js", "check": "node --check MMM-NextcloudCookbook.js && node --check node_helper.js && node --check lib/format-duration.js && node --check lib/nextcloud-public-share.js && node --check lib/recipe-scaler.js && node --check control/app.js",
"test": "npm run check && node --test" "test": "npm run check && node --test"
}, },
"engines": { "engines": {
+15
View File
@@ -0,0 +1,15 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const formatDuration = require("../lib/format-duration");
test("formats Cookbook ISO 8601 durations for people", () => {
assert.equal(formatDuration("PT0H10M0S"), "10 min");
assert.equal(formatDuration("PT1H30M0S"), "1 hr 30 min");
assert.equal(formatDuration("P2DT1H"), "2 days 1 hr");
assert.equal(formatDuration("PT0S"), "0 min");
});
test("preserves duration values it does not recognize", () => {
assert.equal(formatDuration("about an hour"), "about an hour");
assert.equal(formatDuration(null), null);
});