diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fc8e1..1cea067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. +## [1.7.15] - 2026-05-16 + +### Added +- **Full i18n audit** — Comprehensive sweep of all user-visible strings in `app.js` and `index.html`. 25+ new translation keys added across `it.json`, `en.json`, `de.json`, covering: vacuum toast, TTS voice controls, timer step labels, product note labels, error messages, expiry form, barcode hint, category select placeholder, cooking step fallback, `form.select_placeholder`, `btn.yes_short`/`no_short`, `add.vacuum_question`, `add.vacuum_saved`, `move.vacuum_seal_rest`, `cooking.step_fallback`, `error.prefix`/`unknown`, `product.select_variant`, and more. +- **Splash screen redesign** — Logo displayed prominently, spinner below, app version shown at the bottom; version label injected dynamically at boot time so it never gets out of sync. Minimum 3-second display duration enforced: `_splashStart` is recorded before `DOMContentLoaded`; the fade-out is delayed by the remaining time if the app loads faster than 3 s. +- **Demo GIF in README** — `assets/img/demo.gif` (processed at 2× speed, ~36 s) added to the `## 📸 Screenshots` section. +- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`. + +### Fixed +- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`). +- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal. +- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`). +- **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`. +- **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README. + ## [1.7.14] - 2026-05-16 ### Added diff --git a/README.md b/README.md index ed8b108..d5a51da 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ [![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile) [![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE-orange.svg)](translations/) -[![Version](https://img.shields.io/badge/version-1.7.13-brightgreen.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.15-brightgreen.svg)](CHANGELOG.md) [![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers) [![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main) [![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors) @@ -45,7 +45,7 @@ - **Expiry tracking** — Automatic shelf-life estimation based on product type and storage - **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section) - **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items -- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("La quantità è giusta (2 pz)") +- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)") ### 🤖 AI-Powered (Google Gemini) - **Expiry date reading** — Photograph a label and extract the expiry date automatically @@ -55,13 +55,13 @@ - **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated - **Smart chat assistant** — Ask questions about your inventory, get cooking tips - **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip -- **Anomaly explanation** — "🤖 Spiega" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do +- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do - **Model fallback** — All AI endpoints try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically - **Graceful no-key state** — When no Gemini key is configured, AI entry points show a friendly message; the header button is visually greyed with an amber dot ### 🛒 Shopping List - **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app -- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Panna da cucina") rather than brand, keeping the Bring! list clean and consolidated +- **Generic shopping names** — Products are grouped by type (e.g. "Milk", "Cold cuts", "Cooking cream") rather than brand, keeping the Bring! list clean and consolidated - **Smart predictions** — Know what you'll need before you run out - **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed - **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration** — Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load) @@ -72,7 +72,7 @@ - **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled - **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode - **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up -- **Recipe completion** — "Buon appetito!" spoken when the last step is confirmed +- **Recipe completion** — "Bon appétit!" announced via TTS when the last step is confirmed - **Built-in timer** — Automatic timer suggestions based on recipe instructions - **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow @@ -82,7 +82,7 @@ - **Expiry alerts** — Visual warnings for expired and soon-to-expire items - **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries - **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules -- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action +- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and a discard action as the primary action - **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action - **Quick recipe bar** — One-tap recipe suggestion using expiring products - **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit @@ -427,6 +427,12 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE ## 📸 Screenshots +
+ +![EverShelf demo — barcode scan, inventory management and AI recipe generation](assets/img/demo.gif) + +
+ For a live walkthrough with real data and full AI enabled, visit the **[live demo](https://evershelfproject.dadaloop.it/demo)** — no installation required. -> Want to contribute a GIF or screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome! +> Want to contribute additional screenshots? See [CONTRIBUTING.md](CONTRIBUTING.md) — PRs welcome! diff --git a/api/index.php b/api/index.php index 9599167..743a586 100644 --- a/api/index.php +++ b/api/index.php @@ -2869,6 +2869,8 @@ function geminiChat(PDO $db): void { $history = $input['history'] ?? []; $appliances = $input['appliances'] ?? []; $dietaryRestrictions = $input['dietary_restrictions'] ?? ''; + $lang = recipeNormalizeLang($input['lang'] ?? 'it'); + $langName = recipeLangName($lang); if (empty($message)) { echo json_encode(['success' => false, 'error' => 'Messaggio vuoto']); @@ -2916,27 +2918,29 @@ function geminiChat(PDO $db): void { $dietaryText = ''; if (!empty($dietaryRestrictions)) { - $dietaryText = "\nRestrizioni alimentari dell'utente: {$dietaryRestrictions}. Rispetta SEMPRE queste restrizioni."; + $dietaryText = "\nUser dietary restrictions: {$dietaryRestrictions}. Always respect these restrictions."; } + $langName = recipeLangName($lang); $systemPrompt = << false, 'error' => 'empty_ingredient']); return; } + $lang = recipeNormalizeLang($input['lang'] ?? 'it'); + $langName = recipeLangName($lang); // Fetch inventory (same as generateRecipe) $stmt = $db->query(" @@ -3825,18 +3831,18 @@ function recipeFromIngredient(PDO $db): void { $safeName = htmlspecialchars($ingredientName, ENT_QUOTES, 'UTF-8'); $prompt = << ${s.icon} - ${s.cat} + ${t('categories.' + s.cat) || s.cat} ${s.pct}% `).join('')} @@ -3654,7 +3654,7 @@ async function loadDashboard() { const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location }; const qty = parseFloat(item.quantity); const pkgSize = parseFloat(item.default_quantity); - const unitLabels = { 'ml': 'ml', 'g': 'g', 'pz': 'pz' }; + const unitLabels = { 'ml': 'ml', 'g': 'g', 'pz': t('units.pz') }; let qtyText = ''; if (item.unit === 'conf') { @@ -3666,13 +3666,13 @@ async function loadDashboard() { // Only show remainder if it rounds to at least 1 unit const remainderText = remainderAmt >= 0.5 ? formatSubRemainder(remainderAmt, pkgUnit) : ''; if (wholeConf > 0 && remainderText) { - qtyText = `${wholeConf} conf${pkgLabel ? ` (da ${pkgSize}${pkgLabel})` : ''} + ${remainderText}`; + qtyText = `${wholeConf} ${t('units.conf') || 'conf'}${pkgLabel ? ` (${t('units.from') || 'da'} ${pkgSize}${pkgLabel})` : ''} + ${remainderText}`; } else if (wholeConf > 0) { - qtyText = `${wholeConf} conf${pkgLabel ? ` (da ${pkgSize}${pkgLabel})` : ''}`; + qtyText = `${wholeConf} ${t('units.conf') || 'conf'}${pkgLabel ? ` (${t('units.from') || 'da'} ${pkgSize}${pkgLabel})` : ''}`; } else if (remainderText) { qtyText = remainderAmt >= 1 ? remainderText : t('inventory.qty_trace') || '< 1' + (pkgLabel || ''); } else { - qtyText = `${qty} conf`; + qtyText = `${Math.round(qty * 10) / 10} ${t('units.conf') || 'conf'}`; } } else { const unitLabel = unitLabels[item.unit] || item.unit || ''; @@ -3770,7 +3770,7 @@ function quickRecipeSuggestion() { // Navigate to chat and auto-send a prompt about expiring products showPage('chat'); setTimeout(() => { - document.getElementById('chat-input').value = 'Suggeriscimi una ricetta veloce PER UNA PERSONA usando i prodotti che scadono prima! Ignora i prodotti in freezer (hanno scadenze molto lunghe), concentrati su frigo e dispensa.'; + document.getElementById('chat-input').value = t('chat.quick_recipe_prompt') || 'Suggeriscimi una ricetta veloce PER UNA PERSONA usando i prodotti che scadono prima! Ignora i prodotti in freezer (hanno scadenze molto lunghe), concentrati su frigo e dispensa.'; sendChatMessage(); }, 500); } @@ -4348,7 +4348,7 @@ async function explainBannerAnomaly() { } } catch (e) { detailEl.innerHTML = originalHtml; - showToast('Errore AI', 'error'); + showToast(t('error.generic'), 'error'); } } @@ -4432,7 +4432,7 @@ function bannerFinishAll() { showToast(t('toast.finished_all').replace('{name}', item.name), 'success'); showLowStockBringPrompt(res, () => loadDashboard()); } else { - showToast(res.error || 'Errore', 'error'); + showToast(res.error || t('error.generic'), 'error'); } }).catch(() => showToast(t('error.connection'), 'error')); } @@ -4637,8 +4637,8 @@ function _pzFractionLabel(n) { function formatQuantity(qty, unit, defaultQty, packageUnit) { if (!qty && qty !== 0) return ''; const n = parseFloat(qty); - const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' }; - const label = unitLabels[unit] || unit || 'pz'; + const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.conf') }; + const label = unitLabels[unit] || unit || t('units.pz'); // Special handling for conf with partial packages if (unit === 'conf' && packageUnit && defaultQty > 0) { @@ -4647,11 +4647,11 @@ function formatQuantity(qty, unit, defaultQty, packageUnit) { const fractionalConf = Math.round((n - wholeConf) * 1000) / 1000; if (fractionalConf < 0.01) { - return `${wholeConf} conf (da ${defaultQty}${pkgLabel})`; + return `${wholeConf} ${t('units.conf') || 'conf'} (${t('units.from') || 'da'} ${defaultQty}${pkgLabel})`; } const remainderText = formatSubRemainder(fractionalConf * defaultQty, packageUnit); if (wholeConf > 0) { - return `${wholeConf} conf (da ${defaultQty}${pkgLabel}) + ${remainderText}`; + return `${wholeConf} ${t('units.conf') || 'conf'} (${t('units.from') || 'da'} ${defaultQty}${pkgLabel}) + ${remainderText}`; } return remainderText; } @@ -4667,8 +4667,8 @@ function formatQuantity(qty, unit, defaultQty, packageUnit) { // Returns { mainQty: '10', unitLabel: 'conf', packageDetail: 'da 36g', fraction: '¼' } function formatQuantityParts(qty, unit, defaultQty, packageUnit) { const n = parseFloat(qty) || 0; - const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' }; - const label = unitLabels[unit] || unit || 'pz'; + const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.conf') }; + const label = unitLabels[unit] || unit || t('units.pz'); // Special handling for conf with partial packages if (unit === 'conf' && packageUnit && defaultQty > 0) { @@ -4677,11 +4677,11 @@ function formatQuantityParts(qty, unit, defaultQty, packageUnit) { const fractionalConf = Math.round((n - wholeConf) * 1000) / 1000; if (fractionalConf < 0.01) { - return { mainQty: `${wholeConf}`, unitLabel: 'conf', packageDetail: `da ${defaultQty}${pkgLabel}`, fraction: '' }; + return { mainQty: `${wholeConf}`, unitLabel: t('units.conf') || 'conf', packageDetail: `${t('units.from') || 'da'} ${defaultQty}${pkgLabel}`, fraction: '' }; } const remainderText = formatSubRemainder(fractionalConf * defaultQty, packageUnit); if (wholeConf > 0) { - return { mainQty: `${wholeConf}`, unitLabel: 'conf', packageDetail: `da ${defaultQty}${pkgLabel}`, fraction: `+ ${remainderText}` }; + return { mainQty: `${wholeConf}`, unitLabel: t('units.conf') || 'conf', packageDetail: `${t('units.from') || 'da'} ${defaultQty}${pkgLabel}`, fraction: `+ ${remainderText}` }; } return { mainQty: remainderText, unitLabel: '', packageDetail: '', fraction: '' }; } @@ -4978,9 +4978,9 @@ function showItemDetail(inventoryId, productId) { `; @@ -5091,7 +5091,7 @@ function editInventoryItem(id) { // Rebuild modal content for editing (don't close and reopen - just replace content) document.getElementById('modal-content').innerHTML = `
@@ -5112,15 +5112,15 @@ function editInventoryItem(id) { ` : ''}
- +
- +
- + @@ -5183,7 +5183,7 @@ async function submitEditInventory(e, id, productId) { await api('inventory_update', {}, 'POST', payload); closeModal(); - showToast('Aggiornato!', 'success'); + showToast(t('toast.updated'), 'success'); if (_bannerEditPending) { _bannerEditPending = false; // Mark the item as confirmed so it does NOT reappear in the banner @@ -5661,12 +5661,12 @@ async function onBarcodeDetected(barcode) { // Build rich notes with all available info const notesParts = []; - if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`); + if (p.quantity_info) notesParts.push(`${t('product.weight_label')}: ${p.quantity_info}`); if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`); if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`); if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`); - if (p.origin) notesParts.push(`Origine: ${p.origin}`); - if (p.labels) notesParts.push(`Etichette: ${p.labels}`); + if (p.origin) notesParts.push(`${t('product.origin_label')}: ${p.origin}`); + if (p.labels) notesParts.push(`${t('product.labels_label')}: ${p.labels}`); // Save to local DB const saveResult = await api('product_save', {}, 'POST', { @@ -6307,7 +6307,7 @@ function showProductAction() { ${currentProduct.weight_info ? `

⚖️ ${escapeHtml(currentProduct.weight_info)}

` : ''} ${currentProduct.barcode ? `

📊 ${currentProduct.barcode}

` : ''}
- + `; // Check if product needs editing (unknown name, missing info) @@ -6331,7 +6331,7 @@ function showProductAction() { editInfoEl.innerHTML = `
-

${isUnknown ? '⚠️ Prodotto non riconosciuto' : '✏️ Modifica informazioni'}

+

${isUnknown ? '⚠️ ' + t('product.unknown_product') : '✏️ ' + t('product.edit_info')}

${isUnknown ? '

Inserisci il nome e le informazioni del prodotto

' : ''}
@@ -6339,13 +6339,13 @@ function showProductAction() {
- +
- +
@@ -6445,7 +6445,7 @@ function showProductAction() { ✏️ ${t('product.modify_details')}
${t('action.edit_sub')}
- `; @@ -6457,7 +6457,7 @@ function showProductAction() { catalogLink.style.cssText = 'text-align:center;margin-top:6px'; btnsContainer.after(catalogLink); } - catalogLink.innerHTML = ``; + catalogLink.innerHTML = ``; } else { // Product NOT in inventory - show only AGGIUNGI statusBar.style.display = 'none'; @@ -6561,7 +6561,7 @@ function editProductFromAction() { function openInventoryEdit() { const items = _actionInventoryItems; if (!items || items.length === 0) { - showToast('Nessuna voce di inventario trovata', 'error'); + showToast(t('error.no_inventory_entry') || 'Nessuna voce di inventario trovata', 'error'); return; } if (items.length === 1) { @@ -6572,10 +6572,10 @@ function openInventoryEdit() { const contentEl = document.getElementById('modal-content'); contentEl.innerHTML = ` -

Scegli la posizione da modificare:

+

${t('edit.choose_location_hint')}

${items.map(inv => { const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location }; @@ -6610,7 +6610,7 @@ function editActionInventoryItem(inventoryId) { document.getElementById('modal-content').innerHTML = ` @@ -6625,13 +6625,13 @@ function editActionInventoryItem(inventoryId) {
- +
- + @@ -6695,7 +6695,7 @@ async function submitActionEditInventory(e, id, productId) { await api('inventory_update', {}, 'POST', payload); closeModal(); - showToast('Aggiornato!', 'success'); + showToast(t('toast.updated'), 'success'); showProductAction(); // Refresh the action page } @@ -6874,7 +6874,7 @@ async function throwAll() { showToast(t('toast.thrown_away', { name: currentProduct.name }), 'success'); showPage('dashboard'); } else { - showToast(result.error || 'Errore', 'error'); + showToast(result.error || t('error.generic'), 'error'); } } catch(e) { showLoading(false); @@ -6902,7 +6902,7 @@ async function throwPartial() { showToast(t('toast.thrown_away_partial', { qty, unit: currentProduct.unit || 'pz', name: currentProduct.name }), 'success'); showPage('dashboard'); } else { - showToast(result.error || 'Errore', 'error'); + showToast(result.error || t('error.generic'), 'error'); } } catch(e) { showLoading(false); @@ -7120,7 +7120,7 @@ function recalculateAddExpiry() { if (window._historyExpiryDays) suffix = ' (da storico)'; else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum'); else if (loc === 'freezer') suffix = ' (freezer)'; - else if (isVacuum) suffix = ' (sotto vuoto)'; + else if (isVacuum) suffix = ' ' + t('add.suffix_vacuum'); const expiryInput = document.getElementById('add-expiry'); const estimateEl = document.querySelector('.expiry-estimate-label'); @@ -7368,12 +7368,12 @@ function selectPurchaseType(btn, type) { } else { detailDiv.innerHTML = `
- +
-

Inserisci la data di scadenza o scansionala

+

${t('add.expiry_hint')}

@@ -7494,7 +7494,7 @@ async function submitAdd(e) { let qtyInfo = ''; if (result.total_qty) { const u = result.unit || 'pz'; - const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' }; + const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.conf') }; const uLabel = unitLabels[u] || u; if (u === 'conf' && result.package_unit && result.default_quantity > 0) { const pkgLabel = unitLabels[result.package_unit] || result.package_unit; @@ -7547,7 +7547,7 @@ async function submitAdd(e) { window._addExtraBatches = []; } } else { - showToast(result.error || 'Errore', 'error'); + showToast(result.error || t('error.generic'), 'error'); } } catch (err) { showLoading(false); @@ -8082,9 +8082,9 @@ function _showVacuumPrompt(openedId, wasVacuumSealed) { 'width:calc(100% - 32px)', 'box-sizing:border-box', 'overflow:hidden' ].join(';'); bar.innerHTML = ` - 🔒 Messo sotto vuoto? - - + ${t('add.vacuum_question')} + +
`; document.body.appendChild(bar); @@ -8100,7 +8100,7 @@ function _showVacuumPrompt(openedId, wasVacuumSealed) { if (rafH) cancelAnimationFrame(rafH); bar.remove(); api('inventory_update', {}, 'POST', { id: openedId, vacuum_sealed: vacuum ? 1 : 0 }) - .then(() => { if (vacuum) showToast('🔒 Sotto vuoto registrato', 'success'); }) + .then(() => { if (vacuum) showToast(t('add.vacuum_saved'), 'success'); }) .catch(() => {}); } @@ -8322,7 +8322,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu const vacuumRow = ` `; document.getElementById('modal-content').innerHTML = ` `; if (matches.length > 0) { - html += `

Seleziona la variante esatta o usa i dati AI:

`; + html += `

${t('product.select_variant')}

`; html += `
`; matches.forEach((m, idx) => { html += `
`; @@ -9184,7 +9184,7 @@ async function selectProductForAction(productId) { } } catch (err) { showLoading(false); - showToast('Errore', 'error'); + showToast(t('error.generic'), 'error'); } } @@ -10164,10 +10164,10 @@ async function migrateBringNames(btn) { showToast(t('shopping.names_already_updated'), 'info'); } } else { - if (statusEl) statusEl.textContent = '❌ ' + (data.error || 'Errore'); + if (statusEl) statusEl.textContent = '❌ ' + (data.error || t('error.unknown')); } } catch(e) { - if (statusEl) statusEl.textContent = '❌ Errore di connessione'; + if (statusEl) statusEl.textContent = '❌ ' + t('scale.error_connect'); } if (btn) btn.disabled = false; } @@ -10224,7 +10224,7 @@ async function addSmartToBring() { // Reload to refresh badges loadShoppingList(); } else { - showToast(result.error || 'Errore', 'error'); + showToast(result.error || t('error.generic'), 'error'); } } catch (e) { showLoading(false); @@ -10997,7 +10997,7 @@ async function analyzeExpiryImage(dataUrl) { // Close modal after delay setTimeout(() => closeExpiryScanner(), 1500); } else if (result.error === 'no_api_key') { - statusDiv.innerHTML = `

⚠️ Chiave API Gemini non configurata.
Aggiungi GEMINI_API_KEY nel file .env sul server.

`; + statusDiv.innerHTML = `

${t('ai.no_api_key').replace(/\n/g, '
')}

`; } else { statusDiv.innerHTML = `

❌ Non riesco a leggere la data. ${result.raw_text ? '
Letto: ' + escapeHtml(result.raw_text) + '' : ''}

`; @@ -11071,7 +11071,7 @@ const LOG_PAGE_SIZE = 50; async function loadLog(more = false) { if (!more) { _logOffset = 0; - document.getElementById('log-list').innerHTML = '

Caricamento...

'; + document.getElementById('log-list').innerHTML = '

' + t('loading') + '

'; } try { @@ -11722,7 +11722,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec // Build quantity controls let qtySection = ''; - let defaultQtyValue = qtyNumber; + let defaultQtyValue = Math.round(qtyNumber * 10) / 10; if (isConf) { const totalConf = items.reduce((s, i) => s + parseFloat(i.quantity), 0); @@ -11733,7 +11733,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec // qtyNumber from recipe is in sub-units (g, ml) const step = getSubUnitStep(pkgUnit); - defaultQtyValue = qtyNumber; + defaultQtyValue = (pkgUnit === 'g' || pkgUnit === 'ml') ? Math.round(qtyNumber) : Math.round(qtyNumber * 10) / 10; qtySection = `
@@ -11749,7 +11749,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
`; } else { _recipeUseNormalUnit = unit; - const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml' }; + const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml' }; const unitLabel = unitLabels[unit] || unit; const inputMin = '0.1'; qtySection = ` @@ -11961,7 +11961,7 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum) const vacuumRow = ` `; document.getElementById('modal-content').innerHTML = ` @@ -14324,6 +14325,7 @@ function initInactivityWatcher() { } // ===== INITIALIZATION ===== +const _splashStart = Date.now(); document.addEventListener('DOMContentLoaded', () => { // Load translations first, then initialize the app loadTranslations(_currentLang).then(() => { @@ -14652,11 +14654,22 @@ async function _initApp() { startHeartbeat(); _injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView) - // Hide preloader once the dashboard is rendered + // Sync version label in preloader (in case HTML is stale) + const preloaderVer = document.getElementById('preloader-version'); + if (preloaderVer) { + const ver = document.querySelector('.header-version')?.textContent?.trim() || ''; + if (ver) preloaderVer.textContent = ver; + } + + // Hide preloader — enforce minimum 3 s splash regardless of load speed const preloader = document.getElementById('app-preloader'); if (preloader) { - preloader.classList.add('fade-out'); - setTimeout(() => preloader.remove(), 380); + const elapsed = Date.now() - _splashStart; + const minDelay = Math.max(0, 3000 - elapsed); + setTimeout(() => { + preloader.classList.add('fade-out'); + setTimeout(() => preloader.remove(), 380); + }, minDelay); } // Defer update check: fire 6 s after app is ready so it doesn't compete diff --git a/index.html b/index.html index 0217a4c..233adfa 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@ EverShelf - + @@ -53,8 +53,9 @@ @@ -67,7 +68,7 @@

- EverShelfv1.7.14 + EverShelfv1.7.15

@@ -577,7 +578,7 @@
+
- +
-

Aggiungi velocemente:

+

Aggiungi velocemente:

- - - - - - - - - - + + + + + + + + + +
@@ -1030,21 +1031,21 @@

🔑 Token Impostazioni

-

Se SETTINGS_TOKEN è configurato nel .env server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.

+

Se SETTINGS_TOKEN è configurato nel .env server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.

- +
- +

🔒 Certificato HTTPS

-

Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.

+

Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.

-
+
Istruzioni per Chrome (Android):
1. Scarica il certificato qui sopra
2. Vai in Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo
@@ -1088,11 +1089,11 @@
-

Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.

+

Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.

@@ -1158,7 +1159,7 @@
-

Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.

+

Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.

@@ -1174,9 +1175,9 @@