diff --git a/custom_components/cookcli/www/cookcli-card.js b/custom_components/cookcli/www/cookcli-card.js index 2bacada..660ccbc 100644 --- a/custom_components/cookcli/www/cookcli-card.js +++ b/custom_components/cookcli/www/cookcli-card.js @@ -43,10 +43,25 @@ class CookCliCard extends HTMLElement { this._loading = false; this._error = null; this._lastFetch = 0; + this._selectedTag = 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(); } @@ -96,6 +111,9 @@ class CookCliCard extends HTMLElement { 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 { @@ -118,6 +136,19 @@ class CookCliCard extends HTMLElement { } _onClick(event) { + 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); @@ -130,6 +161,56 @@ class CookCliCard extends HTMLElement { 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)); + } + + _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…
`; @@ -141,7 +222,13 @@ class CookCliCard extends HTMLElement { return `
Aucune recette trouvée.
`; } - const cards = this._recipes + 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)}`); @@ -160,18 +247,24 @@ class CookCliCard extends HTMLElement { }) .join(""); - return `
${cards}
`; + 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; + this.shadowRoot.innerHTML = `
${this._renderList()}
`; + + const next = this.shadowRoot.querySelector(".tag-filter"); + if (next) next.scrollLeft = scrollLeft; } _styles() { @@ -184,7 +277,7 @@ class CookCliCard extends HTMLElement { puis 2 / 3 / 4 colonnes selon la largeur disponible. */ .recipe-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(225px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); gap: 12px; } @@ -239,6 +332,42 @@ class CookCliCard extends HTMLElement { overflow: hidden; text-overflow: ellipsis; } + .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); + } `; } } @@ -251,16 +380,20 @@ class CookCliCard extends HTMLElement { * 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", "dashboard_path", "entry_id"]; +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: @@ -274,15 +407,19 @@ class CookCliCardEditor extends HTMLElement { 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(); } @@ -302,12 +439,17 @@ class CookCliCardEditor extends HTMLElement { .sort((a, b) => a.label.localeCompare(b.label)); } - _buildSchema(options) { + _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, custom_value: true, mode: "dropdown" } }, + selector: { select: { options: dashboardOptions, custom_value: true, mode: "dropdown" } }, }, { name: "entry_id", selector: { config_entry: { integration: "cookcli" } } }, ]; @@ -317,8 +459,8 @@ class CookCliCardEditor extends HTMLElement { if (this._form) return; const form = document.createElement("ha-form"); const options = this._dashboardOptions(); - this._optionsSignature = JSON.stringify(options); - form.schema = this._buildSchema(options); + 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)); @@ -326,24 +468,22 @@ class CookCliCardEditor extends HTMLElement { this._form = form; } - // Formulaire créé une fois ; on ne met à jour que ses propriétés. Le schéma - // n'est reconstruit que si la liste des dashboards a changé (hass, lui, - // change en permanence). _update() { if (!this._form) return; if (this._hass) { this._form.hass = this._hass; - const options = this._dashboardOptions(); - const signature = JSON.stringify(options); - if (signature !== this._optionsSignature) { - this._optionsSignature = signature; - this._form.schema = this._buildSchema(options); - } + } + 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; } @@ -356,6 +496,7 @@ class CookCliCardEditor extends HTMLElement { 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; } @@ -368,6 +509,39 @@ class CookCliCardEditor extends HTMLElement { }) ); } + + // 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);