From 5fccb5309c450df84abd2b364067e60649cf98fb Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Sun, 10 May 2026 15:21:21 +0000 Subject: [PATCH] feat: Crea una ricetta per ingrediente + fix bottone Apri ricetta + meal non categorizzato MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bottone 'Apri la ricetta': il transfer btn si trasforma direttamente in '📖 Apri la ricetta' dopo il successo (invece di aggiungere un elemento DOM separato) - meal null: chatToRecipe e recipe_from_ingredient non auto-categorizzano il pasto; renderRecipe mostra il tag meal solo se presente - Nuovo endpoint recipe_from_ingredient: genera una ricetta con l'ingrediente selezionato come protagonista, stessa pipeline di chatToRecipe (Gemini + fuzzy-match) - Bottone '👨‍🍳 Crea una ricetta con questo' nel pannello azione degli alimenti (span-2 sotto la griglia 2x2), apre overlay Ricette in loading state --- api/index.php | 93 +++++++++++++++++++++++++++++++++++++++++++- assets/css/style.css | 21 ++++++++++ assets/js/app.js | 50 ++++++++++++++++++------ index.html | 2 +- translations/de.json | 4 +- translations/en.json | 4 +- translations/it.json | 4 +- 7 files changed, 161 insertions(+), 17 deletions(-) diff --git a/api/index.php b/api/index.php index 6ed08a8..1294128 100644 --- a/api/index.php +++ b/api/index.php @@ -111,7 +111,7 @@ function checkRateLimit(string $action): void { } // Determine limit based on action - $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe']; + $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient']; $loginActions = []; $recipeActions = ['generate_recipe', 'generate_recipe_stream']; $errorActions = ['report_error', 'check_update']; @@ -339,6 +339,10 @@ try { chatToRecipe($db); break; + case 'recipe_from_ingredient': + recipeFromIngredient($db); + break; + // ===== BRING! SHOPPING LIST ===== case 'bring_list': bringGetList(); @@ -3630,7 +3634,7 @@ Convert the recipe text below to a JSON object. Return ONLY the JSON, no markdow Fields: - title: string -- meal: one of "colazione","pranzo","cena","dolce","succo" (infer from context, default "pranzo") +- meal: null (do NOT categorize — leave as null always) - persons: integer (number of servings/people, default 2 if not mentioned) - prep_time: string or null - cook_time: string or null @@ -3688,6 +3692,91 @@ PROMPT; echo json_encode(['success' => true, 'recipe' => $recipe]); } +// ===== RECIPE FROM INGREDIENT ===== +function recipeFromIngredient(PDO $db): void { + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) { + echo json_encode(['success' => false, 'error' => 'no_api_key']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $ingredientName = trim($input['ingredient'] ?? ''); + if (empty($ingredientName)) { + echo json_encode(['success' => false, 'error' => 'empty_ingredient']); + return; + } + + // Fetch inventory (same as generateRecipe) + $stmt = $db->query(" + SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, + CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left + FROM inventory i + JOIN products p ON p.id = i.product_id + WHERE i.quantity > 0 + ORDER BY days_left ASC + "); + $items = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $safeName = htmlspecialchars($ingredientName, ENT_QUOTES, 'UTF-8'); + + $prompt = << [['role' => 'user', 'parts' => [['text' => $prompt]]]], + 'generationConfig' => ['temperature' => 0.7, 'maxOutputTokens' => 8192], + ]; + + $result = callGeminiWithFallback($apiKey, $payload, 45); + + if ($result['http_code'] !== 200) { + echo json_encode(['success' => false, 'error' => $result['data']['error']['message'] ?? 'gemini_error']); + return; + } + + $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; + if (empty($text)) { + echo json_encode(['success' => false, 'error' => 'gemini_error']); + return; + } + + $text = preg_replace('/```(?:json)?\s*/i', '', $text); + $text = str_replace('```', '', $text); + $start = strpos($text, '{'); + $end = strrpos($text, '}'); + if ($start === false || $end === false || $end <= $start) { + echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]); + return; + } + $text = substr($text, $start, $end - $start + 1); + + $recipe = json_decode($text, true); + if (!is_array($recipe) || empty($recipe['title'])) { + echo json_encode(['success' => false, 'error' => 'parse_error', 'raw' => mb_substr($text, 0, 500)]); + return; + } + + if (!empty($recipe['ingredients'])) { + _enrichChatIngredients($recipe['ingredients'], $items); + } + + echo json_encode(['success' => true, 'recipe' => $recipe]); +} + function _enrichChatIngredients(array &$ingredients, array $items): void { if (empty($ingredients) || empty($items)) return; diff --git a/assets/css/style.css b/assets/css/style.css index e7421fe..5abbbe2 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -4991,6 +4991,27 @@ body.cooking-mode-active .app-header { background: #4f46e5; } +.btn-recipe-from-ingredient { + grid-column: 1 / -1; + background: linear-gradient(135deg, #0f766e, #0d9488); + color: white; + border: none; + border-radius: 14px; + padding: 14px 20px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btn-recipe-from-ingredient:active { + opacity: 0.85; +} + /* ===== STRUCTURED QUANTITY IN INVENTORY ===== */ .inv-qty-col { display: flex; diff --git a/assets/js/app.js b/assets/js/app.js index 4229f6d..559137a 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -6009,6 +6009,9 @@ function showProductAction() { ✏️ ${t('product.modify_details')}
${t('action.edit_sub')}
+ `; // Secondary: catalog edit link below the buttons (one instance only) let catalogLink = document.getElementById('catalog-edit-link'); @@ -11471,7 +11474,7 @@ function renderRecipe(r) { // Meta tags html += '
'; - html += `${_mealLabel(r.meal)}`; + if (r.meal) html += `${_mealLabel(r.meal)}`; html += `👥 ${r.persons} ${t('recipes.persons_short')}`; if (r.prep_time) html += `🔪 ${r.prep_time}`; if (r.cook_time) html += `🔥 ${r.cook_time}`; @@ -12447,22 +12450,17 @@ async function chatTransferToRecipes(btn, replyText) { if (!recipe.persons && recipe.servings) recipe.persons = recipe.servings; if (!recipe.persons) recipe.persons = 2; await saveRecipeToArchive(recipe); - _cachedRecipe = { meal: recipe.meal || 'pranzo', recipe }; + _cachedRecipe = { meal: recipe.meal || '', recipe }; renderRecipe(recipe); - btn.textContent = '✅ ' + (t('chat.transferred') || 'Aggiunta alle Ricette!'); - btn.disabled = true; - // Add "Apri la ricetta" button next to the transfer button - const openBtn = document.createElement('button'); - openBtn.className = 'btn-chat-use-recipe'; - openBtn.style.marginLeft = '8px'; - openBtn.textContent = '📖 ' + (t('chat.open_recipe') || 'Apri la ricetta'); - openBtn.onclick = () => { + // Transform the transfer button into "Apri la ricetta" + btn.disabled = false; + btn.textContent = '📖 ' + (t('chat.open_recipe') || 'Apri la ricetta'); + btn.onclick = () => { document.getElementById('recipe-overlay').style.display = 'flex'; document.getElementById('recipe-ask').style.display = 'none'; document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-result').style.display = ''; }; - btn.parentNode.insertBefore(openBtn, btn.nextSibling); showToast('✅ ' + (t('chat.transferred') || 'Aggiunta alle Ricette!'), 'success'); } catch (err) { console.error('[chatTransferToRecipes]', err); @@ -12471,6 +12469,36 @@ async function chatTransferToRecipes(btn, replyText) { } } +async function generateRecipeForIngredient(ingredientName) { + if (!_requireGemini()) return; + document.getElementById('recipe-overlay').style.display = 'flex'; + document.getElementById('recipe-ask').style.display = 'none'; + document.getElementById('recipe-loading').style.display = ''; + document.getElementById('recipe-result').style.display = 'none'; + const loadingMsg = document.getElementById('recipe-loading-msg'); + if (loadingMsg) loadingMsg.textContent = '👨‍🍳 ' + (t('recipes.loading_msg') || 'Sto preparando la ricetta...'); + try { + const result = await api('recipe_from_ingredient', {}, 'POST', { ingredient: ingredientName }); + if (!result || !result.success || !result.recipe) { + document.getElementById('recipe-overlay').style.display = 'none'; + showToast('⚠️ ' + (result?.error || t('error.generic') || 'Errore'), 'error'); + return; + } + const recipe = result.recipe; + if (!recipe.persons && recipe.servings) recipe.persons = recipe.servings; + if (!recipe.persons) recipe.persons = 2; + await saveRecipeToArchive(recipe); + _cachedRecipe = { meal: recipe.meal || '', recipe }; + renderRecipe(recipe); + document.getElementById('recipe-loading').style.display = 'none'; + document.getElementById('recipe-result').style.display = ''; + } catch (err) { + console.error('[generateRecipeForIngredient]', err); + document.getElementById('recipe-overlay').style.display = 'none'; + showToast('⚠️ ' + (t('error.connection') || 'Errore di connessione'), 'error'); + } +} + async function sendChatMessage() { const input = document.getElementById('chat-input'); const text = input.value.trim(); diff --git a/index.html b/index.html index 7f6bb83..50c54cb 100644 --- a/index.html +++ b/index.html @@ -1462,6 +1462,6 @@
- + diff --git a/translations/de.json b/translations/de.json index dec5cbe..3674f0b 100644 --- a/translations/de.json +++ b/translations/de.json @@ -193,7 +193,8 @@ "use_qty_sub": "wie viel verwendet", "throw_btn": "🗑️ ENTSORGEN", "throw_sub": "wegwerfen", - "edit_sub": "Ablauf, Ort…" + "edit_sub": "Ablauf, Ort…", + "create_recipe_btn": "Rezept damit erstellen" }, "add": { "title": "Zum Vorrat hinzufügen", @@ -477,6 +478,7 @@ "transferred": "Zu Rezepten hinzugefügt!", "open_recipe": "Rezept öffnen" }, + "action": { "cooking": { "close": "Schließen", "tts_btn": "Vorlesen", diff --git a/translations/en.json b/translations/en.json index cf664a6..6fdef2c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -193,7 +193,8 @@ "use_qty_sub": "how much you used", "throw_btn": "🗑️ DISCARD", "throw_sub": "throw away", - "edit_sub": "expiry, location…" + "edit_sub": "expiry, location…", + "create_recipe_btn": "Create a recipe with this" }, "add": { "title": "Add to Pantry", @@ -477,6 +478,7 @@ "transferred": "Added to Recipes!", "open_recipe": "Open recipe" }, + "action": { "cooking": { "close": "Close", "tts_btn": "Read aloud", diff --git a/translations/it.json b/translations/it.json index e7184c3..157840b 100644 --- a/translations/it.json +++ b/translations/it.json @@ -193,7 +193,8 @@ "use_qty_sub": "quanto ne hai usato", "throw_btn": "🗑️ BUTTA", "throw_sub": "butta il prodotto", - "edit_sub": "scadenza, luogo…" + "edit_sub": "scadenza, luogo…", + "create_recipe_btn": "Crea una ricetta con questo" }, "add": { "title": "Aggiungi alla Dispensa", @@ -477,6 +478,7 @@ "transferred": "Aggiunta alle Ricette!", "open_recipe": "Apri la ricetta" }, + "action": { "cooking": { "close": "Chiudi", "tts_btn": "Leggi ad alta voce",