Compare commits
54 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c8f791988 | ||
|
|
e94d47a22f | ||
|
|
d90deca759 | ||
|
|
bcdb062d1f | ||
|
|
25fe50be87 | ||
|
|
d5b177a5e8 | ||
|
|
f883bf361a | ||
|
|
3bbf66238d | ||
|
|
8e2b8ec632 | ||
|
|
206e64490e | ||
|
|
abc0c966cb | ||
|
|
127129dcf5 | ||
|
|
ac035b3a97 | ||
|
|
0d292ee14d | ||
|
|
bcfb62ccc4 | ||
|
|
5f89d7c166 | ||
|
|
3275e33d5d | ||
|
|
276e0db7f4 | ||
|
|
e2b2b210f6 | ||
|
|
67bf82b8ad | ||
|
|
a9466ebdd5 | ||
|
|
5a57884fc7 | ||
|
|
058a2bdbb3 | ||
|
|
4c51d60259 | ||
|
|
692d4747ca | ||
|
|
0e7de813aa | ||
|
|
a1ecac5cb8 | ||
|
|
3534fbcccf | ||
|
|
9e33d3c7df | ||
|
|
a2aabbf8a9 | ||
|
|
faf9eed3c1 | ||
|
|
c0e9d3aff6 | ||
|
|
530f66338c | ||
|
|
a1df031f07 | ||
|
|
1ed188f590 | ||
|
|
64a58d01ae | ||
|
|
d9bea6f6fb | ||
|
|
70190a8f0c | ||
|
|
12500b9fa0 | ||
|
|
6d6a5c11b6 | ||
|
|
30504a3ded | ||
|
|
df8cffe74c | ||
|
|
cdf11290a0 | ||
|
|
038bc3126b | ||
|
|
67ee41cb24 | ||
|
|
f876730ead | ||
|
|
7e3b4674c4 | ||
|
|
6668acd5fb | ||
|
|
af7d27c160 | ||
|
|
b06d43b883 | ||
|
|
5c63f62bd6 | ||
|
|
3cbd094edd | ||
|
|
75000a01cf | ||
|
|
a084c7abdb |
37
README.md
37
README.md
|
|
@ -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/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,19 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
|
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 .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
|
||||||
|
|
@ -38,26 +44,108 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"coordinator": coordinator,
|
"coordinator": coordinator,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Les commandes websocket et la vue de proxy d'images sont globales au
|
# Les commandes websocket, la vue de proxy d'images et les chemins
|
||||||
# domaine, on ne les enregistre qu'une seule fois même si plusieurs
|
# statiques sont globaux au domaine : on ne les enregistre qu'une
|
||||||
# serveurs CookCLI sont configurés.
|
# 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))
|
hass.http.register_view(CookCliImageView(hass))
|
||||||
|
|
||||||
if PLATFORMS:
|
www_path = Path(__file__).parent / "www"
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
card_file = www_path / "cookcli-card.js"
|
||||||
|
strategy_file = www_path / "cookcli-recipe-strategy.js"
|
||||||
|
|
||||||
|
await hass.http.async_register_static_paths([
|
||||||
|
StaticPathConfig(
|
||||||
|
f"/api/{DOMAIN}/cookcli-card.js",
|
||||||
|
str(card_file),
|
||||||
|
cache_headers=False,
|
||||||
|
),
|
||||||
|
StaticPathConfig(
|
||||||
|
f"/api/{DOMAIN}/cookcli-recipe-strategy.js",
|
||||||
|
str(strategy_file),
|
||||||
|
cache_headers=False,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
# if 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))
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||||
|
"""Enregistrement global, appelé une seule fois au démarrage de HA."""
|
||||||
|
|
||||||
|
async def _register_card_resources(event=None):
|
||||||
|
"""Enregistre la carte et la stratégie comme ressources Lovelace."""
|
||||||
|
lovelace = hass.data.get("lovelace")
|
||||||
|
if not lovelace or not hasattr(lovelace, "resources"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Lovelace resources indisponibles, carte CookCLI non enregistrée"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
resources = lovelace.resources
|
||||||
|
base_url = f"/api/{DOMAIN}"
|
||||||
|
|
||||||
|
urls_to_register = [
|
||||||
|
f"{base_url}/cookcli-card.js",
|
||||||
|
f"{base_url}/cookcli-recipe-strategy.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
existing_urls = {r.get("url", "") for r in resources.async_items()}
|
||||||
|
|
||||||
|
for url in urls_to_register:
|
||||||
|
if url not in existing_urls:
|
||||||
|
await resources.async_create_item({
|
||||||
|
"url": url,
|
||||||
|
"res_type": "module",
|
||||||
|
})
|
||||||
|
_LOGGER.info("Ressource Lovelace CookCLI enregistrée: %s", url)
|
||||||
|
|
||||||
|
hass.bus.async_listen_once(
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -67,4 +155,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|
||||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Recharge l'entrée si ses options changent (host/port modifiés)."""
|
"""Recharge l'entrée si ses options changent (host/port modifiés)."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CookCliImageView(HomeAssistantView):
|
class CookCliImageView(HomeAssistantView):
|
||||||
"""GET /api/cookcli/image/{entry_id}/{image_ref} -> octets de l'image."""
|
"""GET /api/cookcli/image/{entry_id}/{image_ref}?authSig=... -> octets de l'image."""
|
||||||
|
|
||||||
url = "/api/cookcli/image/{entry_id}/{image_ref:.*}"
|
url = "/api/cookcli/image/{entry_id}{image_ref:.*}"
|
||||||
name = "api:cookcli:image"
|
name = "api:cookcli:image"
|
||||||
requires_auth = True
|
requires_auth = False
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
|
|
|
||||||
7
custom_components/cookcli/services.yaml
Normal file
7
custom_components/cookcli/services.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
reload:
|
||||||
|
fields:
|
||||||
|
entry_id:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: cookcli
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -25,11 +20,13 @@ naviguent vers le même identifiant sans dupliquer la logique de slug.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
import re
|
import re
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.components.http.auth import async_sign_path
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .api import CookCliApiError
|
from .api import CookCliApiError
|
||||||
|
|
@ -48,11 +45,11 @@ def _resolve_entry(
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def _image_url(entry_id: str, image_ref: str | None) -> str | None:
|
def _image_url(hass: HomeAssistant, entry_id: str, image_ref: str | None) -> str | None:
|
||||||
if not image_ref:
|
if not image_ref:
|
||||||
return None
|
return None
|
||||||
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
|
raw_path = f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
|
||||||
|
return async_sign_path(hass, raw_path, timedelta(hours=1))
|
||||||
|
|
||||||
def _view_path(recipe_path: str) -> str:
|
def _view_path(recipe_path: str) -> str:
|
||||||
"""Slug utilisé comme path de vue HA pour cette recette.
|
"""Slug utilisé comme path de vue HA pour cette recette.
|
||||||
|
|
@ -110,7 +107,7 @@ async def ws_list_recipes(
|
||||||
"time": metadata.get("time"),
|
"time": metadata.get("time"),
|
||||||
"servings": metadata.get("servings"),
|
"servings": metadata.get("servings"),
|
||||||
"tags": metadata.get("tags", []),
|
"tags": metadata.get("tags", []),
|
||||||
"image_url": _image_url(entry_id, metadata.get("image")),
|
"image_url": _image_url(hass, entry_id, metadata.get("image")),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
connection.send_result(msg["id"], {"recipes": recipes})
|
connection.send_result(msg["id"], {"recipes": recipes})
|
||||||
|
|
@ -139,12 +136,7 @@ async def ws_get_recipe(
|
||||||
connection.send_error(msg["id"], "cannot_connect", str(err))
|
connection.send_error(msg["id"], "cannot_connect", str(err))
|
||||||
return
|
return
|
||||||
|
|
||||||
recipe["image_url"] = _image_url(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)
|
||||||
|
|
||||||
|
|
|
||||||
800
custom_components/cookcli/www/cookcli-card.js
Normal file
800
custom_components/cookcli/www/cookcli-card.js
Normal file
|
|
@ -0,0 +1,800 @@
|
||||||
|
/**
|
||||||
|
* Carte Lovelace pour l'intégration CookCLI — liste navigable des recettes.
|
||||||
|
*
|
||||||
|
* Le détail d'une recette est une VUE DISTINCTE par recette (path = le
|
||||||
|
* "view_path" renvoyé par le backend), générée par une dashboard strategy
|
||||||
|
* (voir cookcli-recipe-strategy.js). C'est nécessaire : une view strategy ne
|
||||||
|
* régénère son contenu qu'au montage de la vue, pas à chaque changement de
|
||||||
|
* l'URL — avec une seule vue partagée + un paramètre `?path=`, il fallait
|
||||||
|
* recharger la page pour voir une autre recette. Une vraie vue par recette
|
||||||
|
* force un remontage à chaque navigation, donc ça marche en SPA normal.
|
||||||
|
*
|
||||||
|
* Consomme cookcli/recipes (path, view_path, name, time, servings, tags,
|
||||||
|
* image_url).
|
||||||
|
*
|
||||||
|
* Installation :
|
||||||
|
* 1. Copie ce fichier dans config/www/cookcli-card.js
|
||||||
|
* 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter :
|
||||||
|
* URL "/local/cookcli-card.js", type "Module JavaScript"
|
||||||
|
* 3. Cette carte est normalement insérée automatiquement par la dashboard
|
||||||
|
* strategy cookcli-recipe-strategy.js — voir son README pour la config
|
||||||
|
* complète du tableau de bord. Utilisable aussi seule en carte manuelle,
|
||||||
|
* y compris sur un AUTRE tableau de bord que celui des recettes :
|
||||||
|
* type: custom:cookcli-card
|
||||||
|
* title: Mes recettes
|
||||||
|
* dashboard_path: test-cuisine # optionnel — voir plus bas
|
||||||
|
*
|
||||||
|
* Par défaut, un clic navigue vers une vue du tableau de bord COURANT (celui
|
||||||
|
* où la carte est affichée). Si tu places cette carte sur un autre tableau
|
||||||
|
* de bord que celui qui contient les vues générées par la dashboard
|
||||||
|
* strategy (ex: carte sur /lovelace, vues sur /test-cuisine), précise
|
||||||
|
* `dashboard_path: test-cuisine` pour que la navigation cible le bon
|
||||||
|
* tableau de bord plutôt que celui où la carte est physiquement posée.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CookCliCard extends HTMLElement {
|
||||||
|
static getStubConfig() {
|
||||||
|
return { title: "Recettes" };
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(config) {
|
||||||
|
this._config = config || {};
|
||||||
|
this._recipes = null;
|
||||||
|
this._loading = false;
|
||||||
|
this._error = null;
|
||||||
|
this._lastFetch = 0;
|
||||||
|
this._selectedTag = null;
|
||||||
|
this._reloading = false;
|
||||||
|
this._reloadError = null;
|
||||||
|
|
||||||
|
if (!this.shadowRoot) {
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Éditeur visuel : élément dédié (voir CookCliCardEditor plus bas) plutôt
|
||||||
|
* que getConfigForm(), dont le schéma est statique et n'a pas accès à
|
||||||
|
* hass — or la liste des dashboards disponibles en dépend.
|
||||||
|
*/
|
||||||
|
static getConfigElement() {
|
||||||
|
return document.createElement("cookcli-card-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
set hass(hass) {
|
||||||
|
this._hass = hass;
|
||||||
|
this._subscribe();
|
||||||
|
if (this._recipes === null && !this._loading) {
|
||||||
|
this._fetchRecipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si les signatures d'images risquent d'avoir expiré.
|
||||||
|
* Si le dernier chargement date de plus de 45 minutes, on relance une requête propre.
|
||||||
|
*/
|
||||||
|
_checkAndRefresh() {
|
||||||
|
if (this._loading || !this._recipes) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const fortyFiveMinutes = 45 * 60 * 1000;
|
||||||
|
|
||||||
|
if (now - this._lastFetch > fortyFiveMinutes) {
|
||||||
|
this._fetchRecipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardSize() {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchRecipes() {
|
||||||
|
this._loading = true;
|
||||||
|
this._error = null;
|
||||||
|
this._render();
|
||||||
|
try {
|
||||||
|
const msg = { type: "cookcli/recipes" };
|
||||||
|
if (this._config.entry_id) msg.entry_id = this._config.entry_id;
|
||||||
|
const result = await this._hass.connection.sendMessagePromise(msg);
|
||||||
|
this._recipes = result.recipes || [];
|
||||||
|
this._lastFetch = Date.now();
|
||||||
|
if (this._selectedTag !== null && !this._allTags().includes(this._selectedTag)) {
|
||||||
|
this._selectedTag = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._error = (err && err.message) || "Erreur inconnue";
|
||||||
|
} finally {
|
||||||
|
this._loading = false;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_navigateToView(viewPath) {
|
||||||
|
const dashboardRoot = this._config.dashboard_path
|
||||||
|
? `/${this._config.dashboard_path.replace(/^\/+|\/+$/g, "")}`
|
||||||
|
// Racine du dashboard courant, ex: "/lovelace-cookcli" depuis
|
||||||
|
// "/lovelace-cookcli/recettes" — on y accroche la vue de la recette.
|
||||||
|
: window.location.pathname.split("/").slice(0, 2).join("/");
|
||||||
|
const url = `${dashboardRoot}/${viewPath}`;
|
||||||
|
|
||||||
|
// Navigation interne HA, sans recharger la page (comme un tap_action navigate).
|
||||||
|
window.history.pushState(null, "", url);
|
||||||
|
window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClick(event) {
|
||||||
|
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]");
|
||||||
|
if (itemEl) {
|
||||||
|
this._navigateToView(itemEl.dataset.viewPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_escape(str) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = str ?? "";
|
||||||
|
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() {
|
||||||
|
if (this._loading && !this._recipes) {
|
||||||
|
return `<div class="state-msg">Chargement…</div>`;
|
||||||
|
}
|
||||||
|
if (this._error && !this._recipes) {
|
||||||
|
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 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) => {
|
||||||
|
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.background='var(--divider-color)';this.removeAttribute('src')">`
|
||||||
|
: `<div class="recipe-thumb recipe-thumb-placeholder"></div>`;
|
||||||
|
return `
|
||||||
|
<div class="recipe-card" data-view-path="${this._escape(r.view_path)}">
|
||||||
|
${thumb}
|
||||||
|
<div class="recipe-card-text">
|
||||||
|
<div class="recipe-name">${this._escape(r.name)}</div>
|
||||||
|
<div class="recipe-meta">${meta.join("")}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `${filterBar}<div class="recipe-grid">${cards}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
if (!this.shadowRoot) return;
|
||||||
|
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 = `
|
||||||
|
<style>${this._styles()}</style>
|
||||||
|
<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>
|
||||||
|
</ha-card>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const next = this.shadowRoot.querySelector(".tag-filter");
|
||||||
|
if (next) next.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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); }
|
||||||
|
|
||||||
|
/* Grille responsive : 1 colonne par défaut (mobile),
|
||||||
|
puis 2 / 3 / 4 colonnes selon la largeur disponible. */
|
||||||
|
.recipe-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--card-background-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.recipe-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.recipe-card:hover .recipe-name {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-thumb {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: var(--secondary-background-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.recipe-thumb-placeholder {
|
||||||
|
background: var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card-text {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.recipe-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.recipe-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Éditeur visuel de la carte (ha-form + sélecteurs natifs).
|
||||||
|
*
|
||||||
|
* "Chemin du dashboard" propose les dashboards Lovelace du serveur, lus dans
|
||||||
|
* hass.panels (accessible à tous les utilisateurs, contrairement à
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
const COOKCLI_CARD_EDITOR_FIELDS = ["title", "tag", "show_tag_filter", "dashboard_path", "entry_id"];
|
||||||
|
|
||||||
|
const COOKCLI_CARD_EDITOR_LABELS = {
|
||||||
|
title: "Titre",
|
||||||
|
tag: "Tag unique",
|
||||||
|
show_tag_filter: "Filtre par tags",
|
||||||
|
dashboard_path: "Dashboard des recettes",
|
||||||
|
entry_id: "Serveur CookCLI",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COOKCLI_CARD_EDITOR_HELPERS = {
|
||||||
|
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:
|
||||||
|
"Seulement si la carte n'est pas sur le dashboard qui contient les vues recettes. Vide = dashboard courant.",
|
||||||
|
entry_id:
|
||||||
|
"Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.",
|
||||||
|
};
|
||||||
|
|
||||||
|
class CookCliCardEditor extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._config = {};
|
||||||
|
this._hass = undefined;
|
||||||
|
this._form = null;
|
||||||
|
this._optionsSignature = null;
|
||||||
|
this._tagOptions = [];
|
||||||
|
this._tagsLoadedFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(config) {
|
||||||
|
this._config = config || {};
|
||||||
|
this._maybeLoadTags();
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
|
||||||
|
set hass(hass) {
|
||||||
|
this._hass = hass;
|
||||||
|
this._maybeLoadTags();
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._ensureForm();
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
|
||||||
|
_dashboardOptions() {
|
||||||
|
const panels = (this._hass && this._hass.panels) || {};
|
||||||
|
return Object.values(panels)
|
||||||
|
.filter((p) => p.component_name === "lovelace" && p.url_path)
|
||||||
|
.map((p) => ({
|
||||||
|
value: p.url_path,
|
||||||
|
label: p.title ? `${p.title} (${p.url_path})` : p.url_path,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSchema(dashboardOptions, tagOptions) {
|
||||||
|
return [
|
||||||
|
{ name: "title", selector: { text: {} } },
|
||||||
|
{
|
||||||
|
name: "tag",
|
||||||
|
selector: { select: { options: tagOptions, custom_value: true, mode: "dropdown" } },
|
||||||
|
},
|
||||||
|
{ name: "show_tag_filter", selector: { boolean: {} } },
|
||||||
|
{
|
||||||
|
name: "dashboard_path",
|
||||||
|
selector: { select: { options: dashboardOptions, custom_value: true, mode: "dropdown" } },
|
||||||
|
},
|
||||||
|
{ name: "entry_id", selector: { config_entry: { integration: "cookcli" } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureForm() {
|
||||||
|
if (this._form) return;
|
||||||
|
const form = document.createElement("ha-form");
|
||||||
|
const options = this._dashboardOptions();
|
||||||
|
this._optionsSignature = JSON.stringify([options, this._tagOptions]);
|
||||||
|
form.schema = this._buildSchema(options, this._tagOptions);
|
||||||
|
form.computeLabel = (schema) => COOKCLI_CARD_EDITOR_LABELS[schema.name] || schema.name;
|
||||||
|
form.computeHelper = (schema) => COOKCLI_CARD_EDITOR_HELPERS[schema.name] || "";
|
||||||
|
form.addEventListener("value-changed", (ev) => this._valueChanged(ev));
|
||||||
|
this.appendChild(form);
|
||||||
|
this._form = form;
|
||||||
|
}
|
||||||
|
|
||||||
|
_update() {
|
||||||
|
if (!this._form) return;
|
||||||
|
if (this._hass) {
|
||||||
|
this._form.hass = this._hass;
|
||||||
|
}
|
||||||
|
const options = this._dashboardOptions();
|
||||||
|
const signature = JSON.stringify([options, this._tagOptions]);
|
||||||
|
if (signature !== this._optionsSignature) {
|
||||||
|
this._optionsSignature = signature;
|
||||||
|
this._form.schema = this._buildSchema(options, this._tagOptions);
|
||||||
|
}
|
||||||
|
const data = {};
|
||||||
|
for (const name of COOKCLI_CARD_EDITOR_FIELDS) {
|
||||||
|
if (this._config[name] !== undefined) data[name] = this._config[name];
|
||||||
|
}
|
||||||
|
data.show_tag_filter = this._config.show_tag_filter !== false;
|
||||||
|
this._form.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
_valueChanged(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const value = ev.detail.value || {};
|
||||||
|
|
||||||
|
// Garde `type` et toute clé inconnue ; un champ vidé est supprimé.
|
||||||
|
const config = { ...this._config };
|
||||||
|
for (const name of COOKCLI_CARD_EDITOR_FIELDS) {
|
||||||
|
const v = value[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._config = config;
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("config-changed", {
|
||||||
|
detail: { config },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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-editor", CookCliCardEditor);
|
||||||
|
customElements.define("cookcli-checklist-card", CookCliChecklistCard);
|
||||||
|
|
||||||
|
window.customCards = window.customCards || [];
|
||||||
|
window.customCards.push({
|
||||||
|
type: "cookcli-card",
|
||||||
|
name: "CookCLI Recipes",
|
||||||
|
preview: false,
|
||||||
|
description: "Liste navigable de tes recettes CookCLI.",
|
||||||
|
});
|
||||||
713
custom_components/cookcli/www/cookcli-recipe-strategy.js
Normal file
713
custom_components/cookcli/www/cookcli-recipe-strategy.js
Normal file
|
|
@ -0,0 +1,713 @@
|
||||||
|
/**
|
||||||
|
* Strategies CookCLI pour Home Assistant : une dashboard strategy + une view
|
||||||
|
* strategy compagne.
|
||||||
|
*
|
||||||
|
* IMPORTANT : la view strategy seule ne suffit pas pour une navigation
|
||||||
|
* fluide. Une view strategy régénère son contenu au MONTAGE de la vue, pas à
|
||||||
|
* chaque changement d'URL — avec une seule vue partagée "recette?path=..."
|
||||||
|
* pour toutes les recettes, passer de l'une à l'autre sans recharger la
|
||||||
|
* page laissait afficher l'ancienne recette (vue déjà montée, jamais
|
||||||
|
* remontée). D'où la dashboard strategy : elle génère une VUE DISTINCTE par
|
||||||
|
* recette (un vrai `path` HA différent pour chacune), ce qui force un
|
||||||
|
* remontage — donc une régénération — à chaque navigation.
|
||||||
|
*
|
||||||
|
* La vue d'une recette est UNE SEULE carte tabdeck-card : un onglet "Résumé"
|
||||||
|
* (titre, image, ustensiles, tous les ingrédients à cocher, bouton
|
||||||
|
* "Commencer"), puis un onglet par étape (colonne gauche 30% : ingrédients
|
||||||
|
* de l'étape + minuteur ; colonne droite 70% : texte de l'étape ; boutons
|
||||||
|
* Précédent/Suivant en bas).
|
||||||
|
*
|
||||||
|
* Le ratio 30/70 n'a pas d'équivalent natif HA (grid/horizontal-stack ne
|
||||||
|
* font que des colonnes égales) : on s'appuie sur **card-mod**
|
||||||
|
* (https://github.com/thomasloven/lovelace-card-mod, HACS) pour forcer la
|
||||||
|
* largeur des deux colonnes d'un horizontal-stack.
|
||||||
|
*
|
||||||
|
* Pour Précédent/Suivant/Commencer, on n'utilise PAS le hash d'URL
|
||||||
|
* `#tab=...` de tabdeck (son comportement en navigation live après montage
|
||||||
|
* n'est pas documenté avec certitude) mais son mode `remember: entity` —
|
||||||
|
* documenté et fiable : l'onglet actif est piloté par la valeur d'un helper
|
||||||
|
* input_number, que nos boutons changent via `input_number.set_value`.
|
||||||
|
* tabdeck lit cette valeur pour choisir l'onglet actif et se met à jour
|
||||||
|
* comme n'importe quel autre entité HA quand elle change — un mécanisme
|
||||||
|
* standard, pas une supposition sur le comportement interne de la carte.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* - custom:cookcli-checklist-card : checklist des ingrédients (Résumé et étapes), état local
|
||||||
|
* - button (native) : Commencer / Précédent / Suivant / Démarrer minuteur
|
||||||
|
* - custom:circular-timer-card : affichage/contrôle du minuteur partagé
|
||||||
|
* - custom:tabdeck-card : les onglets (Résumé + une par étape)
|
||||||
|
* - custom:mod-card (card-mod) : force le ratio 30/70 des colonnes d'étape
|
||||||
|
* - custom:cookcli-card : la liste (voir cookcli-card.js)
|
||||||
|
*
|
||||||
|
* Configuration du tableau de bord (éditable aussi via l'éditeur visuel,
|
||||||
|
* voir CookcliStrategyEditor ; sinon remplace le contenu YAML du dashboard) :
|
||||||
|
* strategy:
|
||||||
|
* type: custom:cookcli
|
||||||
|
* title: Recettes
|
||||||
|
* timer_entity: timer.recette_en_cours # créé manuellement, voir README
|
||||||
|
* step_entity: input_number.recette_etape # créé manuellement, voir README
|
||||||
|
* entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CookCliDashboardStrategy extends HTMLElement {
|
||||||
|
static getCreateSuggestions() {
|
||||||
|
return { title: "Recettes", icon: "mdi:chef-hat" };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getConfigElement() {
|
||||||
|
return document.createElement("cookcli-strategy-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
static configRequired = true;
|
||||||
|
|
||||||
|
static async generate(config, hass) {
|
||||||
|
config = config || {};
|
||||||
|
|
||||||
|
const wsMsg = { type: "cookcli/recipes" };
|
||||||
|
if (config.entry_id) wsMsg.entry_id = config.entry_id;
|
||||||
|
|
||||||
|
let recipes = [];
|
||||||
|
try {
|
||||||
|
const result = await hass.connection.sendMessagePromise(wsMsg);
|
||||||
|
recipes = result.recipes || [];
|
||||||
|
} catch (err) {
|
||||||
|
// Liste vide plutôt que planter tout le dashboard.
|
||||||
|
}
|
||||||
|
|
||||||
|
const listView = {
|
||||||
|
title: config.title || "Recettes",
|
||||||
|
path: "recettes",
|
||||||
|
panel: true,
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
type: "custom:cookcli-card",
|
||||||
|
title: config.title || "Mes recettes",
|
||||||
|
entry_id: config.entry_id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipeViews = recipes.map((recipe) => ({
|
||||||
|
path: recipe.view_path,
|
||||||
|
subview: true,
|
||||||
|
panel: true,
|
||||||
|
strategy: {
|
||||||
|
type: "custom:cookcli-recipe",
|
||||||
|
path: recipe.path,
|
||||||
|
timer_entity: config.timer_entity,
|
||||||
|
step_entity: config.step_entity,
|
||||||
|
entry_id: config.entry_id,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: config.title || "Recettes",
|
||||||
|
views: [listView, ...recipeViews],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View strategy : génère le contenu de la vue d'UNE recette, sous forme
|
||||||
|
* d'une unique carte tabdeck-card (Résumé + une tab par étape).
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
config = config || {};
|
||||||
|
const path = config.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}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repart de "Résumé" à chaque nouvelle recette ouverte, plutôt que de
|
||||||
|
// garder l'étape où on s'était arrêté sur la recette précédente (le
|
||||||
|
// helper input_number est partagé entre toutes les recettes).
|
||||||
|
if (config.step_entity) {
|
||||||
|
await this._setStepIndex(hass, config.step_entity, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Étapes à plat, avec leur index global (1-based ; 0 est réservé au
|
||||||
|
// "Résumé") pour piloter remember_entity.
|
||||||
|
const flatSteps = [];
|
||||||
|
for (const section of recipe.sections || []) {
|
||||||
|
for (const step of section.steps || []) {
|
||||||
|
flatSteps.push({ section, step });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [this._summaryTab(recipe, config, flatSteps.length)];
|
||||||
|
flatSteps.forEach(({ section, step }, i) => {
|
||||||
|
const tabIndex = i + 1; // 0 = Résumé
|
||||||
|
const isLast = tabIndex === flatSteps.length;
|
||||||
|
tabs.push(
|
||||||
|
this._stepTab(section, step, tabIndex, isLast, config)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabdeckCard = {
|
||||||
|
type: "custom:tabdeck-card",
|
||||||
|
panel: true,
|
||||||
|
default_tab: 0,
|
||||||
|
tabs,
|
||||||
|
};
|
||||||
|
if (config.step_entity) {
|
||||||
|
tabdeckCard.remember = "entity";
|
||||||
|
tabdeckCard.remember_entity = config.step_entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: recipe.title || "Recette",
|
||||||
|
panel: true,
|
||||||
|
cards: [tabdeckCard],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async _setStepIndex(hass, entity, value) {
|
||||||
|
try {
|
||||||
|
await hass.connection.sendMessagePromise({
|
||||||
|
type: "call_service",
|
||||||
|
domain: "input_number",
|
||||||
|
service: "set_value",
|
||||||
|
service_data: { entity_id: entity, value },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Pas bloquant : au pire l'utilisateur atterrit sur le dernier onglet
|
||||||
|
// laissé actif plutôt que sur "Résumé".
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static _navButton(entity, targetIndex, label, icon, accent = false) {
|
||||||
|
return {
|
||||||
|
type: "button",
|
||||||
|
name: label,
|
||||||
|
icon,
|
||||||
|
tap_action: {
|
||||||
|
action: "perform-action",
|
||||||
|
perform_action: "input_number.set_value",
|
||||||
|
target: { entity_id: entity },
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static _summaryTab(recipe, config, stepCount) {
|
||||||
|
let content = "";
|
||||||
|
//if (recipe.image_url) content += `\n\n`;
|
||||||
|
if (recipe.image_url) {
|
||||||
|
const cleanImageUrl = recipe.image_url.replace(/([^:]\/)\/+/g, "\$1");
|
||||||
|
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`;
|
||||||
|
if (recipe.cookware && recipe.cookware.length) {
|
||||||
|
content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftCard = {
|
||||||
|
type: "markdown",
|
||||||
|
content,
|
||||||
|
card_mod: `
|
||||||
|
style:
|
||||||
|
ha-markdown:
|
||||||
|
$: |
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rightCards = [];
|
||||||
|
|
||||||
|
const ingredients = recipe.ingredients || [];
|
||||||
|
if (ingredients.length) {
|
||||||
|
rightCards.push({
|
||||||
|
type: "custom:cookcli-checklist-card",
|
||||||
|
title: "Ingrédients",
|
||||||
|
// État coché propre à cette recette, indépendant de celui des étapes.
|
||||||
|
storage_key: `${config.path}:summary`,
|
||||||
|
items: ingredients.map((ingredient) => {
|
||||||
|
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) {
|
||||||
|
rightCards.push(
|
||||||
|
this._floatingNav([
|
||||||
|
this._navButton(config.step_entity, 1, "Commencer", "mdi:play", true),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = this._responsiveColumns(
|
||||||
|
leftCard,
|
||||||
|
{ type: "vertical-stack", cards: rightCards },
|
||||||
|
"30%"
|
||||||
|
);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const { markdown: stepMarkdown, timers } = this._renderStepMarkdown(step);
|
||||||
|
const stepLabel = section.name
|
||||||
|
? `${section.name} ${step.number ?? ""}`.trim()
|
||||||
|
: `Étape ${tabIndex}`;
|
||||||
|
|
||||||
|
const leftCards = [];
|
||||||
|
const rightCards = [];
|
||||||
|
|
||||||
|
rightCards.push({ type: "markdown", content: `## ${stepLabel}\n\n${stepMarkdown}` })
|
||||||
|
|
||||||
|
const stepIngredients = (step.items || []).filter((item) => item.type === "ingredient");
|
||||||
|
if (stepIngredients.length) {
|
||||||
|
leftCards.push({
|
||||||
|
type: "custom:cookcli-checklist-card",
|
||||||
|
title: "Ingrédients",
|
||||||
|
// recette + étape : l'état coché est propre à chaque étape de chaque recette
|
||||||
|
storage_key: `${config.path}:${tabIndex}`,
|
||||||
|
items: stepIngredients.map((item) => ({
|
||||||
|
quantity: item.quantity
|
||||||
|
? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim()
|
||||||
|
: "",
|
||||||
|
name: item.name ?? "",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.timer_entity && timers.length) {
|
||||||
|
const buttons = [];
|
||||||
|
for (const timer of timers) {
|
||||||
|
const seconds = this._parseDurationSeconds(timer.duration, timer.unit);
|
||||||
|
if (!seconds) continue;
|
||||||
|
const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim();
|
||||||
|
buttons.push({
|
||||||
|
type: "button",
|
||||||
|
name: label,
|
||||||
|
icon: "mdi:timer-outline",
|
||||||
|
tap_action: {
|
||||||
|
action: "perform-action",
|
||||||
|
perform_action: "timer.start",
|
||||||
|
target: { entity_id: config.timer_entity },
|
||||||
|
data: { duration: this._secondsToHms(seconds) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
leftCards.push({ type: "markdown", content: " ", text_only: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = this._responsiveColumns(
|
||||||
|
{ type: "vertical-stack", cards: leftCards },
|
||||||
|
{ type: "vertical-stack", cards: rightCards },
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = [columns];
|
||||||
|
|
||||||
|
if (config.step_entity) {
|
||||||
|
const navButtons = [
|
||||||
|
this._navButton(config.step_entity, tabIndex - 1, "Précédent", "mdi:arrow-left"),
|
||||||
|
];
|
||||||
|
if (!isLast) {
|
||||||
|
navButtons.push(
|
||||||
|
this._navButton(config.step_entity, tabIndex + 1, "Suivant", "mdi:arrow-right", true)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
navButtons.push(this._navSpacer());
|
||||||
|
}
|
||||||
|
cards.push(this._floatingNav(navButtons));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = {
|
||||||
|
name: stepLabel,
|
||||||
|
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) {
|
||||||
|
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 en relançant le bouton "Démarrer"
|
||||||
|
* de l'étape (pas d'édition de durée à la volée sur circular-timer-card).
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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(":");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Éditeur visuel de la dashboard strategy (remplace l'édition YAML).
|
||||||
|
*
|
||||||
|
* Basé sur <ha-form> + sélecteurs natifs HA : mêmes composants, même rendu
|
||||||
|
* que les éditeurs des cartes officielles (thème, sélecteurs d'entités,
|
||||||
|
* traductions). Aucune dépendance Lit : on manipule directement l'élément.
|
||||||
|
*
|
||||||
|
* Contrat HA pour un éditeur de strategy :
|
||||||
|
* - setConfig(config) → reçoit la config courante
|
||||||
|
* - set hass(hass) → reçoit l'objet hass (mis à jour très souvent)
|
||||||
|
* - événement "config-changed" { detail: { config } } → sauvegarde
|
||||||
|
*/
|
||||||
|
const COOKCLI_EDITOR_SCHEMA = [
|
||||||
|
{ name: "title", selector: { text: {} } },
|
||||||
|
{ name: "timer_entity", selector: { entity: { domain: "timer" } } },
|
||||||
|
{ name: "step_entity", selector: { entity: { domain: "input_number" } } },
|
||||||
|
{ name: "entry_id", selector: { config_entry: { integration: "cookcli" } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COOKCLI_EDITOR_LABELS = {
|
||||||
|
title: "Titre du dashboard",
|
||||||
|
timer_entity: "Minuteur",
|
||||||
|
step_entity: "Étape en cours",
|
||||||
|
entry_id: "Serveur CookCLI",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COOKCLI_EDITOR_HELPERS = {
|
||||||
|
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.",
|
||||||
|
};
|
||||||
|
|
||||||
|
class CookcliStrategyEditor extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._config = {};
|
||||||
|
this._hass = undefined;
|
||||||
|
this._form = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(config) {
|
||||||
|
this._config = config || {};
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
|
||||||
|
set hass(hass) {
|
||||||
|
this._hass = hass;
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._ensureForm();
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureForm() {
|
||||||
|
if (this._form) return;
|
||||||
|
const form = document.createElement("ha-form");
|
||||||
|
form.schema = COOKCLI_EDITOR_SCHEMA;
|
||||||
|
form.computeLabel = (schema) => COOKCLI_EDITOR_LABELS[schema.name] || schema.name;
|
||||||
|
form.computeHelper = (schema) => COOKCLI_EDITOR_HELPERS[schema.name] || "";
|
||||||
|
form.addEventListener("value-changed", (ev) => this._valueChanged(ev));
|
||||||
|
this.appendChild(form);
|
||||||
|
this._form = form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le formulaire est créé une seule fois ; on ne met à jour que ses
|
||||||
|
// propriétés (pas de re-rendu du DOM à chaque changement de hass, ce qui
|
||||||
|
// ferait perdre le focus pendant la saisie).
|
||||||
|
_update() {
|
||||||
|
if (!this._form) return;
|
||||||
|
if (this._hass) this._form.hass = this._hass;
|
||||||
|
const data = {};
|
||||||
|
for (const { name } of COOKCLI_EDITOR_SCHEMA) {
|
||||||
|
if (this._config[name] !== undefined) data[name] = this._config[name];
|
||||||
|
}
|
||||||
|
this._form.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
_valueChanged(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const value = ev.detail.value || {};
|
||||||
|
|
||||||
|
// On part de la config existante (garde `type: custom:cookcli` et toute
|
||||||
|
// clé inconnue) et on ne touche qu'aux champs de l'éditeur. Un champ vidé
|
||||||
|
// est supprimé plutôt que stocké comme "".
|
||||||
|
const config = { ...this._config };
|
||||||
|
for (const { name } of COOKCLI_EDITOR_SCHEMA) {
|
||||||
|
const v = value[name];
|
||||||
|
if (v === undefined || v === null || v === "") delete config[name];
|
||||||
|
else config[name] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._config = config;
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("config-changed", {
|
||||||
|
detail: { config },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("ll-strategy-dashboard-cookcli", CookCliDashboardStrategy);
|
||||||
|
customElements.define("ll-strategy-view-cookcli-recipe", CookCliRecipeViewStrategy);
|
||||||
|
customElements.define("cookcli-strategy-editor", CookcliStrategyEditor);
|
||||||
|
|
||||||
|
window.customStrategies = window.customStrategies || [];
|
||||||
|
window.customStrategies.push({
|
||||||
|
type: "cookcli",
|
||||||
|
strategyType: "dashboard",
|
||||||
|
name: "CookCLI",
|
||||||
|
description: "Dashboard de recettes CookCLI : liste + une vue par recette.",
|
||||||
|
documentationURL: "https://git.thethomaas.net/TheThomaas/ha-cookcli",
|
||||||
|
});
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
/**
|
|
||||||
* Carte Lovelace pour l'intégration CookCLI — liste navigable des recettes.
|
|
||||||
*
|
|
||||||
* Le détail d'une recette est une VUE DISTINCTE par recette (path = le
|
|
||||||
* "view_path" renvoyé par le backend), générée par une dashboard strategy
|
|
||||||
* (voir cookcli-recipe-strategy.js). C'est nécessaire : une view strategy ne
|
|
||||||
* régénère son contenu qu'au montage de la vue, pas à chaque changement de
|
|
||||||
* l'URL — avec une seule vue partagée + un paramètre `?path=`, il fallait
|
|
||||||
* recharger la page pour voir une autre recette. Une vraie vue par recette
|
|
||||||
* force un remontage à chaque navigation, donc ça marche en SPA normal.
|
|
||||||
*
|
|
||||||
* Consomme cookcli/recipes (path, view_path, name, time, servings, tags,
|
|
||||||
* image_url).
|
|
||||||
*
|
|
||||||
* Installation :
|
|
||||||
* 1. Copie ce fichier dans config/www/cookcli-card.js
|
|
||||||
* 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter :
|
|
||||||
* URL "/local/cookcli-card.js", type "Module JavaScript"
|
|
||||||
* 3. Cette carte est normalement insérée automatiquement par la dashboard
|
|
||||||
* strategy cookcli-recipe-strategy.js — voir son README pour la config
|
|
||||||
* complète du tableau de bord. Utilisable aussi seule en carte manuelle,
|
|
||||||
* y compris sur un AUTRE tableau de bord que celui des recettes :
|
|
||||||
* type: custom:cookcli-card
|
|
||||||
* title: Mes recettes
|
|
||||||
* dashboard_path: test-cuisine # optionnel — voir plus bas
|
|
||||||
*
|
|
||||||
* Par défaut, un clic navigue vers une vue du tableau de bord COURANT (celui
|
|
||||||
* où la carte est affichée). Si tu places cette carte sur un autre tableau
|
|
||||||
* de bord que celui qui contient les vues générées par la dashboard
|
|
||||||
* strategy (ex: carte sur /lovelace, vues sur /test-cuisine), précise
|
|
||||||
* `dashboard_path: test-cuisine` pour que la navigation cible le bon
|
|
||||||
* tableau de bord plutôt que celui où la carte est physiquement posée.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class CookCliCard extends HTMLElement {
|
|
||||||
static getStubConfig() {
|
|
||||||
return { title: "Recettes" };
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfig(config) {
|
|
||||||
this._config = config || {};
|
|
||||||
this._recipes = 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _fetchRecipes() {
|
|
||||||
this._loading = true;
|
|
||||||
this._error = null;
|
|
||||||
this._render();
|
|
||||||
try {
|
|
||||||
const msg = { type: "cookcli/recipes" };
|
|
||||||
if (this._config.entry_id) msg.entry_id = this._config.entry_id;
|
|
||||||
const result = await this._hass.connection.sendMessagePromise(msg);
|
|
||||||
this._recipes = result.recipes || [];
|
|
||||||
} catch (err) {
|
|
||||||
this._error = (err && err.message) || "Erreur inconnue";
|
|
||||||
} finally {
|
|
||||||
this._loading = false;
|
|
||||||
this._render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_navigateToView(viewPath) {
|
|
||||||
const dashboardRoot = this._config.dashboard_path
|
|
||||||
? `/${this._config.dashboard_path.replace(/^\/+|\/+$/g, "")}`
|
|
||||||
// Racine du dashboard courant, ex: "/lovelace-cookcli" depuis
|
|
||||||
// "/lovelace-cookcli/recettes" — on y accroche la vue de la recette.
|
|
||||||
: window.location.pathname.split("/").slice(0, 2).join("/");
|
|
||||||
const url = `${dashboardRoot}/${viewPath}`;
|
|
||||||
|
|
||||||
// Navigation interne HA, sans recharger la page (comme un tap_action navigate).
|
|
||||||
window.history.pushState(null, "", url);
|
|
||||||
window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } }));
|
|
||||||
}
|
|
||||||
|
|
||||||
_onClick(event) {
|
|
||||||
const itemEl = event.target.closest("[data-view-path]");
|
|
||||||
if (itemEl) {
|
|
||||||
this._navigateToView(itemEl.dataset.viewPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_escape(str) {
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.textContent = str ?? "";
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
_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-view-path="${this._escape(r.view_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>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_render() {
|
|
||||||
if (!this.shadowRoot) return;
|
|
||||||
const title = this._config.title || "Recettes";
|
|
||||||
this.shadowRoot.innerHTML = `
|
|
||||||
<style>${this._styles()}</style>
|
|
||||||
<ha-card header="${this._escape(title)}">
|
|
||||||
<div class="card-content">${this._renderList()}</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;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("cookcli-card", CookCliCard);
|
|
||||||
|
|
||||||
window.customCards = window.customCards || [];
|
|
||||||
window.customCards.push({
|
|
||||||
type: "cookcli-card",
|
|
||||||
name: "CookCLI Recipes",
|
|
||||||
description: "Liste navigable de tes recettes CookCLI.",
|
|
||||||
});
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
/**
|
|
||||||
* Strategies CookCLI pour Home Assistant : une dashboard strategy + une view
|
|
||||||
* strategy compagne.
|
|
||||||
*
|
|
||||||
* IMPORTANT : la view strategy seule ne suffit pas pour une navigation
|
|
||||||
* fluide. Une view strategy régénère son contenu au MONTAGE de la vue, pas à
|
|
||||||
* chaque changement d'URL — avec une seule vue partagée "recette?path=..."
|
|
||||||
* pour toutes les recettes, passer de l'une à l'autre sans recharger la
|
|
||||||
* page laissait afficher l'ancienne recette (vue déjà montée, jamais
|
|
||||||
* remontée). D'où la dashboard strategy : elle génère une VUE DISTINCTE par
|
|
||||||
* recette (un vrai `path` HA différent pour chacune), ce qui force un
|
|
||||||
* remontage — donc une régénération — à chaque navigation.
|
|
||||||
*
|
|
||||||
* 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 backend)
|
|
||||||
* - button (native) : démarre le minuteur avec la bonne durée
|
|
||||||
* - custom:circular-timer-card : affichage/contrôle du minuteur partagé
|
|
||||||
* - custom:tabdeck-card : une tab par étape
|
|
||||||
* - custom:cookcli-card : la liste (voir cookcli-card.js)
|
|
||||||
*
|
|
||||||
* Configuration du tableau de bord (remplace le contenu YAML du dashboard) :
|
|
||||||
* strategy:
|
|
||||||
* type: custom:cookcli
|
|
||||||
* title: Recettes
|
|
||||||
* timer_entity: timer.recette_en_cours # créé manuellement, voir README
|
|
||||||
* entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI
|
|
||||||
*/
|
|
||||||
|
|
||||||
class CookCliDashboardStrategy extends HTMLElement {
|
|
||||||
static getCreateSuggestions() {
|
|
||||||
return { title: "Recettes", icon: "mdi:chef-hat" };
|
|
||||||
}
|
|
||||||
|
|
||||||
static async generate(config, hass) {
|
|
||||||
config = config || {};
|
|
||||||
|
|
||||||
const wsMsg = { type: "cookcli/recipes" };
|
|
||||||
if (config.entry_id) wsMsg.entry_id = config.entry_id;
|
|
||||||
|
|
||||||
let recipes = [];
|
|
||||||
try {
|
|
||||||
const result = await hass.connection.sendMessagePromise(wsMsg);
|
|
||||||
recipes = result.recipes || [];
|
|
||||||
} catch (err) {
|
|
||||||
// Liste vide plutôt que planter tout le dashboard ; la carte affichera
|
|
||||||
// son propre message d'erreur au prochain essai de fetch côté carte.
|
|
||||||
}
|
|
||||||
|
|
||||||
const listView = {
|
|
||||||
title: config.title || "Recettes",
|
|
||||||
path: "recettes",
|
|
||||||
cards: [
|
|
||||||
{
|
|
||||||
type: "custom:cookcli-card",
|
|
||||||
title: config.title || "Mes recettes",
|
|
||||||
entry_id: config.entry_id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const recipeViews = recipes.map((recipe) => ({
|
|
||||||
path: recipe.view_path,
|
|
||||||
subview: true,
|
|
||||||
strategy: {
|
|
||||||
type: "custom:cookcli-recipe",
|
|
||||||
path: recipe.path,
|
|
||||||
timer_entity: config.timer_entity,
|
|
||||||
entry_id: config.entry_id,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: config.title || "Recettes",
|
|
||||||
views: [listView, ...recipeViews],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* View strategy : génère le contenu de la vue d'UNE recette. `config.path`
|
|
||||||
* est fourni par la dashboard strategy ci-dessus ; utilisable seule (une vue
|
|
||||||
* par recette écrite à la main) en le fixant directement dans le YAML.
|
|
||||||
*/
|
|
||||||
class CookCliRecipeViewStrategy extends HTMLElement {
|
|
||||||
static async generate(config, hass) {
|
|
||||||
config = config || {};
|
|
||||||
const path = config.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",
|
|
||||||
style: "fill_horizontal",
|
|
||||||
show_active_header: false,
|
|
||||||
entities: [{
|
|
||||||
entity: config.timer_entity,
|
|
||||||
keep_timer_visible_when_idle: true,
|
|
||||||
name: " "
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
cards.push({
|
|
||||||
type: "horizontal-stack",
|
|
||||||
cards: [
|
|
||||||
{
|
|
||||||
type: "markdown",
|
|
||||||
content: " ",
|
|
||||||
text_only: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "custom:circular-timer-card",
|
|
||||||
entity: config.timer_entity,
|
|
||||||
primary_info: "none"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipe.todo_entity_id) {
|
|
||||||
cards.push({
|
|
||||||
type: "todo-list",
|
|
||||||
entity: recipe.todo_entity_id,
|
|
||||||
title: "Ingrédients à rassembler",
|
|
||||||
card_mod: {
|
|
||||||
style: `
|
|
||||||
ha-list .header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 circular-timer-card (tap =
|
|
||||||
* toggle, double-tap = annuler, appui long = plus d'infos) ou en relançant
|
|
||||||
* timer.start avec une autre durée.
|
|
||||||
*/
|
|
||||||
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-dashboard-cookcli", CookCliDashboardStrategy);
|
|
||||||
customElements.define("ll-strategy-view-cookcli-recipe", CookCliRecipeViewStrategy);
|
|
||||||
|
|
||||||
window.customStrategies = window.customStrategies || [];
|
|
||||||
window.customStrategies.push({
|
|
||||||
type: "cookcli",
|
|
||||||
strategyType: "dashboard",
|
|
||||||
name: "CookCLI",
|
|
||||||
description: "Dashboard de recettes CookCLI : liste + une vue par recette.",
|
|
||||||
documentationURL: "https://git.thethomaas.net/TheThomaas/ha-cookcli",
|
|
||||||
});
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
/**
|
|
||||||
* 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