feat: Crea una ricetta per ingrediente + fix bottone Apri ricetta + meal non categorizzato
- 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
This commit is contained in:
+91
-2
@@ -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 = <<<PROMPT
|
||||
Generate a recipe in Italian that uses "{$safeName}" as a main ingredient.
|
||||
Return ONLY a JSON object, no markdown.
|
||||
|
||||
Fields:
|
||||
- title: string (Italian recipe name)
|
||||
- meal: null (do NOT categorize)
|
||||
- persons: 2
|
||||
- prep_time: string or null
|
||||
- cook_time: string or null
|
||||
- ingredients: array of {"name":"...","qty":"...","qty_number":0.0,"unit":"g|ml|pz|conf|kg|l","from_pantry":true}
|
||||
— "{$safeName}" MUST be the first ingredient; set from_pantry=true for ALL
|
||||
- steps: array of strings (step text only, no numbers)
|
||||
- nutrition_note: string or null
|
||||
PROMPT;
|
||||
|
||||
$payload = [
|
||||
'contents' => [['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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+39
-11
@@ -6009,6 +6009,9 @@ function showProductAction() {
|
||||
<span class="btn-icon">✏️</span>
|
||||
<span class="btn-text">${t('product.modify_details')}<br><small>${t('action.edit_sub')}</small></span>
|
||||
</button>
|
||||
<button class="btn btn-recipe-from-ingredient" onclick="generateRecipeForIngredient(${JSON.stringify(currentProduct.name)})">
|
||||
👨🍳 ${t('action.create_recipe_btn') || 'Crea una ricetta'}
|
||||
</button>
|
||||
`;
|
||||
// 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 += '<div class="recipe-meta">';
|
||||
html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
||||
if (r.meal) html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
||||
html += `<span class="recipe-tag">👥 ${r.persons} ${t('recipes.persons_short')}</span>`;
|
||||
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
|
||||
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
|
||||
@@ -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();
|
||||
|
||||
+1
-1
@@ -1462,6 +1462,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260510i"></script>
|
||||
<script src="assets/js/app.js?v=20260510j"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user