+
${this._escape(r.name)}
${meta.join("")}
@@ -134,7 +165,7 @@ class CookCliCard extends HTMLElement {
})
.join("");
- return `
${rows}
`;
+ return `
${cards}
`;
}
_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.",
});
diff --git a/custom_components/cookcli/www/cookcli-recipe-strategy.js b/custom_components/cookcli/www/cookcli-recipe-strategy.js
new file mode 100644
index 0000000..12d51b8
--- /dev/null
+++ b/custom_components/cookcli/www/cookcli-recipe-strategy.js
@@ -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 += `

\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 = `
Chargement…
`;
+ return;
+ }
+
+ const c = this._config || {};
+
+ // Remplacer temporairement ha-textfield par des inputs natifs
+ this.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ // É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",
+});
\ No newline at end of file
diff --git a/www/cookcli-recipe-strategy.js b/www/cookcli-recipe-strategy.js
deleted file mode 100644
index e629a4d..0000000
--- a/www/cookcli-recipe-strategy.js
+++ /dev/null
@@ -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",
-});
diff --git a/www/simple-cookcli-card.js b/www/simple-cookcli-card.js
deleted file mode 100644
index 2bffef5..0000000
--- a/www/simple-cookcli-card.js
+++ /dev/null
@@ -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 `
${this._escape(item.name)}${
- qty ? ` (${this._escape(qty)})` : ""
- }`;
- }
- case "cookware":
- return `
${this._escape(item.name)}`;
- case "timer": {
- const duration = item.duration ?? "";
- const unit = item.unit ?? "";
- return `
⏱ ${this._escape(String(duration))}${
- unit ? " " + this._escape(unit) : ""
- }`;
- }
- default:
- return "";
- }
- }
-
- _renderList() {
- if (this._loading) {
- return `
Chargement…
`;
- }
- if (this._error) {
- return `
${this._escape(this._error)}
`;
- }
- if (!this._recipes || this._recipes.length === 0) {
- return `
Aucune recette trouvée.
`;
- }
-
- const rows = this._recipes
- .map((r) => {
- const meta = [];
- if (r.time) meta.push(`
⏱ ${this._escape(r.time)}`);
- if (r.servings) meta.push(`
🍽 ${this._escape(String(r.servings))}`);
- const thumb = r.image_url
- ? `
})
`
- : `
`;
- return `
-
- ${thumb}
-
-
${this._escape(r.name)}
-
${meta.join("")}
-
-
`;
- })
- .join("");
-
- return `
${rows}
`;
- }
-
- _renderDetail() {
- if (this._loading) {
- return `
Chargement…
`;
- }
- if (this._error) {
- return `
-
-
${this._escape(this._error)}
`;
- }
- if (!this._selected) return "";
-
- const r = this._selected;
-
- const ingredients = (r.ingredients || [])
- .map((i) => {
- const qty = this._formatQuantity(i.quantity);
- return `
${qty ? `${this._escape(qty)} ` : ""}${this._escape(
- i.name
- )}`;
- })
- .join("");
-
- const cookware = (r.cookware || [])
- .map((c) => `
${this._escape(c.name)}`)
- .join("");
-
- const sections = (r.sections || [])
- .map((section) => {
- const steps = (section.steps || [])
- .map(
- (step) =>
- `
${(step.items || [])
- .map((item) => this._renderStepItem(item))
- .join("")}`
- )
- .join("");
- return `
- ${section.name ? `
${this._escape(section.name)}
` : ""}
-
${steps}
`;
- })
- .join("");
-
- return `
-
- ${
- r.image_url
- ? `
})
`
- : ""
- }
-
${this._escape(r.title || "")}
- ${cookware ? `
Ustensiles
` : ""}
-
Ingrédients
-
-
Préparation
- ${sections}
- `;
- }
-
- _render() {
- if (!this.shadowRoot) return;
- const title = this._config.title || "Recettes";
- this.shadowRoot.innerHTML = `
-
-
-
- ${this._view === "list" ? this._renderList() : this._renderDetail()}
-
-
- `;
- }
-
- _styles() {
- return `
- .card-content { padding: 0 16px 16px; }
- .state-msg { padding: 16px 0; color: var(--secondary-text-color); }
- .state-msg.error { color: var(--error-color, #db4437); }
- .recipe-list { display: flex; flex-direction: column; }
- .recipe-row {
- display: flex; align-items: center; gap: 12px;
- padding: 10px 4px; border-bottom: 1px solid var(--divider-color);
- cursor: pointer;
- }
- .recipe-row:last-child { border-bottom: none; }
- .recipe-row:hover { background: var(--secondary-background-color); }
- .recipe-thumb {
- width: 56px; height: 56px; border-radius: 8px; object-fit: cover;
- flex-shrink: 0; background: var(--secondary-background-color);
- }
- .recipe-thumb-placeholder { background: var(--divider-color); }
- .recipe-row-text { min-width: 0; }
- .recipe-name {
- font-weight: 500; color: var(--primary-text-color);
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- }
- .recipe-meta {
- display: flex; gap: 12px; font-size: 0.85em;
- color: var(--secondary-text-color); white-space: nowrap;
- }
- .recipe-hero {
- width: 100%; max-height: 220px; object-fit: cover;
- border-radius: 8px; margin: 4px 0 8px; display: block;
- }
- .back-btn {
- background: none; border: none; color: var(--primary-color);
- font-size: 0.95em; cursor: pointer; padding: 12px 0 4px; display: block;
- }
- h2 { margin: 4px 0 8px; color: var(--primary-text-color); }
- h4 { margin: 16px 0 4px; color: var(--primary-text-color); }
- ul, ol { margin: 4px 0; padding-left: 20px; color: var(--primary-text-color); }
- ul.ingredients li, ul.cookware-list li { margin: 2px 0; }
- ol.steps li { margin: 8px 0; line-height: 1.5; }
- .ingredient { color: var(--primary-color); font-weight: 500; }
- .cookware { font-style: italic; color: var(--secondary-text-color); }
- .timer { color: var(--warning-color, #ff9800); font-weight: 500; }
- `;
- }
-}
-
-customElements.define("simple-cookcli-card", SimpleCookCliCard);
-
-window.customCards = window.customCards || [];
-window.customCards.push({
- type: "simple-cookcli-card",
- name: "CookCLI Recipes",
- description: "Liste navigable de tes recettes CookCLI, avec ingrédients et étapes.",
-});