Compare commits

...

48 commits
0.0.6 ... main

Author SHA1 Message Date
TheThomaas 9c8f791988 Update README.md 2026-09-25 21:19:10 +02:00
TheThomaas e94d47a22f Update cookcli-recipe-strategy.js 2026-09-25 21:10:13 +02:00
TheThomaas d90deca759 Remove todo list integration 2026-09-25 21:02:43 +02:00
TheThomaas bcdb062d1f Remove todo list handling 2026-09-25 20:59:11 +02:00
TheThomaas 25fe50be87 Update cookcli-recipe-strategy.js 2026-09-25 17:38:32 +02:00
TheThomaas d5b177a5e8 Update cookcli-recipe-strategy.js 2026-09-25 17:33:23 +02:00
TheThomaas f883bf361a Update cookcli-recipe-strategy.js 2026-09-25 17:30:20 +02:00
TheThomaas 3bbf66238d Update cookcli-recipe-strategy.js 2026-09-25 17:17:05 +02:00
TheThomaas 8e2b8ec632 Update cookcli-recipe-strategy.js 2026-09-25 17:09:26 +02:00
TheThomaas 206e64490e Update cookcli-recipe-strategy.js 2026-09-25 17:05:41 +02:00
TheThomaas abc0c966cb Remove previous changes 2026-09-25 17:00:30 +02:00
TheThomaas 127129dcf5 Add timer card 2026-09-25 16:56:31 +02:00
TheThomaas ac035b3a97 Update cookcli-recipe-strategy.js 2026-09-25 16:43:48 +02:00
TheThomaas 0d292ee14d Add step name inside tab 2026-09-25 16:37:33 +02:00
TheThomaas bcfb62ccc4 Update cookcli-recipe-strategy.js 2026-09-24 18:48:07 +02:00
TheThomaas 5f89d7c166 Update cookcli-recipe-strategy.js 2026-09-24 18:43:24 +02:00
TheThomaas 3275e33d5d Update cookcli-recipe-strategy.js 2026-09-24 18:40:20 +02:00
TheThomaas 276e0db7f4 Update cookcli-recipe-strategy.js 2026-09-24 18:37:16 +02:00
TheThomaas e2b2b210f6 Update cookcli-recipe-strategy.js 2026-09-24 18:23:54 +02:00
TheThomaas 67bf82b8ad Update cookcli-recipe-strategy.js 2026-09-24 18:18:40 +02:00
TheThomaas a9466ebdd5 Update cookcli-recipe-strategy.js 2026-09-24 16:51:25 +02:00
TheThomaas 5a57884fc7 Update cookcli-recipe-strategy.js 2026-09-24 16:47:51 +02:00
TheThomaas 058a2bdbb3 Update cookcli-recipe-strategy.js 2026-09-24 16:44:30 +02:00
TheThomaas 4c51d60259 Update cookcli-recipe-strategy.js 2026-09-24 16:35:56 +02:00
TheThomaas 692d4747ca Update cookcli-recipe-strategy.js 2026-09-24 16:32:40 +02:00
TheThomaas 0e7de813aa Update cookcli-recipe-strategy.js 2026-09-24 16:29:59 +02:00
TheThomaas a1ecac5cb8 Update cookcli-recipe-strategy.js 2026-09-24 16:24:27 +02:00
TheThomaas 3534fbcccf Update cookcli-card.js 2026-09-24 16:24:25 +02:00
TheThomaas 9e33d3c7df Update cookcli-recipe-strategy.js 2026-09-24 15:09:50 +02:00
TheThomaas a2aabbf8a9 Add custom checklist card 2026-09-24 15:01:58 +02:00
TheThomaas faf9eed3c1 Update cookcli-recipe-strategy.js 2026-09-24 14:52:28 +02:00
TheThomaas c0e9d3aff6 Update cookcli-recipe-strategy.js 2026-09-24 14:48:12 +02:00
TheThomaas 530f66338c Update cookcli-recipe-strategy.js 2026-09-24 14:41:29 +02:00
TheThomaas a1df031f07 Update cookcli-recipe-strategy.js 2026-09-24 14:38:37 +02:00
TheThomaas 1ed188f590 Update cookcli-recipe-strategy.js 2026-09-24 14:32:54 +02:00
TheThomaas 64a58d01ae Update cookcli-recipe-strategy.js 2026-09-24 14:29:29 +02:00
TheThomaas d9bea6f6fb Update cookcli-recipe-strategy.js 2026-09-24 14:23:49 +02:00
TheThomaas 70190a8f0c Update cookcli-recipe-strategy.js 2026-09-24 14:17:23 +02:00
TheThomaas 12500b9fa0 Update cookcli-recipe-strategy.js 2026-09-24 14:13:01 +02:00
TheThomaas 6d6a5c11b6 Update cookcli-recipe-strategy.js 2026-09-24 14:02:14 +02:00
TheThomaas 30504a3ded Update cookcli-recipe-strategy.js 2026-09-24 13:51:14 +02:00
TheThomaas df8cffe74c Update cookcli-recipe-strategy.js 2026-09-24 13:43:16 +02:00
TheThomaas cdf11290a0 Update cookcli-recipe-strategy.js 2026-09-24 13:36:41 +02:00
TheThomaas 038bc3126b Update cookcli-recipe-strategy.js 2026-09-24 13:33:01 +02:00
TheThomaas 67ee41cb24 Update cookcli-recipe-strategy.js 2026-09-24 13:04:46 +02:00
TheThomaas f876730ead Add service to reload recipes 2026-09-20 18:49:38 +02:00
TheThomaas 7e3b4674c4 Update cookcli-recipe-strategy.js 2026-09-20 10:54:01 +02:00
TheThomaas 6668acd5fb Update cookcli-card.js 2026-09-20 10:51:11 +02:00
12 changed files with 786 additions and 188 deletions

View file

@ -11,16 +11,6 @@ Cette intégration fonctionne main dans la main avec une **stratégie de tableau
- 🥕 **Vue détaillée** de chaque recette : ingrédients et étapes de préparation - 🥕 **Vue détaillée** de chaque recette : ingrédients et étapes de préparation
- 🧭 **Carte de navigation** : liste toutes les recettes et permet de naviguer vers la page de détail - 🧭 **Carte de navigation** : liste toutes les recettes et permet de naviguer vers la page de détail
## 🧩 Architecture
Le projet est composé de **trois dépôts** qui travaillent ensemble :
| Composant | Rôle | Dépôt |
|---|---|---|
| **Intégration** | Se connecte au serveur CookCLI et expose les recettes à Home Assistant | [ha-cookcli](https://git.thethomaas.net/TheThomaas/ha-cookcli) |
| **Stratégie de tableau de bord** | Crée automatiquement une page par recette (ingrédients + étapes) | [ha-cookcli-strategy](https://git.thethomaas.net/TheThomaas/ha-cookcli-strategy) |
| **Carte personnalisée** | Liste toutes les recettes et permet de naviguer vers le détail d'une recette | [ha-cookcli-card](https://git.thethomaas.net/TheThomaas/ha-cookcli-card) |
## 📦 Installation ## 📦 Installation
### Prérequis ### Prérequis
@ -47,13 +37,6 @@ Le projet est composé de **trois dépôts** qui travaillent ensemble :
2. Copiez le dossier `custom_components/cookcli` dans le répertoire `config/custom_components/` de votre instance Home Assistant. 2. Copiez le dossier `custom_components/cookcli` dans le répertoire `config/custom_components/` de votre instance Home Assistant.
3. **Redémarrez Home Assistant.** 3. **Redémarrez Home Assistant.**
### Installation de la carte et de la stratégie
Répétez l'opération via HACS (catégorie **Lovelace** / **Dashboard**) pour :
- [ha-cookcli-card](https://git.thethomaas.net/TheThomaas/ha-cookcli-card) — la carte
- [ha-cookcli-strategy](https://git.thethomaas.net/TheThomaas/ha-cookcli-strategy) — la stratégie de tableau de bord
## ⚙️ Configuration ## ⚙️ Configuration
1. Dans Home Assistant, allez dans **Paramètres** → **Appareils et services**. 1. Dans Home Assistant, allez dans **Paramètres** → **Appareils et services**.
@ -70,6 +53,14 @@ Répétez l'opération via HACS (catégorie **Lovelace** / **Dashboard**) pour :
Ajoutez la carte `cookcli-card` à un tableau de bord pour afficher la liste de toutes vos recettes. Cliquer sur une recette vous amène vers sa page de détail. Ajoutez la carte `cookcli-card` à un tableau de bord pour afficher la liste de toutes vos recettes. Cliquer sur une recette vous amène vers sa page de détail.
Options disponibles :
- **title** : Titre de la carte. Par défaut : « Recettes ».
- **tag** : N'afficher que les recettes portant ce tag. Vide = toutes les recettes.
- **show_tag_filter** : Affiche des pills cliquables en haut de la liste pour filtrer par tag.
- **dashboard_path** : Seulement si la carte n'est pas sur le dashboard qui contient les vues recettes.
- **entry_id** : Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.
Exemple (YAML) : Exemple (YAML) :
```yaml ```yaml
@ -77,8 +68,6 @@ type: custom:cookcli-card
title: Mes recettes title: Mes recettes
``` ```
> Adaptez la configuration selon les options exposées par la carte. Consultez le [README de ha-cookcli-card](https://git.thethomaas.net/TheThomaas/ha-cookcli-card) pour la liste complète des options.
### Stratégie de tableau de bord ### Stratégie de tableau de bord
La stratégie `cookcli-recipe-strategy` génère automatiquement un tableau de bord avec **une page par recette**. Chaque page affiche : La stratégie `cookcli-recipe-strategy` génère automatiquement un tableau de bord avec **une page par recette**. Chaque page affiche :
@ -86,6 +75,13 @@ La stratégie `cookcli-recipe-strategy` génère automatiquement un tableau de b
- la liste des **ingrédients**, - la liste des **ingrédients**,
- les **étapes** de préparation. - les **étapes** de préparation.
Options disponibles :
- **title** : Titre de la liste et du dashboard. Par défaut : « Recettes ».
- **timer_entity** : Aide « Minuteur » partagée entre toutes les recettes (ex. `timer.recette_en_cours`). Vide = pas de boutons de minuteur.
- **step_entity** : Aide « Nombre » qui pilote l'onglet actif (ex. `input_number.recette_etape`). Vide = pas de boutons Commencer / Précédent / Suivant.
- **entry_id** : Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.
Pour l'utiliser, créez un nouveau tableau de bord et sélectionnez la stratégie CookCLI, ou référencez-la dans votre configuration de tableau de bord. Pour l'utiliser, créez un nouveau tableau de bord et sélectionnez la stratégie CookCLI, ou référencez-la dans votre configuration de tableau de bord.
## 📄 Licence ## 📄 Licence
@ -94,9 +90,6 @@ Ce projet est distribué sous licence présente dans le fichier [LICENSE](./LICE
## 🔗 Liens ## 🔗 Liens
- **Intégration** : https://git.thethomaas.net/TheThomaas/ha-cookcli
- **Stratégie** : https://git.thethomaas.net/TheThomaas/ha-cookcli-strategy
- **Carte** : https://git.thethomaas.net/TheThomaas/ha-cookcli-card
- **CookCLI** : https://github.com/cooklang/CookCLI - **CookCLI** : https://github.com/cooklang/CookCLI
- **Cooklang** : https://cooklang.org/ - **Cooklang** : https://cooklang.org/

View file

@ -4,15 +4,18 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.components.http import StaticPathConfig from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
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
# from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
from .coordinator import CookCliCoordinator from .coordinator import CookCliCoordinator
from .image_proxy import CookCliImageView from .image_proxy import CookCliImageView
from .websocket_api import async_setup_websocket_api from .websocket_api import async_setup_websocket_api
@ -65,8 +68,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
), ),
]) ])
if PLATFORMS: # if PLATFORMS:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload(entry.add_update_listener(_async_update_listener))
@ -106,14 +109,43 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
hass.bus.async_listen_once( hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, _register_card_resources EVENT_HOMEASSISTANT_STARTED, _register_card_resources
) )
async def _handle_reload(call: ServiceCall) -> None:
"""cookcli.reload : resynchronise l'add-on puis rafraîchit la liste."""
entries = hass.data.get(DOMAIN, {})
requested = call.data.get("entry_id")
if requested is not None:
if requested not in entries:
raise ServiceValidationError(f"Serveur CookCLI inconnu : {requested}")
targets = {requested: entries[requested]}
else:
targets = dict(entries)
for entry_id, data in targets.items():
try:
await data["client"].async_reload()
except CookCliApiError as err:
raise HomeAssistantError(f"Reload CookCLI impossible : {err}") from err
await data["coordinator"].async_refresh()
# Les cartes ouvertes écoutent cet événement pour recharger leur liste.
hass.bus.async_fire(f"{DOMAIN}_reloaded", {"entry_id": entry_id})
hass.services.async_register(
DOMAIN,
"reload",
_handle_reload,
schema=vol.Schema({vol.Optional("entry_id"): str}),
)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Décharge une entrée de configuration CookCLI.""" """Décharge une entrée de configuration CookCLI."""
unload_ok = True unload_ok = True
if PLATFORMS: # if PLATFORMS:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) # unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)

View file

@ -58,12 +58,14 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from .const import RELOAD_PORT
import aiohttp import aiohttp
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=10) DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=10)
RELOAD_TIMEOUT = aiohttp.ClientTimeout(total=120)
class CookCliApiError(Exception): class CookCliApiError(Exception):
"""Erreur générique lors d'un appel à l'API CookCLI.""" """Erreur générique lors d'un appel à l'API CookCLI."""
@ -92,6 +94,7 @@ class CookCliApiClient:
def __init__(self, session: aiohttp.ClientSession, host: str, port: int) -> None: def __init__(self, session: aiohttp.ClientSession, host: str, port: int) -> None:
self._session = session self._session = session
self._base_url = f"http://{host}:{port}/api" self._base_url = f"http://{host}:{port}/api"
self._reload_url = f"http://{host}:{RELOAD_PORT}/reload"
async def async_test_connection(self) -> None: async def async_test_connection(self) -> None:
"""Vérifie que le serveur répond, utilisé par le config_flow.""" """Vérifie que le serveur répond, utilisé par le config_flow."""
@ -113,6 +116,10 @@ class CookCliApiClient:
data = await self._request_json(f"/recipes/{path}") data = await self._request_json(f"/recipes/{path}")
return _resolve_recipe(data) return _resolve_recipe(data)
async def async_reload(self) -> None:
async with self._get(self._reload_url, method="POST", timeout=RELOAD_TIMEOUT):
pass
async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]: async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]:
"""Récupère les octets d'une image statique CookCLI. """Récupère les octets d'une image statique CookCLI.
@ -143,13 +150,16 @@ class CookCliApiClient:
return f"/static/{image_ref}" return f"/static/{image_ref}"
@asynccontextmanager @asynccontextmanager
async def _get(self, endpoint: str): async def _get(
"""Contexte partagé pour un GET vers CookCLI, avec gestion d'erreurs.""" self,
url = f"{self._base_url}{endpoint}" endpoint: str,
method: str = "GET",
timeout: aiohttp.ClientTimeout = DEFAULT_TIMEOUT,
):
"""Contexte partagé pour un appel vers CookCLI, avec gestion d'erreurs."""
url = endpoint if endpoint.startswith("http") else f"{self._base_url}{endpoint}"
try: try:
async with self._session.request( async with self._session.request(method, url, timeout=timeout) as resp:
"GET", url, timeout=DEFAULT_TIMEOUT
) as resp:
if resp.status >= 400: if resp.status >= 400:
raise CookCliResponseError( raise CookCliResponseError(
f"CookCLI a répondu {resp.status} pour {url}" f"CookCLI a répondu {resp.status} pour {url}"

View file

@ -6,6 +6,7 @@ CONF_HOST = "host"
CONF_PORT = "port" CONF_PORT = "port"
DEFAULT_PORT = 9081 DEFAULT_PORT = 9081
RELOAD_PORT = 8098
# Nom du service tel que poussé par bashio::discovery.set côté add-on # Nom du service tel que poussé par bashio::discovery.set côté add-on
HASSIO_DISCOVERY_SLUG = "cookcli" HASSIO_DISCOVERY_SLUG = "cookcli"
@ -14,5 +15,3 @@ HASSIO_DISCOVERY_SLUG = "cookcli"
# Les fichiers .cook ne changent qu'après un sync git côté add-on, # Les fichiers .cook ne changent qu'après un sync git côté add-on,
# donc pas besoin de poller souvent. # donc pas besoin de poller souvent.
UPDATE_INTERVAL_MINUTES = 15 UPDATE_INTERVAL_MINUTES = 15
PLATFORMS: list[str] = ["todo"]

View file

@ -0,0 +1,7 @@
reload:
fields:
entry_id:
required: false
selector:
config_entry:
integration: cookcli

View file

@ -30,5 +30,17 @@
} }
} }
} }
},
"services": {
"reload": {
"name": "Recharger les recettes",
"description": "Resynchronise les recettes depuis le dépôt git de l'add-on.",
"fields": {
"entry_id": {
"name": "Serveur CookCLI",
"description": "Laisser vide pour recharger tous les serveurs."
}
}
}
} }
} }

View file

@ -1,72 +0,0 @@
"""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

@ -30,5 +30,17 @@
} }
} }
} }
},
"services": {
"reload": {
"name": "Reload recipes",
"description": "Sync recipes from git repository in the addon.",
"fields": {
"entry_id": {
"name": "CookCLI Server",
"description": "Leave empty to reload all servers."
}
}
}
} }
} }

View file

@ -30,5 +30,17 @@
} }
} }
} }
},
"services": {
"reload": {
"name": "Recharger les recettes",
"description": "Resynchronise les recettes depuis le dépôt git de l'add-on.",
"fields": {
"entry_id": {
"name": "Serveur CookCLI",
"description": "Laisser vide pour recharger tous les serveurs."
}
}
}
} }
} }

View file

@ -12,11 +12,6 @@ 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`.
cookcli/recipes inclut "view_path" par recette : le slug utilisé comme path cookcli/recipes inclut "view_path" par recette : le slug utilisé comme path
de vue HA, calculé une seule fois ici (voir _view_path) pour que de vue HA, calculé une seule fois ici (voir _view_path) pour que
cookcli-card.js et la dashboard strategy (cookcli-recipe-strategy.js) cookcli-card.js et la dashboard strategy (cookcli-recipe-strategy.js)
@ -143,11 +138,6 @@ async def ws_get_recipe(
recipe["image_url"] = _image_url(hass, entry_id, recipe.get("image")) recipe["image_url"] = _image_url(hass, 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)

View file

@ -43,10 +43,27 @@ class CookCliCard extends HTMLElement {
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this._lastFetch = 0; this._lastFetch = 0;
this._selectedTag = null;
this._reloading = false;
this._reloadError = null;
if (!this.shadowRoot) { if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this.shadowRoot.addEventListener("click", (event) => this._onClick(event)); this.shadowRoot.addEventListener("click", (event) => this._onClick(event));
this.shadowRoot.addEventListener(
"wheel",
(event) => {
const bar = event.target.closest(".tag-filter");
if (!bar || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
const max = bar.scrollWidth - bar.clientWidth;
const atStart = bar.scrollLeft <= 0 && event.deltaY < 0;
const atEnd = bar.scrollLeft >= max && event.deltaY > 0;
if (max <= 0 || atStart || atEnd) return; // laisse la page défiler
event.preventDefault();
bar.scrollLeft += event.deltaY;
},
{ passive: false }
);
} }
this._render(); this._render();
} }
@ -62,6 +79,7 @@ class CookCliCard extends HTMLElement {
set hass(hass) { set hass(hass) {
this._hass = hass; this._hass = hass;
this._subscribe();
if (this._recipes === null && !this._loading) { if (this._recipes === null && !this._loading) {
this._fetchRecipes(); this._fetchRecipes();
} }
@ -96,6 +114,9 @@ class CookCliCard extends HTMLElement {
const result = await this._hass.connection.sendMessagePromise(msg); const result = await this._hass.connection.sendMessagePromise(msg);
this._recipes = result.recipes || []; this._recipes = result.recipes || [];
this._lastFetch = Date.now(); this._lastFetch = Date.now();
if (this._selectedTag !== null && !this._allTags().includes(this._selectedTag)) {
this._selectedTag = null;
}
} catch (err) { } catch (err) {
this._error = (err && err.message) || "Erreur inconnue"; this._error = (err && err.message) || "Erreur inconnue";
} finally { } finally {
@ -118,6 +139,23 @@ class CookCliCard extends HTMLElement {
} }
_onClick(event) { _onClick(event) {
if (event.target.closest("[data-action='reload']")) {
this._reload();
return;
}
if (event.target.closest("[data-tag-clear]")) {
this._selectedTag = null;
this._render();
return;
}
const tagEl = event.target.closest("[data-tag]");
if (tagEl) {
const tag = tagEl.dataset.tag;
// Un second clic sur la pill active revient à « Toutes ».
this._selectedTag = this._selectedTag === tag ? null : tag;
this._render();
return;
}
const itemEl = event.target.closest("[data-view-path]"); const itemEl = event.target.closest("[data-view-path]");
if (itemEl) { if (itemEl) {
this._navigateToView(itemEl.dataset.viewPath); this._navigateToView(itemEl.dataset.viewPath);
@ -130,6 +168,113 @@ class CookCliCard extends HTMLElement {
return div.innerHTML; return div.innerHTML;
} }
// Les tags peuvent arriver sous forme de tableau ou de chaîne "a, b".
_tagsOf(recipe) {
const tags = recipe.tags;
if (Array.isArray(tags)) return tags.map((t) => String(t).trim()).filter(Boolean);
if (typeof tags === "string") {
return tags.split(",").map((t) => t.trim()).filter(Boolean);
}
return [];
}
_allTags() {
const tags = new Set();
for (const r of this._recipes || []) {
for (const t of this._tagsOf(r)) tags.add(t);
}
return [...tags].sort((a, b) => a.localeCompare(b));
}
_tagFilterEnabled() {
// Les pills n'ont pas de sens quand un tag est déjà imposé par la config.
return this._config.show_tag_filter !== false && !this._config.tag;
}
_filteredRecipes() {
const fixedTag = this._config.tag;
if (fixedTag) {
return this._recipes.filter((r) => this._tagsOf(r).includes(fixedTag));
}
if (!this._tagFilterEnabled() || this._selectedTag === null) return this._recipes;
return this._recipes.filter((r) => this._tagsOf(r).includes(this._selectedTag));
}
connectedCallback() {
this._subscribe();
}
disconnectedCallback() {
if (this._unsub) {
this._unsub();
this._unsub = null;
}
}
async _subscribe() {
if (this._unsub || this._subscribing || !this._hass || !this.isConnected) return;
this._subscribing = true;
try {
this._unsub = await this._hass.connection.subscribeEvents(
(ev) => this._onReloaded(ev),
"cookcli_reloaded"
);
} catch (err) {
// Pas d'abonnement : le bouton retombe sur un rafraîchissement manuel.
} finally {
this._subscribing = false;
}
// La carte a pu être retirée du DOM pendant l'attente.
if (!this.isConnected && this._unsub) {
this._unsub();
this._unsub = null;
}
}
_onReloaded(ev) {
const target = this._config.entry_id;
if (target && ev.data && ev.data.entry_id !== target) return;
this._fetchRecipes();
}
async _reload() {
if (this._reloading || !this._hass) return;
this._reloading = true;
this._reloadError = null;
this._render();
try {
const data = {};
if (this._config.entry_id) data.entry_id = this._config.entry_id;
await this._hass.callService("cookcli", "reload", data);
// Normalement l'événement a déjà déclenché le rechargement ;
// on ne le refait à la main que si l'abonnement a échoué.
if (!this._unsub) await this._fetchRecipes();
} catch (err) {
this._reloadError = (err && err.message) || "Échec du rechargement";
} finally {
this._reloading = false;
this._render();
}
}
_renderTagFilter() {
if (!this._tagFilterEnabled()) return "";
const tags = this._allTags();
if (tags.length === 0) return "";
const allActive = this._selectedTag === null;
const pills = tags
.map((t) => {
const active = this._selectedTag === t;
return `<button type="button" class="tag-pill${active ? " active" : ""}" data-tag="${this._escape(t)}" aria-pressed="${active}">${this._escape(t)}</button>`;
})
.join("");
return `
<div class="tag-filter">
<button type="button" class="tag-pill${allActive ? " active" : ""}" data-tag-clear aria-pressed="${allActive}">Toutes</button>
${pills}
</div>`;
}
_renderList() { _renderList() {
if (this._loading && !this._recipes) { if (this._loading && !this._recipes) {
return `<div class="state-msg">Chargement…</div>`; return `<div class="state-msg">Chargement…</div>`;
@ -141,7 +286,13 @@ class CookCliCard extends HTMLElement {
return `<div class="state-msg">Aucune recette trouvée.</div>`; return `<div class="state-msg">Aucune recette trouvée.</div>`;
} }
const cards = this._recipes const filtered = this._filteredRecipes();
const filterBar = this._renderTagFilter();
if (filtered.length === 0) {
return `${filterBar}<div class="state-msg">Aucune recette avec ce tag.</div>`;
}
const cards = filtered
.map((r) => { .map((r) => {
const meta = []; const meta = [];
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`); if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`);
@ -160,18 +311,38 @@ class CookCliCard extends HTMLElement {
}) })
.join(""); .join("");
return `<div class="recipe-grid">${cards}</div>`; return `${filterBar}<div class="recipe-grid">${cards}</div>`;
} }
_render() { _render() {
if (!this.shadowRoot) return; if (!this.shadowRoot) return;
const title = this._config.title || "Recettes"; const title = this._config.title || "Recettes";
const previous = this.shadowRoot.querySelector(".tag-filter");
const scrollLeft = previous ? previous.scrollLeft : 0;
const errorMsg = this._reloadError
? `<div class="state-msg error reload-error">${this._escape(this._reloadError)}</div>`
: "";
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style>${this._styles()}</style> <style>${this._styles()}</style>
<ha-card header="${this._escape(title)}"> <ha-card>
<div class="card-header">
<span class="card-title">${this._escape(title)}</span>
<ha-icon-button
class="reload-btn ${this._reloading ? "spinning" : ""}"
data-action="reload"
label="Recharger les recettes"
${this._reloading ? "disabled" : ""}
>
<ha-icon icon="mdi:refresh"></ha-icon>
</ha-icon-button>
</div>
${errorMsg}
<div class="card-content">${this._renderList()}</div> <div class="card-content">${this._renderList()}</div>
</ha-card> </ha-card>
`; `;
const next = this.shadowRoot.querySelector(".tag-filter");
if (next) next.scrollLeft = scrollLeft;
} }
_styles() { _styles() {
@ -184,7 +355,7 @@ class CookCliCard extends HTMLElement {
puis 2 / 3 / 4 colonnes selon la largeur disponible. */ puis 2 / 3 / 4 colonnes selon la largeur disponible. */
.recipe-grid { .recipe-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(225px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
gap: 12px; gap: 12px;
} }
@ -239,6 +410,56 @@ class CookCliCard extends HTMLElement {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 8px 8px 16px;
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, 24px);
line-height: 32px;
}
.reload-error { padding: 0 16px 8px; }
.reload-btn.spinning ha-icon { animation: cookcli-spin 1s linear infinite; }
@keyframes cookcli-spin { to { transform: rotate(360deg); } }
.tag-filter {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-bottom: 12px;
overflow-x: auto;
/* Marge de fin pour que la dernière pill sorte de la zone de fondu. */
padding-right: 24px;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
}
.tag-filter::-webkit-scrollbar {
display: none;
}
.tag-pill {
font: inherit;
font-size: 0.85em;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
flex-shrink: 0;
white-space: nowrap;
}
.tag-pill:hover {
border-color: var(--primary-color);
}
.tag-pill.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--text-primary-color, #fff);
}
`; `;
} }
} }
@ -251,16 +472,20 @@ class CookCliCard extends HTMLElement {
* lovelace/dashboards/list qui demande les droits admin). custom_value reste * lovelace/dashboards/list qui demande les droits admin). custom_value reste
* activé pour pouvoir saisir un chemin qui n'apparaît pas dans la liste. * activé pour pouvoir saisir un chemin qui n'apparaît pas dans la liste.
*/ */
const COOKCLI_CARD_EDITOR_FIELDS = ["title", "dashboard_path", "entry_id"]; const COOKCLI_CARD_EDITOR_FIELDS = ["title", "tag", "show_tag_filter", "dashboard_path", "entry_id"];
const COOKCLI_CARD_EDITOR_LABELS = { const COOKCLI_CARD_EDITOR_LABELS = {
title: "Titre", title: "Titre",
tag: "Tag unique",
show_tag_filter: "Filtre par tags",
dashboard_path: "Dashboard des recettes", dashboard_path: "Dashboard des recettes",
entry_id: "Serveur CookCLI", entry_id: "Serveur CookCLI",
}; };
const COOKCLI_CARD_EDITOR_HELPERS = { const COOKCLI_CARD_EDITOR_HELPERS = {
title: "Titre de la carte. Par défaut : « Recettes ».", title: "Titre de la carte. Par défaut : « Recettes ».",
tag: "N'afficher que les recettes portant ce tag. Vide = toutes les recettes.",
show_tag_filter: "Affiche des pills cliquables en haut de la liste pour filtrer par tag. Sans effet si un tag unique est choisi.",
dashboard_path: dashboard_path:
"Seulement si la carte n'est pas sur le dashboard qui contient les vues recettes. Vide = dashboard courant.", "Seulement si la carte n'est pas sur le dashboard qui contient les vues recettes. Vide = dashboard courant.",
entry_id: entry_id:
@ -274,15 +499,19 @@ class CookCliCardEditor extends HTMLElement {
this._hass = undefined; this._hass = undefined;
this._form = null; this._form = null;
this._optionsSignature = null; this._optionsSignature = null;
this._tagOptions = [];
this._tagsLoadedFor = null;
} }
setConfig(config) { setConfig(config) {
this._config = config || {}; this._config = config || {};
this._maybeLoadTags();
this._update(); this._update();
} }
set hass(hass) { set hass(hass) {
this._hass = hass; this._hass = hass;
this._maybeLoadTags();
this._update(); this._update();
} }
@ -302,12 +531,17 @@ class CookCliCardEditor extends HTMLElement {
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
} }
_buildSchema(options) { _buildSchema(dashboardOptions, tagOptions) {
return [ return [
{ name: "title", selector: { text: {} } }, { name: "title", selector: { text: {} } },
{
name: "tag",
selector: { select: { options: tagOptions, custom_value: true, mode: "dropdown" } },
},
{ name: "show_tag_filter", selector: { boolean: {} } },
{ {
name: "dashboard_path", name: "dashboard_path",
selector: { select: { options, custom_value: true, mode: "dropdown" } }, selector: { select: { options: dashboardOptions, custom_value: true, mode: "dropdown" } },
}, },
{ name: "entry_id", selector: { config_entry: { integration: "cookcli" } } }, { name: "entry_id", selector: { config_entry: { integration: "cookcli" } } },
]; ];
@ -317,8 +551,8 @@ class CookCliCardEditor extends HTMLElement {
if (this._form) return; if (this._form) return;
const form = document.createElement("ha-form"); const form = document.createElement("ha-form");
const options = this._dashboardOptions(); const options = this._dashboardOptions();
this._optionsSignature = JSON.stringify(options); this._optionsSignature = JSON.stringify([options, this._tagOptions]);
form.schema = this._buildSchema(options); form.schema = this._buildSchema(options, this._tagOptions);
form.computeLabel = (schema) => COOKCLI_CARD_EDITOR_LABELS[schema.name] || schema.name; form.computeLabel = (schema) => COOKCLI_CARD_EDITOR_LABELS[schema.name] || schema.name;
form.computeHelper = (schema) => COOKCLI_CARD_EDITOR_HELPERS[schema.name] || ""; form.computeHelper = (schema) => COOKCLI_CARD_EDITOR_HELPERS[schema.name] || "";
form.addEventListener("value-changed", (ev) => this._valueChanged(ev)); form.addEventListener("value-changed", (ev) => this._valueChanged(ev));
@ -326,24 +560,22 @@ class CookCliCardEditor extends HTMLElement {
this._form = form; this._form = form;
} }
// Formulaire créé une fois ; on ne met à jour que ses propriétés. Le schéma
// n'est reconstruit que si la liste des dashboards a changé (hass, lui,
// change en permanence).
_update() { _update() {
if (!this._form) return; if (!this._form) return;
if (this._hass) { if (this._hass) {
this._form.hass = this._hass; this._form.hass = this._hass;
const options = this._dashboardOptions(); }
const signature = JSON.stringify(options); const options = this._dashboardOptions();
if (signature !== this._optionsSignature) { const signature = JSON.stringify([options, this._tagOptions]);
this._optionsSignature = signature; if (signature !== this._optionsSignature) {
this._form.schema = this._buildSchema(options); this._optionsSignature = signature;
} this._form.schema = this._buildSchema(options, this._tagOptions);
} }
const data = {}; const data = {};
for (const name of COOKCLI_CARD_EDITOR_FIELDS) { for (const name of COOKCLI_CARD_EDITOR_FIELDS) {
if (this._config[name] !== undefined) data[name] = this._config[name]; if (this._config[name] !== undefined) data[name] = this._config[name];
} }
data.show_tag_filter = this._config.show_tag_filter !== false;
this._form.data = data; this._form.data = data;
} }
@ -356,6 +588,7 @@ class CookCliCardEditor extends HTMLElement {
for (const name of COOKCLI_CARD_EDITOR_FIELDS) { for (const name of COOKCLI_CARD_EDITOR_FIELDS) {
const v = value[name]; const v = value[name];
if (v === undefined || v === null || v === "") delete config[name]; if (v === undefined || v === null || v === "") delete config[name];
else if (name === "show_tag_filter" && v === true) delete config[name];
else config[name] = v; else config[name] = v;
} }
@ -368,10 +601,195 @@ class CookCliCardEditor extends HTMLElement {
}) })
); );
} }
// Récupère la liste des tags existants pour le sélecteur. Rechargée si le
// serveur (entry_id) change ; l'échec laisse simplement la liste vide, la
// saisie libre restant possible.
_maybeLoadTags() {
if (!this._hass) return;
const key = this._config.entry_id || "";
if (this._tagsLoadedFor === key) return;
this._tagsLoadedFor = key;
const msg = { type: "cookcli/recipes" };
if (key) msg.entry_id = key;
this._hass.connection
.sendMessagePromise(msg)
.then((result) => {
const tags = new Set();
for (const r of result.recipes || []) {
const raw = r.tags;
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
for (const t of list) {
const s = String(t).trim();
if (s) tags.add(s);
}
}
this._tagOptions = [...tags]
.sort((a, b) => a.localeCompare(b))
.map((t) => ({ value: t, label: t }));
})
.catch(() => {
this._tagOptions = [];
})
.finally(() => this._update());
}
}
/**
* Checklist d'ingrédients d'une étape (cases à cocher, état local).
*
* L'état coché est stocké dans localStorage du navigateur, par recette et par
* étape (`storage_key`) : il n'est pas partagé entre appareils, ni lié à la
* todo du Résumé. Config :
* type: custom:cookcli-checklist-card
* storage_key: chocolat-chaud.cook:2
* items: [{ quantity: "200 ml", name: "lait" }, ...]
* title: Ingrédients # optionnel
*/
class CookCliChecklistCard extends HTMLElement {
setConfig(config) {
if (!config || !Array.isArray(config.items)) {
throw new Error("La carte a besoin d'une liste `items`.");
}
this._config = config;
this._checked = this._load();
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" });
this.shadowRoot.addEventListener("change", (ev) => this._onChange(ev));
this.shadowRoot.addEventListener("click", (ev) => this._onClick(ev));
}
this._render();
}
getCardSize() {
return (this._config?.items?.length || 0) + 1;
}
_storageKey() {
return `cookcli-checklist:${this._config.storage_key || "default"}`;
}
// Identifiant d'une ligne : index + libellé, pour rester correct si deux
// lignes ont le même nom ou si la recette est modifiée.
_itemId(item, index) {
return `${index}:${item.quantity || ""} ${item.name || ""}`.trim();
}
_load() {
try {
const raw = localStorage.getItem(this._storageKey());
const parsed = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(parsed) ? parsed : []);
} catch (err) {
return new Set();
}
}
_save() {
try {
localStorage.setItem(this._storageKey(), JSON.stringify([...this._checked]));
} catch (err) {
// localStorage indisponible : l'état reste valable jusqu'au prochain rendu.
}
}
_onChange(ev) {
const input = ev.target.closest("input[data-id]");
if (!input) return;
if (input.checked) this._checked.add(input.dataset.id);
else this._checked.delete(input.dataset.id);
this._save();
this._render();
}
_onClick(ev) {
if (ev.target.closest("[data-reset]")) {
this._checked.clear();
this._save();
this._render();
}
}
_escape(str) {
const div = document.createElement("div");
div.textContent = str ?? "";
return div.innerHTML;
}
_render() {
if (!this.shadowRoot) return;
const items = this._config.items;
const anyChecked = items.some((item, i) => this._checked.has(this._itemId(item, i)));
const rows = items
.map((item, i) => {
const id = this._itemId(item, i);
const checked = this._checked.has(id);
return `
<label class="row ${checked ? "done" : ""}">
<input type="checkbox" data-id="${this._escape(id)}" ${checked ? "checked" : ""}>
<span class="label">
${item.quantity ? `<strong>${this._escape(item.quantity)}</strong> ` : ""}${this._escape(item.name)}
</span>
</label>`;
})
.join("");
this.shadowRoot.innerHTML = `
<style>
ha-card { padding: 8px 16px; }
.title {
padding: 8px 0 4px;
font-size: var(--ha-card-header-font-size, 24px);
font-weight: var(--ha-card-header-font-weight, 400);
line-height: 32px;
color: var(--ha-card-header-color, var(--primary-text-color));
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
cursor: pointer;
color: var(--primary-text-color);
}
.row input {
width: 20px;
height: 20px;
flex-shrink: 0;
accent-color: var(--primary-color);
cursor: pointer;
}
.row.done .label {
text-decoration: line-through;
color: var(--secondary-text-color);
}
.reset {
display: ${anyChecked ? "block" : "none"};
margin-left: auto;
padding: 4px 0 0;
background: none;
border: none;
font: inherit;
font-size: 0.85em;
color: var(--primary-color);
cursor: pointer;
}
</style>
<ha-card>
${this._config.title ? `<div class="title">${this._escape(this._config.title)}</div>` : ""}
${rows}
<button class="reset" data-reset>Tout décocher</button>
</ha-card>
`;
}
} }
customElements.define("cookcli-card", CookCliCard); customElements.define("cookcli-card", CookCliCard);
customElements.define("cookcli-card-editor", CookCliCardEditor); customElements.define("cookcli-card-editor", CookCliCardEditor);
customElements.define("cookcli-checklist-card", CookCliChecklistCard);
window.customCards = window.customCards || []; window.customCards = window.customCards || [];
window.customCards.push({ window.customCards.push({

View file

@ -34,7 +34,7 @@
* Cartes utilisées, toutes tierces ou natives — aucune carte custom pour le * Cartes utilisées, toutes tierces ou natives — aucune carte custom pour le
* rendu du contenu lui-même : * rendu du contenu lui-même :
* - markdown (native) : titre, image, ustensiles, texte des étapes * - markdown (native) : titre, image, ustensiles, texte des étapes
* - todo-list (native) : checklist des ingrédients (todo.py backend) * - custom:cookcli-checklist-card : checklist des ingrédients (Résumé et étapes), état local
* - button (native) : Commencer / Précédent / Suivant / Démarrer minuteur * - button (native) : Commencer / Précédent / Suivant / Démarrer minuteur
* - custom:circular-timer-card : affichage/contrôle du minuteur partagé * - custom:circular-timer-card : affichage/contrôle du minuteur partagé
* - custom:tabdeck-card : les onglets (Résumé + une par étape) * - custom:tabdeck-card : les onglets (Résumé + une par étape)
@ -114,6 +114,10 @@ class CookCliDashboardStrategy extends HTMLElement {
* d'une unique carte tabdeck-card (Résumé + une tab par étape). * d'une unique carte tabdeck-card (Résumé + une tab par étape).
*/ */
class CookCliRecipeViewStrategy extends HTMLElement { class CookCliRecipeViewStrategy extends HTMLElement {
// Largeur unique de tous les boutons de navigation (Commencer, Précédent,
// Suivant) : c'est elle qu'on ajuste pour les agrandir ou les réduire.
static NAV_BUTTON_WIDTH = "clamp(36px, 20vw, 65px)";
static async generate(config, hass) { static async generate(config, hass) {
config = config || {}; config = config || {};
const path = config.path; const path = config.path;
@ -198,17 +202,150 @@ class CookCliRecipeViewStrategy extends HTMLElement {
} }
} }
static _navButton(entity, targetIndex, label, icon) { static _navButton(entity, targetIndex, label, icon, accent = false) {
return { return {
type: "button", type: "button",
name: label, name: label,
icon, icon,
tap_action: { tap_action: {
action: "call-service", action: "perform-action",
service: "input_number.set_value", perform_action: "input_number.set_value",
target: { entity_id: entity }, target: { entity_id: entity },
data: { value: targetIndex }, data: { value: targetIndex },
}, },
card_mod: {
style: `
:host {
flex: 0 0 ${this.NAV_BUTTON_WIDTH} !important;
width: ${this.NAV_BUTTON_WIDTH} !important;
height: ${this.NAV_BUTTON_WIDTH} !important;
min-width: 0 !important;
}
ha-card {
border-radius: 999px;
--ha-card-border-width: 0;
box-shadow: none;
${accent
? `background: var(--primary-color);
--state-color: var(--text-primary-color, #fff) !important;
--paper-item-icon-color: var(--text-primary-color, #fff);`
: `background: var(--secondary-background-color);`}
}
ha-card > span {
border: 0 !important;
clip-path: inset(50%) !important;
height: 1px !important;
margin: -1px !important;
overflow: hidden !important;
padding: 0 !important;
width: 1px !important;
white-space: nowrap !important;
}
`,
},
};
}
/**
* Occupe exactement la même place qu'un bouton de navigation, en invisible
* et non cliquable : sert à garder "Précédent" à la même position sur la
* dernière étape que sur les autres, où il partage la barre avec "Suivant".
*/
static _navSpacer() {
return {
type: "button",
show_name: false,
show_icon: false,
card_mod: {
style: `
:host {
flex: 0 0 ${this.NAV_BUTTON_WIDTH} !important;
width: ${this.NAV_BUTTON_WIDTH} !important;
height: ${this.NAV_BUTTON_WIDTH} !important;
min-width: 0 !important;
visibility: hidden;
pointer-events: none;
}
`,
},
};
}
/**
* Barre de navigation flottante en bas de l'écran. Les boutons ont une
* largeur fixe (et non flex: 1) : un seul bouton fait la même taille que
* deux, et la barre s'adapte à son contenu (max-content).
*/
static _floatingNav(buttons) {
return {
type: "horizontal-stack",
cards: buttons,
card_mod: {
style: `
:host {
position: fixed;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
right: calc(16px + env(safe-area-inset-bottom, 0px));
width: max-content;
z-index: 5;
padding: 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--card-background-color) 80%, transparent);
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
height: auto !important;
}
#root {
justify-content: center;
}
@media (max-width: 869px) {
:host {
right: 0;
}
}
`,
},
};
}
/**
* Deux colonnes sur grand écran (gauche étroite, droite large), une seule
* colonne empilée sur mobile. Un horizontal-stack natif ne sait faire ni l'un
* ni l'autre : on surcharge son conteneur interne (#root) avec card-mod.
* 870px = le seuil "mobile" de Home Assistant.
*/
static _responsiveColumns(leftCard, rightCard, leftWidth = "30%") {
return {
type: "horizontal-stack",
cards: [leftCard, rightCard],
card_mod: {
style: `
#root {
flex-direction: column !important;
}
#root > * {
flex: 0 0 auto !important;
width: 100% !important;
min-width: 0;
}
@media (min-width: 870px) {
#root {
flex-direction: row !important;
align-items: flex-start;
}
#root > :first-child {
display: flex;
flex: 0 0 ${leftWidth} !important;
width: auto !important;
}
#root > :last-child {
display: flex;
flex: 1 1 0 !important;
width: auto !important;
}
}
`,
},
}; };
} }
@ -217,17 +354,17 @@ class CookCliRecipeViewStrategy extends HTMLElement {
//if (recipe.image_url) content += `![image de la recette](${recipe.image_url})\n\n`; //if (recipe.image_url) content += `![image de la recette](${recipe.image_url})\n\n`;
if (recipe.image_url) { if (recipe.image_url) {
const cleanImageUrl = recipe.image_url.replace(/([^:]\/)\/+/g, "\$1"); const cleanImageUrl = recipe.image_url.replace(/([^:]\/)\/+/g, "\$1");
content += `<img src='${cleanImageUrl}' alt='image de la recette 2' style='width: 100%;aspect-ratio: 16 / 10;object-fit: cover;border-radius: 12px;margin-bottom: 8px;' />\n\n`; content += `<img src='${cleanImageUrl}' alt='image de la recette' style='width: 100%;aspect-ratio: 16 / 10;object-fit: cover;border-radius: 12px;margin-bottom: 8px;' />\n\n`;
} }
content += `## ${recipe.title || ""}\n`; content += `## ${recipe.title || ""}\n`;
if (recipe.cookware && recipe.cookware.length) { if (recipe.cookware && recipe.cookware.length) {
content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`; content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`;
} }
const cards = [{ const leftCard = {
type: "markdown", type: "markdown",
content, content,
card_mod:` card_mod: `
style: style:
ha-markdown: ha-markdown:
$: | $: |
@ -237,82 +374,122 @@ class CookCliRecipeViewStrategy extends HTMLElement {
object-fit: cover; object-fit: cover;
border-radius: 12px; border-radius: 12px;
margin-bottom: 8px; margin-bottom: 8px;
}` }`,
}]; };
const rightCards = []; const rightCards = [];
if (recipe.todo_entity_id) { const ingredients = recipe.ingredients || [];
if (ingredients.length) {
rightCards.push({ rightCards.push({
type: "todo-list", type: "custom:cookcli-checklist-card",
entity: recipe.todo_entity_id,
title: "Ingrédients", title: "Ingrédients",
card_mod: { // État coché propre à cette recette, indépendant de celui des étapes.
style: ` storage_key: `${config.path}:summary`,
ha-list .header { items: ingredients.map((ingredient) => {
display: none; const quantity = ingredient.quantity || {};
} return {
` quantity: [quantity.value, quantity.unit]
} .filter((part) => part !== null && part !== undefined && part !== "")
.join(" "),
name: ingredient.name ?? "",
};
}),
}); });
} }
if (config.step_entity && stepCount > 0) { if (config.step_entity && stepCount > 0) {
rightCards.push(this._navButton(config.step_entity, 1, "Commencer", "mdi:play")); rightCards.push(
this._floatingNav([
this._navButton(config.step_entity, 1, "Commencer", "mdi:play", true),
])
);
} }
cards.push({ type: "vertical-stack", cards: rightCards }); const columns = this._responsiveColumns(
leftCard,
{ type: "vertical-stack", cards: rightCards },
"30%"
);
return { name: "Résumé", icon: "mdi:book-open-variant", card: { type: "horizontal-stack", cards } }; const tab = {
name: "Résumé",
icon: "mdi:book-open-variant",
card: {
type: "vertical-stack",
cards: [columns],
// Réserve la place de la barre flottante sous la checklist.
card_mod: { style: ":host { display: block; padding-bottom: 96px; }" },
},
};
if (config.step_entity) tab.auto_select = { entity: config.step_entity, state: "0.0" };
return tab;
} }
static _stepTab(section, step, tabIndex, isLast, config) { static _stepTab(section, step, tabIndex, isLast, config) {
const { markdown: stepMarkdown, timers } = this._renderStepMarkdown(step); const { markdown: stepMarkdown, timers } = this._renderStepMarkdown(step);
const stepLabel = section.name
? `${section.name} ${step.number ?? ""}`.trim()
: `Étape ${tabIndex}`;
const leftCards = []; const leftCards = [];
const rightCards = [];
rightCards.push({ type: "markdown", content: `## ${stepLabel}\n\n${stepMarkdown}` })
const stepIngredients = (step.items || []).filter((item) => item.type === "ingredient"); const stepIngredients = (step.items || []).filter((item) => item.type === "ingredient");
if (stepIngredients.length) { if (stepIngredients.length) {
const lines = stepIngredients.map((item) => { leftCards.push({
const qty = item.quantity ?? "" type: "custom:cookcli-checklist-card",
? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim() title: "Ingrédients",
: ""; // recette + étape : l'état coché est propre à chaque étape de chaque recette
return `- ${qty ? `**${qty}** ` : ""}${item.name ?? ""}`; storage_key: `${config.path}:${tabIndex}`,
items: stepIngredients.map((item) => ({
quantity: item.quantity
? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim()
: "",
name: item.name ?? "",
})),
}); });
leftCards.push({ type: "markdown", content: lines.join("\n") });
} }
if (config.timer_entity) { if (config.timer_entity && timers.length) {
const buttons = [];
for (const timer of timers) { for (const timer of timers) {
const seconds = this._parseDurationSeconds(timer.duration, timer.unit); const seconds = this._parseDurationSeconds(timer.duration, timer.unit);
if (!seconds) continue; if (!seconds) continue;
const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim(); const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim();
leftCards.push({ buttons.push({
type: "button", type: "button",
name: `Démarrer ${label}`, name: label,
icon: "mdi:timer-outline", icon: "mdi:timer-outline",
tap_action: { tap_action: {
action: "call-service", action: "perform-action",
service: "timer.start", perform_action: "timer.start",
target: { entity_id: config.timer_entity }, target: { entity_id: config.timer_entity },
data: { duration: this._secondsToHms(seconds) }, data: { duration: this._secondsToHms(seconds) },
}, },
}); });
leftCards.push({ type: "custom:circular-timer-card", entity: config.timer_entity }); }
if (buttons.length) {
leftCards.push({ type: "custom:circular-timer-card", entity: config.timer_entity, name: " " });
rightCards.push({
type: "grid",
square: false,
cards: [...buttons, { type: "markdown", content: " ", text_only: true }, { type: "markdown", content: " ", text_only: true }],
});
} }
} }
if (!leftCards.length) { if (!leftCards.length) {
// horizontal-stack veut deux cartes ; une carte vide maintient le ratio. leftCards.push({ type: "markdown", content: " ", text_only: true });
leftCards.push({ type: "markdown", content: " " });
} }
const columns = { const columns = this._responsiveColumns(
type: "horizontal-stack", { type: "vertical-stack", cards: leftCards },
cards: [ { type: "vertical-stack", cards: rightCards },
{ type: "vertical-stack", cards: leftCards }, );
{ type: "markdown", content: stepMarkdown },
],
};
const cards = [columns]; const cards = [columns];
@ -322,16 +499,24 @@ class CookCliRecipeViewStrategy extends HTMLElement {
]; ];
if (!isLast) { if (!isLast) {
navButtons.push( navButtons.push(
this._navButton(config.step_entity, tabIndex + 1, "Suivant", "mdi:arrow-right") this._navButton(config.step_entity, tabIndex + 1, "Suivant", "mdi:arrow-right", true)
); );
} else {
navButtons.push(this._navSpacer());
} }
cards.push({ type: "horizontal-stack", cards: navButtons }); cards.push(this._floatingNav(navButtons));
} }
return { const tab = {
name: section.name ? `${section.name} ${step.number ?? ""}`.trim() : `Étape ${tabIndex}`, name: stepLabel,
card: { type: "vertical-stack", cards }, card: {
type: "vertical-stack",
cards,
card_mod: { style: ":host { display: block; padding-bottom: 96px; }" },
},
}; };
if (config.step_entity) tab.auto_select = { entity: config.step_entity, state: `${tabIndex}.0` };
return tab;
} }
static _renderStepMarkdown(step) { static _renderStepMarkdown(step) {