/** * 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 ``; }) .join(""); return `
${pills}
`; } _renderList() { if (this._loading && !this._recipes) { return `
Chargement…
`; } if (this._error && !this._recipes) { return `
${this._escape(this._error)}
`; } if (!this._recipes || this._recipes.length === 0) { return `
Aucune recette trouvée.
`; } const filtered = this._filteredRecipes(); const filterBar = this._renderTagFilter(); if (filtered.length === 0) { return `${filterBar}
Aucune recette avec ce tag.
`; } const cards = filtered .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 `${filterBar}
${cards}
`; } _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 ? `
${this._escape(this._reloadError)}
` : ""; this.shadowRoot.innerHTML = `
${this._escape(title)}
${errorMsg}
${this._renderList()}
`; 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()); } } customElements.define("cookcli-card", CookCliCard); customElements.define("cookcli-card-editor", CookCliCardEditor); window.customCards = window.customCards || []; window.customCards.push({ type: "cookcli-card", name: "CookCLI Recipes", preview: false, description: "Liste navigable de tes recettes CookCLI.", });