Correction PDF

This commit is contained in:
2025-09-16 22:20:21 +02:00
parent d9fca86145
commit 41bd8254e7
17 changed files with 591 additions and 110 deletions

View File

@@ -33,13 +33,6 @@
const normalize = (s) => (s ?? "").toString().trim().toLowerCase();
const proxify = (url) => url ? `/HelloFresh/ProxyPdf?url=${encodeURIComponent(url)}` : "";
// Détection iOS (iPhone/iPad et iPadOS mode desktop)
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.userAgent.includes("Mac") && "ontouchend" in document);
els.btnOpen?.addEventListener("click", () => {
const r = RECIPES[CUR]; if (!r?.pdf) return;
const u = `/HelloFresh/ProxyPdf?url=${encodeURIComponent(r.pdf)}`;
if (IS_IOS) window.open(u, "_blank", "noopener"); else openViewer();
});

View File

@@ -37,6 +37,19 @@ const splitTags = (str) => (str || '').split(/[,•]/).map(t => t.trim()).filter
const esc = (s) => { const d = document.createElement('div'); d.textContent = (s ?? ''); return d.innerHTML; };
function capFirst(s) { if (s == null) return ''; s = s.toString().trim(); if (!s) return ''; const f = s[0].toLocaleUpperCase('fr-FR'); return f + s.slice(1); }
const DEBOUNCE_MS = 250;
function debounce(fn, wait = DEBOUNCE_MS) {
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
}
function onlySearchActive() {
return normalize(currentSearchTerm) !== '' &&
appliedFilters.ingredients.length === 0 &&
appliedFilters.tags.length === 0 &&
appliedFilters.timeMin == null &&
appliedFilters.timeMax == null;
}
/***********************
* COLORS for tags (persistées localStorage)
***********************/
@@ -326,28 +339,44 @@ function buildRecipeCardOwned(recipe) {
async function loadRecipes(page = 1) {
try {
const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin);
url.searchParams.set('page', page); url.searchParams.set('count', countPerPage);
const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`);
url.searchParams.set('page', page);
url.searchParams.set('count', countPerPage);
// 👉 On laisse le serveur faire la recherche quand il n'y a QUE la recherche active
if (onlySearchActive()) {
url.searchParams.set('search', currentSearchTerm || '');
}
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const { recipes, currentPage: serverPage, lastPage } = data;
currentPage = serverPage; lastPageGlobal = lastPage;
const grid = document.getElementById('recipeGrid'); grid.innerHTML = '';
if (!recipes || recipes.length === 0) {
grid.innerHTML = '<p class="placeholder">Aucune recette disponible pour le moment.</p>';
renderPagination(currentPage, lastPageGlobal); return;
renderPagination(currentPage, lastPageGlobal);
return;
}
recipes.forEach(r => grid.appendChild(buildRecipeCardList(r)));
if (window.tippy) {
tippy('[data-tippy-content]', { placement: 'top', arrow: true, delay: [100, 0], theme: 'light-border', maxWidth: 250, allowHTML: true });
}
await hydrateIngredientsForPage(recipes);
refreshSelectedBorders();
if ((currentSearchTerm ?? '').trim() !== '') applySearchFilter();
// ⚠️ si tu avais une ancienne fonction applySearchFilter(), on ne l'appelle plus ici.
// if ((currentSearchTerm ?? '').trim() !== '' && typeof applySearchFilter === 'function') applySearchFilter();
refreshDoneFlagsOnVisibleCards();
renderPagination(currentPage, lastPageGlobal);
} catch (e) { console.error('loadRecipes error', e); }
}
async function loadRecipesOwned({ onEmpty = 'placeholder' } = {}) {
const gridOwned = document.getElementById('recipeGridOwned'); if (!gridOwned) return false;
const wasOpen = document.getElementById('recipeOwnedWrap')?.classList.contains('open');
@@ -450,19 +479,40 @@ function anyFilterActive() {
appliedFilters.timeMin != null ||
appliedFilters.timeMax != null;
}
async function fetchAllRecipes() {
if (ALL_RECIPES_CACHE) return ALL_RECIPES_CACHE;
async function fetchAllRecipes(opts = {}) {
const search = (opts.search || '').trim();
if (!search && ALL_RECIPES_CACHE) return ALL_RECIPES_CACHE;
let page = 1, last = 1, all = [];
do {
const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin);
url.searchParams.set('page', page); url.searchParams.set('count', countPerPage);
const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`);
url.searchParams.set('page', page);
url.searchParams.set('count', countPerPage);
if (search) url.searchParams.set('search', search);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
all.push(...(data.recipes || []));
last = data.lastPage || 1; page++;
last = data.lastPage || 1;
page++;
} while (page <= last);
ALL_RECIPES_CACHE = all; return all;
if (!search) ALL_RECIPES_CACHE = all;
return all;
}
async function fetchDetailsBatched(ids, batchSize = 150) {
const out = [];
for (let i = 0; i < ids.length; i += batchSize) {
const part = ids.slice(i, i + batchSize);
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(part.join(','))}`);
if (res.ok) out.push(...(await res.json()));
}
return out;
}
function recipeMatchesFilters(cardMeta) {
const term = normalize(currentSearchTerm);
if (term && !cardMeta.name.includes(term)) return false;
@@ -487,14 +537,16 @@ function extractIngredientNames(ingredientsJson) {
} catch { }
return [...out];
}
async function buildMetaFor(recipes) {
const ids = recipes.map(r => r.id).join(',');
const ids = recipes.map(r => r.id);
const metaById = {};
try {
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(ids)}`);
const details = await res.json(); // [{id, ingredientsJson}]
const details = await fetchDetailsBatched(ids, 150); // ← chunks pour éviter les URLs géantes
details.forEach(d => { metaById[d.id] = { ing: extractIngredientNames(d.ingredientsJson).map(normalize) }; });
} catch (e) { console.warn('details fail', e); }
recipes.forEach(r => {
const tagsVal = (r.tags ?? r.Tags ?? '').trim();
const tags = splitTags(tagsVal).map(normalize);
@@ -502,24 +554,45 @@ async function buildMetaFor(recipes) {
const name = normalize(r.name || '');
metaById[r.id] = Object.assign({ tags, time, name, ing: [] }, metaById[r.id] || {});
});
return metaById;
}
async function applyAllFilters() {
if (!anyFilterActive()) {
const hasSearch = normalize(currentSearchTerm) !== '';
const hasOtherFilters = appliedFilters.ingredients.length || appliedFilters.tags.length ||
appliedFilters.timeMin != null || appliedFilters.timeMax != null;
// A) Rien d'actif → liste classique
if (!hasSearch && !hasOtherFilters) {
CURRENT_CLIENT_LIST = null;
await loadRecipes(1);
updateActiveFilterBadge(); // peut masquer le badge si rien dactif
updateActiveFilterBadge();
return;
}
const all = await fetchAllRecipes();
// B) Uniquement la recherche → serveur (rapide, paginé)
if (hasSearch && !hasOtherFilters) {
CURRENT_CLIENT_LIST = null; // on revient au flux serveur paginé
currentPage = 1;
await loadRecipes(1); // loadRecipes enverra ?search=...
updateActiveFilterBadge();
return;
}
// C) Recherche + (tags/ingrédients/temps) OU filtres sans recherche
// → on réduit d'abord côté serveur avec ?search=..., puis filtre côté client
const all = await fetchAllRecipes({ search: hasSearch ? currentSearchTerm : '' });
const meta = await buildMetaFor(all);
const filtered = all.filter(r => recipeMatchesFilters(meta[r.id] || {}));
CURRENT_CLIENT_LIST = filtered;
currentPage = 1;
lastPageGlobal = Math.max(1, Math.ceil(filtered.length / countPerPage));
await renderClientPage();
updateActiveFilterBadge(); // badge = basé sur appliedFilters UNIQUEMENT
updateActiveFilterBadge();
}
async function renderClientPage() {
const list = CURRENT_CLIENT_LIST || [];
const start = (currentPage - 1) * countPerPage;
@@ -809,9 +882,10 @@ document.addEventListener('DOMContentLoaded', async () => {
// Live search
const searchInput = document.querySelector('#divSearch input');
if (searchInput) {
searchInput.addEventListener('input', async (e) => {
const debounced = debounce(async () => { await applyAllFilters(); }, 250);
searchInput.addEventListener('input', (e) => {
currentSearchTerm = e.target.value;
await applyAllFilters(); // search est immédiat
debounced();
});
}

View File

@@ -12,11 +12,18 @@ 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)
const GET_ALL_RECIPES_URL = "/HelloFresh/GetAllRecipes";
const GET_MY_OWNED_URL = "/HelloFresh/GetRecipesOwned?isUserImportant=true";
const CORE_USERS = Array.isArray(window.CORE_USERS) ? window.CORE_USERS : ["Mae", "Byakuya"];
// État “Ingrédients à avoir”
let LAST_ING_TO_HAD = null;
let LAST_PORTIONS = 1;
let lastNeededSignature = "";
let prevNotShippedCount = -1;
const IS_CORE_USER = (window.IS_CORE_USER === true || window.IS_CORE_USER === 'true');
const ACCENT_RX = /[\u0300-\u036f]/g;
const normalizeName = (s) => (s || "").trim().toLowerCase();
@@ -295,21 +302,40 @@ function reapplyCurrentSearch() {
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()));
if (IS_CORE_USER) {
// 🔒 Vue partagée: ingrédients = uniquement Mae/Byakuya
const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`;
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const rows = await res.json();
window.allRecipes = Array.isArray(rows) ? rows : [];
const items = await buildAggregatedIngredientsFromRows(window.allRecipes);
ALL_AGG_INGS = items;
const list = sortIngredientsForDisplay(applyExclusionsTo(items));
els.list.innerHTML = "";
renderItems(list.length ? list : []);
if (!list.length) els.list.innerHTML = "<em>Aucun ingrédient</em>";
return;
}
// Utilisateur "tiers" : ingrédients = SES recettes (déjà en place)
const mineAgg = await buildMyAggregatedIngredients();
ALL_AGG_INGS = mineAgg;
const list = sortIngredientsForDisplay(applyExclusionsTo(mineAgg));
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"; }
renderItems(list.length ? list : []);
if (!list.length) els.list.innerHTML = "<em>Aucun ingrédient</em>";
} catch (e) {
console.error("[ingredients] loadAndRenderIngredients 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"));
@@ -536,13 +562,15 @@ function buildRecipeCardList(recipe) {
});
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);
if (IS_CORE_USER) {
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
@@ -558,42 +586,227 @@ function buildRecipeCardList(recipe) {
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 { }
// === Vue "partagée" (Mae/Byakuya) : ne montrer QUE leurs recettes ===
if (IS_CORE_USER) {
try {
const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`;
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const onlyCore = await res.json();
window.allRecipes = Array.isArray(onlyCore) ? onlyCore : [];
strip.innerHTML = "";
if (!window.allRecipes.length) {
strip.innerHTML = '<p class="placeholder">Aucune recette (Mae/Byakuya).</p>';
updateNeededList();
return;
}
window.allRecipes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" }));
window.allRecipes.forEach(r => strip.appendChild(buildRecipeCardList(r)));
updateNeededList("afterCoreRecipes");
} catch (e) {
console.error("loadAllRecipesHorizontal (core) error", e);
strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>';
updateNeededList();
}
return;
}
// === Utilisateur "tiers" : uniquement SES recettes ===
try {
const res = await fetch(GET_ALL_RECIPES_URL, { credentials: "same-origin" });
const res = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const all = await res.json();
window.allRecipes = all;
const mine = await res.json();
window.allRecipes = Array.isArray(mine) ? mine : [];
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);
if (!window.allRecipes.length) {
strip.innerHTML = '<p class="placeholder">Vous navez pas encore de recettes sélectionnées.</p>';
updateNeededList();
return;
}
window.allRecipes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" }));
window.allRecipes.forEach(r => strip.appendChild(buildRecipeCardList(r)));
updateNeededList("afterGetMyRecipes");
} catch (e) {
console.error("loadAllRecipesHorizontal error", e);
console.error("loadAllRecipesHorizontal (mine) error", e);
strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>';
updateNeededList();
}
}
async function refreshOwnedSet() {
try {
const r = await fetch("/HelloFresh/GetOwnedIngredients");
if (r.ok) {
const names = await r.json();
OWNED_SET = new Set((names || []).map(normalizeName));
}
} catch { /* ignore */ }
}
async function buildAggregatedIngredientsFromRows(rows) {
const ids = (rows || []).map(r => r.id);
const details = await fetchDetailsBatched(ids);
const byId = new Map(details.map(d => [String(d.id), d]));
await refreshOwnedSet();
const rawItems = [];
for (const r of (rows || [])) {
const det = byId.get(String(r.id));
if (!det || !det.ingredientsJson) continue;
let portions = Number(r.portions || 1);
if (!Number.isFinite(portions) || portions < 1) portions = 1;
const base = Math.min(4, Math.max(1, portions));
const scale = (portions <= 4) ? 1 : (portions / 4);
let obj; try { obj = JSON.parse(det.ingredientsJson); } catch { obj = null; }
const arr = (obj && Array.isArray(obj[String(base)])) ? obj[String(base)] : [];
for (const el of arr) {
const it = normalizeItem(el);
if (!it.Name) continue;
const { value, unit } = parseQuantity(it.Quantity);
if (!Number.isNaN(value) && scale !== 1) {
const scaled = value * scale;
it.Quantity = unit ? `${fmtNumber(scaled)} ${unit}` : fmtNumber(scaled);
}
it.Owned = isOwned(it.Name);
rawItems.push(it);
}
}
return aggregateRawItems(rawItems);
}
function fmtNumber(n) {
const i = Math.round(n);
return (Math.abs(n - i) < 1e-9) ? String(i) : String(Number(n.toFixed(2))).replace(/\.?0+$/, '');
}
function aggregateRawItems(rawItems) {
// Somme numérique par (name, unit) + “meilleure” quantité textuelle sinon
const numMap = new Map(); // key: name##unit -> {sum, unit, item}
const textMap = new Map(); // key: name -> {set:Set(qty), item}
for (const raw of rawItems) {
const it = normalizeItem(raw);
if (!it.Name) continue;
const keyName = normalizeName(it.Name);
const q = it.Quantity || "";
const { value, unit } = parseQuantity(q);
if (!Number.isNaN(value)) {
const k = `${keyName}##${unit || ""}`;
const prev = numMap.get(k);
if (!prev) {
numMap.set(k, { sum: value, unit: unit || "", item: { ...it } });
} else {
prev.sum += value;
// garder une image si absente
if (!prev.item.Image && it.Image) prev.item.Image = it.Image;
prev.item.Owned = prev.item.Owned || it.Owned;
}
} else {
const p = textMap.get(keyName);
if (!p) {
const s = new Set(); if (q) s.add(q);
textMap.set(keyName, { set: s, item: { ...it } });
} else {
if (q) p.set.add(q);
if (!p.item.Image && it.Image) p.item.Image = it.Image;
p.item.Owned = p.item.Owned || it.Owned;
}
}
}
const out = [];
// numérique → format “val unit”
for (const { sum, unit, item } of numMap.values()) {
const qty = unit ? `${fmtNumber(sum)} ${unit}` : fmtNumber(sum);
out.push({ ...item, Quantity: qty });
}
// textuel → pickBestQuantity
for (const { set, item } of textMap.values()) {
const qty = pickBestQuantity(Array.from(set));
out.push({ ...item, Quantity: qty });
}
return out;
}
async function fetchDetailsBatched(ids, batchSize = 120) {
const all = [];
for (let i = 0; i < ids.length; i += batchSize) {
const part = ids.slice(i, i + batchSize);
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(part.join(','))}`);
if (res.ok) all.push(...await res.json());
}
return all;
}
async function buildMyAggregatedIngredients() {
// 1) mes recettes + portions
const r = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const mine = await r.json(); // [{id, portions, ...}]
if (!Array.isArray(mine) || mine.length === 0) return [];
// 2) détails (ingrédients par 1..4)
const ids = mine.map(x => x.id);
const details = await fetchDetailsBatched(ids);
const byId = new Map(details.map(d => [String(d.id), d]));
// 3) Owned flags (facultatif, pour tri visuel)
await refreshOwnedSet();
// 4) construire la liste brute + mise à léchelle des quantités (>4 portions)
const rawItems = [];
for (const rcp of mine) {
const id = String(rcp.id);
const det = byId.get(id);
if (!det || !det.ingredientsJson) continue;
let portions = Number(rcp.portions || 1);
if (!Number.isFinite(portions) || portions < 1) portions = 1;
const base = Math.min(4, Math.max(1, portions));
const scale = (portions <= 4) ? 1 : (portions / 4);
let obj;
try { obj = JSON.parse(det.ingredientsJson); } catch { obj = null; }
const arr = (obj && Array.isArray(obj[String(base)])) ? obj[String(base)] : [];
for (const el of arr) {
const it = normalizeItem(el);
if (!it.Name) continue;
// scale quantité si numérique
const { value, unit } = parseQuantity(it.Quantity);
if (!Number.isNaN(value) && scale !== 1) {
const scaled = value * scale;
it.Quantity = unit ? `${fmtNumber(scaled)} ${unit}` : fmtNumber(scaled);
}
it.Owned = isOwned(it.Name);
rawItems.push(it);
}
}
return aggregateRawItems(rawItems);
}
// ======================
// Init
// ======================

View File

@@ -55,6 +55,22 @@
})(jQuery); // End of use strict
async function openRecipePdfById(id, initialUrl) {
let pdfUrl = initialUrl || '';
if (!pdfUrl) {
try {
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(id)}`);
if (res.ok) {
const arr = await res.json();
pdfUrl = (arr?.[0]?.pdf || arr?.[0]?.Pdf || '');
}
} catch { /* ignore */ }
}
if (!pdfUrl) { alert("Aucun PDF pour cette recette."); return; }
const proxied = `/HelloFresh/ProxyPdf?url=${encodeURIComponent(pdfUrl)}`;
showPdfModal(proxied);
}
/**
* 💶 Formate un nombre en chaîne de caractères au format français avec division par 1000.
* Exemples :