Ajout de HelloFresh

This commit is contained in:
2025-09-03 20:17:50 +02:00
parent bcef0a472b
commit d287112b7d
429 changed files with 82881 additions and 22074 deletions

View File

@@ -0,0 +1,638 @@
// 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 });
});