diff --git a/custom_components/cookcli/__init__.py b/custom_components/cookcli/__init__.py
index 5775e1c..72126df 100644
--- a/custom_components/cookcli/__init__.py
+++ b/custom_components/cookcli/__init__.py
@@ -11,8 +11,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import CookCliApiClient, CookCliApiError
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
from .coordinator import CookCliCoordinator
-from .websocket_api import async_setup_websocket_api
from .image_proxy import CookCliImageView
+from .websocket_api import async_setup_websocket_api
_LOGGER = logging.getLogger(__name__)
@@ -38,10 +38,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"coordinator": coordinator,
}
- # Les commandes websocket sont globales au domaine, on ne les enregistre
- # qu'une seule fois même si plusieurs serveurs CookCLI sont configurés.
+ # Les commandes websocket et la vue de proxy d'images sont globales au
+ # domaine, on ne les enregistre qu'une seule fois même si plusieurs
+ # serveurs CookCLI sont configurés.
if len(hass.data[DOMAIN]) == 1:
async_setup_websocket_api(hass)
+ hass.http.register_view(CookCliImageView(hass))
if PLATFORMS:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/custom_components/cookcli/const.py b/custom_components/cookcli/const.py
index 9f8a0cc..77cfb7d 100644
--- a/custom_components/cookcli/const.py
+++ b/custom_components/cookcli/const.py
@@ -15,4 +15,4 @@ HASSIO_DISCOVERY_SLUG = "cookcli"
# donc pas besoin de poller souvent.
UPDATE_INTERVAL_MINUTES = 15
-PLATFORMS: list[str] = []
+PLATFORMS: list[str] = ["todo"]
diff --git a/custom_components/cookcli/todo.py b/custom_components/cookcli/todo.py
new file mode 100644
index 0000000..140072e
--- /dev/null
+++ b/custom_components/cookcli/todo.py
@@ -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()
diff --git a/custom_components/cookcli/websocket_api.py b/custom_components/cookcli/websocket_api.py
index 431ab61..6a3308a 100644
--- a/custom_components/cookcli/websocket_api.py
+++ b/custom_components/cookcli/websocket_api.py
@@ -11,6 +11,11 @@ Les deux commandes ajoutent un champ "image_url" déjà prêt à l'emploi pour
un
, qui pointe vers CookCliImageView (voir image_proxy.py) —
le frontend n'a pas besoin de connaître le format brut des chemins d'image
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
@@ -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='/')}"
+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(
{
vol.Required("type"): "cookcli/recipes",
@@ -99,6 +119,12 @@ async def ws_get_recipe(
return
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)
diff --git a/www/cookcli-card.js b/www/cookcli-card.js
index 63a2e91..d538286 100644
--- a/www/cookcli-card.js
+++ b/www/cookcli-card.js
@@ -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 :
- * - cookcli/recipes -> liste (path, name, time, servings, tags)
- * - cookcli/recipe -> détail (title, ingredients, cookware, sections)
+ * Le détail d'une recette n'est plus géré par cette carte : un clic sur une
+ * ligne navigue vers une vue générée par cookcli-recipe-strategy.js (voir ce
+ * 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
- * 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.
+ * Consomme cookcli/recipes (path, name, time, servings, tags, image_url).
*
* Installation :
* 1. Copie ce fichier dans config/www/cookcli-card.js
@@ -18,18 +15,17 @@
* 3. Ajoute une carte manuelle sur un tableau de bord :
* type: custom:cookcli-card
* title: Mes recettes
+ * detail_view_path: recette # doit correspondre au "path" de la vue de détail
*/
class CookCliCard extends HTMLElement {
static getStubConfig() {
- return { title: "Recettes" };
+ return { title: "Recettes", detail_view_path: "recette" };
}
setConfig(config) {
this._config = config || {};
- this._view = "list";
this._recipes = null;
- this._selected = null;
this._loading = false;
this._error = null;
@@ -51,22 +47,14 @@ class CookCliCard extends HTMLElement {
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" })
- );
+ const msg = { 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 || [];
} catch (err) {
this._error = (err && err.message) || "Erreur inconnue";
@@ -76,36 +64,22 @@ class CookCliCard extends HTMLElement {
}
}
- 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();
- }
+ _navigateToRecipe(path) {
+ const detailPath = this._config.detail_view_path || "recette";
+ // Racine du dashboard courant, ex: "/lovelace-cookcli" depuis
+ // "/lovelace-cookcli/recettes" — on y accroche la vue de détail.
+ const dashboardRoot = window.location.pathname.split("/").slice(0, 2).join("/");
+ const url = `${dashboardRoot}/${detailPath}?path=${encodeURIComponent(path)}`;
+
+ // Navigation interne HA, sans recharger la page (comme un tap_action navigate).
+ window.history.pushState(null, "", url);
+ window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } }));
}
_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);
+ this._navigateToRecipe(itemEl.dataset.path);
}
}
@@ -115,37 +89,6 @@ class CookCliCard extends HTMLElement {
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 `${this._escape(item.name)}${
- qty ? ` (${this._escape(qty)})` : ""
- }`;
- }
- case "cookware":
- return `${this._escape(item.name)}`;
- case "timer": {
- const duration = item.duration ?? "";
- const unit = item.unit ?? "";
- return `⏱ ${this._escape(String(duration))}${
- unit ? " " + this._escape(unit) : ""
- }`;
- }
- default:
- return "";
- }
- }
-
_renderList() {
if (this._loading) {
return `
Chargement…
`;
@@ -179,73 +122,13 @@ class CookCliCard extends HTMLElement {
return `${rows}
`;
}
- _renderDetail() {
- if (this._loading) {
- return `Chargement…
`;
- }
- if (this._error) {
- return `
-
- ${this._escape(this._error)}
`;
- }
- if (!this._selected) return "";
-
- const r = this._selected;
-
- const ingredients = (r.ingredients || [])
- .map((i) => {
- const qty = this._formatQuantity(i.quantity);
- return `${qty ? `${this._escape(qty)} ` : ""}${this._escape(
- i.name
- )}`;
- })
- .join("");
-
- const cookware = (r.cookware || [])
- .map((c) => `${this._escape(c.name)}`)
- .join("");
-
- const sections = (r.sections || [])
- .map((section) => {
- const steps = (section.steps || [])
- .map(
- (step) =>
- `${(step.items || [])
- .map((item) => this._renderStepItem(item))
- .join("")}`
- )
- .join("");
- return `
- ${section.name ? `${this._escape(section.name)}
` : ""}
- ${steps}
`;
- })
- .join("");
-
- return `
-
- ${
- r.image_url
- ? `
`
- : ""
- }
- ${this._escape(r.title || "")}
- ${cookware ? `Ustensiles
` : ""}
- Ingrédients
-
- Préparation
- ${sections}
- `;
- }
-
_render() {
if (!this.shadowRoot) return;
const title = this._config.title || "Recettes";
this.shadowRoot.innerHTML = `
-
-
- ${this._view === "list" ? this._renderList() : this._renderDetail()}
-
+
+ ${this._renderList()}
`;
}
@@ -277,22 +160,6 @@ class CookCliCard extends HTMLElement {
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; }
`;
}
}
@@ -303,5 +170,5 @@ 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.",
+ description: "Liste navigable de tes recettes CookCLI.",
});
diff --git a/www/cookcli-recipe-strategy.js b/www/cookcli-recipe-strategy.js
new file mode 100644
index 0000000..175d42a
--- /dev/null
+++ b/www/cookcli-recipe-strategy.js
@@ -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 += `\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);
diff --git a/www/simple-cookcli-card.js b/www/simple-cookcli-card.js
new file mode 100644
index 0000000..2bffef5
--- /dev/null
+++ b/www/simple-cookcli-card.js
@@ -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 `${this._escape(item.name)}${
+ qty ? ` (${this._escape(qty)})` : ""
+ }`;
+ }
+ case "cookware":
+ return `${this._escape(item.name)}`;
+ case "timer": {
+ const duration = item.duration ?? "";
+ const unit = item.unit ?? "";
+ return `⏱ ${this._escape(String(duration))}${
+ unit ? " " + this._escape(unit) : ""
+ }`;
+ }
+ default:
+ return "";
+ }
+ }
+
+ _renderList() {
+ if (this._loading) {
+ return `Chargement…
`;
+ }
+ if (this._error) {
+ return `${this._escape(this._error)}
`;
+ }
+ if (!this._recipes || this._recipes.length === 0) {
+ return `Aucune recette trouvée.
`;
+ }
+
+ const rows = this._recipes
+ .map((r) => {
+ const meta = [];
+ if (r.time) meta.push(`⏱ ${this._escape(r.time)}`);
+ if (r.servings) meta.push(`🍽 ${this._escape(String(r.servings))}`);
+ const thumb = r.image_url
+ ? `
`
+ : ``;
+ return `
+
+ ${thumb}
+
+
${this._escape(r.name)}
+
${meta.join("")}
+
+
`;
+ })
+ .join("");
+
+ return `${rows}
`;
+ }
+
+ _renderDetail() {
+ if (this._loading) {
+ return `Chargement…
`;
+ }
+ if (this._error) {
+ return `
+
+ ${this._escape(this._error)}
`;
+ }
+ if (!this._selected) return "";
+
+ const r = this._selected;
+
+ const ingredients = (r.ingredients || [])
+ .map((i) => {
+ const qty = this._formatQuantity(i.quantity);
+ return `${qty ? `${this._escape(qty)} ` : ""}${this._escape(
+ i.name
+ )}`;
+ })
+ .join("");
+
+ const cookware = (r.cookware || [])
+ .map((c) => `${this._escape(c.name)}`)
+ .join("");
+
+ const sections = (r.sections || [])
+ .map((section) => {
+ const steps = (section.steps || [])
+ .map(
+ (step) =>
+ `${(step.items || [])
+ .map((item) => this._renderStepItem(item))
+ .join("")}`
+ )
+ .join("");
+ return `
+ ${section.name ? `${this._escape(section.name)}
` : ""}
+ ${steps}
`;
+ })
+ .join("");
+
+ return `
+
+ ${
+ r.image_url
+ ? `
`
+ : ""
+ }
+ ${this._escape(r.title || "")}
+ ${cookware ? `Ustensiles
` : ""}
+ Ingrédients
+
+ Préparation
+ ${sections}
+ `;
+ }
+
+ _render() {
+ if (!this.shadowRoot) return;
+ const title = this._config.title || "Recettes";
+ this.shadowRoot.innerHTML = `
+
+
+
+ ${this._view === "list" ? this._renderList() : this._renderDetail()}
+
+
+ `;
+ }
+
+ _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.",
+});