From d9831b0a149668007c2b381989ebfcfec9093436 Mon Sep 17 00:00:00 2001 From: TheThomaas Date: Mon, 14 Sep 2026 08:51:31 +0200 Subject: [PATCH] Add custom view strategy --- custom_components/cookcli/__init__.py | 8 +- custom_components/cookcli/const.py | 2 +- custom_components/cookcli/todo.py | 72 +++++ custom_components/cookcli/websocket_api.py | 26 ++ www/cookcli-card.js | 183 ++---------- www/cookcli-recipe-strategy.js | 215 +++++++++++++++ www/simple-cookcli-card.js | 307 +++++++++++++++++++++ 7 files changed, 651 insertions(+), 162 deletions(-) create mode 100644 custom_components/cookcli/todo.py create mode 100644 www/cookcli-recipe-strategy.js create mode 100644 www/simple-cookcli-card.js 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 += `![](${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); 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.", +});