Files
administration/wwwroot/js/HelloFresh/ingredients.js
2025-09-03 20:17:50 +02:00

639 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// wwwroot/js/ingredients.js
// ======================
// Globals & Helpers
// ======================
let OWNED_SET = new Set();
let ALL_AGG_INGS = [];
let AI_CTRL = null;
const AI_DELAY = 300;
const SEARCH_STATE = { query: "", mode: null };
const els = { list: null, search: null };
// --- Exclusions par recette ---
const RECIPE_ING_CACHE = new Map(); // id -> { '1': [noms], '2': [...], ... }
const EXCLUDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés)
// État “Ingrédients à avoir”
let LAST_ING_TO_HAD = null;
let LAST_PORTIONS = 1;
let lastNeededSignature = "";
let prevNotShippedCount = -1;
const ACCENT_RX = /[\u0300-\u036f]/g;
const normalizeName = (s) => (s || "").trim().toLowerCase();
const normalizeText = (s) => {
let t = (s || "").toLowerCase();
try { t = t.normalize("NFD").replace(ACCENT_RX, ""); } catch { }
return t.replace(/\s/g, " ").trim();
};
// Fractions unicode → décimaux
const VULGAR_FRAC = { "½": "0.5", "¼": "0.25", "¾": "0.75", "⅓": "0.3333", "⅔": "0.6667", "⅛": "0.125", "⅜": "0.375", "⅝": "0.625", "⅞": "0.875" };
// Rang unités
const UNIT_RANK = { "pièce": 6, "piece": 6, "pcs": 6, "pc": 6, "cs": 5, "tbsp": 5, "cc": 4, "tsp": 4, "g": 3, "kg": 3, "mg": 3, "ml": 3, "cl": 3, "l": 3, "": 1 };
const nameKey = (s) => (s || "").trim().toLowerCase();
// --- Exclusions par recette (ingrédients "principaux" ET "à avoir") ---
const EXCLUDED_NEEDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés)
function excludedUnionFrom(map) {
const out = new Set();
for (const set of map.values()) for (const n of set) out.add(n);
return out;
}
function applyNeededExclusions(items) {
if (EXCLUDED_NEEDED_BY_RECIPE.size === 0) return items;
const ex = excludedUnionFrom(EXCLUDED_NEEDED_BY_RECIPE);
return (items || []).filter(it => !ex.has(nameKey(it?.Name)));
}
// Récupère les noms "à avoir" dune recette (à partir de lobjet recette du strip)
function getNeededNamesForRecipeObj(recipe, portions) {
if (!recipe) return [];
const det = detectIngredientsToHadProp(recipe);
const arr = det ? (det.parsed?.[String(portions)] || []) : [];
return (Array.isArray(arr) ? arr : [])
.map(el => (el?.Name ?? el?.name ?? "").toString().trim())
.filter(Boolean);
}
(function addExcludeStyle() {
const id = "hf-exclude-style";
if (document.getElementById(id)) return;
const st = document.createElement("style"); st.id = id;
st.textContent = `
#recipeStrip .card.card--excluded{ opacity:.45; outline:2px dashed #888; }
#recipeStrip .card.card--excluded:hover{ opacity:.7; }
`;
document.head.appendChild(st);
})();
// Retourne les noms d'ingrédients (par portion 1..4) pour une recette
async function getIngredientNamesForRecipe(recipeId, portions = 1) {
const id = String(recipeId);
if (!RECIPE_ING_CACHE.has(id)) {
try {
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(id)}`);
if (!res.ok) throw new Error("HTTP " + res.status);
const arr = await res.json();
const one = Array.isArray(arr) ? arr.find(x => String(x.id) === id) : null;
const map = {};
if (one && one.ingredientsJson) {
try {
const obj = JSON.parse(one.ingredientsJson);
for (const k of ["1", "2", "3", "4"]) {
const list = Array.isArray(obj[k]) ? obj[k] : [];
map[k] = list.map(el => (el?.Name ?? el?.name ?? "").toString().trim()).filter(Boolean);
}
} catch { /* ignore */ }
}
RECIPE_ING_CACHE.set(id, map);
} catch (e) {
console.warn("[ingredients] GetRecipesDetails fail for", id, e);
RECIPE_ING_CACHE.set(id, {}); // évite re-fetch en boucle
}
}
const cache = RECIPE_ING_CACHE.get(id) || {};
return (cache[String(portions)] || []).slice();
}
// Union des exclusions (Set global)
function excludedUnion() {
const out = new Set();
for (const set of EXCLUDED_BY_RECIPE.values()) {
for (const n of set) out.add(n);
}
return out;
}
// Filtre une liste ditems en appliquant les exclusions
function applyExclusionsTo(items) {
if (EXCLUDED_BY_RECIPE.size === 0) return items;
const ex = excludedUnion();
return (items || []).filter(it => !ex.has(nameKey(it?.Name)));
}
// ========== Normalisation quantités ==========
function normalizeQuantityText(q) {
if (!q) return "";
let s = String(q).toLowerCase();
s = s.replace(/[½¼¾⅓⅔⅛⅜⅝⅞]/g, m => VULGAR_FRAC[m] || m);
s = s.replace(/\b(\d+)\s*\/\s*(\d+)\b/g, (_, a, b) => {
const num = parseFloat(a), den = parseFloat(b);
return (!den || isNaN(num) || isNaN(den)) ? `${a}/${b}` : (num / den).toString();
});
s = s.replace(/,/g, ".").replace(/\s+/g, " ").trim();
s = s
.replace(/\b(pieces?)\b/g, "pièce")
.replace(/\b(pcs?)\b/g, "pièce")
.replace(/\b(gr|grammes?|grams?)\b/g, "g")
.replace(/\b(millilitres?|milliliters?|mls?)\b/g, "ml")
.replace(/\b(litres?|liters?)\b/g, "l");
s = s
.replace(/\b(c(?:uill[eè]re)?s?\s*(?:a|à)\s*s(?:oupe)?s?\.?)\b/gi, "cs")
.replace(/\b(c(?:uill[eè]re)?s?\s*(?:a|à)\s*caf[eé]s?s?\.?)\b/gi, "cc");
s = s.replace(/[\/]+/g, " ").trim();
return s;
}
function parseQuantity(q) {
const s = normalizeQuantityText(q);
const m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*([a-zéû]+)?$/i);
if (!m) return { value: NaN, unit: "", raw: s };
return { value: parseFloat(m[1]), unit: (m[2] || "").trim(), raw: s };
}
function quantityScore(q) {
const { value, unit, raw } = parseQuantity(q);
const isNum = !Number.isNaN(value);
const unitRank = UNIT_RANK[unit] ?? (unit ? 2 : 1);
return { isNum, unitRank, value: isNum ? value : -Infinity, raw };
}
function pickBestQuantity(qtys) {
const arr = Array.from(qtys || []);
if (arr.length === 0) return "";
const dedup = Array.from(new Map(arr.map(q => [normalizeQuantityText(q), q])).values());
dedup.sort((a, b) => {
const sa = quantityScore(a), sb = quantityScore(b);
if (sa.isNum !== sb.isNum) return sa.isNum ? -1 : 1;
if (sa.unitRank !== sb.unitRank) return sb.unitRank - sa.unitRank;
if (sa.value !== sb.value) return sb.value - sa.value;
return sa.raw.localeCompare(sb.raw, "fr");
});
return normalizeQuantityText(dedup[0]);
}
// ========== Ingrédients utils ==========
function normalizeItem(it) {
if (!it || typeof it !== "object") return { Name: "", Quantity: "", Image: "", Owned: false };
return {
Name: it.Name ?? it.name ?? "",
Quantity: it.Quantity ?? it.quantity ?? "",
Image: it.Image ?? it.image ?? "",
Owned: Boolean(it.Owned ?? it.owned ?? false),
};
}
function isOwned(name) { return OWNED_SET.has(normalizeName(name)); }
function mergeDuplicates(items) {
const map = new Map();
for (const raw of items) {
const it = normalizeItem(raw);
if (!it.Name) continue;
const key = normalizeName(it.Name);
const prev = map.get(key);
if (!prev) {
map.set(key, { ...it, _qty: new Set(it.Quantity ? [it.Quantity] : []) });
} else {
if (!prev.Image && it.Image) prev.Image = it.Image;
if (it.Quantity) prev._qty.add(it.Quantity);
prev.Owned = prev.Owned || it.Owned;
}
}
return Array.from(map.values()).map(x => ({ ...x, Quantity: pickBestQuantity(x._qty) }));
}
function sortIngredientsForDisplay(items) {
return items.map(normalizeItem).filter(x => x.Name).sort((a, b) => {
const ao = isOwned(a.Name) ? 1 : 0, bo = isOwned(b.Name) ? 1 : 0;
if (ao !== bo) return ao - bo;
return (a.Name || "").localeCompare((b.Name || ""), "fr", { sensitivity: "base" });
});
}
// ========== UI cartes ingrédients ==========
function renderIngredientCard(ingRaw) {
const ing = normalizeItem(ingRaw);
const div = document.createElement("div");
div.className = "card";
div.dataset.name = ing.Name;
if (isOwned(ing.Name)) div.classList.add("checked");
div.innerHTML = `
<div class="image-container">
<img src="${ing.Image || ""}" alt="${ing.Name}">
</div>
<div class="card-content">
<h3>${ing.Name || "(ingrédient)"}</h3>
<div class="footerCard">
<div class="selection-slot"></div>
<div class="recipe-info">
<span class="prep-time">${ing.Quantity || ""}</span>
</div>
</div>
</div>
`;
let busy = false;
div.addEventListener("click", async () => {
if (busy) return; busy = true;
const name = div.dataset.name, key = normalizeName(name), was = div.classList.contains("checked");
div.classList.toggle("checked");
try {
const res = await fetch("/HelloFresh/ToggleOwnedIngredient", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(name),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const { status } = await res.json();
if (status === "added") OWNED_SET.add(key);
else if (status === "removed") OWNED_SET.delete(key);
else div.classList.toggle("checked", was);
const idx = ALL_AGG_INGS.findIndex(x => normalizeName(x.Name) === key);
if (idx !== -1) ALL_AGG_INGS[idx].Owned = (status === "added");
} catch (e) {
console.error("Erreur toggle ingrédient", e);
div.classList.toggle("checked", was);
} finally {
busy = false; reapplyCurrentSearch();
}
});
return div;
}
function renderItems(items) {
if (!els.list) return;
els.list.innerHTML = "";
items.forEach(ing => els.list.appendChild(renderIngredientCard(ing)));
}
// ========== Recherche ==========
async function applyAISearch(q) {
SEARCH_STATE.query = q; SEARCH_STATE.mode = q ? "ai" : null;
if (!q?.trim()) { renderItems(sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]))); return; }
if (els.list) els.list.innerHTML = "<em>Recherche IA…</em>";
try {
if (AI_CTRL) AI_CTRL.abort(); AI_CTRL = new AbortController();
const res = await fetch("/HelloFresh/SmartSearch?debug=true", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: q }), signal: AI_CTRL.signal
});
const raw = await res.clone().text();
if (!res.ok) { console.warn("AI SmartSearch HTTP", res.status, raw); applyLiveFilter(q); return; }
const data = JSON.parse(raw);
const items = Array.isArray(data?.items) ? data.items : [];
if (!items.length) { applyLiveFilter(q); return; }
renderItems(sortIngredientsForDisplay(mergeDuplicates(items)));
} catch (err) {
if (err.name === "AbortError") console.log("[AI] annulée");
else { console.error("AI SmartSearch error:", err); applyLiveFilter(q); }
} finally { AI_CTRL = null; }
}
function applyLiveFilter(q) {
SEARCH_STATE.query = String(q ?? ""); SEARCH_STATE.mode = q ? "live" : null;
const nq = normalizeText(q);
if (!nq) { renderItems(sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]))); return; }
const filtered = ALL_AGG_INGS.filter(x => normalizeText(x.Name).includes(nq));
const list = sortIngredientsForDisplay(mergeDuplicates(applyExclusionsTo(filtered)));
renderItems(list);
}
function reapplyCurrentSearch() {
const q = SEARCH_STATE.query || "";
if (!q) {
const base = sortIngredientsForDisplay(mergeDuplicates([...ALL_AGG_INGS]));
renderItems(applyExclusionsTo(base));
return;
}
if (SEARCH_STATE.mode === "ai") applyAISearch(q); else applyLiveFilter(q);
}
// ========== Chargement liste principale ==========
async function loadAndRenderIngredients() {
if (!els.list) return;
els.list.innerHTML = "Chargement…";
try {
const res = await fetch("/HelloFresh/AggregatedIngredients");
if (!res.ok) { const txt = await res.text().catch(() => ""); console.error("[AggregatedIngredients] HTTP", res.status, txt); els.list.innerHTML = `Erreur chargement ingrédients (${res.status})`; return; }
const data = await res.json();
ALL_AGG_INGS = Array.isArray(data) ? data : [];
OWNED_SET = new Set(ALL_AGG_INGS.filter(x => x?.Name && x.Owned === true).map(x => x.Name.trim().toLowerCase()));
els.list.innerHTML = "";
const initialMerged = mergeDuplicates([...ALL_AGG_INGS]);
const initial = sortIngredientsForDisplay(applyExclusionsTo(initialMerged));
els.list.innerHTML = "";
renderItems(initial.length ? initial : []);
if (!initial.length) els.list.innerHTML = "<em>Aucun ingrédient</em>";
} catch (e) { console.error("[AggregatedIngredients] exception", e); els.list.innerHTML = "Erreur chargement ingrédients"; }
}
// ========== Not shipped (DOM) + BDD ==========
function getNotShippedIngredientsFromDOM(root = document) {
const nodes = Array.from(root.querySelectorAll(".ingredient-item-not-shipped"));
return nodes.map(n => {
const name = (n.querySelector('[data-test="ingredient-name"], .ingredient-name, .name, h3, span')?.textContent || n.getAttribute("data-name") || n.querySelector("img")?.alt || "").trim();
const qty = (n.querySelector('[data-test="ingredient-quantity"], .ingredient-amount, .quantity, .qty')?.textContent || n.getAttribute("data-quantity") || "").trim();
const img = (n.querySelector("img")?.src || n.getAttribute("data-img") || "").trim();
return normalizeItem({ Name: name, Quantity: qty, Image: img, Owned: isOwned(name) });
}).filter(x => x.Name);
}
// Détecte la propriété "ingredients à avoir" dans un objet recette,
// même si le nom varie (ingredientsToHad / IngredientsToHad / pantry / etc.)
function detectIngredientsToHadProp(recipe) {
if (!recipe || typeof recipe !== "object") return null;
// Candidats fréquents (essayés d'abord)
const CANDIDATES = [
"ingredientsToHad", "IngredientsToHad", "ingredients_to_had",
"ingredientsToHaveAtHome", "ingredientsToHave", "toHaveAtHome",
"pantry", "pantryItems", "pantry_ingredients", "ingredientsPantry"
];
for (const key of CANDIDATES) {
if (key in recipe && recipe[key]) {
try {
const val = recipe[key];
const obj = (typeof val === "string") ? JSON.parse(val) : val;
if (obj && typeof obj === "object" &&
(Array.isArray(obj["1"]) || Array.isArray(obj["2"]) || Array.isArray(obj["3"]) || Array.isArray(obj["4"]))) {
return { prop: key, parsed: obj };
}
} catch { }
}
}
// Heuristique : scanne toutes les props à la recherche dun objet 1..4 -> array dobjets Name/Quantity
for (const [key, val] of Object.entries(recipe)) {
if (!val) continue;
try {
const obj = (typeof val === "string") ? JSON.parse(val) : val;
if (obj && typeof obj === "object") {
const arr = obj["1"] || obj["2"] || obj["3"] || obj["4"];
if (Array.isArray(arr) && arr.length) {
const it = arr[0] || {};
if (typeof it === "object" && ("Name" in it || "name" in it)) {
return { prop: key, parsed: obj };
}
}
}
} catch { }
}
return null;
}
// Lit une recette en tolérant la casse / formats
function getIngredientsToHadFromRecipe(r) {
const det = detectIngredientsToHadProp(r);
if (!det) return [];
const pRaw = r?.portions ?? r?.Portions ?? 0;
const portions = clampQty(pRaw) + 1; // 1..4
const list = Array.isArray(det.parsed[portions]) ? det.parsed[portions] : [];
return list.map(normalizeItem);
}
function collectIngredientsToHadFromAllRecipes() {
const res = [];
const all = Array.isArray(window.allRecipes) ? window.allRecipes : [];
for (const r of all) res.push(...getIngredientsToHadFromRecipe(r));
return res;
}
function computeSignature(items) {
const merged = mergeDuplicates(items || []);
return JSON.stringify(merged.map(i => ({ n: normalizeName(i.Name), q: normalizeQuantityText(i.Quantity), img: i.Image || "" })));
}
function renderNeededList(itemsArr) {
const container = document.getElementById("neededIngredients");
if (!container) return;
const merged = mergeDuplicates(itemsArr || []);
const sig = computeSignature(merged);
if (sig === lastNeededSignature) return;
lastNeededSignature = sig;
container.innerHTML = "";
for (const ing of merged) {
const li = document.createElement("li");
li.className = "card needed";
li.innerHTML = `
<div class="image-container"><img src="${ing.Image || ""}" alt="${ing.Name}"></div>
<div class="card-content">
<h3>${ing.Name || "(ingrédient)"}</h3>
<div class="footerCard"><div class="recipe-info"></div></div>
</div>`;
container.appendChild(li);
}
}
function updateNeededList(trigger = "manual") {
const container = document.getElementById("neededIngredients");
if (!container) {
console.debug("[needed]", trigger, "→ container #neededIngredients introuvable (on réessaiera)");
return;
}
console.debug("[needed]", trigger, "→ container OK");
// 1) DOM (non livrés)
const domItems = getNotShippedIngredientsFromDOM();
if (domItems.length) {
const filtered = applyNeededExclusions(domItems);
renderNeededList(filtered);
return;
}
// 2) Agrégation de TOUTES les recettes
const aggregated = collectIngredientsToHadFromAllRecipes();
if (aggregated.length) {
const filtered = applyNeededExclusions(aggregated);
renderNeededList(filtered);
return;
}
// 3) Dernière recette vue (fallback)
if (LAST_ING_TO_HAD) {
try {
const parsed = JSON.parse(LAST_ING_TO_HAD || "{}");
const list = Array.isArray(parsed[LAST_PORTIONS]) ? parsed[LAST_PORTIONS] : [];
const filtered = applyNeededExclusions(list);
if (filtered.length) {
renderNeededList(filtered);
return;
}
} catch (e) { /* ... */ }
}
// 4) Rien
const emptySig = "EMPTY";
if (lastNeededSignature !== emptySig) {
lastNeededSignature = emptySig;
container.innerHTML = "<p>Aucun ingrédient à afficher.</p>";
}
console.warn("[needed]", trigger, "→ aucun item");
}
// Scan DOM non-livrés (anti-boucle)
function scanNotShippedAndUpdate() {
const count = document.querySelectorAll(".ingredient-item-not-shipped").length;
if (count !== prevNotShippedCount) { prevNotShippedCount = count; updateNeededList(); }
}
// ========== Recettes (strip) ==========
const ownedMap = new Map();
const MAX_PORTIONS = 14;
const clampQty = n => Math.max(0, Math.min(3, Number(n) || 0));
const truncate = (s, m) => (s && s.length > m ? s.slice(0, m) + "…" : (s ?? ""));
function setSelectedUI(card, selected) {
const slot = card?.querySelector?.(".selection-slot"); if (!slot) return;
if (selected) {
slot.classList.add("active");
if (!slot.querySelector(".selection-indicator")) {
slot.innerHTML = `<div class="selection-indicator"><span class="selection-text">Sélectionné</span></div>`;
}
} else { slot.classList.remove("active"); slot.innerHTML = ""; }
}
function applySelectionUIById(id) {
const card = document.getElementById(String(id));
const selected = (ownedMap.get(String(id)) || 0) > 0;
setSelectedUI(card, selected);
}
function buildLabels(tagsStr) {
const wrap = document.createElement("div");
wrap.className = "label-container";
const tags = (tagsStr || "").split(/[,•]/).map(t => t.trim()).filter(Boolean).filter(t => t !== "•" && t !== "\u2022");
tags.forEach(tag => {
const span = document.createElement("span");
span.className = "label-badge";
const c = (window.labelColors || {})[tag];
span.style.backgroundColor = c?.bg ?? "#eee";
span.style.color = c?.color ?? "#333";
span.textContent = tag;
wrap.appendChild(span);
});
return wrap;
}
function buildRecipeCardList(recipe) {
const card = document.createElement("div");
card.className = "card";
card.id = recipe.id ?? recipe.Id;
card.innerHTML = `
<div class="image-container"><img src="${recipe.image}" alt="${truncate(recipe.name, 10)}"></div>
<div class="card-content">
<h3>${recipe.name}</h3>
<div class="footerCard"><div class="recipe-info"><span class="prep-time">${recipe.tempsDePreparation}min</span></div></div>
</div>
`.replace(/\${/g, "${");
card.addEventListener("click", async (e) => {
if (e.ctrlKey) return; // laisse le Ctrl+clic tranquille
const id = recipe.id ?? recipe.Id;
const portions = clampQty(recipe.portions ?? recipe.Portions ?? 0) + 1; // 1..4
if (EXCLUDED_BY_RECIPE.has(String(id))) {
// ré-afficher
EXCLUDED_BY_RECIPE.delete(String(id));
EXCLUDED_NEEDED_BY_RECIPE.delete(String(id)); // 👈 aussi pour "à avoir"
card.classList.remove("card--excluded");
} else {
// cacher
const namesMain = await getIngredientNamesForRecipe(id, portions); // déjà en place chez toi
EXCLUDED_BY_RECIPE.set(String(id), new Set(namesMain.map(nameKey)));
const namesNeeded = getNeededNamesForRecipeObj(recipe, portions); // 👈 "à avoir" depuis lobjet recette
EXCLUDED_NEEDED_BY_RECIPE.set(String(id), new Set(namesNeeded.map(nameKey)));
card.classList.add("card--excluded");
}
// Re-rendre les deux panneaux
reapplyCurrentSearch(); // liste principale
updateNeededList("exclude-toggle"); // “à avoir”
});
const usersVal = recipe.Users ?? recipe.users ?? recipe.User ?? recipe.user ?? [];
const usersArr = Array.isArray(usersVal) ? usersVal.filter(Boolean) : String(usersVal || "").split(/[,•]/).map(s => s.trim()).filter(Boolean);
if (usersArr.length) {
const img = card.querySelector(".image-container");
const wrap = document.createElement("div"); wrap.className = "label-container";
usersArr.forEach(u => { const span = document.createElement("span"); span.className = "label-badge user-badge"; span.textContent = u; wrap.appendChild(span); });
img?.appendChild(wrap);
}
// Mémorise la dernière recette pour un fallback, via détection auto
const det = detectIngredientsToHadProp(recipe);
const portions = clampQty(recipe.portions ?? recipe.Portions ?? 0) + 1;
LAST_PORTIONS = portions;
LAST_ING_TO_HAD = det ? JSON.stringify(det.parsed) : null;
console.debug("[needed] buildRecipe detected prop:", det?.prop || "(none)", "portions:", portions);
updateNeededList("buildRecipe");
return card;
}
const GET_ALL_RECIPES_URL = "/HelloFresh/GetAllRecipes";
async function loadAllRecipesHorizontal() {
const strip = document.getElementById("recipeStrip");
if (!strip) return;
try {
const r = await fetch("/HelloFresh/GetRecipesOwned?isUserImportant=false", { credentials: "same-origin" });
if (r.ok) { const data = await r.json(); ownedMap.clear(); (data || []).forEach(x => ownedMap.set(String(x.id), clampQty(x.portions || 0))); }
} catch { }
try {
const res = await fetch(GET_ALL_RECIPES_URL, { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const all = await res.json();
window.allRecipes = all;
strip.innerHTML = "";
if (!Array.isArray(all) || all.length === 0) { strip.innerHTML = '<p class="placeholder">Aucune recette disponible.</p>'; updateNeededList(); return; }
all.sort((a, b) => {
const ua = String(a.userId ?? ""), ub = String(b.userId ?? "");
if (ua !== ub) return ua.localeCompare(ub);
return String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" });
});
all.forEach(r => strip.appendChild(buildRecipeCardList(r)));
updateNeededList("afterGetAllRecipes");
console.debug("[needed] recipes loaded:", Array.isArray(window.allRecipes) ? window.allRecipes.length : 0);
} catch (e) {
console.error("loadAllRecipesHorizontal error", e);
strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>';
updateNeededList();
}
}
// ======================
// Init
// ======================
document.addEventListener("DOMContentLoaded", () => {
els.list = document.getElementById("ingredientsList") || null;
els.search = document.getElementById("hfSearch") || null;
document.querySelectorAll(".dropdown-toggle").forEach(el => el.addEventListener("click", (e) => e.preventDefault()));
if (els.search) {
let aiTimer;
els.search.addEventListener("input", (e) => { clearTimeout(aiTimer); const val = e.target.value || ""; aiTimer = setTimeout(() => applyAISearch(val), AI_DELAY); });
els.search.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); clearTimeout(aiTimer); applyAISearch(els.search.value || ""); } });
} else {
console.error("[ingredients] #hfSearch introuvable dans le DOM");
}
loadAndRenderIngredients();
loadAllRecipesHorizontal();
// Premier rendu (même si le UL n'est pas encore dans le DOM, on loggue)
updateNeededList("dom-ready");
// Petits réessaies pour les pages qui injectent tardivement le UL / les cartes
setTimeout(() => updateNeededList("post-300ms"), 300);
setTimeout(() => updateNeededList("post-1200ms"), 1200);
const mo = new MutationObserver(() => {
// Throttle : on regroupe les mutations
if (typeof window !== "undefined") {
if (mo._scheduled) return;
mo._scheduled = true;
setTimeout(() => {
mo._scheduled = false;
updateNeededList("mutation");
}, 120);
} else {
updateNeededList("mutation");
}
});
mo.observe(document.body, { childList: true, subtree: true });
});