First version of custom card

This commit is contained in:
TheThomaas 2026-09-13 21:26:38 +02:00
parent a7e4da5523
commit aca61a9069

283
www/cookcli-card.js Normal file
View 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.",
});