First version of custom card
This commit is contained in:
parent
a7e4da5523
commit
aca61a9069
283
www/cookcli-card.js
Normal file
283
www/cookcli-card.js
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
/**
|
||||||
|
* Carte Lovelace pour l'intégration CookCLI.
|
||||||
|
*
|
||||||
|
* Consomme les commandes websocket exposées par l'intégration :
|
||||||
|
* - cookcli/recipes -> liste (path, name, time, servings, tags)
|
||||||
|
* - cookcli/recipe -> détail (title, ingredients, cookware, sections)
|
||||||
|
*
|
||||||
|
* Limitation connue : les images de recettes ne sont pas affichées. Le
|
||||||
|
* serveur CookCLI n'est joignable depuis le navigateur ni sur son port
|
||||||
|
* ingress (restreint au Supervisor) ni sur son port direct (non exposé au
|
||||||
|
* LAN) — il faudrait un endpoint de proxy côté intégration pour les servir.
|
||||||
|
* Voir le README du projet pour plus de détails si tu veux l'ajouter.
|
||||||
|
*
|
||||||
|
* Installation :
|
||||||
|
* 1. Copie ce fichier dans config/www/cookcli-card.js
|
||||||
|
* 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter :
|
||||||
|
* URL "/local/cookcli-card.js", type "Module JavaScript"
|
||||||
|
* 3. Ajoute une carte manuelle sur un tableau de bord :
|
||||||
|
* type: custom:cookcli-card
|
||||||
|
* title: Mes recettes
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CookCliCard extends HTMLElement {
|
||||||
|
static getStubConfig() {
|
||||||
|
return { title: "Recettes" };
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(config) {
|
||||||
|
this._config = config || {};
|
||||||
|
this._view = "list";
|
||||||
|
this._recipes = null;
|
||||||
|
this._selected = null;
|
||||||
|
this._loading = false;
|
||||||
|
this._error = null;
|
||||||
|
|
||||||
|
if (!this.shadowRoot) {
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
this.shadowRoot.addEventListener("click", (event) => this._onClick(event));
|
||||||
|
}
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
set hass(hass) {
|
||||||
|
this._hass = hass;
|
||||||
|
if (this._recipes === null && !this._loading) {
|
||||||
|
this._fetchRecipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardSize() {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wsMessage(extra) {
|
||||||
|
const msg = { ...extra };
|
||||||
|
if (this._config.entry_id) {
|
||||||
|
msg.entry_id = this._config.entry_id;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchRecipes() {
|
||||||
|
this._loading = true;
|
||||||
|
this._error = null;
|
||||||
|
this._render();
|
||||||
|
try {
|
||||||
|
const result = await this._hass.connection.sendMessagePromise(
|
||||||
|
this._wsMessage({ type: "cookcli/recipes" })
|
||||||
|
);
|
||||||
|
this._recipes = result.recipes || [];
|
||||||
|
} catch (err) {
|
||||||
|
this._error = (err && err.message) || "Erreur inconnue";
|
||||||
|
} finally {
|
||||||
|
this._loading = false;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _openRecipe(path) {
|
||||||
|
this._loading = true;
|
||||||
|
this._error = null;
|
||||||
|
this._render();
|
||||||
|
try {
|
||||||
|
const result = await this._hass.connection.sendMessagePromise(
|
||||||
|
this._wsMessage({ type: "cookcli/recipe", path })
|
||||||
|
);
|
||||||
|
this._selected = result;
|
||||||
|
this._view = "detail";
|
||||||
|
} catch (err) {
|
||||||
|
this._error = (err && err.message) || "Erreur inconnue";
|
||||||
|
} finally {
|
||||||
|
this._loading = false;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClick(event) {
|
||||||
|
const backEl = event.target.closest("[data-action='back']");
|
||||||
|
if (backEl) {
|
||||||
|
this._view = "list";
|
||||||
|
this._selected = null;
|
||||||
|
this._error = null;
|
||||||
|
this._render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const itemEl = event.target.closest("[data-path]");
|
||||||
|
if (itemEl) {
|
||||||
|
this._openRecipe(itemEl.dataset.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_escape(str) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = str ?? "";
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatQuantity(quantity) {
|
||||||
|
if (!quantity) return "";
|
||||||
|
const value = quantity.value ?? "";
|
||||||
|
const unit = quantity.unit ?? "";
|
||||||
|
return `${value} ${unit}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStepItem(item) {
|
||||||
|
switch (item.type) {
|
||||||
|
case "text":
|
||||||
|
return this._escape(item.value);
|
||||||
|
case "ingredient": {
|
||||||
|
const qty = this._formatQuantity(item.quantity);
|
||||||
|
return `<span class="ingredient">${this._escape(item.name)}${
|
||||||
|
qty ? ` (${this._escape(qty)})` : ""
|
||||||
|
}</span>`;
|
||||||
|
}
|
||||||
|
case "cookware":
|
||||||
|
return `<span class="cookware">${this._escape(item.name)}</span>`;
|
||||||
|
case "timer": {
|
||||||
|
const duration = item.duration ?? "";
|
||||||
|
const unit = item.unit ?? "";
|
||||||
|
return `<span class="timer">⏱ ${this._escape(String(duration))}${
|
||||||
|
unit ? " " + this._escape(unit) : ""
|
||||||
|
}</span>`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderList() {
|
||||||
|
if (this._loading) {
|
||||||
|
return `<div class="state-msg">Chargement…</div>`;
|
||||||
|
}
|
||||||
|
if (this._error) {
|
||||||
|
return `<div class="state-msg error">${this._escape(this._error)}</div>`;
|
||||||
|
}
|
||||||
|
if (!this._recipes || this._recipes.length === 0) {
|
||||||
|
return `<div class="state-msg">Aucune recette trouvée.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = this._recipes
|
||||||
|
.map((r) => {
|
||||||
|
const meta = [];
|
||||||
|
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`);
|
||||||
|
if (r.servings) meta.push(`<span>🍽 ${this._escape(String(r.servings))}</span>`);
|
||||||
|
return `
|
||||||
|
<div class="recipe-row" data-path="${this._escape(r.path)}">
|
||||||
|
<div class="recipe-name">${this._escape(r.name)}</div>
|
||||||
|
<div class="recipe-meta">${meta.join("")}</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<div class="recipe-list">${rows}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDetail() {
|
||||||
|
if (this._loading) {
|
||||||
|
return `<div class="state-msg">Chargement…</div>`;
|
||||||
|
}
|
||||||
|
if (this._error) {
|
||||||
|
return `
|
||||||
|
<button class="back-btn" data-action="back">← Retour à la liste</button>
|
||||||
|
<div class="state-msg error">${this._escape(this._error)}</div>`;
|
||||||
|
}
|
||||||
|
if (!this._selected) return "";
|
||||||
|
|
||||||
|
const r = this._selected;
|
||||||
|
|
||||||
|
const ingredients = (r.ingredients || [])
|
||||||
|
.map((i) => {
|
||||||
|
const qty = this._formatQuantity(i.quantity);
|
||||||
|
return `<li>${qty ? `<strong>${this._escape(qty)}</strong> ` : ""}${this._escape(
|
||||||
|
i.name
|
||||||
|
)}</li>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const cookware = (r.cookware || [])
|
||||||
|
.map((c) => `<li>${this._escape(c.name)}</li>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const sections = (r.sections || [])
|
||||||
|
.map((section) => {
|
||||||
|
const steps = (section.steps || [])
|
||||||
|
.map(
|
||||||
|
(step) =>
|
||||||
|
`<li>${(step.items || [])
|
||||||
|
.map((item) => this._renderStepItem(item))
|
||||||
|
.join("")}</li>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
${section.name ? `<h4>${this._escape(section.name)}</h4>` : ""}
|
||||||
|
<ol class="steps">${steps}</ol>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<button class="back-btn" data-action="back">← Retour à la liste</button>
|
||||||
|
<h2>${this._escape(r.title || "")}</h2>
|
||||||
|
${cookware ? `<h4>Ustensiles</h4><ul class="cookware-list">${cookware}</ul>` : ""}
|
||||||
|
<h4>Ingrédients</h4>
|
||||||
|
<ul class="ingredients">${ingredients}</ul>
|
||||||
|
<h4>Préparation</h4>
|
||||||
|
${sections}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
if (!this.shadowRoot) return;
|
||||||
|
const title = this._config.title || "Recettes";
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>${this._styles()}</style>
|
||||||
|
<ha-card header="${this._view === "list" ? this._escape(title) : ""}">
|
||||||
|
<div class="card-content">
|
||||||
|
${this._view === "list" ? this._renderList() : this._renderDetail()}
|
||||||
|
</div>
|
||||||
|
</ha-card>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_styles() {
|
||||||
|
return `
|
||||||
|
.card-content { padding: 0 16px 16px; }
|
||||||
|
.state-msg { padding: 16px 0; color: var(--secondary-text-color); }
|
||||||
|
.state-msg.error { color: var(--error-color, #db4437); }
|
||||||
|
.recipe-list { display: flex; flex-direction: column; }
|
||||||
|
.recipe-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 4px; border-bottom: 1px solid var(--divider-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.recipe-row:last-child { border-bottom: none; }
|
||||||
|
.recipe-row:hover { background: var(--secondary-background-color); }
|
||||||
|
.recipe-name { font-weight: 500; color: var(--primary-text-color); }
|
||||||
|
.recipe-meta {
|
||||||
|
display: flex; gap: 12px; font-size: 0.85em;
|
||||||
|
color: var(--secondary-text-color); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
background: none; border: none; color: var(--primary-color);
|
||||||
|
font-size: 0.95em; cursor: pointer; padding: 12px 0 4px; display: block;
|
||||||
|
}
|
||||||
|
h2 { margin: 4px 0 8px; color: var(--primary-text-color); }
|
||||||
|
h4 { margin: 16px 0 4px; color: var(--primary-text-color); }
|
||||||
|
ul, ol { margin: 4px 0; padding-left: 20px; color: var(--primary-text-color); }
|
||||||
|
ul.ingredients li, ul.cookware-list li { margin: 2px 0; }
|
||||||
|
ol.steps li { margin: 8px 0; line-height: 1.5; }
|
||||||
|
.ingredient { color: var(--primary-color); font-weight: 500; }
|
||||||
|
.cookware { font-style: italic; color: var(--secondary-text-color); }
|
||||||
|
.timer { color: var(--warning-color, #ff9800); font-weight: 500; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cookcli-card", CookCliCard);
|
||||||
|
|
||||||
|
window.customCards = window.customCards || [];
|
||||||
|
window.customCards.push({
|
||||||
|
type: "cookcli-card",
|
||||||
|
name: "CookCLI Recipes",
|
||||||
|
description: "Liste navigable de tes recettes CookCLI, avec ingrédients et étapes.",
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue