Compare commits

...

4 commits

Author SHA1 Message Date
TheThomaas eebe1bbd7e Add images 2026-09-13 21:40:47 +02:00
TheThomaas aca61a9069 First version of custom card 2026-09-13 21:26:38 +02:00
TheThomaas a7e4da5523 Update websocket exposed items 2026-09-13 21:26:23 +02:00
TheThomaas 53fc14dbef Update api.py 2026-09-13 21:04:34 +02:00
5 changed files with 660 additions and 57 deletions

View file

@ -12,6 +12,7 @@ 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
_LOGGER = logging.getLogger(__name__)

View file

@ -1,24 +1,60 @@
"""Client API pour le serveur CookCLI.
Endpoints CookCLI utilisés :
- GET /api/recipes -> liste/arborescence des recettes
- GET /api/recipes -> arborescence complète des recettes
- GET /api/recipes/{path} -> détail d'une recette (ingredients, steps, metadata)
Le endpoint de liste n'a pas pu être vérifié à 100% depuis les sources au
moment de l'écriture de ce client : le format exact (liste plate vs
arborescence de dossiers) doit être confirmé avec un `curl` réel contre le
serveur, ex :
Format réel confirmé pour GET /api/recipes (arborescence récursive) :
curl http://<ip-addon>:9081/api/recipes | jq
{
"name": "recipes", "path": "/config/recipes", "recipe": null,
"children": {
"Chocolat chaud": {
"name": "Chocolat chaud",
"path": "/config/recipes/chocolat-chaud.cook",
"recipe": {"metadata": {...}, "source": {...}},
"children": {}
},
"example": {
"name": "example", "path": "/config/recipes/example", "recipe": null,
"children": { ... sous-dossiers/recettes imbriqués ... }
}
}
}
`_normalize_recipe_list` ci-dessous encaisse les deux formes les plus
probables ; ajuste-le si le format réel diffère.
Un nœud est une recette si sa clé "recipe" n'est pas null ; sinon c'est un
dossier à parcourir récursivement via "children". `_normalize_recipe_list`
aplatit cette arborescence en une liste de `RecipeSummary`, avec un `path`
relatif au dossier racine (ex: "chocolat-chaud.cook",
"example/sauces/Hollandaise.cook") pour correspondre à ce qu'attend
GET /api/recipes/{path}.
Hypothèse à vérifier : ce path relatif inclut l'extension ".cook", ce qui
correspond au path filesystem brut renvoyé par le serveur. Si
GET /api/recipes/{path} attend un format différent (sans extension, slugifié,
etc.), ajuste `async_get_recipe` en conséquence.
Format réel confirmé pour GET /api/recipes/{path} (détail d'une recette) :
la réponse encapsule un objet `recipe` du parser cooklang-rs, où les listes
`ingredients`/`cookware`/`timers` sont à plat, et les étapes
(`recipe.sections[].content[].value.items[]`) référencent ces objets **par
index** plutôt que de les inclure directement (ex: `{"type": "ingredient",
"index": 0}`). `async_get_recipe` résout ces références côté intégration
avant de renvoyer les données, pour éviter que la carte Lovelace (ou tout
autre consommateur) ait à recroiser les index elle-même.
Les valeurs numériques suivent aussi un format imbriqué
(`{"type": "number", "value": {"type": "regular", "value": 1.0}}`). Seul le
sous-type "regular" a été observé en pratique ; les sous-types "fraction" ou
similaires (non confirmés) sont retournés bruts par `_resolve_value` en cas
de format inconnu, plutôt que de faire planter la résolution.
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
@ -63,65 +99,249 @@ class CookCliApiClient:
async def async_list_recipes(self) -> list[RecipeSummary]:
"""Récupère la liste des recettes disponibles."""
data = await self._request("GET", "/recipes")
data = await self._request_json("/recipes")
return self._normalize_recipe_list(data)
async def async_get_recipe(self, path: str) -> dict[str, Any]:
"""Récupère le détail complet d'une recette (ingredients + steps)."""
return await self._request("GET", f"/recipes/{path}")
"""Récupère le détail d'une recette, avec étapes déjà résolues.
async def _request(self, method: str, endpoint: str) -> Any:
La réponse brute de CookCLI référence les ingrédients/ustensiles/
minuteurs par index dans les étapes ; ce qui est renvoyé ici a déjà
remplacé ces index par les objets correspondants (voir
`_resolve_recipe`).
"""
data = await self._request_json(f"/recipes/{path}")
return _resolve_recipe(data)
async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]:
"""Récupère les octets d'une image statique CookCLI.
`image_ref` peut prendre trois formes selon d'où il vient :
- "/api/static/img/x.jpg" : valeur brute du champ "image" du détail
d'une recette (chemin déjà préfixé par /api).
- "/img/x.jpg" ou "img/x.jpg" : valeur brute de metadata.image dans
l'arborescence de /api/recipes (chemin relatif, avec ou sans "/"
initial selon comment la recette a été écrite).
Ces trois formes ont été observées dans des réponses réelles du
serveur (pas juste supposées) ; `_image_endpoint` les normalise
toutes vers /static/... — à vérifier visuellement une fois la carte
rechargée, au cas où une recette aurait un format différent.
"""
endpoint = self._image_endpoint(image_ref)
async with self._get(endpoint) as resp:
content_type = resp.headers.get("Content-Type")
data = await resp.read()
return data, content_type
@staticmethod
def _image_endpoint(image_ref: str) -> str:
if image_ref.startswith("/api/"):
return image_ref[len("/api") :]
if image_ref.startswith("/"):
return f"/static{image_ref}"
return f"/static/{image_ref}"
@asynccontextmanager
async def _get(self, endpoint: str):
"""Contexte partagé pour un GET vers CookCLI, avec gestion d'erreurs."""
url = f"{self._base_url}{endpoint}"
try:
async with self._session.request(
method, url, timeout=DEFAULT_TIMEOUT
"GET", url, timeout=DEFAULT_TIMEOUT
) as resp:
if resp.status >= 400:
raise CookCliResponseError(
f"CookCLI a répondu {resp.status} pour {url}"
)
try:
return await resp.json()
except (aiohttp.ContentTypeError, ValueError) as err:
raise CookCliResponseError(
f"Réponse non-JSON de CookCLI pour {url}"
) from err
yield resp
except asyncio.TimeoutError as err:
raise CookCliConnectionError(f"Timeout en contactant {url}") from err
except aiohttp.ClientError as err:
raise CookCliConnectionError(f"Impossible de joindre {url}: {err}") from err
async def _request_json(self, endpoint: str) -> Any:
async with self._get(endpoint) as resp:
try:
return await resp.json()
except (aiohttp.ContentTypeError, ValueError) as err:
raise CookCliResponseError(
f"Réponse non-JSON de CookCLI pour {endpoint}"
) from err
@staticmethod
def _normalize_recipe_list(data: Any) -> list[RecipeSummary]:
"""Normalise la réponse de listing en une liste plate de RecipeSummary.
Encaisse plusieurs formes possibles :
- une liste de chemins/objets directement
- un dict avec une clé "recipes" ou "index"
- une arborescence imbriquée par dossier
"""
items: list[Any]
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("recipes") or data.get("index") or []
else:
items = []
"""Aplatit l'arborescence renvoyée par GET /api/recipes en liste plate."""
if not isinstance(data, dict):
_LOGGER.warning(
"Format de /api/recipes inattendu (attendu un objet, reçu %s)",
type(data),
)
return []
root_path = data.get("path", "")
summaries: list[RecipeSummary] = []
for item in items:
if isinstance(item, str):
summaries.append(RecipeSummary(path=item, name=_name_from_path(item)))
elif isinstance(item, dict):
path = item.get("path") or item.get("slug") or item.get("name", "")
name = item.get("name") or _name_from_path(path)
summaries.append(RecipeSummary(path=path, name=name, raw=item))
else:
_LOGGER.debug("Entrée de recette ignorée (format inattendu): %s", item)
CookCliApiClient._walk_recipe_tree(data, root_path, summaries)
return summaries
@staticmethod
def _walk_recipe_tree(
node: dict[str, Any], root_path: str, out: list[RecipeSummary]
) -> None:
"""Parcourt récursivement un nœud de l'arborescence de recettes."""
recipe = node.get("recipe")
if recipe is not None:
abs_path = node.get("path", "")
rel_path = abs_path
if root_path and abs_path.startswith(root_path):
rel_path = abs_path[len(root_path) :].lstrip("/")
name = node.get("name") or _name_from_path(rel_path)
out.append(RecipeSummary(path=rel_path, name=name, raw=node))
for child in (node.get("children") or {}).values():
CookCliApiClient._walk_recipe_tree(child, root_path, out)
def _name_from_path(path: str) -> str:
"""Dérive un nom lisible depuis un chemin de fichier .cook."""
return path.rsplit("/", 1)[-1].removesuffix(".cook")
# ---------------------------------------------------------------------------
# Résolution du détail d'une recette : remplace les références par index
# (ingredient/cookware/timer) par les objets correspondants, et aplatit les
# valeurs numériques imbriquées du parser cooklang-rs.
# ---------------------------------------------------------------------------
def _resolve_number(number_obj: Any) -> Any:
"""Résout un objet 'number' cooklang-rs en valeur simple.
Seul le sous-type "regular" (nombre décimal simple) a été observé en
pratique. Les autres sous-types (fractions, valeurs mixtes...) ne sont
pas confirmés : on renvoie l'objet brut plutôt que de deviner un format.
"""
if not isinstance(number_obj, dict):
return number_obj
if number_obj.get("type") == "regular":
return number_obj.get("value")
_LOGGER.debug("Sous-type de nombre non géré, valeur brute conservée: %s", number_obj)
return number_obj
def _resolve_value(value_obj: Any) -> Any:
"""Résout un objet 'value' générique (nombre, texte...) en valeur simple."""
if not isinstance(value_obj, dict):
return value_obj
value_type = value_obj.get("type")
inner = value_obj.get("value")
if value_type == "number":
return _resolve_number(inner)
if value_type == "text":
return inner
_LOGGER.debug("Type de valeur non géré, valeur brute conservée: %s", value_obj)
return inner if inner is not None else value_obj
def _resolve_quantity(quantity_obj: Any) -> dict[str, Any] | None:
"""Résout une quantité (ingredient/cookware/timer) en {value, unit}."""
if not isinstance(quantity_obj, dict):
return None
return {
"value": _resolve_value(quantity_obj.get("value")),
"unit": quantity_obj.get("unit"),
}
def _resolve_ingredient(ingredient: dict[str, Any]) -> dict[str, Any]:
return {
"name": ingredient.get("name"),
"quantity": _resolve_quantity(ingredient.get("quantity")),
"note": ingredient.get("note"),
}
def _resolve_cookware(cookware: dict[str, Any]) -> dict[str, Any]:
return {
"name": cookware.get("name"),
"quantity": _resolve_quantity(cookware.get("quantity")),
"note": cookware.get("note"),
}
def _resolve_timer(timer: dict[str, Any]) -> dict[str, Any]:
quantity = timer.get("quantity") or {}
return {
"name": timer.get("name"),
"duration": _resolve_value(quantity.get("value")),
"unit": quantity.get("unit"),
}
def _resolve_step_item(
item: dict[str, Any],
ingredients: list[dict[str, Any]],
cookware: list[dict[str, Any]],
timers: list[dict[str, Any]],
) -> dict[str, Any]:
"""Remplace un item d'étape référencé par index par l'objet résolu."""
item_type = item.get("type")
if item_type == "text":
return {"type": "text", "value": item.get("value", "")}
lookup = {"ingredient": ingredients, "cookware": cookware, "timer": timers}
if item_type in lookup:
index = item.get("index")
objects = lookup[item_type]
resolved = objects[index] if index is not None and 0 <= index < len(objects) else {}
if not resolved:
_LOGGER.debug(
"Index %s introuvable pour un item de type %s", index, item_type
)
return {"type": item_type, **resolved}
_LOGGER.debug("Type d'item d'étape non géré, conservé brut: %s", item_type)
return {"type": item_type, "raw": item}
def _resolve_recipe(data: dict[str, Any]) -> dict[str, Any]:
"""Transforme la réponse brute de GET /api/recipes/{path} en structure
directement exploitable (titre, ingrédients, étapes avec items résolus).
"""
recipe = data.get("recipe") or {}
metadata = (recipe.get("metadata") or {}).get("map") or {}
ingredients = [_resolve_ingredient(i) for i in recipe.get("ingredients", [])]
cookware = [_resolve_cookware(c) for c in recipe.get("cookware", [])]
timers = [_resolve_timer(t) for t in recipe.get("timers", [])]
sections: list[dict[str, Any]] = []
for section in recipe.get("sections", []):
steps: list[dict[str, Any]] = []
for content_item in section.get("content", []):
if content_item.get("type") != "step":
_LOGGER.debug(
"Type de contenu de section ignoré: %s", content_item.get("type")
)
continue
step_value = content_item.get("value") or {}
items = [
_resolve_step_item(it, ingredients, cookware, timers)
for it in step_value.get("items", [])
]
steps.append({"number": step_value.get("number"), "items": items})
sections.append({"name": section.get("name"), "steps": steps})
return {
"title": metadata.get("title"),
"image": data.get("image"),
"scale": data.get("scale"),
"metadata": metadata,
"ingredients": ingredients,
"cookware": cookware,
"sections": sections,
}

View file

@ -0,0 +1,52 @@
"""Proxy HTTP pour les images statiques de CookCLI.
Le navigateur ne peut pas atteindre le serveur CookCLI directement (ni le
port ingress 9080, restreint à l'IP du Supervisor par nginx, ni le port
direct 9081, non publié sur le LAN). Seul le backend Home Assistant, sur le
même réseau Docker interne que l'add-on, peut le faire. Cette vue relaie
donc les octets de l'image en passant par ce backend.
"""
from __future__ import annotations
import logging
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from .api import CookCliApiError
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class CookCliImageView(HomeAssistantView):
"""GET /api/cookcli/image/{entry_id}/{image_ref} -> octets de l'image."""
url = "/api/cookcli/image/{entry_id}/{image_ref:.*}"
name = "api:cookcli:image"
requires_auth = True
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
async def get(
self, request: web.Request, entry_id: str, image_ref: str
) -> web.Response:
entry_data = self.hass.data.get(DOMAIN, {}).get(entry_id)
if entry_data is None:
return web.Response(status=404, text="Intégration CookCLI introuvable")
client = entry_data["client"]
try:
data, content_type = await client.async_get_image(image_ref)
except CookCliApiError as err:
_LOGGER.debug("Échec de récupération de l'image %s: %s", image_ref, err)
return web.Response(status=502, text="Impossible de récupérer l'image")
return web.Response(
body=data,
content_type=content_type or "application/octet-stream",
headers={"Cache-Control": "max-age=3600"},
)

View file

@ -6,10 +6,17 @@ Deux commandes :
Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser
lequel interroger ; sinon, le premier configuré est utilisé.
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) —
le frontend n'a pas besoin de connaître le format brut des chemins d'image
renvoyés par CookCLI.
"""
from __future__ import annotations
from urllib.parse import quote
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
@ -18,13 +25,22 @@ from .api import CookCliApiError
from .const import DOMAIN
def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | None:
def _resolve_entry(
hass: HomeAssistant, entry_id: str | None
) -> tuple[str | None, dict | None]:
entries = hass.data.get(DOMAIN, {})
if entry_id is not None:
return entries.get(entry_id)
return entry_id, entries.get(entry_id)
if entries:
return next(iter(entries.values()))
first_id, first_data = next(iter(entries.items()))
return first_id, first_data
return None, None
def _image_url(entry_id: str, image_ref: str | None) -> str | None:
if not image_ref:
return None
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
@websocket_api.websocket_command(
@ -37,20 +53,26 @@ def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | Non
async def ws_list_recipes(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
entry_data = _resolve_entry_data(hass, msg.get("entry_id"))
entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id"))
if entry_data is None:
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
return
coordinator = entry_data["coordinator"]
connection.send_result(
msg["id"],
recipes = []
for r in coordinator.data or []:
metadata = ((r.raw or {}).get("recipe") or {}).get("metadata") or {}
recipes.append(
{
"recipes": [
{"path": r.path, "name": r.name} for r in coordinator.data or []
]
},
"path": r.path,
"name": r.name,
"time": metadata.get("time"),
"servings": metadata.get("servings"),
"tags": metadata.get("tags", []),
"image_url": _image_url(entry_id, metadata.get("image")),
}
)
connection.send_result(msg["id"], {"recipes": recipes})
@websocket_api.websocket_command(
@ -64,7 +86,7 @@ async def ws_list_recipes(
async def ws_get_recipe(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
entry_data = _resolve_entry_data(hass, msg.get("entry_id"))
entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id"))
if entry_data is None:
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
return
@ -76,6 +98,7 @@ async def ws_get_recipe(
connection.send_error(msg["id"], "cannot_connect", str(err))
return
recipe["image_url"] = _image_url(entry_id, recipe.get("image"))
connection.send_result(msg["id"], recipe)

307
www/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/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>`);
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("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.",
});