Add custom view strategy

This commit is contained in:
TheThomaas 2026-09-14 08:51:31 +02:00
parent 5f30dc00a7
commit d9831b0a14
7 changed files with 651 additions and 162 deletions

View file

@ -11,8 +11,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import CookCliApiClient, CookCliApiError from .api import CookCliApiClient, CookCliApiError
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
from .coordinator import CookCliCoordinator from .coordinator import CookCliCoordinator
from .websocket_api import async_setup_websocket_api
from .image_proxy import CookCliImageView from .image_proxy import CookCliImageView
from .websocket_api import async_setup_websocket_api
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -38,10 +38,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"coordinator": coordinator, "coordinator": coordinator,
} }
# Les commandes websocket sont globales au domaine, on ne les enregistre # Les commandes websocket et la vue de proxy d'images sont globales au
# qu'une seule fois même si plusieurs serveurs CookCLI sont configurés. # domaine, on ne les enregistre qu'une seule fois même si plusieurs
# serveurs CookCLI sont configurés.
if len(hass.data[DOMAIN]) == 1: if len(hass.data[DOMAIN]) == 1:
async_setup_websocket_api(hass) async_setup_websocket_api(hass)
hass.http.register_view(CookCliImageView(hass))
if PLATFORMS: if PLATFORMS:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View file

@ -15,4 +15,4 @@ HASSIO_DISCOVERY_SLUG = "cookcli"
# donc pas besoin de poller souvent. # donc pas besoin de poller souvent.
UPDATE_INTERVAL_MINUTES = 15 UPDATE_INTERVAL_MINUTES = 15
PLATFORMS: list[str] = [] PLATFORMS: list[str] = ["todo"]

View file

@ -0,0 +1,72 @@
"""Plateforme to-do : une checklist d'ingrédients par entrée CookCLI.
Une seule entité par serveur CookCLI configuré (pas une par recette) : elle
est repeuplée avec les ingrédients de la recette qu'on vient d'ouvrir à
chaque appel de `cookcli/recipe` (voir websocket_api.py), et affichée dans
la vue générée par la view strategy avec la carte native `todo-list`.
Seule la case à cocher est éditable par l'utilisateur (UPDATE_TODO_ITEM) —
pas d'ajout/suppression manuel, la liste est entièrement dérivée de la
recette ouverte.
"""
from __future__ import annotations
import logging
from homeassistant.components.todo import (
TodoItem,
TodoItemStatus,
TodoListEntity,
TodoListEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Crée l'entité todo pour cette entrée de configuration."""
entity = CookCliIngredientsTodoListEntity(entry)
hass.data[DOMAIN][entry.entry_id]["todo_entity"] = entity
async_add_entities([entity])
class CookCliIngredientsTodoListEntity(TodoListEntity):
"""Checklist des ingrédients de la recette actuellement affichée."""
_attr_has_entity_name = True
_attr_name = "Ingrédients à rassembler"
_attr_supported_features = TodoListEntityFeature.UPDATE_TODO_ITEM
def __init__(self, entry: ConfigEntry) -> None:
self._attr_unique_id = f"{entry.entry_id}_ingredients"
self._attr_todo_items: list[TodoItem] = []
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Coche/décoche un ingrédient — seule modification permise ici."""
items = list(self._attr_todo_items or [])
for index, existing in enumerate(items):
if existing.uid == item.uid:
items[index] = item
break
self._attr_todo_items = items
self.async_write_ha_state()
def set_ingredients(self, summaries: list[str]) -> None:
"""Remplace toute la liste par les ingrédients de la recette ouverte.
Appelé depuis websocket_api.py à chaque `cookcli/recipe` — pas partie
de l'API TodoListEntity standard, c'est notre propre point d'entrée.
"""
self._attr_todo_items = [
TodoItem(uid=str(i), summary=summary, status=TodoItemStatus.NEEDS_ACTION)
for i, summary in enumerate(summaries)
]
self.async_write_ha_state()

View file

@ -11,6 +11,11 @@ Les deux commandes ajoutent un champ "image_url" déjà prêt à l'emploi pour
un <img src="...">, qui pointe vers CookCliImageView (voir image_proxy.py) — un <img src="...">, qui pointe vers CookCliImageView (voir image_proxy.py) —
le frontend n'a pas besoin de connaître le format brut des chemins d'image le frontend n'a pas besoin de connaître le format brut des chemins d'image
renvoyés par CookCLI. renvoyés par CookCLI.
cookcli/recipe repeuple aussi l'entité todo d'ingrédients (todo.py) avec les
ingrédients de la recette ouverte, et renvoie son entity_id dans
"todo_entity_id" pour que le frontend puisse l'afficher avec la carte
native `todo-list`.
""" """
from __future__ import annotations from __future__ import annotations
@ -43,6 +48,21 @@ def _image_url(entry_id: str, image_ref: str | None) -> str | None:
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}" return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
def _ingredient_summaries(ingredients: list[dict]) -> list[str]:
"""Formate chaque ingrédient en une ligne lisible pour la checklist."""
summaries = []
for ingredient in ingredients:
quantity = ingredient.get("quantity") or {}
value = quantity.get("value")
unit = quantity.get("unit")
prefix = " ".join(
str(part) for part in (value, unit) if part not in (None, "")
)
name = ingredient.get("name", "")
summaries.append(f"{prefix} {name}".strip() if prefix else name)
return summaries
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "cookcli/recipes", vol.Required("type"): "cookcli/recipes",
@ -99,6 +119,12 @@ async def ws_get_recipe(
return return
recipe["image_url"] = _image_url(entry_id, recipe.get("image")) recipe["image_url"] = _image_url(entry_id, recipe.get("image"))
todo_entity = entry_data.get("todo_entity")
if todo_entity is not None:
todo_entity.set_ingredients(_ingredient_summaries(recipe.get("ingredients", [])))
recipe["todo_entity_id"] = todo_entity.entity_id
connection.send_result(msg["id"], recipe) connection.send_result(msg["id"], recipe)

View file

@ -1,15 +1,12 @@
/** /**
* Carte Lovelace pour l'intégration CookCLI. * Carte Lovelace pour l'intégration CookCLI — liste navigable des recettes.
* *
* Consomme les commandes websocket exposées par l'intégration : * Le détail d'une recette n'est plus géré par cette carte : un clic sur une
* - cookcli/recipes -> liste (path, name, time, servings, tags) * ligne navigue vers une vue générée par cookcli-recipe-strategy.js (voir ce
* - cookcli/recipe -> détail (title, ingredients, cookware, sections) * fichier), qui affiche étapes/ingrédients/minuteur via des cartes natives
* et des cartes tierces (tabdeck-card, simple-timer-card).
* *
* Limitation connue : les images de recettes ne sont pas affichées. Le * Consomme cookcli/recipes (path, name, time, servings, tags, image_url).
* 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 : * Installation :
* 1. Copie ce fichier dans config/www/cookcli-card.js * 1. Copie ce fichier dans config/www/cookcli-card.js
@ -18,18 +15,17 @@
* 3. Ajoute une carte manuelle sur un tableau de bord : * 3. Ajoute une carte manuelle sur un tableau de bord :
* type: custom:cookcli-card * type: custom:cookcli-card
* title: Mes recettes * title: Mes recettes
* detail_view_path: recette # doit correspondre au "path" de la vue de détail
*/ */
class CookCliCard extends HTMLElement { class CookCliCard extends HTMLElement {
static getStubConfig() { static getStubConfig() {
return { title: "Recettes" }; return { title: "Recettes", detail_view_path: "recette" };
} }
setConfig(config) { setConfig(config) {
this._config = config || {}; this._config = config || {};
this._view = "list";
this._recipes = null; this._recipes = null;
this._selected = null;
this._loading = false; this._loading = false;
this._error = null; this._error = null;
@ -51,22 +47,14 @@ class CookCliCard extends HTMLElement {
return 6; return 6;
} }
_wsMessage(extra) {
const msg = { ...extra };
if (this._config.entry_id) {
msg.entry_id = this._config.entry_id;
}
return msg;
}
async _fetchRecipes() { async _fetchRecipes() {
this._loading = true; this._loading = true;
this._error = null; this._error = null;
this._render(); this._render();
try { try {
const result = await this._hass.connection.sendMessagePromise( const msg = { type: "cookcli/recipes" };
this._wsMessage({ type: "cookcli/recipes" }) if (this._config.entry_id) msg.entry_id = this._config.entry_id;
); const result = await this._hass.connection.sendMessagePromise(msg);
this._recipes = result.recipes || []; this._recipes = result.recipes || [];
} catch (err) { } catch (err) {
this._error = (err && err.message) || "Erreur inconnue"; this._error = (err && err.message) || "Erreur inconnue";
@ -76,36 +64,22 @@ class CookCliCard extends HTMLElement {
} }
} }
async _openRecipe(path) { _navigateToRecipe(path) {
this._loading = true; const detailPath = this._config.detail_view_path || "recette";
this._error = null; // Racine du dashboard courant, ex: "/lovelace-cookcli" depuis
this._render(); // "/lovelace-cookcli/recettes" — on y accroche la vue de détail.
try { const dashboardRoot = window.location.pathname.split("/").slice(0, 2).join("/");
const result = await this._hass.connection.sendMessagePromise( const url = `${dashboardRoot}/${detailPath}?path=${encodeURIComponent(path)}`;
this._wsMessage({ type: "cookcli/recipe", path })
); // Navigation interne HA, sans recharger la page (comme un tap_action navigate).
this._selected = result; window.history.pushState(null, "", url);
this._view = "detail"; window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } }));
} catch (err) {
this._error = (err && err.message) || "Erreur inconnue";
} finally {
this._loading = false;
this._render();
}
} }
_onClick(event) { _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]"); const itemEl = event.target.closest("[data-path]");
if (itemEl) { if (itemEl) {
this._openRecipe(itemEl.dataset.path); this._navigateToRecipe(itemEl.dataset.path);
} }
} }
@ -115,37 +89,6 @@ class CookCliCard extends HTMLElement {
return div.innerHTML; 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() { _renderList() {
if (this._loading) { if (this._loading) {
return `<div class="state-msg">Chargement…</div>`; return `<div class="state-msg">Chargement…</div>`;
@ -179,73 +122,13 @@ class CookCliCard extends HTMLElement {
return `<div class="recipe-list">${rows}</div>`; 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>
${
r.image_url
? `<img class="recipe-hero" src="${this._escape(r.image_url)}" alt="" onerror="this.style.display='none'">`
: ""
}
<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() { _render() {
if (!this.shadowRoot) return; if (!this.shadowRoot) return;
const title = this._config.title || "Recettes"; const title = this._config.title || "Recettes";
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style>${this._styles()}</style> <style>${this._styles()}</style>
<ha-card header="${this._view === "list" ? this._escape(title) : ""}"> <ha-card header="${this._escape(title)}">
<div class="card-content"> <div class="card-content">${this._renderList()}</div>
${this._view === "list" ? this._renderList() : this._renderDetail()}
</div>
</ha-card> </ha-card>
`; `;
} }
@ -277,22 +160,6 @@ class CookCliCard extends HTMLElement {
display: flex; gap: 12px; font-size: 0.85em; display: flex; gap: 12px; font-size: 0.85em;
color: var(--secondary-text-color); white-space: nowrap; color: var(--secondary-text-color); white-space: nowrap;
} }
.recipe-hero {
width: 100%; max-height: 220px; object-fit: cover;
border-radius: 8px; margin: 4px 0 8px; display: block;
}
.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; }
`; `;
} }
} }
@ -303,5 +170,5 @@ window.customCards = window.customCards || [];
window.customCards.push({ window.customCards.push({
type: "cookcli-card", type: "cookcli-card",
name: "CookCLI Recipes", name: "CookCLI Recipes",
description: "Liste navigable de tes recettes CookCLI, avec ingrédients et étapes.", description: "Liste navigable de tes recettes CookCLI.",
}); });

View file

@ -0,0 +1,215 @@
/**
* View strategy CookCLI : génère la vue de détail d'une recette.
*
* Contrairement à une carte custom classique, une view strategy ne gère pas
* son propre état de navigation : elle génère la config d'UNE vue HA
* (titre + liste de cartes) à partir de `config` (donné en YAML) et de
* `hass`, au moment où la vue est ouverte. Voir :
* https://developers.home-assistant.io/docs/frontend/custom-ui/custom-strategy/#views
*
* Le chemin de la recette à afficher (`path`) est lu soit dans `config`
* (fixe, une vue par recette), soit — cas normal ici — dans le paramètre
* d'URL `?path=...` que cookcli-card.js pose avant de naviguer. Une view
* strategy tourne dans le navigateur, donc lire window.location est légitime
* même si HA ne le passe pas explicitement dans `config`.
*
* Cartes utilisées, toutes tierces ou natives — aucune carte custom pour le
* rendu du contenu lui-même :
* - markdown (native) : titre, image, ustensiles, texte des étapes
* - todo-list (native) : checklist des ingrédients (todo.py côté backend)
* - button (native) : démarre le minuteur partagé avec la bonne durée
* - custom:simple-timer-card : affichage/contrôle du minuteur partagé
* - custom:tabdeck-card : une tab par étape
*
* Configuration de la vue (YAML) :
* views:
* - path: recette
* strategy:
* type: custom:cookcli-recipe
* timer_entity: timer.cookcli # créé manuellement, voir README
* entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI
*/
class CookCliRecipeViewStrategy extends HTMLElement {
static async generate(config, hass) {
config = config || {};
const params = new URLSearchParams(window.location.search);
const path = config.path || params.get("path");
if (!path) {
return {
cards: [{ type: "markdown", content: "Aucune recette sélectionnée." }],
};
}
const wsMsg = { type: "cookcli/recipe", path };
if (config.entry_id) wsMsg.entry_id = config.entry_id;
let recipe;
try {
recipe = await hass.connection.sendMessagePromise(wsMsg);
} catch (err) {
return {
cards: [
{
type: "markdown",
content: `**Erreur en chargeant la recette**\n\n${(err && err.message) || err}`,
},
],
};
}
const cards = [];
cards.push(this._headerCard(recipe));
if (config.timer_entity) {
cards.push({
type: "custom:simple-timer-card",
entities: [config.timer_entity],
show_active_header: false
});
}
if (recipe.todo_entity_id) {
cards.push({
type: "todo-list",
entity: recipe.todo_entity_id,
title: "Ingrédients à rassembler",
});
}
const tabs = this._stepTabs(recipe, config.timer_entity);
if (tabs.length) {
cards.push({ type: "custom:tabdeck-card", tabs });
}
return {
title: recipe.title || "Recette",
cards,
};
}
static _headerCard(recipe) {
let content = "";
if (recipe.image_url) content += `![](${recipe.image_url})\n\n`;
content += `## ${recipe.title || ""}\n`;
if (recipe.cookware && recipe.cookware.length) {
content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`;
}
return { type: "markdown", content };
}
static _stepTabs(recipe, timerEntity) {
const tabs = [];
let stepCounter = 0;
for (const section of recipe.sections || []) {
for (const step of section.steps || []) {
stepCounter += 1;
const { markdown, timers } = this._renderStepMarkdown(step);
const stepCards = [{ type: "markdown", content: markdown }];
if (timerEntity) {
for (const timer of timers) {
const seconds = this._parseDurationSeconds(timer.duration, timer.unit);
if (!seconds) continue;
const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim();
stepCards.push({
type: "button",
name: `Démarrer ${label}`,
icon: "mdi:timer-outline",
tap_action: {
action: "call-service",
service: "timer.start",
target: { entity_id: timerEntity },
data: { duration: this._secondsToHms(seconds) },
},
});
}
}
tabs.push({
name: section.name ? `${section.name} ${step.number ?? ""}`.trim() : `Étape ${stepCounter}`,
card: { type: "vertical-stack", cards: stepCards },
});
}
}
return tabs;
}
static _renderStepMarkdown(step) {
let markdown = "";
const timers = [];
for (const item of step.items || []) {
switch (item.type) {
case "text":
markdown += item.value ?? "";
break;
case "ingredient":
case "cookware": {
const qty = item.quantity
? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim()
: "";
markdown += `**${item.name}${qty ? ` (${qty})` : ""}**`;
break;
}
case "timer": {
const label = `${item.duration ?? ""} ${item.unit ?? ""}`.trim();
markdown += `⏱ *${label}*`;
timers.push(item);
break;
}
default:
break;
}
}
return { markdown, timers };
}
/**
* Devine une durée en secondes à partir d'une valeur de minuteur résolue.
* `duration` peut être un nombre (secondes/minutes selon `unit`) ou du
* texte libre façon "2-3 minutes" (Cooklang autorise les plages en texte
* libre) — dans ce cas on prend le premier nombre trouvé, ce qui donne une
* estimation basse ; ajustable ensuite via les boutons +/- de
* simple-timer-card ou l'éditeur de durée intégré.
*/
static _parseDurationSeconds(duration, unit) {
let value = null;
let unitHint = (unit || "").toLowerCase();
if (typeof duration === "number") {
value = duration;
} else if (typeof duration === "string") {
const match = duration.match(/(\d+(?:[.,]\d+)?)/);
if (match) {
value = parseFloat(match[1].replace(",", "."));
if (!unitHint) unitHint = duration.toLowerCase();
}
}
if (value === null) return null;
if (unitHint.includes("heure") || unitHint.includes("hour") || unitHint === "h") {
return Math.round(value * 3600);
}
if (unitHint.includes("sec") || unitHint === "s") {
return Math.round(value);
}
// Par défaut (minutes, ou pas d'unité reconnue) : on suppose des minutes.
return Math.round(value * 60);
}
static _secondsToHms(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = Math.floor(totalSeconds % 60);
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
}
}
customElements.define("ll-strategy-view-cookcli-recipe", CookCliRecipeViewStrategy);

307
www/simple-cookcli-card.js Normal file
View file

@ -0,0 +1,307 @@
/**
* 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/simple-cookcli-card.js
* 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter :
* URL "/local/simple-cookcli-card.js", type "Module JavaScript"
* 3. Ajoute une carte manuelle sur un tableau de bord :
* type: custom:simple-cookcli-card
* title: Mes recettes
*/
class SimpleCookCliCard 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>`);
const thumb = r.image_url
? `<img class="recipe-thumb" src="${this._escape(r.image_url)}" alt="" loading="lazy" onerror="this.style.display='none'">`
: `<div class="recipe-thumb recipe-thumb-placeholder"></div>`;
return `
<div class="recipe-row" data-path="${this._escape(r.path)}">
${thumb}
<div class="recipe-row-text">
<div class="recipe-name">${this._escape(r.name)}</div>
<div class="recipe-meta">${meta.join("")}</div>
</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>
${
r.image_url
? `<img class="recipe-hero" src="${this._escape(r.image_url)}" alt="" onerror="this.style.display='none'">`
: ""
}
<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; align-items: center; gap: 12px;
padding: 10px 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-thumb {
width: 56px; height: 56px; border-radius: 8px; object-fit: cover;
flex-shrink: 0; background: var(--secondary-background-color);
}
.recipe-thumb-placeholder { background: var(--divider-color); }
.recipe-row-text { min-width: 0; }
.recipe-name {
font-weight: 500; color: var(--primary-text-color);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.recipe-meta {
display: flex; gap: 12px; font-size: 0.85em;
color: var(--secondary-text-color); white-space: nowrap;
}
.recipe-hero {
width: 100%; max-height: 220px; object-fit: cover;
border-radius: 8px; margin: 4px 0 8px; display: block;
}
.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("simple-cookcli-card", SimpleCookCliCard);
window.customCards = window.customCards || [];
window.customCards.push({
type: "simple-cookcli-card",
name: "CookCLI Recipes",
description: "Liste navigable de tes recettes CookCLI, avec ingrédients et étapes.",
});