/**
* 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 `