Add card and strategy automatically
This commit is contained in:
parent
3cbd094edd
commit
5c63f62bd6
|
|
@ -3,10 +3,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
|
||||
from .api import CookCliApiClient, CookCliApiError
|
||||
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
|
||||
|
|
@ -38,13 +41,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
"coordinator": coordinator,
|
||||
}
|
||||
|
||||
# Les commandes websocket et la vue de proxy d'images sont globales au
|
||||
# domaine, on ne les enregistre qu'une seule fois même si plusieurs
|
||||
# serveurs CookCLI sont configurés.
|
||||
# Les commandes websocket, la vue de proxy d'images et les chemins
|
||||
# statiques sont globaux au domaine : on ne les enregistre qu'une
|
||||
# seule fois, même si plusieurs serveurs CookCLI sont configurés.
|
||||
if len(hass.data[DOMAIN]) == 1:
|
||||
async_setup_websocket_api(hass)
|
||||
hass.http.register_view(CookCliImageView(hass))
|
||||
|
||||
www_path = Path(__file__).parent / "www"
|
||||
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)
|
||||
|
||||
|
|
@ -53,6 +73,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
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
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Décharge une entrée de configuration CookCLI."""
|
||||
unload_ok = True
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class CookCliCard extends HTMLElement {
|
|||
this._recipes = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._lastFetch = 0;
|
||||
|
||||
if (!this.shadowRoot) {
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
|
@ -50,6 +51,20 @@ class CookCliCard extends HTMLElement {
|
|||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Éditeur de configuration automatique intégré à Home Assistant.
|
||||
* HA utilise ce schéma pour générer le formulaire visuel.
|
||||
*/
|
||||
static getConfigForm() {
|
||||
return {
|
||||
schema: [
|
||||
{ name: "title", selector: { text: {} }, label: "Titre" },
|
||||
{ name: "dashboard_path", selector: { text: {} }, label: "Chemin du dashboard", helper: "Laisser vide pour le dashboard courant" },
|
||||
{ name: "entry_id", selector: { text: {} }, label: "Entry ID", helper: "Optionnel — si plusieurs serveurs CookCLI" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
set hass(hass) {
|
||||
this._hass = hass;
|
||||
if (this._recipes === null && !this._loading) {
|
||||
|
|
@ -57,6 +72,21 @@ class CookCliCard extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -70,6 +100,7 @@ class CookCliCard extends HTMLElement {
|
|||
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();
|
||||
} catch (err) {
|
||||
this._error = (err && err.message) || "Erreur inconnue";
|
||||
} finally {
|
||||
|
|
@ -105,28 +136,28 @@ class CookCliCard extends HTMLElement {
|
|||
}
|
||||
|
||||
_renderList() {
|
||||
if (this._loading) {
|
||||
if (this._loading && !this._recipes) {
|
||||
return `<div class="state-msg">Chargement…</div>`;
|
||||
}
|
||||
if (this._error) {
|
||||
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 rows = this._recipes
|
||||
const cards = 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'">`
|
||||
? `<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-row" data-view-path="${this._escape(r.view_path)}">
|
||||
<div class="recipe-card" data-view-path="${this._escape(r.view_path)}">
|
||||
${thumb}
|
||||
<div class="recipe-row-text">
|
||||
<div class="recipe-card-text">
|
||||
<div class="recipe-name">${this._escape(r.name)}</div>
|
||||
<div class="recipe-meta">${meta.join("")}</div>
|
||||
</div>
|
||||
|
|
@ -134,7 +165,7 @@ class CookCliCard extends HTMLElement {
|
|||
})
|
||||
.join("");
|
||||
|
||||
return `<div class="recipe-list">${rows}</div>`;
|
||||
return `<div class="recipe-grid">${cards}</div>`;
|
||||
}
|
||||
|
||||
_render() {
|
||||
|
|
@ -153,27 +184,65 @@ class CookCliCard extends HTMLElement {
|
|||
.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);
|
||||
|
||||
/* 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-fit, 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-row:last-child { border-bottom: none; }
|
||||
.recipe-row:hover { background: var(--secondary-background-color); }
|
||||
.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: 56px; height: 56px; border-radius: 8px; object-fit: cover;
|
||||
flex-shrink: 0; background: var(--secondary-background-color);
|
||||
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-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;
|
||||
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;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.85em;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
|
@ -185,5 +254,6 @@ window.customCards = window.customCards || [];
|
|||
window.customCards.push({
|
||||
type: "cookcli-card",
|
||||
name: "CookCLI Recipes",
|
||||
preview: false,
|
||||
description: "Liste navigable de tes recettes CookCLI.",
|
||||
});
|
||||
492
custom_components/cookcli/www/cookcli-recipe-strategy.js
Normal file
492
custom_components/cookcli/www/cookcli-recipe-strategy.js
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
/**
|
||||
* 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
|
||||
* - todo-list (native) : checklist des ingrédients (todo.py backend)
|
||||
* - 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 (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 {
|
||||
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) {
|
||||
return {
|
||||
type: "button",
|
||||
name: label,
|
||||
icon,
|
||||
tap_action: {
|
||||
action: "call-service",
|
||||
service: "input_number.set_value",
|
||||
target: { entity_id: entity },
|
||||
data: { value: targetIndex },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 2' 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 cards = [{
|
||||
type: "markdown",
|
||||
content,
|
||||
card_mod: `
|
||||
style: |
|
||||
img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`
|
||||
}];
|
||||
|
||||
const rightCards = [];
|
||||
|
||||
if (recipe.todo_entity_id) {
|
||||
rightCards.push({
|
||||
type: "todo-list",
|
||||
entity: recipe.todo_entity_id,
|
||||
title: "Ingrédients",
|
||||
card_mod: {
|
||||
style: `
|
||||
ha-list .header {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (config.step_entity && stepCount > 0) {
|
||||
rightCards.push(this._navButton(config.step_entity, 1, "Commencer", "mdi:play"));
|
||||
}
|
||||
|
||||
cards.push({ type: "vertical-stack", cards: rightCards });
|
||||
|
||||
return { name: "Résumé", icon: "mdi:book-open-variant", card: { type: "horizontal-stack", cards } };
|
||||
}
|
||||
|
||||
static _stepTab(section, step, tabIndex, isLast, config) {
|
||||
const { markdown: stepMarkdown, timers } = this._renderStepMarkdown(step);
|
||||
|
||||
const leftCards = [];
|
||||
const stepIngredients = (step.items || []).filter((item) => item.type === "ingredient");
|
||||
if (stepIngredients.length) {
|
||||
const lines = stepIngredients.map((item) => {
|
||||
const qty = item.quantity ?? ""
|
||||
? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim()
|
||||
: "";
|
||||
return `- ${qty ? `**${qty}** ` : ""}${item.name ?? ""}`;
|
||||
});
|
||||
leftCards.push({ type: "markdown", content: lines.join("\n") });
|
||||
}
|
||||
|
||||
if (config.timer_entity) {
|
||||
for (const timer of timers) {
|
||||
const seconds = this._parseDurationSeconds(timer.duration, timer.unit);
|
||||
if (!seconds) continue;
|
||||
const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim();
|
||||
leftCards.push({
|
||||
type: "button",
|
||||
name: `Démarrer ${label}`,
|
||||
icon: "mdi:timer-outline",
|
||||
tap_action: {
|
||||
action: "call-service",
|
||||
service: "timer.start",
|
||||
target: { entity_id: config.timer_entity },
|
||||
data: { duration: this._secondsToHms(seconds) },
|
||||
},
|
||||
});
|
||||
leftCards.push({ type: "custom:circular-timer-card", entity: config.timer_entity });
|
||||
}
|
||||
}
|
||||
|
||||
if (!leftCards.length) {
|
||||
// horizontal-stack veut deux cartes ; une carte vide maintient le ratio.
|
||||
leftCards.push({ type: "markdown", content: " " });
|
||||
}
|
||||
|
||||
const columns = {
|
||||
type: "horizontal-stack",
|
||||
cards: [
|
||||
{ type: "vertical-stack", cards: leftCards },
|
||||
{ type: "markdown", content: stepMarkdown },
|
||||
],
|
||||
};
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
cards.push({ type: "horizontal-stack", cards: navButtons });
|
||||
}
|
||||
|
||||
return {
|
||||
name: section.name ? `${section.name} ${step.number ?? ""}`.trim() : `Étape ${tabIndex}`,
|
||||
card: { type: "vertical-stack", cards },
|
||||
};
|
||||
}
|
||||
|
||||
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(":");
|
||||
}
|
||||
}
|
||||
|
||||
class CookcliStrategyEditor extends HTMLElement {
|
||||
setConfig(config) {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
set hass(hass) {
|
||||
this._hass = hass;
|
||||
}
|
||||
|
||||
// Appelé automatiquement quand l'élément est inséré dans le DOM
|
||||
connectedCallback() {
|
||||
this._render();
|
||||
}
|
||||
|
||||
_render() {
|
||||
console.log("Editor render appelé", this._config, this._hass);
|
||||
|
||||
// On attend d'avoir hass avant de rendre : ha-input en a besoin
|
||||
// pour son thème et ses styles.
|
||||
if (!this._hass) {
|
||||
this.innerHTML = `<div style="padding:16px">Chargement…</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const c = this._config || {};
|
||||
|
||||
// Remplacer temporairement ha-textfield par des inputs natifs
|
||||
this.innerHTML = `
|
||||
<div style="padding: 16px; display: flex; flex-direction: column; gap: 16px;">
|
||||
<ha-input
|
||||
label="Titre du dashboard"
|
||||
value="${c.title || ""}"
|
||||
data-config-key="title"
|
||||
helper="Titre affiché dans l'onglet"
|
||||
style="width: 100%;"
|
||||
></ha-input>
|
||||
|
||||
<ha-input
|
||||
label="Entité minuteur"
|
||||
value="${c.timer_entity || ""}"
|
||||
data-config-key="timer_entity"
|
||||
helper="Ex: timer.recette_en_cours"
|
||||
style="width: 100%;"
|
||||
></ha-input>
|
||||
|
||||
<ha-input
|
||||
label="Entité étape"
|
||||
value="${c.step_entity || ""}"
|
||||
data-config-key="step_entity"
|
||||
helper="Ex: input_number.recette_etape"
|
||||
style="width: 100%;"
|
||||
></ha-input>
|
||||
|
||||
<ha-input
|
||||
label="Entry ID (optionnel)"
|
||||
value="${c.entry_id || ""}"
|
||||
data-config-key="entry_id"
|
||||
helper="Laisser vide si un seul serveur CookCLI"
|
||||
style="width: 100%;"
|
||||
></ha-input>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Écouteurs natifs (change au lieu de input pour éviter les re-rendus intempestifs)
|
||||
this.querySelectorAll("[data-config-key]").forEach((champ) => {
|
||||
champ.addEventListener("change", (ev) => {
|
||||
const cle = ev.target.dataset.configKey;
|
||||
const nouvelleConfig = { ...this._config, [cle]: ev.target.value };
|
||||
this._config = nouvelleConfig;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,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