feat(offline): full offline mode — cache sync, write queue, startup recovery
- Full-screen network error overlay (z-index 300000, above screensaver) - 'Continue offline' button after 3s, auto-enter after 8s - Inventory + settings synced to localStorage at startup (during health check) - inventory_summary and stats computed from local cache while offline - Write queue (add/use/update/delete): optimistic UI + sync on reconnect - Pending ops survive page refresh — detected and re-synced at next startup - Buffered remoteLog/reportError flushed to server (GitHub issues) on restore - AI/network sections hidden in offline mode (CSS body.offline-mode) - Banner: pulsing dot while loading cache, item count when ready - Broken external images replaced with grey SVG placeholder - Fix: opened items marked is_edible:true offline (was flooding banner) - Fix: _semverGt() prevents update badge for older GitHub releases - Bump version to v1.7.25
This commit is contained in:
+12
-7
@@ -11,15 +11,20 @@ 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.25] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately after removing an item, so the purchased blocklist is populated before the next background refresh. `autoAddCriticalItems` now also respects the blocklist for depleted items (quantity = 0), preventing re-addition of just-bought products.
|
||||
- **Barcode lookup false "not found"** — Added `_offFetchProduct()` PHP helper that tries up to three barcode candidates (given code, UPC-A → EAN-13 by prepending `0`, EAN-13 → UPC-A by stripping leading `0`) against two Open Food Facts locales (`it` and default), with one automatic retry on network error. UPCItemDB fallback also iterates the same candidates. Reduces false negatives on 12-digit US barcodes and region-specific EAN codes.
|
||||
- **Partial throw from expired-items banner** — The "Butta" button in the dashboard banner previously discarded the entire inventory row with no confirmation. It now opens the existing throw modal (location picker + quantity input + "Butta tutto" option), consistent with the throw flow available from the scan → action page.
|
||||
## [1.7.25] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family (matching first name token or shopping name, different product ID) already at home, so you avoid double-buying.
|
||||
- **Home Assistant integration** — Full bidirectional HA support: inventory sensor (`sensor.evershelf_*`) exposes item counts, expiring items, shopping total, opened items and next-expiry info. Webhooks fire on inventory changes (add/use/shopping). Daily cron alert notifies via HA for items expiring within the configured threshold. TTS announces cooking steps through HA Media Player. New Settings tab 🏠 with connection test, TTS preset (Piper, Google, Nabu Casa), webhook config, and YAML snippet for `configuration.yaml`. Resolves [#111](https://github.com/dadaloop82/EverShelf/issues/111).
|
||||
- **Offline mode** — Full offline-first support. Full-screen overlay on network loss; "Continue offline" button after 3 s, auto-enter after 8 s. Inventory and settings are synced to `localStorage` at startup and cached on every successful API call. Writes (add/use/update/delete) are queued and synced on reconnect with optimistic UI updates. Pending operations survive page refresh and are re-synced automatically at next startup. AI/network-dependent sections (anti-waste chart, nutrition analysis, recipe generator, price fetching, Gemini chat) are hidden in offline mode. `remoteLog` and `reportError` are buffered offline and flushed on restore. Broken external images replaced with a grey placeholder.
|
||||
- **Offline-computed dashboard** — While offline, `inventory_summary` and `stats` (expiring/expired/opened) are derived client-side from the local cache so all dashboard stat cards and expiry alerts show accurate data.
|
||||
|
||||
### Fixed
|
||||
- **Offline banner flood** — Opened items in the offline `stats` response lacked `is_edible`; `!undefined` evaluated to `true`, causing every opened item to be shown as "not edible" in the dashboard banner. Field is now set to `true` (client-side shelf-life check already handles genuinely expired items).
|
||||
- **Version update badge showing older versions** — `_checkWebappUpdate` used `latestTag !== _loadedVersion` (inequality only), so running a newer dev build triggered an "update available" badge for an older GitHub release. Now uses `_semverGt(latest, current)` so only genuinely newer releases trigger the badge.
|
||||
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately, and `autoAddCriticalItems` respects the blocklist for depleted items.
|
||||
- **Barcode lookup false "not found"** — New `_offFetchProduct()` tries three barcode candidates (given, UPC-A↔EAN-13 conversion) across two Open Food Facts locales with auto-retry.
|
||||
- **Partial throw from expired-items banner** — "Butta" now opens the throw modal (qty + location) instead of silently deleting the entire inventory row.
|
||||
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family already at home.
|
||||
|
||||
## [1.7.24] - 2026-05-21
|
||||
|
||||
|
||||
@@ -137,7 +137,16 @@ Connect your pantry to your smart home in minutes — no YAML, no manual sensor
|
||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||
- **Installable** — Add to home screen for a native app experience
|
||||
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
|
||||
|
||||
### 📶 Offline Mode
|
||||
- **Automatic detection** — Full-screen overlay appears immediately on network loss; shows a "Continue offline" button after 3 s, and auto-enters offline mode after 8 s
|
||||
- **Local inventory cache** — Inventory is synced to `localStorage` at every startup and on each successful API call; the offline view always reflects the last known state
|
||||
- **Write queue** — Add, use, update and delete operations performed while offline are queued locally and synced to the server automatically on reconnect (including after a page refresh)
|
||||
- **Optimistic UI** — Queued writes are applied immediately to the local cache so the interface stays responsive
|
||||
- **Offline-computed stats** — Expiring and expired items are derived client-side from the cache; dashboard stat cards show real counts instead of zeros
|
||||
- **AI/network sections hidden** — Anti-waste chart, nutrition analysis, recipe generator, price fetching, and Gemini chat are hidden in offline mode; the inventory, history, and manually-managed shopping list remain fully functional
|
||||
- **Broken image fallback** — External product images (Open Food Facts, etc.) that fail to load are replaced with a neutral grey placeholder, keeping the layout intact
|
||||
- **Startup recovery** — If the page is refreshed while operations are queued, they are detected and synced automatically on the next successful startup
|
||||
- **Buffered error reporting** — `remoteLog` and `reportError` calls made while offline are stored locally and flushed to the server (and to GitHub issues) when the connection is restored
|
||||
### ⚖️ Smart Scale Integration (Add-on)
|
||||
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
||||
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
||||
|
||||
+139
-1
@@ -596,13 +596,37 @@ body {
|
||||
}
|
||||
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
|
||||
|
||||
/* Pulsing dot shown in the banner while the offline cache is being read */
|
||||
.offline-banner-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #f87171;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
animation: offline-dot-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes offline-dot-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* When server is offline, block interactions with the main content */
|
||||
body.server-offline .app-content {
|
||||
body.server-offline:not(.offline-mode) .app-content {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
/* In offline-mode the app is usable; just a subtle left-border indicator */
|
||||
body.offline-mode .app-content {
|
||||
border-left: 3px solid rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
/* Hide the "Retry" button in the banner when in offline mode — use the Continue button instead */
|
||||
body.offline-mode .offline-banner-retry {
|
||||
display: none;
|
||||
}
|
||||
body.server-offline .bottom-nav {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
@@ -7655,3 +7679,117 @@ body.cooking-mode-active .app-header {
|
||||
|
||||
/* ── Appliance remove active ── */
|
||||
[data-theme="dark"] .appliance-item .appliance-remove:active { background: #2a0808; }
|
||||
|
||||
/* ===== NETWORK ERROR OVERLAY ===== */
|
||||
#network-error-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 8, 20, 0.97);
|
||||
z-index: 300000; /* highest: above screensaver(10000), cooking(99999), preloader(200000) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
#network-error-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.net-error-body {
|
||||
text-align: center;
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.net-error-icon {
|
||||
font-size: 5.5rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 1.75rem;
|
||||
animation: net-pulse 2.2s ease-in-out infinite;
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 32px rgba(248, 113, 113, 0.35));
|
||||
}
|
||||
#network-error-overlay.restored .net-error-icon {
|
||||
animation: none;
|
||||
filter: drop-shadow(0 0 32px rgba(74, 222, 128, 0.45));
|
||||
}
|
||||
#network-error-overlay.checking .net-error-icon {
|
||||
animation: net-spin 1.2s linear infinite;
|
||||
}
|
||||
@keyframes net-pulse {
|
||||
0%, 100% { opacity: 0.45; transform: scale(0.92); }
|
||||
50% { opacity: 1; transform: scale(1.06); }
|
||||
}
|
||||
@keyframes net-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.net-error-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #f87171;
|
||||
margin-bottom: 0.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.4s;
|
||||
}
|
||||
#network-error-overlay.restored .net-error-title {
|
||||
color: #4ade80;
|
||||
}
|
||||
.net-error-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 420px;
|
||||
line-height: 1.6;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.net-error-status {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.88rem;
|
||||
color: #475569;
|
||||
min-height: 1.3em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
/* "Continue in offline mode" button — appears after 3 s */
|
||||
.net-error-continue-btn {
|
||||
margin-top: 2.2rem;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid rgba(255,255,255,0.22);
|
||||
color: #94a3b8;
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 1.6rem;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, transform 0.3s, opacity 0.3s;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.net-error-continue-btn.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.net-error-continue-btn:hover {
|
||||
background: rgba(255,255,255,0.16);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ─── Offline mode: hide AI and network-dependent UI ────────────────────────
|
||||
Sections that require a live server response or AI are hidden so the user
|
||||
isn't confronted with empty/broken widgets while offline. */
|
||||
body.offline-mode #waste-chart-section,
|
||||
body.offline-mode #nutrition-section,
|
||||
body.offline-mode #quick-recipe-bar,
|
||||
body.offline-mode .header-gemini-btn,
|
||||
body.offline-mode #btn-suggest,
|
||||
body.offline-mode #btn-fetch-prices,
|
||||
body.offline-mode .recipe-generate-btn {
|
||||
display: none !important;
|
||||
}
|
||||
/* Smart-shopping AI section: show as disabled rather than disappearing entirely */
|
||||
body.offline-mode #smart-shopping {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
|
||||
+494
-16
@@ -13,6 +13,8 @@
|
||||
// 2. reportError() — immediate single POST → report_error endpoint → GitHub Issue
|
||||
|
||||
const _remoteLogBuffer = [];
|
||||
const _OFFLINE_LOGS_KEY = '_evershelf_offline_logs'; // buffered log msgs while offline
|
||||
const _OFFLINE_ERRORS_KEY = '_evershelf_offline_errors'; // buffered error reports while offline
|
||||
let _remoteLogTimer = null;
|
||||
const _origConsoleError = console.error.bind(console);
|
||||
const _origConsoleWarn = console.warn.bind(console);
|
||||
@@ -33,11 +35,25 @@ function flushRemoteLog() {
|
||||
_remoteLogTimer = null;
|
||||
if (_remoteLogBuffer.length === 0) return;
|
||||
const msgs = _remoteLogBuffer.splice(0);
|
||||
// If offline, buffer for flush on reconnect instead of losing them
|
||||
const isOfflineNow = (typeof _serverOffline !== 'undefined' && _serverOffline) ||
|
||||
(typeof _networkDown !== 'undefined' && _networkDown) ||
|
||||
(typeof _offlineMode !== 'undefined' && _offlineMode);
|
||||
if (isOfflineNow) { _bufferOfflineLogs(msgs); return; }
|
||||
fetch(`api/index.php?action=client_log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: msgs })
|
||||
}).catch(() => {});
|
||||
}).catch(() => { _bufferOfflineLogs(msgs); }); // store if request itself fails
|
||||
}
|
||||
|
||||
function _bufferOfflineLogs(msgs) {
|
||||
try {
|
||||
const pending = JSON.parse(localStorage.getItem(_OFFLINE_LOGS_KEY) || '[]');
|
||||
pending.push(...msgs);
|
||||
if (pending.length > 500) pending.splice(0, pending.length - 500);
|
||||
localStorage.setItem(_OFFLINE_LOGS_KEY, JSON.stringify(pending));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Override console.error and console.warn to also send remotely
|
||||
@@ -65,21 +81,48 @@ function reportError(payload) {
|
||||
version: document.querySelector('.header-version')?.textContent?.trim() || '',
|
||||
url: location.href,
|
||||
user_agent: navigator.userAgent,
|
||||
ts: new Date().toISOString(),
|
||||
}, payload);
|
||||
|
||||
// When offline, buffer for replay when reconnected (→ GitHub issue on restore)
|
||||
const isOfflineNow = (typeof _serverOffline !== 'undefined' && _serverOffline) ||
|
||||
(typeof _networkDown !== 'undefined' && _networkDown) ||
|
||||
(typeof _offlineMode !== 'undefined' && _offlineMode);
|
||||
if (isOfflineNow) { _bufferOfflineError(body); return; }
|
||||
|
||||
fetch('api/index.php?action=report_error', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {}); // fire-and-forget; never throw from error handler
|
||||
}).catch(() => { _bufferOfflineError(body); }); // store if request itself fails
|
||||
// Note: the server will also skip issue creation if this version is not the latest.
|
||||
}
|
||||
|
||||
function _bufferOfflineError(body) {
|
||||
try {
|
||||
const pending = JSON.parse(localStorage.getItem(_OFFLINE_ERRORS_KEY) || '[]');
|
||||
pending.push(body);
|
||||
if (pending.length > 50) pending.splice(0, pending.length - 50);
|
||||
localStorage.setItem(_OFFLINE_ERRORS_KEY, JSON.stringify(pending));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Webapp update notification ───────────────────────────────────────────────
|
||||
// Checks both the deployed webapp version and the latest GitHub release.
|
||||
// Fires on tab focus and every 5 minutes.
|
||||
const _loadedVersion = (document.querySelector('.header-version')?.textContent?.trim() || '').replace(/^v/, '');
|
||||
|
||||
// ── Broken image fallback ─────────────────────────────────────────────────────
|
||||
// External product images (Open Food Facts, etc.) are unavailable when offline.
|
||||
// Replace any broken <img> with a neutral grey placeholder so the layout stays intact.
|
||||
document.addEventListener('error', (e) => {
|
||||
if (e.target.tagName !== 'IMG' || e.target.dataset.offlineErr) return;
|
||||
e.target.dataset.offlineErr = '1';
|
||||
// 60x60 grey placeholder SVG with a '?' glyph
|
||||
e.target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Crect width='60' height='60' rx='8' fill='%231e293b'/%3E%3Ctext x='30' y='38' text-anchor='middle' fill='%2364748b' font-size='24' font-family='sans-serif'%3E%3F%3C/text%3E%3C/svg%3E";
|
||||
e.target.style.opacity = '0.45';
|
||||
}, true);
|
||||
|
||||
// ── Gemini AI availability ────────────────────────────────────────────────────
|
||||
// Set to true in _initApp / syncSettingsFromDB once server confirms key is set.
|
||||
// All AI entry points call _requireGemini() before opening camera / API calls.
|
||||
@@ -133,6 +176,17 @@ function _applyDemoModeUI() {
|
||||
}
|
||||
}
|
||||
|
||||
function _semverGt(a, b) {
|
||||
// Returns true if version string a is strictly greater than b (e.g. "1.7.25" > "1.7.23")
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const na = pa[i] || 0, nb = pb[i] || 0;
|
||||
if (na !== nb) return na > nb;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _checkWebappUpdate() {
|
||||
const STORAGE_KEY = '_evershelf_update_checked_at';
|
||||
const SEEN_KEY = '_evershelf_update_seen_ts';
|
||||
@@ -151,7 +205,7 @@ function _checkWebappUpdate() {
|
||||
|
||||
// ── Check 1: server has a newer version deployed since this page loaded ──
|
||||
const serverVer = (data.webapp_version || '').replace(/^v/, '');
|
||||
const deployChanged = serverVer && _loadedVersion && serverVer !== _loadedVersion;
|
||||
const deployChanged = serverVer && _loadedVersion && _semverGt(serverVer, _loadedVersion);
|
||||
|
||||
// ── Check 2: a newer GitHub release not yet acknowledged ──
|
||||
const publishedAt = data.published_at || '';
|
||||
@@ -159,7 +213,7 @@ function _checkWebappUpdate() {
|
||||
const latestTag = (data.latest_tag || '').replace(/^v/, '');
|
||||
const releaseNewer = publishedAt && publishedAt !== seenTs &&
|
||||
/^\d+\.\d+/.test(latestTag) &&
|
||||
_loadedVersion && latestTag !== _loadedVersion;
|
||||
_loadedVersion && _semverGt(latestTag, _loadedVersion);
|
||||
|
||||
if (!deployChanged && !releaseNewer) return;
|
||||
|
||||
@@ -3611,6 +3665,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
||||
return { success: true, purchase: shoppingItems, listUUID: 'demo-list', _demo: true };
|
||||
}
|
||||
}
|
||||
// In offline mode, serve from cache / queue writes
|
||||
if (_offlineMode) {
|
||||
return _handleOfflineApi(action, params, body);
|
||||
}
|
||||
let url = `${API_BASE}?action=${action}`;
|
||||
if (method === 'GET') {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
@@ -3624,7 +3682,20 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
||||
} else if (Object.keys(extraHeaders).length > 0) {
|
||||
opts.headers = { ...extraHeaders };
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, opts);
|
||||
// Server responded → reset failure counter and hide overlay if it was showing
|
||||
if (_networkDown) _hideNetworkOverlay(true);
|
||||
_networkFailCount = 0;
|
||||
} catch (fetchErr) {
|
||||
// Network-level failure (no route to host, Wi-Fi down, etc.)
|
||||
_networkFailCount++;
|
||||
if (_networkFailCount >= _NETWORK_FAIL_THRESHOLD) {
|
||||
_showNetworkOverlay();
|
||||
}
|
||||
throw fetchErr;
|
||||
}
|
||||
if (!res.ok) {
|
||||
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
|
||||
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
|
||||
@@ -3637,6 +3708,13 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
||||
}
|
||||
}
|
||||
const data = await res.json();
|
||||
// Keep local caches fresh for offline use (only ever written when server responds successfully)
|
||||
if (action === 'inventory_list' && data && Array.isArray(data.inventory)) {
|
||||
_offlineCacheSet(data.inventory);
|
||||
}
|
||||
if (action === 'get_settings' && data && data.success !== false) {
|
||||
_offlineCacheSetSettings(data);
|
||||
}
|
||||
if (data && data.error) {
|
||||
remoteLog('API_FAIL', `${action}: ${data.error}`);
|
||||
}
|
||||
@@ -13514,12 +13592,13 @@ function renderCookingStep() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Show ALL unused from_pantry ingredients (not filtered by step text).
|
||||
// The AI often uses pronouns ("tagliarla", "aggiungile") instead of the ingredient
|
||||
// name, so text-matching would miss them. Better to always show what's available.
|
||||
const ings = (_cookingRecipe.ingredients || [])
|
||||
// Show ALL unused from_pantry ingredients only on the first step.
|
||||
// On subsequent steps the ingredient panel stays hidden to avoid distraction.
|
||||
const ings = _cookingStep === 0
|
||||
? (_cookingRecipe.ingredients || [])
|
||||
.map((ing, idx) => ({ ...ing, _idx: idx }))
|
||||
.filter(ing => ing.from_pantry && ing.product_id && ing.used !== true);
|
||||
.filter(ing => ing.from_pantry && ing.product_id && ing.used !== true)
|
||||
: [];
|
||||
|
||||
const ingsEl = document.getElementById('cooking-step-ings');
|
||||
if (ings.length > 0) {
|
||||
@@ -15032,6 +15111,328 @@ function saveChatHistory() {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ===== NETWORK ERROR OVERLAY + OFFLINE MODE =====
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
let _networkDown = false; // true while overlay is visible
|
||||
let _networkFailCount = 0; // consecutive TypeError failures in api()
|
||||
let _offlineMode = false; // true = overlay hidden, banner showing, cache reads/write queue
|
||||
let _offlineBannerTimer = null; // auto-enter offline mode after delay
|
||||
let _continueBtnTimer = null; // show "Continue offline" button after 3 s
|
||||
const _NETWORK_FAIL_THRESHOLD = 3;
|
||||
const _OFFLINE_MODE_DELAY_MS = 8000; // auto-enter offline mode after 8 s of overlay
|
||||
const _OFFLINE_CACHE_KEY = '_evershelf_inv_cache';
|
||||
const _OFFLINE_SETTINGS_KEY = '_evershelf_settings_cache';
|
||||
const _OFFLINE_QUEUE_KEY = '_evershelf_op_queue';
|
||||
|
||||
// ─── Local cache helpers ────────────────────────────────────────────────────
|
||||
function _offlineCacheGet() {
|
||||
try { return JSON.parse(localStorage.getItem(_OFFLINE_CACHE_KEY)) || null; } catch { return null; }
|
||||
}
|
||||
function _offlineCacheSet(inventory) {
|
||||
try { localStorage.setItem(_OFFLINE_CACHE_KEY, JSON.stringify(inventory)); } catch(e) {}
|
||||
}
|
||||
function _offlineCacheGetSettings() {
|
||||
try { return JSON.parse(localStorage.getItem(_OFFLINE_SETTINGS_KEY)) || null; } catch { return null; }
|
||||
}
|
||||
function _offlineCacheSetSettings(settings) {
|
||||
try { localStorage.setItem(_OFFLINE_SETTINGS_KEY, JSON.stringify(settings)); } catch(e) {}
|
||||
}
|
||||
function _offlineQueueGet() {
|
||||
try { return JSON.parse(localStorage.getItem(_OFFLINE_QUEUE_KEY)) || []; } catch { return []; }
|
||||
}
|
||||
function _offlineQueueSet(q) {
|
||||
try { localStorage.setItem(_OFFLINE_QUEUE_KEY, JSON.stringify(q)); } catch(e) {}
|
||||
}
|
||||
function _offlineQueuePush(action, body) {
|
||||
const q = _offlineQueueGet();
|
||||
if (q.length >= 100) q.shift(); // cap at 100
|
||||
q.push({ action, body: body ? { ...body } : null, ts: Date.now() });
|
||||
_offlineQueueSet(q);
|
||||
_renderOfflineBanner();
|
||||
}
|
||||
|
||||
// ─── Offline API handler: called by api() when _offlineMode is true ─────────
|
||||
function _handleOfflineApi(action, params, body) {
|
||||
// ─── Reads: computed from or served directly from local cache ────────────
|
||||
if (action === 'inventory_list') {
|
||||
let inv = _offlineCacheGet() || [];
|
||||
// Client-side location filter so per-location views work correctly
|
||||
if (params && params.location) inv = inv.filter(i => i.location === params.location);
|
||||
return { success: true, inventory: inv, _offline: true };
|
||||
}
|
||||
if (action === 'inventory_summary') {
|
||||
const inv = _offlineCacheGet() || [];
|
||||
const byLoc = {};
|
||||
inv.forEach(item => {
|
||||
const loc = item.location || 'other';
|
||||
if (!byLoc[loc]) byLoc[loc] = { location: loc, product_count: 0 };
|
||||
byLoc[loc].product_count++;
|
||||
});
|
||||
return { success: true, summary: Object.values(byLoc), _offline: true };
|
||||
}
|
||||
if (action === 'stats') {
|
||||
const inv = _offlineCacheGet() || [];
|
||||
const now = Date.now();
|
||||
const MS = 86400000;
|
||||
const expiring_soon = inv.filter(i => {
|
||||
if (!i.expiry_date || !(parseFloat(i.quantity) > 0)) return false;
|
||||
const d = Math.floor((new Date(i.expiry_date).getTime() - now) / MS);
|
||||
return d >= 0 && d <= 7;
|
||||
});
|
||||
const expired = inv.filter(i => {
|
||||
if (!i.expiry_date || !(parseFloat(i.quantity) > 0)) return false;
|
||||
return new Date(i.expiry_date).getTime() < now;
|
||||
});
|
||||
return {
|
||||
success: true, _offline: true,
|
||||
expiring_soon, expired,
|
||||
// Mark is_edible:true so the banner's server-trust check produces no false positives offline.
|
||||
// The client-side opened-shelf-life check in loadBannerAlerts() already handles genuinely
|
||||
// expired opened items using estimateOpenedExpiryDays().
|
||||
opened: inv.filter(i => i.opened_at && parseFloat(i.quantity) > 0).map(i => ({ ...i, is_edible: true })),
|
||||
used_30d: 0, wasted_30d: 0, used_prev_30d: 0, wasted_prev_30d: 0,
|
||||
used_prev_60d: 0, wasted_prev_60d: 0,
|
||||
};
|
||||
}
|
||||
if (action === 'get_settings') {
|
||||
const cached = _offlineCacheGetSettings();
|
||||
if (cached) return { ...cached, _offline: true };
|
||||
return { success: false, _offline: true };
|
||||
}
|
||||
if (action === 'get_settings') {
|
||||
const cached = _offlineCacheGetSettings();
|
||||
if (cached) return { ...cached, _offline: true };
|
||||
return { success: false, _offline: true };
|
||||
}
|
||||
// Safe empty responses for read-only endpoints that can't be served from cache
|
||||
const EMPTY_READS = {
|
||||
'recent_popular_products': { success: true, recent: [], popular: [], recent_ids: [], _offline: true },
|
||||
'consumption_predictions': { success: true, predictions: [], _offline: true },
|
||||
'inventory_anomalies': { success: true, anomalies: [], _offline: true },
|
||||
'inventory_finished_items':{ success: true, finished: [], _offline: true },
|
||||
'shopping_list': { success: true, purchase: [], listUUID: '', _offline: true },
|
||||
'bring_list': { success: true, purchase: [], listUUID: '', _offline: true },
|
||||
'smart_shopping': { success: true, items: [], _offline: true },
|
||||
'recipe_archive': { success: true, recipes: [], _offline: true },
|
||||
'food_facts': { success: false, _offline: true },
|
||||
'notifications': { success: true, notifications: [], _offline: true },
|
||||
};
|
||||
if (EMPTY_READS[action]) return EMPTY_READS[action];
|
||||
|
||||
// ─── Writes: queue and apply optimistic update to cache ──────────────────
|
||||
const QUEUEABLE = ['inventory_update', 'inventory_use', 'inventory_delete',
|
||||
'inventory_add', 'inventory_confirm_finished'];
|
||||
if (QUEUEABLE.includes(action)) {
|
||||
_offlineQueuePush(action, body);
|
||||
_applyOptimisticUpdate(action, body);
|
||||
return { success: true, _offline: true, _queued: true };
|
||||
}
|
||||
// Everything else (AI, Bring!, etc.): return offline error
|
||||
return { success: false, error: 'offline', _offline: true };
|
||||
}
|
||||
|
||||
// Optimistically update the cached inventory so the UI reflects the change immediately
|
||||
function _applyOptimisticUpdate(action, body) {
|
||||
if (!body) return;
|
||||
const cache = _offlineCacheGet();
|
||||
if (!cache) return;
|
||||
let changed = false;
|
||||
if (action === 'inventory_update' && body.id) {
|
||||
const idx = cache.findIndex(i => i.id === body.id);
|
||||
if (idx >= 0) { Object.assign(cache[idx], body); changed = true; }
|
||||
} else if (action === 'inventory_use' && body.id) {
|
||||
const idx = cache.findIndex(i => i.id === body.id);
|
||||
if (idx >= 0) {
|
||||
const used = parseFloat(body.qty ?? body.amount ?? 0);
|
||||
cache[idx].quantity = Math.max(0, parseFloat(cache[idx].quantity ?? 0) - used);
|
||||
changed = true;
|
||||
}
|
||||
} else if (action === 'inventory_delete' && body.id) {
|
||||
const idx = cache.findIndex(i => i.id === body.id);
|
||||
if (idx >= 0) { cache.splice(idx, 1); changed = true; }
|
||||
} else if (action === 'inventory_add') {
|
||||
cache.push({ ...body, id: -(Date.now()), _offline: true });
|
||||
changed = true;
|
||||
}
|
||||
if (changed) _offlineCacheSet(cache);
|
||||
}
|
||||
|
||||
// ─── Offline mode: banner + cache reads/write queue ─────────────────────────
|
||||
function _enterOfflineMode() {
|
||||
if (_offlineMode) return;
|
||||
_offlineMode = true;
|
||||
clearTimeout(_offlineBannerTimer);
|
||||
clearTimeout(_continueBtnTimer);
|
||||
// Hide the full overlay (no "restored" animation)
|
||||
_hideNetworkOverlay(false);
|
||||
// Unblock the UI and show a subtle offline indicator
|
||||
document.body.classList.add('offline-mode');
|
||||
_renderOfflineBanner(true); // loading state
|
||||
// Load page content from the local cache, then update banner to "ready"
|
||||
const p = refreshCurrentPage();
|
||||
const afterLoad = () => _renderOfflineBanner(false);
|
||||
if (p && typeof p.then === 'function') p.then(afterLoad).catch(afterLoad);
|
||||
else setTimeout(afterLoad, 600);
|
||||
}
|
||||
|
||||
async function _exitOfflineMode() {
|
||||
_offlineMode = false; // first — so api() calls inside sync go to server
|
||||
document.body.classList.remove('offline-mode');
|
||||
const q = _offlineQueueGet();
|
||||
if (q.length > 0) {
|
||||
const synced = await _syncOfflineQueue();
|
||||
if (synced > 0) {
|
||||
showToast(t('error.offline_synced').replace('{n}', synced), 'success');
|
||||
refreshCurrentPage();
|
||||
}
|
||||
}
|
||||
const banner = document.getElementById('offline-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
}
|
||||
|
||||
function _renderOfflineBanner(loading = false) {
|
||||
const banner = document.getElementById('offline-banner');
|
||||
const textEl = banner ? banner.querySelector('.offline-banner-text') : null;
|
||||
if (!banner || !textEl) return;
|
||||
const q = _offlineQueueGet();
|
||||
if (q.length > 0) {
|
||||
textEl.innerHTML = t('error.offline_ops_pending').replace('{n}', q.length);
|
||||
} else if (loading) {
|
||||
textEl.innerHTML = `<span class="offline-banner-dot"></span>${t('error.offline_reading_cache')}`;
|
||||
} else {
|
||||
const n = (_offlineCacheGet() || []).filter(i => parseFloat(i.quantity) > 0).length;
|
||||
const msg = t('error.offline_cache_ready').replace('{n}', n);
|
||||
textEl.innerHTML = msg;
|
||||
}
|
||||
banner.style.display = '';
|
||||
}
|
||||
|
||||
async function _syncOfflineQueue() {
|
||||
const q = _offlineQueueGet();
|
||||
if (q.length === 0) return 0;
|
||||
let synced = 0;
|
||||
const failed = [];
|
||||
const tempIdMap = {}; // negative temp id → real server id
|
||||
for (const op of q) {
|
||||
if (!op.action) continue;
|
||||
let body = op.body ? { ...op.body } : {};
|
||||
// Resolve any temp IDs from earlier add operations in this batch
|
||||
if (typeof body.id === 'number' && body.id < 0 && tempIdMap[body.id]) {
|
||||
body = { ...body, id: tempIdMap[body.id] };
|
||||
}
|
||||
try {
|
||||
const result = await api(op.action, {}, 'POST', body);
|
||||
if (result && result._offline) { failed.push(op); continue; }
|
||||
if (op.action === 'inventory_add' && result?.id && op.body?.id < 0) {
|
||||
tempIdMap[op.body.id] = result.id;
|
||||
}
|
||||
synced++;
|
||||
} catch(_) { failed.push(op); }
|
||||
}
|
||||
_offlineQueueSet(failed);
|
||||
return synced;
|
||||
}
|
||||
|
||||
// ─── Overlay show / hide ────────────────────────────────────────────────────
|
||||
function _showNetworkOverlay() {
|
||||
if (_networkDown) return;
|
||||
if (_offlineMode) return; // Already in offline mode — don't interrupt the user
|
||||
_networkDown = true;
|
||||
_networkFailCount = 0;
|
||||
const el = document.getElementById('network-error-overlay');
|
||||
if (!el) return;
|
||||
el.classList.remove('restored', 'checking');
|
||||
const titleEl = document.getElementById('net-error-title');
|
||||
const subtitleEl = document.getElementById('net-error-subtitle');
|
||||
const iconEl = document.getElementById('net-error-icon');
|
||||
const statusEl = document.getElementById('net-error-status');
|
||||
const contBtn = document.getElementById('net-error-continue-btn');
|
||||
if (titleEl) titleEl.textContent = t('error.offline_title');
|
||||
if (subtitleEl) subtitleEl.textContent = t('error.offline_subtitle');
|
||||
if (iconEl) iconEl.textContent = '📡';
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
if (contBtn) { contBtn.style.display = 'none'; contBtn.classList.remove('visible'); }
|
||||
el.style.display = 'flex';
|
||||
requestAnimationFrame(() => el.classList.add('visible'));
|
||||
// Show "Continue offline" button after 3 s
|
||||
clearTimeout(_continueBtnTimer);
|
||||
_continueBtnTimer = setTimeout(() => {
|
||||
if (!_networkDown) return;
|
||||
if (contBtn) {
|
||||
contBtn.textContent = t('error.offline_continue');
|
||||
contBtn.style.display = '';
|
||||
requestAnimationFrame(() => contBtn.classList.add('visible'));
|
||||
}
|
||||
}, 3000);
|
||||
// Auto-enter offline mode after 8 s if user hasn't acted
|
||||
clearTimeout(_offlineBannerTimer);
|
||||
_offlineBannerTimer = setTimeout(() => {
|
||||
if (_networkDown && !_offlineMode) _enterOfflineMode();
|
||||
}, _OFFLINE_MODE_DELAY_MS);
|
||||
}
|
||||
|
||||
function _hideNetworkOverlay(showRestoredMsg) {
|
||||
clearTimeout(_continueBtnTimer);
|
||||
clearTimeout(_offlineBannerTimer);
|
||||
_networkDown = false;
|
||||
_networkFailCount = 0;
|
||||
const el = document.getElementById('network-error-overlay');
|
||||
const contBtn = document.getElementById('net-error-continue-btn');
|
||||
if (contBtn) { contBtn.style.display = 'none'; contBtn.classList.remove('visible'); }
|
||||
if (!el) return;
|
||||
if (showRestoredMsg) {
|
||||
el.classList.add('restored');
|
||||
el.classList.remove('checking');
|
||||
const titleEl = document.getElementById('net-error-title');
|
||||
const iconEl = document.getElementById('net-error-icon');
|
||||
const statusEl = document.getElementById('net-error-status');
|
||||
if (titleEl) titleEl.textContent = t('error.offline_restored');
|
||||
if (iconEl) iconEl.textContent = '✅';
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
setTimeout(() => {
|
||||
el.classList.remove('visible');
|
||||
setTimeout(() => { el.style.display = 'none'; el.classList.remove('restored', 'checking'); }, 450);
|
||||
}, 1800);
|
||||
} else {
|
||||
el.classList.remove('visible');
|
||||
setTimeout(() => { el.style.display = 'none'; el.classList.remove('restored', 'checking'); }, 450);
|
||||
}
|
||||
}
|
||||
|
||||
// Ping the server; if reachable call _handleServerRestored() via heartbeat
|
||||
async function _networkPingOnce() {
|
||||
const el = document.getElementById('network-error-overlay');
|
||||
const iconEl = document.getElementById('net-error-icon');
|
||||
const statusEl = document.getElementById('net-error-status');
|
||||
if (el && el.classList.contains('visible')) {
|
||||
el.classList.add('checking');
|
||||
if (iconEl) iconEl.textContent = '🔄';
|
||||
if (statusEl) statusEl.textContent = t('error.offline_checking');
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=ping&_t=${Date.now()}`, {
|
||||
method: 'GET', cache: 'no-store',
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Let heartbeat confirm the state authoritatively
|
||||
_heartbeatRetry();
|
||||
} else { throw new Error('not-ok'); }
|
||||
} catch (_) {
|
||||
if (el) el.classList.remove('checking');
|
||||
if (iconEl) iconEl.textContent = '📡';
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Browser-native online/offline events
|
||||
window.addEventListener('offline', () => _showNetworkOverlay());
|
||||
window.addEventListener('online', () => {
|
||||
clearTimeout(_offlineBannerTimer); // don't auto-enter offline mode if we just came back
|
||||
_networkPingOnce();
|
||||
});
|
||||
|
||||
// ===== SCREENSAVER & INACTIVITY AUTO-REFRESH =====
|
||||
let _inactivityTimer = null;
|
||||
let _screensaverActive = false;
|
||||
@@ -16074,19 +16475,61 @@ function _setServerOffline(offline) {
|
||||
}
|
||||
_serverOffline = offline;
|
||||
document.body.classList.toggle('server-offline', offline);
|
||||
const banner = document.getElementById('offline-banner');
|
||||
if (banner) banner.style.display = offline ? '' : 'none';
|
||||
if (offline) {
|
||||
showToast(t('error.server_offline'), 'error');
|
||||
if (!_offlineMode) {
|
||||
// Show the full-screen network overlay (also auto-enters offline mode after 8 s)
|
||||
_showNetworkOverlay();
|
||||
}
|
||||
// In offline mode the banner is already managed by _renderOfflineBanner()
|
||||
} else {
|
||||
showToast(t('error.server_restored'), 'success');
|
||||
// Refresh the current page since updates may have been missed
|
||||
refreshCurrentPage();
|
||||
// Server came back: exit offline mode (sync queue, refresh) then hide overlay
|
||||
_handleServerRestored();
|
||||
}
|
||||
_heartbeatTimer = setTimeout(_runHeartbeat,
|
||||
offline ? _HB_INTERVAL_OFFLINE : _HB_INTERVAL_ONLINE);
|
||||
}
|
||||
|
||||
/** Flush log messages and error reports that were buffered while offline. */
|
||||
async function _flushOfflineReports() {
|
||||
try {
|
||||
const logs = JSON.parse(localStorage.getItem(_OFFLINE_LOGS_KEY) || '[]');
|
||||
if (logs.length > 0) {
|
||||
localStorage.removeItem(_OFFLINE_LOGS_KEY);
|
||||
await fetch('api/index.php?action=client_log', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: logs })
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
try {
|
||||
const errors = JSON.parse(localStorage.getItem(_OFFLINE_ERRORS_KEY) || '[]');
|
||||
if (errors.length > 0) {
|
||||
localStorage.removeItem(_OFFLINE_ERRORS_KEY);
|
||||
for (const errBody of errors) {
|
||||
await fetch('api/index.php?action=report_error', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(errBody)
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
/** Async handler called when the server comes back online. */
|
||||
async function _handleServerRestored() {
|
||||
if (_offlineMode) {
|
||||
await _exitOfflineMode();
|
||||
}
|
||||
// Now hide the overlay with the "restored" message (if still visible)
|
||||
if (_networkDown) {
|
||||
_hideNetworkOverlay(true);
|
||||
}
|
||||
// Flush all logs and error reports buffered while offline → GitHub issues when applicable
|
||||
_flushOfflineReports().catch(() => {});
|
||||
showToast(t('error.server_restored'), 'success');
|
||||
refreshCurrentPage();
|
||||
}
|
||||
|
||||
/** Called by the banner "Retry" button to trigger an immediate check. */
|
||||
function _heartbeatRetry() {
|
||||
clearTimeout(_heartbeatTimer);
|
||||
@@ -16280,6 +16723,23 @@ async function _runStartupCheck() {
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
}
|
||||
|
||||
// ── Final step: sync local offline cache (inventory + settings) ──────────
|
||||
// This ensures the offline copy is always fresh at startup while connected.
|
||||
// The bar already shows 100%; we just update the label for a moment.
|
||||
try {
|
||||
setProgress(100, tl('syncing_local', 'Sincronizzazione dati locali...'), 'ok');
|
||||
const [invData, settingsData] = await Promise.all([
|
||||
fetch('api/index.php?action=inventory_list').then(r => r.json()).catch(() => null),
|
||||
fetch('api/index.php?action=get_settings').then(r => r.json()).catch(() => null),
|
||||
]);
|
||||
if (invData && Array.isArray(invData.inventory)) _offlineCacheSet(invData.inventory);
|
||||
if (settingsData && settingsData.success !== false) _offlineCacheSetSettings(settingsData);
|
||||
setProgress(100, tl('sync_done', 'Dati locali aggiornati'), 'ok');
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
} catch(e) {
|
||||
// Non-critical — app continues normally; cache may be stale or empty
|
||||
}
|
||||
|
||||
wrapEl.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
@@ -16401,6 +16861,24 @@ async function _initApp() {
|
||||
initScreensaverShortcuts();
|
||||
startBgShoppingRefresh();
|
||||
startHeartbeat();
|
||||
// ── Recover any pending offline operations left over from a previous session ──
|
||||
// This handles the case where the user refreshed the page while offline ops
|
||||
// were queued — the queue survives in localStorage but _offlineMode is false.
|
||||
(() => {
|
||||
const startupQueue = _offlineQueueGet();
|
||||
if (startupQueue.length === 0) return;
|
||||
setTimeout(async () => {
|
||||
const synced = await _syncOfflineQueue();
|
||||
await _flushOfflineReports().catch(() => {});
|
||||
if (synced > 0) {
|
||||
showToast(t('error.offline_synced').replace('{n}', synced), 'success');
|
||||
refreshCurrentPage();
|
||||
} else {
|
||||
// All ops failed to sync — keep them for next attempt
|
||||
showToast(t('error.offline_ops_pending').replace('{n}', _offlineQueueGet().length), 'warning');
|
||||
}
|
||||
}, 1200);
|
||||
})();
|
||||
_injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView)
|
||||
|
||||
// Sync version label in preloader (in case HTML is stale)
|
||||
|
||||
+14
-3
@@ -64,7 +64,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.23</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.25</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.23</span>
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.25</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -672,7 +672,7 @@
|
||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||
</div>
|
||||
<div class="recipe-page-container">
|
||||
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()" data-i18n="recipes.generate">
|
||||
<button class="btn btn-large btn-success full-width recipe-generate-btn" onclick="openRecipeDialog()" data-i18n="recipes.generate">
|
||||
✨ Genera nuova ricetta
|
||||
</button>
|
||||
<div id="recipe-archive" class="recipe-archive"></div>
|
||||
@@ -1875,6 +1875,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== NETWORK ERROR OVERLAY ===== -->
|
||||
<div id="network-error-overlay" style="display:none" aria-live="assertive" role="alert">
|
||||
<div class="net-error-body">
|
||||
<div class="net-error-icon" id="net-error-icon">📡</div>
|
||||
<div class="net-error-title" id="net-error-title" data-i18n="error.offline_title">Nessuna connessione</div>
|
||||
<div class="net-error-subtitle" id="net-error-subtitle" data-i18n="error.offline_subtitle">L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.</div>
|
||||
<div class="net-error-status" id="net-error-status"></div>
|
||||
<button class="net-error-continue-btn" id="net-error-continue-btn" onclick="_enterOfflineMode()" data-i18n="error.offline_continue" style="display:none">Continua in modalità offline</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== COOKING MODE OVERLAY ===== -->
|
||||
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
|
||||
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.24",
|
||||
"version": "1.7.25",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+14
-2
@@ -1056,7 +1056,17 @@
|
||||
"server_retry": "Erneut versuchen",
|
||||
"unknown": "Unbekannter Fehler",
|
||||
"prefix": "Fehler",
|
||||
"no_inventory_entry": "Kein Inventareintrag gefunden"
|
||||
"no_inventory_entry": "Kein Inventareintrag gefunden",
|
||||
"offline_title": "Keine Verbindung",
|
||||
"offline_subtitle": "Die App kann den Server nicht erreichen. Überprüfe deine WLAN-Verbindung.",
|
||||
"offline_checking": "Verbindung prüfen…",
|
||||
"offline_restored": "Verbindung wiederhergestellt!",
|
||||
"offline_continue": "Im Offline-Modus fortfahren",
|
||||
"offline_reading_cache": "Lese aus lokalem Cache",
|
||||
"offline_ops_pending": "{n} Aktionen ausstehend",
|
||||
"offline_synced": "{n} Aktionen synchronisiert",
|
||||
"offline_ai_disabled": "Offline nicht verfügbar",
|
||||
"offline_cache_ready": "Offline — {n} Produkte im Cache"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
|
||||
@@ -1402,6 +1412,8 @@
|
||||
"critical_error_intro": "Die App kann aufgrund folgender Probleme nicht gestartet werden:",
|
||||
"error_network": "Server nicht erreichbar.",
|
||||
"error_network_detail": "Der Browser kann den PHP-Server nicht erreichen.\n\nMögliche Ursachen:\n• Apache/PHP-Server läuft nicht\n• Netzwerk- oder Firewall-Problem\n• Falsche App-URL\n\nBitte Server starten und erneut versuchen.",
|
||||
"retry": "Erneut versuchen"
|
||||
"retry": "Erneut versuchen",
|
||||
"syncing_local": "Lokale Daten synchronisieren...",
|
||||
"sync_done": "Lokale Daten aktualisiert"
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -1056,7 +1056,17 @@
|
||||
"server_retry": "Retry",
|
||||
"unknown": "Unknown error",
|
||||
"prefix": "Error",
|
||||
"no_inventory_entry": "No inventory entry found"
|
||||
"no_inventory_entry": "No inventory entry found",
|
||||
"offline_title": "No connection",
|
||||
"offline_subtitle": "The app cannot reach the server. Check your Wi-Fi connection.",
|
||||
"offline_checking": "Checking connection…",
|
||||
"offline_restored": "Connection restored!",
|
||||
"offline_continue": "Continue in offline mode",
|
||||
"offline_reading_cache": "Reading from local cache",
|
||||
"offline_ops_pending": "{n} operations pending",
|
||||
"offline_synced": "{n} operations synced",
|
||||
"offline_ai_disabled": "Not available offline",
|
||||
"offline_cache_ready": "Offline — {n} items cached"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Do you really want to remove this product from inventory?",
|
||||
@@ -1402,6 +1412,8 @@
|
||||
"critical_error_intro": "The app cannot start due to the following issues:",
|
||||
"error_network": "Cannot reach the server.",
|
||||
"error_network_detail": "The browser cannot reach the PHP server.\n\nPossible causes:\n• Apache/PHP server is not running\n• Network or firewall issue\n• Incorrect app URL\n\nMake sure the server is started and try again.",
|
||||
"retry": "Retry"
|
||||
"retry": "Retry",
|
||||
"syncing_local": "Syncing local data...",
|
||||
"sync_done": "Local data synced"
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -1008,7 +1008,17 @@
|
||||
"server_retry": "Reintentar",
|
||||
"unknown": "Error desconocido",
|
||||
"prefix": "Error",
|
||||
"no_inventory_entry": "No se encontró ninguna entrada de inventario"
|
||||
"no_inventory_entry": "No se encontró ninguna entrada de inventario",
|
||||
"offline_title": "Sin conexión",
|
||||
"offline_subtitle": "La app no puede conectar con el servidor. Verifica tu conexión Wi-Fi.",
|
||||
"offline_checking": "Verificando conexión…",
|
||||
"offline_restored": "¡Conexión restaurada!",
|
||||
"offline_continue": "Continuar en modo sin conexión",
|
||||
"offline_reading_cache": "Leyendo desde caché local",
|
||||
"offline_ops_pending": "{n} operaciones pendientes",
|
||||
"offline_synced": "{n} operaciones sincronizadas",
|
||||
"offline_ai_disabled": "No disponible sin conexión",
|
||||
"offline_cache_ready": "Offline — {n} productos en caché"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "¿Realmente quieres eliminar este producto del inventario?",
|
||||
@@ -1346,6 +1356,8 @@
|
||||
"critical_error_short": "Error crítico",
|
||||
"critical_error": "Error crítico: la aplicación no puede iniciarse. Revisa los registros del servidor.",
|
||||
"error_network": "No se puede contactar con el servidor. Comprueba tu conexión de red.",
|
||||
"retry": "Reintentar"
|
||||
"retry": "Reintentar",
|
||||
"syncing_local": "Sincronizando datos locales...",
|
||||
"sync_done": "Datos locales sincronizados"
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -1008,7 +1008,17 @@
|
||||
"server_retry": "Réessayer",
|
||||
"unknown": "Erreur inconnue",
|
||||
"prefix": "Erreur",
|
||||
"no_inventory_entry": "Aucune entrée d'inventaire trouvée"
|
||||
"no_inventory_entry": "Aucune entrée d'inventaire trouvée",
|
||||
"offline_title": "Aucune connexion",
|
||||
"offline_subtitle": "L'app ne peut pas atteindre le serveur. Vérifiez votre connexion Wi-Fi.",
|
||||
"offline_checking": "Vérification de la connexion…",
|
||||
"offline_restored": "Connexion rétablie !",
|
||||
"offline_continue": "Continuer en mode hors ligne",
|
||||
"offline_reading_cache": "Lecture depuis le cache local",
|
||||
"offline_ops_pending": "{n} opérations en attente",
|
||||
"offline_synced": "{n} opérations synchronisées",
|
||||
"offline_ai_disabled": "Indisponible hors ligne",
|
||||
"offline_cache_ready": "Offline — {n} produits en cache"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Voulez-vous vraiment supprimer ce produit de l'inventaire ?",
|
||||
@@ -1346,6 +1356,8 @@
|
||||
"critical_error_short": "Erreur critique",
|
||||
"critical_error": "Erreur critique : l'application ne peut pas démarrer. Vérifiez les logs.",
|
||||
"error_network": "Impossible de contacter le serveur. Vérifiez votre connexion réseau.",
|
||||
"retry": "Réessayer"
|
||||
"retry": "Réessayer",
|
||||
"syncing_local": "Synchronisation des données locales...",
|
||||
"sync_done": "Données locales synchronisées"
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -1056,7 +1056,17 @@
|
||||
"server_retry": "Riprova",
|
||||
"unknown": "Errore sconosciuto",
|
||||
"prefix": "Errore",
|
||||
"no_inventory_entry": "Nessuna voce di inventario trovata"
|
||||
"no_inventory_entry": "Nessuna voce di inventario trovata",
|
||||
"offline_title": "Nessuna connessione",
|
||||
"offline_subtitle": "L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.",
|
||||
"offline_checking": "Verifica connessione…",
|
||||
"offline_restored": "Connessione ripristinata!",
|
||||
"offline_continue": "Continua in modalità offline",
|
||||
"offline_reading_cache": "Lettura dalla cache locale",
|
||||
"offline_ops_pending": "{n} operazioni in attesa",
|
||||
"offline_synced": "{n} operazioni sincronizzate",
|
||||
"offline_ai_disabled": "Non disponibile offline",
|
||||
"offline_cache_ready": "Offline — {n} prodotti in cache"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
@@ -1402,6 +1412,8 @@
|
||||
"critical_error_intro": "L'app non può avviarsi a causa dei seguenti problemi:",
|
||||
"error_network": "Impossibile contattare il server.",
|
||||
"error_network_detail": "Il browser non riesce a raggiungere il server PHP.\n\nPossibili cause:\n• Il server Apache/PHP non è in esecuzione\n• Problema di rete o firewall\n• URL dell'app non corretta\n\nControlla che il server sia avviato e riprova.",
|
||||
"retry": "Riprova"
|
||||
"retry": "Riprova",
|
||||
"syncing_local": "Sincronizzazione dati locali...",
|
||||
"sync_done": "Dati locali aggiornati"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user