Add Nextcloud recipe display and controller
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
const apiBase = "/MMM-NextcloudCookbook/api";
|
||||
const status = document.querySelector("#status");
|
||||
const list = document.querySelector("#recipes");
|
||||
const search = document.querySelector("#search");
|
||||
const exit = document.querySelector("#exit");
|
||||
let recipes = [];
|
||||
let activeRecipeId = null;
|
||||
|
||||
async function request(path, options) {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed with HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
const matches = recipes.filter((recipe) => recipe.name.toLocaleLowerCase().includes(query));
|
||||
list.replaceChildren(...matches.map((recipe) => {
|
||||
const item = document.createElement("li");
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = recipe.id === activeRecipeId ? "recipe active" : "recipe";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = recipe.name;
|
||||
button.appendChild(title);
|
||||
if (recipe.description) {
|
||||
const description = document.createElement("span");
|
||||
description.textContent = recipe.description;
|
||||
button.appendChild(description);
|
||||
}
|
||||
button.addEventListener("click", () => activate(recipe));
|
||||
item.appendChild(button);
|
||||
return item;
|
||||
}));
|
||||
if (!matches.length) status.textContent = "No matching recipes.";
|
||||
}
|
||||
|
||||
async function activate(recipe) {
|
||||
status.textContent = `Opening ${recipe.name}…`;
|
||||
try {
|
||||
const state = await request("/activate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ recipeId: recipe.id })
|
||||
});
|
||||
activeRecipeId = state.recipeId;
|
||||
status.textContent = `${recipe.name} is now on the mirror.`;
|
||||
render();
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
exit.addEventListener("click", async () => {
|
||||
status.textContent = "Leaving recipe mode…";
|
||||
try {
|
||||
await request("/exit", { method: "POST", body: "{}" });
|
||||
activeRecipeId = null;
|
||||
status.textContent = "The mirror is back to its normal display.";
|
||||
render();
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
search.addEventListener("input", render);
|
||||
|
||||
Promise.all([request("/state"), request("/recipes")])
|
||||
.then(([state, loadedRecipes]) => {
|
||||
activeRecipeId = state.recipeId;
|
||||
recipes = loadedRecipes;
|
||||
status.textContent = `${recipes.length} recipes available.`;
|
||||
render();
|
||||
})
|
||||
.catch((error) => {
|
||||
status.textContent = error.message;
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kitchen Mirror Recipes</title>
|
||||
<link rel="stylesheet" href="/MMM-NextcloudCookbook/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Kitchen Mirror</p>
|
||||
<h1>Recipes</h1>
|
||||
</div>
|
||||
<button id="exit" class="secondary" type="button">Exit recipe mode</button>
|
||||
</header>
|
||||
<p id="status" role="status">Loading recipes…</p>
|
||||
<label for="search">Find a recipe</label>
|
||||
<input id="search" type="search" autocomplete="off" placeholder="Search recipes">
|
||||
<ul id="recipes" aria-live="polite"></ul>
|
||||
</main>
|
||||
<script src="/MMM-NextcloudCookbook/assets/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #27231b;
|
||||
background: #f3efe5;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body { margin: 0; }
|
||||
|
||||
main {
|
||||
width: min(760px, 100%);
|
||||
min-height: 100vh;
|
||||
margin: auto;
|
||||
padding: 24px 18px 60px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h1 { margin: 0; font-size: 2.3rem; }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 2px;
|
||||
color: #75623a;
|
||||
font-size: .8rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
label { display: block; margin: 24px 0 7px; font-weight: 700; }
|
||||
|
||||
input, button { font: inherit; }
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 1px solid #b7aa91;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
padding: 11px 13px;
|
||||
color: #fff;
|
||||
background: #5a4b30;
|
||||
}
|
||||
|
||||
#status { min-height: 1.5em; color: #665a45; }
|
||||
|
||||
#recipes { display: grid; gap: 10px; padding: 0; list-style: none; }
|
||||
|
||||
.recipe {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 5px rgb(61 48 24 / 12%);
|
||||
}
|
||||
|
||||
.recipe.active { outline: 3px solid #b58b35; }
|
||||
|
||||
.recipe strong { font-size: 1.12rem; }
|
||||
|
||||
.recipe span {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #756b59;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
Reference in New Issue
Block a user