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 .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)

View file

@ -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"]

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) —
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)

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 :
* - 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 `<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>`;
@ -179,73 +122,13 @@ class CookCliCard extends HTMLElement {
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 header="${this._escape(title)}">
<div class="card-content">${this._renderList()}</div>
</ha-card>
`;
}
@ -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.",
});

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.",
});