Add custom view strategy
This commit is contained in:
parent
5f30dc00a7
commit
d9831b0a14
|
|
@ -11,8 +11,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from .api import CookCliApiClient, CookCliApiError
|
from .api import CookCliApiClient, CookCliApiError
|
||||||
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
|
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
|
||||||
from .coordinator import CookCliCoordinator
|
from .coordinator import CookCliCoordinator
|
||||||
from .websocket_api import async_setup_websocket_api
|
|
||||||
from .image_proxy import CookCliImageView
|
from .image_proxy import CookCliImageView
|
||||||
|
from .websocket_api import async_setup_websocket_api
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -38,10 +38,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"coordinator": coordinator,
|
"coordinator": coordinator,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Les commandes websocket sont globales au domaine, on ne les enregistre
|
# Les commandes websocket et la vue de proxy d'images sont globales au
|
||||||
# qu'une seule fois même si plusieurs serveurs CookCLI sont configurés.
|
# domaine, on ne les enregistre qu'une seule fois même si plusieurs
|
||||||
|
# serveurs CookCLI sont configurés.
|
||||||
if len(hass.data[DOMAIN]) == 1:
|
if len(hass.data[DOMAIN]) == 1:
|
||||||
async_setup_websocket_api(hass)
|
async_setup_websocket_api(hass)
|
||||||
|
hass.http.register_view(CookCliImageView(hass))
|
||||||
|
|
||||||
if PLATFORMS:
|
if PLATFORMS:
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,4 @@ HASSIO_DISCOVERY_SLUG = "cookcli"
|
||||||
# donc pas besoin de poller souvent.
|
# donc pas besoin de poller souvent.
|
||||||
UPDATE_INTERVAL_MINUTES = 15
|
UPDATE_INTERVAL_MINUTES = 15
|
||||||
|
|
||||||
PLATFORMS: list[str] = []
|
PLATFORMS: list[str] = ["todo"]
|
||||||
|
|
|
||||||
72
custom_components/cookcli/todo.py
Normal file
72
custom_components/cookcli/todo.py
Normal 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()
|
||||||
|
|
@ -11,6 +11,11 @@ Les deux commandes ajoutent un champ "image_url" déjà prêt à l'emploi pour
|
||||||
un <img src="...">, qui pointe vers CookCliImageView (voir image_proxy.py) —
|
un <img src="...">, qui pointe vers CookCliImageView (voir image_proxy.py) —
|
||||||
le frontend n'a pas besoin de connaître le format brut des chemins d'image
|
le frontend n'a pas besoin de connaître le format brut des chemins d'image
|
||||||
renvoyés par CookCLI.
|
renvoyés par CookCLI.
|
||||||
|
|
||||||
|
cookcli/recipe repeuple aussi l'entité todo d'ingrédients (todo.py) avec les
|
||||||
|
ingrédients de la recette ouverte, et renvoie son entity_id dans
|
||||||
|
"todo_entity_id" pour que le frontend puisse l'afficher avec la carte
|
||||||
|
native `todo-list`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -43,6 +48,21 @@ def _image_url(entry_id: str, image_ref: str | None) -> str | None:
|
||||||
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
|
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ingredient_summaries(ingredients: list[dict]) -> list[str]:
|
||||||
|
"""Formate chaque ingrédient en une ligne lisible pour la checklist."""
|
||||||
|
summaries = []
|
||||||
|
for ingredient in ingredients:
|
||||||
|
quantity = ingredient.get("quantity") or {}
|
||||||
|
value = quantity.get("value")
|
||||||
|
unit = quantity.get("unit")
|
||||||
|
prefix = " ".join(
|
||||||
|
str(part) for part in (value, unit) if part not in (None, "")
|
||||||
|
)
|
||||||
|
name = ingredient.get("name", "")
|
||||||
|
summaries.append(f"{prefix} {name}".strip() if prefix else name)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "cookcli/recipes",
|
vol.Required("type"): "cookcli/recipes",
|
||||||
|
|
@ -99,6 +119,12 @@ async def ws_get_recipe(
|
||||||
return
|
return
|
||||||
|
|
||||||
recipe["image_url"] = _image_url(entry_id, recipe.get("image"))
|
recipe["image_url"] = _image_url(entry_id, recipe.get("image"))
|
||||||
|
|
||||||
|
todo_entity = entry_data.get("todo_entity")
|
||||||
|
if todo_entity is not None:
|
||||||
|
todo_entity.set_ingredients(_ingredient_summaries(recipe.get("ingredients", [])))
|
||||||
|
recipe["todo_entity_id"] = todo_entity.entity_id
|
||||||
|
|
||||||
connection.send_result(msg["id"], recipe)
|
connection.send_result(msg["id"], recipe)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* Carte Lovelace pour l'intégration CookCLI.
|
* Carte Lovelace pour l'intégration CookCLI — liste navigable des recettes.
|
||||||
*
|
*
|
||||||
* Consomme les commandes websocket exposées par l'intégration :
|
* Le détail d'une recette n'est plus géré par cette carte : un clic sur une
|
||||||
* - cookcli/recipes -> liste (path, name, time, servings, tags)
|
* ligne navigue vers une vue générée par cookcli-recipe-strategy.js (voir ce
|
||||||
* - cookcli/recipe -> détail (title, ingredients, cookware, sections)
|
* fichier), qui affiche étapes/ingrédients/minuteur via des cartes natives
|
||||||
|
* et des cartes tierces (tabdeck-card, simple-timer-card).
|
||||||
*
|
*
|
||||||
* Limitation connue : les images de recettes ne sont pas affichées. Le
|
* Consomme cookcli/recipes (path, name, time, servings, tags, image_url).
|
||||||
* serveur CookCLI n'est joignable depuis le navigateur ni sur son port
|
|
||||||
* ingress (restreint au Supervisor) ni sur son port direct (non exposé au
|
|
||||||
* LAN) — il faudrait un endpoint de proxy côté intégration pour les servir.
|
|
||||||
* Voir le README du projet pour plus de détails si tu veux l'ajouter.
|
|
||||||
*
|
*
|
||||||
* Installation :
|
* Installation :
|
||||||
* 1. Copie ce fichier dans config/www/cookcli-card.js
|
* 1. Copie ce fichier dans config/www/cookcli-card.js
|
||||||
|
|
@ -18,18 +15,17 @@
|
||||||
* 3. Ajoute une carte manuelle sur un tableau de bord :
|
* 3. Ajoute une carte manuelle sur un tableau de bord :
|
||||||
* type: custom:cookcli-card
|
* type: custom:cookcli-card
|
||||||
* title: Mes recettes
|
* title: Mes recettes
|
||||||
|
* detail_view_path: recette # doit correspondre au "path" de la vue de détail
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class CookCliCard extends HTMLElement {
|
class CookCliCard extends HTMLElement {
|
||||||
static getStubConfig() {
|
static getStubConfig() {
|
||||||
return { title: "Recettes" };
|
return { title: "Recettes", detail_view_path: "recette" };
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig(config) {
|
setConfig(config) {
|
||||||
this._config = config || {};
|
this._config = config || {};
|
||||||
this._view = "list";
|
|
||||||
this._recipes = null;
|
this._recipes = null;
|
||||||
this._selected = null;
|
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
|
|
||||||
|
|
@ -51,22 +47,14 @@ class CookCliCard extends HTMLElement {
|
||||||
return 6;
|
return 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
_wsMessage(extra) {
|
|
||||||
const msg = { ...extra };
|
|
||||||
if (this._config.entry_id) {
|
|
||||||
msg.entry_id = this._config.entry_id;
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _fetchRecipes() {
|
async _fetchRecipes() {
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._render();
|
this._render();
|
||||||
try {
|
try {
|
||||||
const result = await this._hass.connection.sendMessagePromise(
|
const msg = { type: "cookcli/recipes" };
|
||||||
this._wsMessage({ type: "cookcli/recipes" })
|
if (this._config.entry_id) msg.entry_id = this._config.entry_id;
|
||||||
);
|
const result = await this._hass.connection.sendMessagePromise(msg);
|
||||||
this._recipes = result.recipes || [];
|
this._recipes = result.recipes || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._error = (err && err.message) || "Erreur inconnue";
|
this._error = (err && err.message) || "Erreur inconnue";
|
||||||
|
|
@ -76,36 +64,22 @@ class CookCliCard extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _openRecipe(path) {
|
_navigateToRecipe(path) {
|
||||||
this._loading = true;
|
const detailPath = this._config.detail_view_path || "recette";
|
||||||
this._error = null;
|
// Racine du dashboard courant, ex: "/lovelace-cookcli" depuis
|
||||||
this._render();
|
// "/lovelace-cookcli/recettes" — on y accroche la vue de détail.
|
||||||
try {
|
const dashboardRoot = window.location.pathname.split("/").slice(0, 2).join("/");
|
||||||
const result = await this._hass.connection.sendMessagePromise(
|
const url = `${dashboardRoot}/${detailPath}?path=${encodeURIComponent(path)}`;
|
||||||
this._wsMessage({ type: "cookcli/recipe", path })
|
|
||||||
);
|
// Navigation interne HA, sans recharger la page (comme un tap_action navigate).
|
||||||
this._selected = result;
|
window.history.pushState(null, "", url);
|
||||||
this._view = "detail";
|
window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } }));
|
||||||
} catch (err) {
|
|
||||||
this._error = (err && err.message) || "Erreur inconnue";
|
|
||||||
} finally {
|
|
||||||
this._loading = false;
|
|
||||||
this._render();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onClick(event) {
|
_onClick(event) {
|
||||||
const backEl = event.target.closest("[data-action='back']");
|
|
||||||
if (backEl) {
|
|
||||||
this._view = "list";
|
|
||||||
this._selected = null;
|
|
||||||
this._error = null;
|
|
||||||
this._render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const itemEl = event.target.closest("[data-path]");
|
const itemEl = event.target.closest("[data-path]");
|
||||||
if (itemEl) {
|
if (itemEl) {
|
||||||
this._openRecipe(itemEl.dataset.path);
|
this._navigateToRecipe(itemEl.dataset.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,37 +89,6 @@ class CookCliCard extends HTMLElement {
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
_formatQuantity(quantity) {
|
|
||||||
if (!quantity) return "";
|
|
||||||
const value = quantity.value ?? "";
|
|
||||||
const unit = quantity.unit ?? "";
|
|
||||||
return `${value} ${unit}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderStepItem(item) {
|
|
||||||
switch (item.type) {
|
|
||||||
case "text":
|
|
||||||
return this._escape(item.value);
|
|
||||||
case "ingredient": {
|
|
||||||
const qty = this._formatQuantity(item.quantity);
|
|
||||||
return `<span class="ingredient">${this._escape(item.name)}${
|
|
||||||
qty ? ` (${this._escape(qty)})` : ""
|
|
||||||
}</span>`;
|
|
||||||
}
|
|
||||||
case "cookware":
|
|
||||||
return `<span class="cookware">${this._escape(item.name)}</span>`;
|
|
||||||
case "timer": {
|
|
||||||
const duration = item.duration ?? "";
|
|
||||||
const unit = item.unit ?? "";
|
|
||||||
return `<span class="timer">⏱ ${this._escape(String(duration))}${
|
|
||||||
unit ? " " + this._escape(unit) : ""
|
|
||||||
}</span>`;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderList() {
|
_renderList() {
|
||||||
if (this._loading) {
|
if (this._loading) {
|
||||||
return `<div class="state-msg">Chargement…</div>`;
|
return `<div class="state-msg">Chargement…</div>`;
|
||||||
|
|
@ -179,73 +122,13 @@ class CookCliCard extends HTMLElement {
|
||||||
return `<div class="recipe-list">${rows}</div>`;
|
return `<div class="recipe-list">${rows}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderDetail() {
|
|
||||||
if (this._loading) {
|
|
||||||
return `<div class="state-msg">Chargement…</div>`;
|
|
||||||
}
|
|
||||||
if (this._error) {
|
|
||||||
return `
|
|
||||||
<button class="back-btn" data-action="back">← Retour à la liste</button>
|
|
||||||
<div class="state-msg error">${this._escape(this._error)}</div>`;
|
|
||||||
}
|
|
||||||
if (!this._selected) return "";
|
|
||||||
|
|
||||||
const r = this._selected;
|
|
||||||
|
|
||||||
const ingredients = (r.ingredients || [])
|
|
||||||
.map((i) => {
|
|
||||||
const qty = this._formatQuantity(i.quantity);
|
|
||||||
return `<li>${qty ? `<strong>${this._escape(qty)}</strong> ` : ""}${this._escape(
|
|
||||||
i.name
|
|
||||||
)}</li>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const cookware = (r.cookware || [])
|
|
||||||
.map((c) => `<li>${this._escape(c.name)}</li>`)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const sections = (r.sections || [])
|
|
||||||
.map((section) => {
|
|
||||||
const steps = (section.steps || [])
|
|
||||||
.map(
|
|
||||||
(step) =>
|
|
||||||
`<li>${(step.items || [])
|
|
||||||
.map((item) => this._renderStepItem(item))
|
|
||||||
.join("")}</li>`
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
return `
|
|
||||||
${section.name ? `<h4>${this._escape(section.name)}</h4>` : ""}
|
|
||||||
<ol class="steps">${steps}</ol>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return `
|
|
||||||
<button class="back-btn" data-action="back">← Retour à la liste</button>
|
|
||||||
${
|
|
||||||
r.image_url
|
|
||||||
? `<img class="recipe-hero" src="${this._escape(r.image_url)}" alt="" onerror="this.style.display='none'">`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
<h2>${this._escape(r.title || "")}</h2>
|
|
||||||
${cookware ? `<h4>Ustensiles</h4><ul class="cookware-list">${cookware}</ul>` : ""}
|
|
||||||
<h4>Ingrédients</h4>
|
|
||||||
<ul class="ingredients">${ingredients}</ul>
|
|
||||||
<h4>Préparation</h4>
|
|
||||||
${sections}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_render() {
|
_render() {
|
||||||
if (!this.shadowRoot) return;
|
if (!this.shadowRoot) return;
|
||||||
const title = this._config.title || "Recettes";
|
const title = this._config.title || "Recettes";
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>${this._styles()}</style>
|
<style>${this._styles()}</style>
|
||||||
<ha-card header="${this._view === "list" ? this._escape(title) : ""}">
|
<ha-card header="${this._escape(title)}">
|
||||||
<div class="card-content">
|
<div class="card-content">${this._renderList()}</div>
|
||||||
${this._view === "list" ? this._renderList() : this._renderDetail()}
|
|
||||||
</div>
|
|
||||||
</ha-card>
|
</ha-card>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -277,22 +160,6 @@ class CookCliCard extends HTMLElement {
|
||||||
display: flex; gap: 12px; font-size: 0.85em;
|
display: flex; gap: 12px; font-size: 0.85em;
|
||||||
color: var(--secondary-text-color); white-space: nowrap;
|
color: var(--secondary-text-color); white-space: nowrap;
|
||||||
}
|
}
|
||||||
.recipe-hero {
|
|
||||||
width: 100%; max-height: 220px; object-fit: cover;
|
|
||||||
border-radius: 8px; margin: 4px 0 8px; display: block;
|
|
||||||
}
|
|
||||||
.back-btn {
|
|
||||||
background: none; border: none; color: var(--primary-color);
|
|
||||||
font-size: 0.95em; cursor: pointer; padding: 12px 0 4px; display: block;
|
|
||||||
}
|
|
||||||
h2 { margin: 4px 0 8px; color: var(--primary-text-color); }
|
|
||||||
h4 { margin: 16px 0 4px; color: var(--primary-text-color); }
|
|
||||||
ul, ol { margin: 4px 0; padding-left: 20px; color: var(--primary-text-color); }
|
|
||||||
ul.ingredients li, ul.cookware-list li { margin: 2px 0; }
|
|
||||||
ol.steps li { margin: 8px 0; line-height: 1.5; }
|
|
||||||
.ingredient { color: var(--primary-color); font-weight: 500; }
|
|
||||||
.cookware { font-style: italic; color: var(--secondary-text-color); }
|
|
||||||
.timer { color: var(--warning-color, #ff9800); font-weight: 500; }
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -303,5 +170,5 @@ window.customCards = window.customCards || [];
|
||||||
window.customCards.push({
|
window.customCards.push({
|
||||||
type: "cookcli-card",
|
type: "cookcli-card",
|
||||||
name: "CookCLI Recipes",
|
name: "CookCLI Recipes",
|
||||||
description: "Liste navigable de tes recettes CookCLI, avec ingrédients et étapes.",
|
description: "Liste navigable de tes recettes CookCLI.",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
215
www/cookcli-recipe-strategy.js
Normal file
215
www/cookcli-recipe-strategy.js
Normal 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 += `\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
307
www/simple-cookcli-card.js
Normal 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.",
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue