Compare commits

...

38 Commits

Author SHA1 Message Date
dadaloop82 b3a0e83dde Merge branch 'develop' 2026-05-17 09:40:29 +00:00
dadaloop82 d3b119c7fe feat: startup health check during splash screen (v1.7.20)
- Add ?action=health_check PHP endpoint (early-exit, before rate-limiter)
  Checks: PHP version, required extensions, data/ writability, SQLite DB
  connection + table integrity, .env file, Gemini AI key, Bring! token
- Display animated checklist in splash screen with per-item icons
  (ok/warn/error); critical failures block app launch with clear error
  message and Retry button; optional warnings shown but don't block
- New JS: _runStartupCheck(), _startupRetry(); called first in _initApp()
- New HTML elements in #app-preloader: #preloader-checks, #preloader-error-msg,
  #preloader-retry-btn (hidden until startup check completes)
- New CSS: .preloader-checks, .preloader-check-row, .preloader-error-msg,
  .preloader-retry-btn with state colors (ok=green, warn=amber, error=red)
- Translations: startup.* keys (10 per language) in IT, EN, DE, FR, ES
- Asset version bump: v=20260520a
2026-05-17 09:40:11 +00:00
dadaloop82 9b8164b141 docs: highlight zero-waste tips in README 2026-05-17 09:20:02 +00:00
dadaloop82 8750e44687 docs: highlight zero-waste tips prominently in README 2026-05-17 09:19:53 +00:00
github-actions[bot] 57f66c17df chore: auto-merge develop → main
Triggered by: a602726 feat: zero-waste tips during cooking mode (#76)
2026-05-17 09:18:32 +00:00
dadaloop82 2630905146 feat: zero-waste tips during cooking mode (#76) 2026-05-17 09:17:04 +00:00
dadaloop82 a602726531 feat: zero-waste tips during cooking mode (#76) 2026-05-17 09:16:48 +00:00
github-actions[bot] 3f55f07220 chore: auto-merge develop → main
Triggered by: 06f6d58 docs: update README with dark mode + export inventory features (v1.7.18)
2026-05-17 09:03:27 +00:00
dadaloop82 06f6d58fb5 docs: update README with dark mode + export inventory features (v1.7.18) 2026-05-17 09:01:48 +00:00
dadaloop82 c1ef4c5e13 Merge develop: dark mode + export inventory v1.7.18 (#78, #64) 2026-05-17 08:59:46 +00:00
dadaloop82 0a6e653692 feat: dark mode (Off/On/Auto) + export inventory CSV/PDF (#78, #64) 2026-05-17 08:59:40 +00:00
dadaloop82 a99b35225a Merge develop: feat #77 French + Spanish translations (v1.7.17) 2026-05-17 08:36:53 +00:00
dadaloop82 3ba4f7eaad feat: add French and Spanish translations (#77)
- Complete fr.json (1049 keys, 52 sections)
- Complete es.json (1049 keys, 52 sections)
- Language selector updated with Francais and Espanol
- Setup wizard localized for fr/es
- Default fallback language changed from 'it' to 'en'
- Version bump to 1.7.17
2026-05-17 08:36:46 +00:00
dadaloop82 fdfd5cd0ec Merge develop: docs v1.7.16 README 2026-05-17 08:07:58 +00:00
dadaloop82 b973284aeb docs: update README for v1.7.16 (scan history + server-side data sync) 2026-05-17 08:07:56 +00:00
dadaloop82 0a5629e881 Merge develop: feat #68 scan history + server-side data centralisation 2026-05-17 08:03:39 +00:00
dadaloop82 d901939da1 feat: barcode scan history + full server-side data centralisation (#68)
- Add scan history (last 20 products) stored server-side via app_settings
- Render recent chips in scan page; tap to select product without re-scanning
- Migrate shopping_tags, pinned_bring, pref_use_loc, pref_move_loc,
  auto_added_bring, bring_blocklist, no_expiry_dismissed from localStorage
  to server-synced in-memory caches (_saveToServer pattern)
- Extend syncSettingsFromDB to load all 7 data caches + scan_history on startup
- One-time migration: existing localStorage data auto-uploaded to server on
  first load, old keys removed
- Fix dangling try/catch in toggleShoppingTag (was missing opening try)
2026-05-17 08:03:33 +00:00
dadaloop82 245e14cc3b Merge develop → main: fix opened items banner + remove strikethrough 2026-05-16 20:33:03 +00:00
dadaloop82 aaf9323ba5 fix: opened-but-not-edible items missing from banner + remove confusing strikethrough
- Banner (loadBannerAlerts): add step 1b — any item from statsData.opened
  with is_edible=false is queued as expired even when client-side
  getExpiredSafety would consider it 'ok' (e.g. conserve <30d past expiry).
  Applies to all product types, not just conserve.
  Requires fetching stats in the same Promise.all (no extra round-trip since
  loadDashboard already calls stats separately).
- CSS: remove text-decoration:line-through from .alert-item-spoiled .alert-item-name.
  The badge (/⚠️) already communicates the state; strikethrough added no
  information and confused users into thinking the item had been deleted.
2026-05-16 20:32:51 +00:00
dadaloop82 78c3306d9e Merge develop → main: fix camera button intercepted by kiosk overlay 2026-05-16 18:03:11 +00:00
dadaloop82 0f567c4ba0 fix: camera button (📷) intercepted by kiosk native btnSettings overlay
- Kiosk (Android): btnSettings was positioned top|end with alpha=0.12,
  sitting invisibly on top of the HTML scan button in the webapp header.
  Moved to bottom|end (marginBottom=80dp, alpha=0.28) so it never
  overlaps the header. Kiosk versionCode 15→16, versionName 1.7.15.
- Web (Android Chrome/Brave): pointerleave fired before pointerup when
  finger drifted, cancelling the long-press timer and letting a synthetic
  click bubble to an unintended handler. Fixed with setPointerCapture +
  preventDefault + replaced pointerleave with pointercancel. Added
  touch-action:manipulation to .header-scan-btn CSS.
2026-05-16 18:02:36 +00:00
dadaloop82 169e32bff3 Merge develop → main: assets + gitignore cleanup 2026-05-16 16:32:45 +00:00
dadaloop82 d28055a512 chore: add social preview image and demo GIF; untrack opened_shelf_cache 2026-05-16 16:32:40 +00:00
dadaloop82 68f7756e2c Merge develop → main: fix Brave TTS voice proxy crash (#63) 2026-05-16 16:28:10 +00:00
dadaloop82 b82b4d9d94 fix: guard against Brave user-script fake SpeechSynthesisVoice proxy (#63)
Brave on iOS injects a user-script that wraps SpeechSynthesisVoice objects
with a fake proxy. Accessing v.lang on the proxy threw 'undefined is not an
object (evaluating Object.getPrototypeOf(voice))'.

Fix: wrap the v.lang access in _initBrowserTtsVoices filter() inside its
own try/catch — bad proxies are silently discarded.
2026-05-16 16:28:06 +00:00
dependabot[bot] 91b4ecd670 ci: bump actions/checkout from 4 to 6 (#47)
chore: dependabot CI dependency update
2026-05-16 18:27:43 +02:00
dadaloop82 380fa8ee99 Merge develop → main: roadmap → GitHub Project 2026-05-16 16:21:26 +00:00
dadaloop82 89b8686f4f docs: replace static roadmap with link to GitHub Project 2026-05-16 16:21:22 +00:00
dadaloop82 b6aa07a1fd Merge develop → main: v1.7.15 settings centralization 2026-05-16 16:09:59 +00:00
dadaloop82 47c26ffdc8 v1.7.15 — centralize all settings to server (.env + SQLite)
- TTS: tts_engine, tts_rate, tts_pitch, tts_auth_header_name, tts_auth_header_value,
  tts_extra_fields now stored in .env and synced across devices via get_settings/save_settings
- meal_plan: persisted to SQLite app_settings table on every edit (selectMealPlanType,
  resetMealPlan) and restored on startup via syncSettingsFromDB — all devices stay in sync
- tts_voice: also synced to SQLite for best-effort cross-device restore
- saveSettings() sends meal_plan + tts_voice to app_settings_save after env write
- Remove deprecated SPESA_PROVIDER and SPESA_AI_PROMPT from .env
- .env.example: full rewrite documenting all 30+ keys in labelled sections
  (AI, Shopping, TTS, Preferences, Appliances, Scale, Meal Plan, Screensaver, Prices,
  Security, Developer)
2026-05-16 16:09:49 +00:00
dadaloop82 12357db933 v1.7.15 — i18n audit, appliance translation, splash min 3s, demo GIF, decimal precision, gemini key fix 2026-05-16 15:48:53 +00:00
dadaloop82 6def94948b v1.7.15 — appliance translation, gemini key preserve on save
- _applianceDisplayName(): reverse lookup from canonical Italian names
  to settings.appliances.* i18n keys, with emoji stripping — appliance
  chips now show 'Air fryer', 'Heißluftfritteuse', etc. in EN/DE
- renderAppliances(): uses translated display name; remove button title
  uses t('btn.delete') instead of hardcoded 'Rimuovi'
- addApplianceQuick(): toast now uses t('toast.appliance_added') instead
  of hardcoded Italian ' aggiunto'
- saveSettings(): gemini_key in localStorage preserved when input is empty
  (key is not pre-populated for security — blank != user deleted the key)
- saveSettings(): _geminiAvailable re-synced from server after each save
  so recipe buttons immediately reflect correct state without page reload
2026-05-16 15:48:37 +00:00
github-actions[bot] abbc2772ff chore: auto-merge develop → main
Triggered by: 473d3f5 v1.7.15 — i18n audit, splash min 3s, decimal precision, demo GIF, README fixes
2026-05-16 15:38:14 +00:00
dadaloop82 473d3f59a4 v1.7.15 — i18n audit, splash min 3s, decimal precision, demo GIF, README fixes
- Complete i18n audit: 25+ new translation keys (en/it/de) — vacuum toast,
  TTS voices, timer steps, product notes, error prefixes, form placeholders,
  barcode hints, recipe/cooking ingredient labels, unit variants
- pz/conf unit labels now use t('units.pz') / t('units.conf') throughout
- Splash screen: minimum 3-second display (_splashStart recorded at parse
  time, fade delayed by remaining ms if app loads faster)
- Quantity decimal precision: qtyNum in recipe/cooking buttons and conf
  fallback display capped to 1 decimal (was showing 7+ from raw AI output)
- Recipe/cooking buttons: removed Italian fallback strings from t() calls
- README: translated remaining Italian phrases; added demo.gif to Screenshots
- CHANGELOG: updated 1.7.15 entry with all session changes
- assets/img/demo.gif: EverShelf.gif processed at 2x speed (~36s)
2026-05-16 15:36:31 +00:00
github-actions[bot] e7ae5c90c7 chore: auto-merge develop → main
Triggered by: 195c3d3 fix(i18n): comprehensive translation pass — inventory tabs, product form, page-ai, nav, settings (recipe/mealplan/TTS/security/camera/scale/kiosk), setup wizard, screensaver timeouts; add 25+ missing i18n keys across all 3 languages
2026-05-16 13:58:16 +00:00
dadaloop82 195c3d3bfa fix(i18n): comprehensive translation pass — inventory tabs, product form, page-ai, nav, settings (recipe/mealplan/TTS/security/camera/scale/kiosk), setup wizard, screensaver timeouts; add 25+ missing i18n keys across all 3 languages 2026-05-16 13:56:41 +00:00
github-actions[bot] 85ba22c7c8 chore: auto-merge develop → main
Triggered by: 698eb72 fix(i18n): add data-i18n to all static page-use/page-add/page-action labels; fix common.cancel → btn.cancel
2026-05-16 13:44:47 +00:00
dadaloop82 698eb721f2 fix(i18n): add data-i18n to all static page-use/page-add/page-action labels; fix common.cancel → btn.cancel 2026-05-16 13:43:11 +00:00
26 changed files with 4401 additions and 683 deletions
+94 -17
View File
@@ -1,25 +1,102 @@
# EverShelf - Configuration # EverShelf Configuration
# Copy this file to .env and fill in your values # Copy this file to .env and fill in your values:
# cp .env.example .env # cp .env.example .env
#
# All settings here can also be changed from the in-app Settings screen and
# will be written back to this file automatically.
# ─────────────────────────────────────────────────────────────────────────────
# Google Gemini AI API Key (required for AI features) # ── AI ────────────────────────────────────────────────────────────────────────
# Get one at: https://aistudio.google.com/app/apikey # Google Gemini API key (required for AI features: expiry reading, recipe gen, …)
# Get one free at: https://aistudio.google.com/app/apikey
GEMINI_API_KEY= GEMINI_API_KEY=
# Bring! Shopping List credentials (optional) # ── Shopping list (Bring!) ────────────────────────────────────────────────────
# Sign up at: https://www.getbring.com/ # Credentials for the Bring! app (optional — app works without it)
BRING_EMAIL= BRING_EMAIL=
BRING_PASSWORD= BRING_PASSWORD=
# TTS (Text-to-Speech) for cooking mode voice guidance (optional) # ── Text-to-Speech (TTS) ─────────────────────────────────────────────────────
# Works with Home Assistant, or any HTTP endpoint that accepts text # Works with Home Assistant, a local TTS server, or any HTTP endpoint.
TTS_URL= # TTS_ENABLED: master switch (true/false)
TTS_TOKEN=
TTS_METHOD=POST
TTS_AUTH_TYPE=bearer
TTS_CONTENT_TYPE=application/json
TTS_PAYLOAD_KEY=message
TTS_ENABLED=false TTS_ENABLED=false
# TTS_URL: endpoint that receives the text payload
TTS_URL=
# TTS_TOKEN: Authorization token sent as Bearer header (or empty)
TTS_TOKEN=
# TTS_METHOD: HTTP method (POST or GET)
TTS_METHOD=POST
# TTS_AUTH_TYPE: how the token is sent (bearer | basic | none)
TTS_AUTH_TYPE=bearer
# TTS_CONTENT_TYPE: request Content-Type header
TTS_CONTENT_TYPE=application/json
# TTS_PAYLOAD_KEY: JSON key that carries the text (e.g. "message", "text")
TTS_PAYLOAD_KEY=message
# TTS_ENGINE: preferred browser TTS engine ('browser', 'server', 'custom') — optional
TTS_ENGINE=
# TTS_RATE / TTS_PITCH: speech rate and pitch multipliers (1 = normal)
TTS_RATE=1
TTS_PITCH=1
# TTS_AUTH_HEADER_NAME / VALUE: custom HTTP header for authentication (optional)
TTS_AUTH_HEADER_NAME=
TTS_AUTH_HEADER_VALUE=
# TTS_EXTRA_FIELDS: additional JSON fields as key=value pairs, comma-separated (optional)
TTS_EXTRA_FIELDS=
# GitHub Error Reporting: token is hardcoded in api/index.php (same for all clients). # ── User preferences ─────────────────────────────────────────────────────────
# No .env entry needed — update GH_ISSUE_TOKEN constant in api/index.php to rotate. # These mirror the toggle switches in the Settings screen.
DEFAULT_PERSONS=1
PREF_VELOCE=false
PREF_POCAFAME=false
PREF_SCADENZE=true
PREF_HEALTHY=false
PREF_OPENED=true
PREF_ZEROWASTE=false
# Dietary restrictions shown to the AI (e.g. "vegetariano,senza glutine")
DIETARY=
# ── Appliances ────────────────────────────────────────────────────────────────
# Comma-separated list of appliances available in your kitchen.
# Used by the AI when generating recipes.
APPLIANCES=Forno,Microonde,Friggitrice ad aria,Pentola a pressione
# ── Camera ───────────────────────────────────────────────────────────────────
# Default camera for barcode scanning ('environment' = rear, 'user' = front)
CAMERA_FACING=environment
# ── Smart Kitchen Scale ───────────────────────────────────────────────────────
# SCALE_ENABLED: enables the scale integration
SCALE_ENABLED=false
# SCALE_GATEWAY_URL: address of the EverShelf Scale Gateway (Android app)
SCALE_GATEWAY_URL=
# ── Meal Plan ────────────────────────────────────────────────────────────────
# MEAL_PLAN_ENABLED: show the weekly meal planner tab in Settings
MEAL_PLAN_ENABLED=false
# ── Screensaver (kiosk / tablet mode) ────────────────────────────────────────
SCREENSAVER_ENABLED=false
# SCREENSAVER_TIMEOUT: inactivity seconds before screensaver activates (default 5 min)
SCREENSAVER_TIMEOUT=300
# ── Price estimates ───────────────────────────────────────────────────────────
# PRICE_ENABLED: show AI-estimated price column on the shopping list
PRICE_ENABLED=false
# PRICE_COUNTRY: country used for price context (e.g. "Italia", "Germany")
PRICE_COUNTRY=Italia
# PRICE_CURRENCY: ISO 4217 currency code (e.g. EUR, USD, GBP)
PRICE_CURRENCY=EUR
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
PRICE_UPDATE_MONTHS=3
# ── Security ─────────────────────────────────────────────────────────────────
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
# Leave empty to allow anyone with access to the server to change settings.
SETTINGS_TOKEN=
# ── Developer / demo ─────────────────────────────────────────────────────────
# DEMO_MODE: when true, all write operations are blocked (for public demos)
DEMO_MODE=false
# NOTE: GitHub error reporting uses a token hardcoded in api/index.php.
# To rotate it, update the GH_ISSUE_TOKEN constant there.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v4
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v4
+6 -6
View File
@@ -11,7 +11,7 @@ jobs:
name: PHP Syntax Check name: PHP Syntax Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -27,7 +27,7 @@ jobs:
name: JavaScript Lint name: JavaScript Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Check JS syntax - name: Check JS syntax
run: | run: |
@@ -37,7 +37,7 @@ jobs:
name: Docker Build Test name: Docker Build Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Build Docker image - name: Build Docker image
run: docker build -t evershelf-test . run: docker build -t evershelf-test .
@@ -53,7 +53,7 @@ jobs:
name: Validate Translation Files name: Validate Translation Files
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Validate JSON syntax - name: Validate JSON syntax
run: | run: |
@@ -99,7 +99,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout (full history) - name: Checkout (full history)
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
@@ -133,7 +133,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout main - name: Checkout main
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
ref: main ref: main
fetch-depth: 0 fetch-depth: 0
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Build Docker image - name: Build Docker image
run: docker build -t evershelf:scan . run: docker build -t evershelf:scan .
@@ -51,7 +51,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Run Trivy filesystem scanner - name: Run Trivy filesystem scanner
uses: aquasecurity/trivy-action@v0.36.0 uses: aquasecurity/trivy-action@v0.36.0
+1
View File
@@ -50,3 +50,4 @@ data/error_reports.log
data/latest_release_cache.json data/latest_release_cache.json
data/food_facts_cache.json data/food_facts_cache.json
data/category_ai_cache.json data/category_ai_cache.json
assets/img/logo/*_backup.*
+55
View File
@@ -11,6 +11,61 @@ 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. - **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.20] - 2026-05-20
### Added
- **Startup health check** — During the splash screen, the app now runs a comprehensive server-side diagnostic before loading: PHP version, required extensions (pdo_sqlite, curl, mbstring, json), `data/` directory writability, SQLite database connection and table integrity, `.env` file presence, Gemini AI key and Bring! token. Results are displayed as an animated checklist (✅ / ⚠️ / ❌). Critical failures (DB, extensions, data dir) block the app with a clear error message and a "Retry" button — the app never starts silently broken. Non-critical warnings (missing Gemini key, Bring! token) are shown as amber items but do not block startup.
- New `?action=health_check` PHP endpoint (early-exit, no rate-limit, no auth).
- New translation keys `startup.*` in all 5 languages (IT, EN, DE, FR, ES).
## [1.7.19] - 2026-05-19
### Added
- **Zero-waste tips during cooking** — When cooking mode is active, a ♻️ card appears below each step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Gemini generates the tips as part of the recipe JSON at no extra API cost. Tips are dismissible per-step and reset on recipe restart. Opt-in toggle in Settings → Zero-waste tips (default OFF). Resolves [#76](https://github.com/dadaloop82/EverShelf/issues/76).
- New translation keys `cooking.zerowaste_*` and `settings.zerowaste.*` in all 5 languages (IT, EN, DE, FR, ES).
## [1.7.18] - 2026-05-19
### Added
- **Dark mode** — New theme selector in Settings (Appearance card): **Off (Light)**, **On (Dark)**, **Auto (follows system)**. Applied immediately on page load to prevent white flash. Resolves [#78](https://github.com/dadaloop82/EverShelf/issues/78).
- **Export inventory** — New 📤 button in inventory page header opens a modal to download the inventory as **CSV** (UTF-8 with BOM, Excel-compatible) or open a **print-ready HTML page** (auto-triggers print dialog for PDF). Export card also available in Settings tab. Resolves [#64](https://github.com/dadaloop82/EverShelf/issues/64).
- `translations/de.json`: fixed missing `log.recipe_prefix` key.
## [1.7.17] - 2026-05-19
### Added
- **French translation (🇫🇷 Français)** — Complete `translations/fr.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
- **Spanish translation (🇪🇸 Español)** — Complete `translations/es.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77).
- Language selector in Settings now shows all 5 languages: 🇮🇹 Italiano, 🇬🇧 English, 🇩🇪 Deutsch, 🇫🇷 Français, 🇪🇸 Español.
- Default fallback language changed from Italian to English (for users with unsupported browser locale).
- Setup wizard "Done" screen and navigation buttons localised for French and Spanish.
## [1.7.16] - 2026-05-17
### Added
- **Barcode scan history** — Last 20 scanned products are stored server-side (SQLite `app_settings`) and shown as chips in the scan page (`#scan-recents-chips`). Tapping a chip selects the product directly — no need to scan again. Resolves [#68](https://github.com/dadaloop82/EverShelf/issues/68).
- **Full server-side user-data centralisation** — All user preferences previously siloed in `localStorage` per-device are now synced to the server via `app_settings_save` and loaded back at startup via `app_settings_get`. Affected data: shopping tags, pinned Bring! items, location preferences (use/move), auto-added Bring! entries, Bring! purchased blocklist, no-expiry dismissed products. Data is now shared across all devices (desktop, phone, kiosk, Android app).
- **One-time localStorage migration** — On first load, any data found in the old localStorage keys (`shopping_tags`, `_userPinnedBring`, `_prefUseLoc`, `_prefMoveLoc`, `_autoAddedBring`, `_bringPurchasedBlocklist`, `_noExpiryDismissed`, `evershelf_scan_recents`) is automatically migrated to the server and the local keys are removed.
## [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
- **Camera button (📷) opened kiosk SettingsActivity on Android** — The native `btnSettings` ImageButton in the kiosk layout was positioned `top|end` with `alpha=0.12` (nearly invisible), sitting directly on top of the HTML scan button in the webapp header. Every tap on the 📷 button was intercepted by the native View and opened `SettingsActivity`. Fixed: moved `btnSettings` to `bottom|end` (above the bottom nav bar, `marginBottom=80dp`) and increased `alpha` to `0.28` so it is clearly separate from the header. Kiosk versionCode bumped to 16.
- **Camera button (📷) opened settings on Android Chrome/Brave** — `pointerleave` fired before `pointerup` when finger drifted slightly, cancelling the long-press timer and leaving the browser to dispatch a synthetic `click` that bubbled to an unintended handler. Fixed: added `setPointerCapture` (prevents `pointerleave` during touch) and `preventDefault` (blocks synthetic click); replaced `pointerleave` with `pointercancel` handler. Added `touch-action: manipulation` to `.header-scan-btn` CSS.
- **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.
- **Appliance chips translated** — `renderAppliances()` now shows translated names (e.g. "Air fryer" in EN, "Heißluftfritteuse" in DE) for all known canonical Italian appliance names via `_applianceDisplayName()` lookup. `addApplianceQuick` toast no longer hardcoded Italian. Remove-button title translated.
- **Gemini API key not preserved on settings save** — `saveSettings()` was overwriting `s.gemini_key = ""` when the Gemini input field was empty (it is intentionally not pre-populated for security). Key is now preserved if the input is blank. `_geminiAvailable` is re-fetched from the server after every settings save so the recipe buttons reflect the real state immediately.
## [1.7.14] - 2026-05-16 ## [1.7.14] - 2026-05-16
### Added ### Added
+29 -39
View File
@@ -24,8 +24,8 @@
[![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/) [![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/)
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![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) [![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/) [![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-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.19-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers) [![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) [![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) [![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
@@ -38,14 +38,20 @@
## ✨ Features ## ✨ Features
> ♻️ **New in v1.7.19 — Zero-waste cooking tips**
> During cooking, EverShelf shows a contextual ♻️ tip card for each step that generates reusable scraps — peels, cooking water, egg whites, cheese rinds, bread crusts and more.
> Tips are generated by Gemini *as part of the recipe* at zero extra API cost, shown inline in cooking mode, and dismissible per step.
> Enable the toggle in **Settings → Zero-waste tips** (default: off).
### 📦 Inventory Management ### 📦 Inventory Management
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS - **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
- **Barcode scanning** — Scan products with your phone camera using QuaggaJS; last 20 scanned products saved as tappable chips so you can re-select them without rescanning
- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error - **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error
- **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations - **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage - **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) - **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 - **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) ### 🤖 AI-Powered (Google Gemini)
- **Expiry date reading** — Photograph a label and extract the expiry date automatically - **Expiry date reading** — Photograph a label and extract the expiry date automatically
@@ -55,24 +61,25 @@
- **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 - **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 - **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 - **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 - **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 - **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 ### 🛒 Shopping List
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app - **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 - **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-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) - **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)
- **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app - **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app
### 🍳 Cooking Mode ### 🍳 Cooking Mode
- **♻️ Zero-waste tips** — For each cooking step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.), a dismissible ♻️ tip card appears with a practical reuse idea; tips are generated by Gemini as part of the recipe at no extra API cost; opt-in toggle in Settings (default OFF)
- **Step-by-step guidance** — Follow recipes with a hands-free cooking interface - **Step-by-step guidance** — Follow recipes with a hands-free cooking interface
- **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 - **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 - **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 - **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 - **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 - **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow
@@ -82,7 +89,7 @@
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items - **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 - **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 - **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 - **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 - **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 - **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
@@ -90,10 +97,13 @@
- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications - **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications
- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access - **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access
### 🌙 Appearance
- **Dark mode** — Three modes: Light, Dark, and Auto (follows the OS/browser setting); theme is applied before the first render to prevent a white flash on dark-mode systems; toggle in Settings → Appearance
### 📱 Progressive Web App ### 📱 Progressive Web App
- **Mobile-first design** — Optimized for phones, works on tablets and desktop - **Mobile-first design** — Optimized for phones, works on tablets and desktop
- **Installable** — Add to home screen for a native app experience - **Installable** — Add to home screen for a native app experience
- **Multi-device** — Settings and data sync across devices on the same server - **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
### ⚖️ Smart Scale Integration (Add-on) ### ⚖️ Smart Scale Integration (Add-on)
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket - **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
@@ -352,35 +362,7 @@ The application uses no build tools — edit files directly and refresh.
## 📋 Roadmap ## 📋 Roadmap
### High Priority Feature requests, bug reports and planned work are tracked in the [**EverShelf Roadmap**](https://github.com/users/dadaloop82/projects/2) GitHub Project.
- [ ] **Cooking mode — 3D wheel JS** — swipe navigation, gyroscope tilt, haptic feedback
- [ ] **Cooking mode — step timers** — auto-detect "X minutes" in recipe steps, countdown + alert
- [ ] **Push notifications** — daily expiry alerts via PWA Service Worker + VAPID
- [ ] **Quick search / quick-add bar** — always-visible search above the nav, PWA shortcuts
### Medium Priority
- [ ] **Receipt OCR → bulk add** — photo of receipt → Gemini Vision → auto-fill inventory
- [ ] **CSV/JSON export & import** — download/upload inventory from Settings
- [ ] **Custom storage locations** — user-defined locations beyond Fridge/Freezer/Pantry
- [ ] **Multi-user support** — PIN-based user distinction, action log with user label
- [ ] **AI optimal purchase prediction** — suggest "buy X units of Y within Z days"
- [ ] **Price history sparklines** — per-product price chart from the AI cache data
### Low Priority / Nice to Have
- [ ] **Dark mode** — CSS custom properties are already structured to support it
- [ ] **Full offline mode** — Service Worker cache to show inventory read-only when server is down
- [ ] **French & Spanish translations** (`fr.json`, `es.json`)
- [ ] **Swipe actions on inventory rows** — swipe left to use/discard, right to edit
- [ ] **PHP unit tests** — PHPUnit coverage for shelf-life, price calc, and key helpers
### Completed ✅
- ✅ AI price estimation in shopping list
- ✅ Server heartbeat + offline banner
- ✅ In-app bug reporter → automatic GitHub issue creation
- ✅ Cooking mode (start, steps, 3D wheel CSS)
- ✅ Kiosk ⚙️ Settings overlay button (replaces Android native button)
- ✅ Adaptive consumption anomaly detection
- ✅ CI/CD pipeline (PHP lint, JS lint, Docker build, Trivy security scan)
--- ---
@@ -393,6 +375,8 @@ The app supports multiple languages via JSON translation files in the `translati
| 🇮🇹 Italian (it) | ✅ Complete (base) | | 🇮🇹 Italian (it) | ✅ Complete (base) |
| 🇬🇧 English (en) | ✅ Complete | | 🇬🇧 English (en) | ✅ Complete |
| 🇩🇪 German (de) | ✅ Complete | | 🇩🇪 German (de) | ✅ Complete |
| 🇫🇷 French (fr) | ✅ Complete |
| 🇪🇸 Spanish (es) | ✅ Complete |
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR! **Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
@@ -427,6 +411,12 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
## 📸 Screenshots ## 📸 Screenshots
<div align="center">
![EverShelf demo — barcode scan, inventory management and AI recipe generation](assets/img/demo.gif)
</div>
For a live walkthrough with real data and full AI enabled, visit the **[live demo](https://evershelfproject.dadaloop.it/demo)** — no installation required. 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!
+219 -23
View File
@@ -106,6 +106,70 @@ if (($_GET['action'] ?? '') === 'ping') {
exit; exit;
} }
// ── Health check — startup diagnostic (no rate-limit, no auth required) ──────
if (($_GET['action'] ?? '') === 'health_check') {
$checks = [];
// 1. PHP version
$phpOk = version_compare(PHP_VERSION, '8.0.0', '>=');
$checks['php'] = ['ok' => $phpOk, 'value' => PHP_VERSION];
// 2. Required PHP extensions
$requiredExts = ['pdo_sqlite', 'curl', 'mbstring', 'json'];
$missingExts = array_filter($requiredExts, fn($e) => !extension_loaded($e));
$checks['php_extensions'] = ['ok' => empty($missingExts), 'missing' => array_values($missingExts)];
// 3. data/ directory writable
$dataDir = __DIR__ . '/../data';
$dataWritable = is_dir($dataDir) && is_writable($dataDir);
if (!$dataWritable && !is_dir($dataDir)) {
@mkdir($dataDir, 0775, true);
$dataWritable = is_dir($dataDir) && is_writable($dataDir);
}
$checks['data_dir'] = ['ok' => $dataWritable, 'path' => realpath($dataDir) ?: $dataDir];
// 4. SQLite DB accessible
$dbOk = false; $dbError = '';
try {
$dbPath = $dataDir . '/dispensa.db';
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->query('SELECT 1');
// Check at least inventory table exists
$tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN);
$dbOk = in_array('inventory', $tables);
if (!$dbOk) $dbError = 'Missing tables (fresh install?)';
} catch (\Throwable $e) {
$dbError = $e->getMessage();
}
$checks['database'] = ['ok' => $dbOk, 'error' => $dbError ?: null];
// 5. .env loaded + Gemini key present
$envPath = __DIR__ . '/../.env';
$envLoaded = file_exists($envPath);
$geminiKey = env('GEMINI_API_KEY');
$checks['env_file'] = ['ok' => $envLoaded];
$checks['gemini_key'] = ['ok' => !empty($geminiKey)];
// 6. Bring! token (optional — warning only)
$bringToken = env('BRING_ACCESS_TOKEN');
$checks['bring_token'] = ['ok' => !empty($bringToken), 'optional' => true];
// 7. cURL available + internet reachable (light check, no actual call)
$curlOk = function_exists('curl_init');
$checks['curl'] = ['ok' => $curlOk];
// Overall: critical = php, php_extensions, data_dir, database
$critical = ['php', 'php_extensions', 'data_dir', 'database'];
$allOk = array_reduce($critical, fn($c, $k) => $c && ($checks[$k]['ok'] ?? false), true);
header('Content-Type: application/json');
echo json_encode(['ok' => $allOk, 'checks' => $checks], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== RATE LIMITING ===== // ===== RATE LIMITING =====
/** /**
* Simple file-based rate limiter. * Simple file-based rate limiter.
@@ -474,6 +538,10 @@ try {
guessCategoryFromAI(); guessCategoryFromAI();
break; break;
case 'export_inventory':
exportInventory($db);
break;
default: default:
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]); echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -485,6 +553,107 @@ try {
} }
endif; // end !CRON_MODE endif; // end !CRON_MODE
// ===== EXPORT INVENTORY =====
function exportInventory(PDO $db): void {
$format = strtolower($_GET['format'] ?? 'csv');
$stmt = $db->query("
SELECT p.name, p.brand, p.category, i.location, i.quantity, p.unit,
i.expiry_date, i.added_at, i.opened_at,
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed,
p.barcode, p.notes
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0
ORDER BY p.name ASC
");
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$date = date('Y-m-d');
if ($format === 'html') {
// Print-ready HTML for browser PDF
header('Content-Type: text/html; charset=utf-8');
$rows_html = '';
foreach ($rows as $r) {
$loc_icon = ['dispensa'=>'🗄️','frigo'=>'🧊','freezer'=>'❄️','altro'=>'📦'][$r['location']] ?? '📦';
$expiry = $r['expiry_date'] ? htmlspecialchars($r['expiry_date']) : '—';
$brand = $r['brand'] ? htmlspecialchars($r['brand']) : '';
$rows_html .= '<tr>'
. '<td>' . htmlspecialchars($r['name']) . ($brand ? '<br><small>' . $brand . '</small>' : '') . '</td>'
. '<td>' . htmlspecialchars(ucfirst($r['category'] ?? '')) . '</td>'
. '<td>' . $loc_icon . ' ' . htmlspecialchars(ucfirst($r['location'])) . '</td>'
. '<td style="text-align:right">' . htmlspecialchars($r['quantity']) . ' ' . htmlspecialchars($r['unit'] ?? 'pz') . '</td>'
. '<td>' . $expiry . '</td>'
. '<td>' . ($r['opened_at'] ? '📭 ' . htmlspecialchars($r['opened_at']) : '') . '</td>'
. '</tr>';
}
$count = count($rows);
echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>EverShelf Inventory Export {$date}</title>
<style>
body{font-family:Arial,sans-serif;font-size:12px;margin:24px;color:#1a1a1a}
h1{font-size:18px;margin-bottom:4px}
.subtitle{color:#6b7280;font-size:11px;margin-bottom:16px}
table{width:100%;border-collapse:collapse}
th{background:#2d5016;color:#fff;padding:7px 10px;text-align:left;font-size:11px}
td{padding:6px 10px;border-bottom:1px solid #e5e7eb;vertical-align:top}
tr:nth-child(even) td{background:#f8fafc}
small{color:#6b7280}
@media print{
body{margin:12px}
button{display:none}
@page{margin:15mm}
}
</style>
</head>
<body>
<button onclick="window.print()" style="margin-bottom:16px;padding:8px 16px;background:#2d5016;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px">🖨️ Print / Save as PDF</button>
<h1>🏠 EverShelf Inventory</h1>
<div class="subtitle">Exported: {$date} &nbsp;·&nbsp; {$count} items</div>
<table>
<thead><tr>
<th>Name / Brand</th><th>Category</th><th>Location</th><th>Qty</th><th>Expiry</th><th>Opened</th>
</tr></thead>
<tbody>{$rows_html}</tbody>
</table>
<script>window.onload=function(){window.print();}</script>
</body>
</html>
HTML;
exit;
}
// Default: CSV download
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="evershelf-inventory-' . $date . '.csv"');
// UTF-8 BOM for Excel compatibility
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
fputcsv($out, ['Name','Brand','Category','Location','Quantity','Unit','Expiry Date','Added','Opened At','Vacuum Sealed','Barcode','Notes']);
foreach ($rows as $r) {
fputcsv($out, [
$r['name'],
$r['brand'] ?? '',
$r['category'] ?? '',
$r['location'],
$r['quantity'],
$r['unit'] ?? 'pz',
$r['expiry_date'] ?? '',
$r['added_at'] ?? '',
$r['opened_at'] ?? '',
$r['vacuum_sealed'] ? 'Yes' : 'No',
$r['barcode'] ?? '',
$r['notes'] ?? '',
]);
}
fclose($out);
exit;
}
// ===== TTS PROXY ===== // ===== TTS PROXY =====
function ttsProxy() { function ttsProxy() {
$body = json_decode(file_get_contents('php://input'), true); $body = json_decode(file_get_contents('php://input'), true);
@@ -2273,6 +2442,12 @@ function getServerSettings(): void {
'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'), 'tts_content_type' => env('TTS_CONTENT_TYPE', 'application/json'),
'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'), 'tts_payload_key' => env('TTS_PAYLOAD_KEY', 'message'),
'tts_enabled' => env('TTS_ENABLED', 'false') === 'true', 'tts_enabled' => env('TTS_ENABLED', 'false') === 'true',
'tts_engine' => env('TTS_ENGINE', ''),
'tts_rate' => (float)env('TTS_RATE', '1'),
'tts_pitch' => (float)env('TTS_PITCH', '1'),
'tts_auth_header_name' => env('TTS_AUTH_HEADER_NAME', ''),
'tts_auth_header_value' => env('TTS_AUTH_HEADER_VALUE', ''),
'tts_extra_fields' => env('TTS_EXTRA_FIELDS', ''),
// User preferences (now server-side) // User preferences (now server-side)
'default_persons' => intval(env('DEFAULT_PERSONS', '1')), 'default_persons' => intval(env('DEFAULT_PERSONS', '1')),
'pref_veloce' => env('PREF_VELOCE', 'false') === 'true', 'pref_veloce' => env('PREF_VELOCE', 'false') === 'true',
@@ -2323,11 +2498,15 @@ function saveSettings(): void {
'tts_auth_type' => 'TTS_AUTH_TYPE', 'tts_auth_type' => 'TTS_AUTH_TYPE',
'tts_content_type'=> 'TTS_CONTENT_TYPE', 'tts_content_type'=> 'TTS_CONTENT_TYPE',
'tts_payload_key' => 'TTS_PAYLOAD_KEY', 'tts_payload_key' => 'TTS_PAYLOAD_KEY',
'camera_facing' => 'CAMERA_FACING', 'camera_facing' => 'CAMERA_FACING',
'dietary' => 'DIETARY', 'dietary' => 'DIETARY',
'scale_gateway_url' => 'SCALE_GATEWAY_URL', 'scale_gateway_url' => 'SCALE_GATEWAY_URL',
'price_country' => 'PRICE_COUNTRY', 'price_country' => 'PRICE_COUNTRY',
'price_currency' => 'PRICE_CURRENCY', 'price_currency' => 'PRICE_CURRENCY',
'tts_engine' => 'TTS_ENGINE',
'tts_auth_header_name' => 'TTS_AUTH_HEADER_NAME',
'tts_auth_header_value' => 'TTS_AUTH_HEADER_VALUE',
'tts_extra_fields' => 'TTS_EXTRA_FIELDS',
]; ];
// Boolean keys // Boolean keys
$boolMap = [ $boolMap = [
@@ -2349,6 +2528,11 @@ function saveSettings(): void {
'screensaver_timeout' => 'SCREENSAVER_TIMEOUT', 'screensaver_timeout' => 'SCREENSAVER_TIMEOUT',
'price_update_months' => 'PRICE_UPDATE_MONTHS', 'price_update_months' => 'PRICE_UPDATE_MONTHS',
]; ];
// Float keys
$floatMap = [
'tts_rate' => 'TTS_RATE',
'tts_pitch' => 'TTS_PITCH',
];
foreach ($keyMap as $inKey => $envKey) { foreach ($keyMap as $inKey => $envKey) {
if (array_key_exists($inKey, $input)) { if (array_key_exists($inKey, $input)) {
@@ -2365,6 +2549,11 @@ function saveSettings(): void {
$envVars[$envKey] = (string)intval($input[$inKey]); $envVars[$envKey] = (string)intval($input[$inKey]);
} }
} }
foreach ($floatMap as $inKey => $envKey) {
if (array_key_exists($inKey, $input)) {
$envVars[$envKey] = (string)(float)$input[$inKey];
}
}
// Arrays stored as comma-separated // Arrays stored as comma-separated
if (array_key_exists('appliances', $input)) { if (array_key_exists('appliances', $input)) {
$envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances']; $envVars['APPLIANCES'] = is_array($input['appliances']) ? implode(',', $input['appliances']) : (string)$input['appliances'];
@@ -2869,6 +3058,8 @@ function geminiChat(PDO $db): void {
$history = $input['history'] ?? []; $history = $input['history'] ?? [];
$appliances = $input['appliances'] ?? []; $appliances = $input['appliances'] ?? [];
$dietaryRestrictions = $input['dietary_restrictions'] ?? ''; $dietaryRestrictions = $input['dietary_restrictions'] ?? '';
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
$langName = recipeLangName($lang);
if (empty($message)) { if (empty($message)) {
echo json_encode(['success' => false, 'error' => 'Messaggio vuoto']); echo json_encode(['success' => false, 'error' => 'Messaggio vuoto']);
@@ -2916,27 +3107,29 @@ function geminiChat(PDO $db): void {
$dietaryText = ''; $dietaryText = '';
if (!empty($dietaryRestrictions)) { 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 = <<<PROMPT $systemPrompt = <<<PROMPT
Sei un assistente cucina italiano esperto, amichevole e conciso. L'utente ha una dispensa e ti chiede consigli su cosa preparare. You are an expert kitchen assistant, friendly and concise. The user has a pantry and asks you for advice on what to prepare.
IMPORTANT: Always respond in {$langName}, using a colloquial and friendly tone.
CONTESTO - INGREDIENTI DISPONIBILI IN DISPENSA: CONTEXT - AVAILABLE PANTRY INGREDIENTS:
{$ingredientsText} {$ingredientsText}
{$appliancesText}{$dietaryText} {$appliancesText}{$dietaryText}
REGOLE: RULES:
1. Rispondi SEMPRE in italiano, in modo colloquiale e amichevole 1. Always respond in {$langName}
2. Usa SOLO gli ingredienti dalla dispensa dell'utente (più acqua, sale, pepe, olio che si presumono sempre disponibili) 2. Use ONLY ingredients from the user's pantry (plus water, salt, pepper, oil which are assumed always available)
3. Dai priorità agli ingredienti in scadenza 3. Prioritize ingredients that expire soon
4. Sii conciso: non fare liste chilometriche, vai al sodo 4. Be concise: no lengthy lists, get to the point
5. Se l'utente chiede una ricetta o preparazione, dai istruzioni chiare con quantità 5. If the user asks for a recipe or preparation, give clear instructions with quantities
6. Se non ci sono ingredienti adatti per la richiesta, dillo onestamente e suggerisci alternative 6. If there are no suitable ingredients for the request, say so honestly and suggest alternatives
7. Puoi suggerire combinazioni creative 7. You can suggest creative combinations
8. Quando menzioni quantità, usa le stesse unità di misura della dispensa 8. When mentioning quantities, use the same units as in the pantry
9. Ricorda il contesto della conversazione precedente 9. Remember the context of the previous conversation
10. Se l'utente chiede esplicitamente una ricetta per un apparecchio specifico (es. macchina del pane, Cookeo, friggitrice ad aria), fornisci la ricetta SOLO per quell'apparecchio, con istruzioni specifiche per quel dispositivo (programmi, ordine degli ingredienti, tempi, temperature) 10. If the user explicitly asks for a recipe for a specific appliance (e.g. bread machine, Cookeo, air fryer), provide the recipe ONLY for that appliance, with device-specific instructions (programs, ingredient order, times, temperatures)
PROMPT; PROMPT;
// Build conversation for Gemini // Build conversation for Gemini
@@ -3810,6 +4003,8 @@ function recipeFromIngredient(PDO $db): void {
echo json_encode(['success' => false, 'error' => 'empty_ingredient']); echo json_encode(['success' => false, 'error' => 'empty_ingredient']);
return; return;
} }
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
$langName = recipeLangName($lang);
// Fetch inventory (same as generateRecipe) // Fetch inventory (same as generateRecipe)
$stmt = $db->query(" $stmt = $db->query("
@@ -3825,18 +4020,18 @@ function recipeFromIngredient(PDO $db): void {
$safeName = htmlspecialchars($ingredientName, ENT_QUOTES, 'UTF-8'); $safeName = htmlspecialchars($ingredientName, ENT_QUOTES, 'UTF-8');
$prompt = <<<PROMPT $prompt = <<<PROMPT
Generate a recipe in Italian that uses "{$safeName}" as a main ingredient. Generate a recipe in {$langName} that uses "{$safeName}" as a main ingredient.
Return ONLY a JSON object, no markdown. Return ONLY a JSON object, no markdown.
Fields: Fields:
- title: string (Italian recipe name) - title: string (recipe name in {$langName})
- meal: null (do NOT categorize) - meal: null (do NOT categorize)
- persons: 2 - persons: 2
- prep_time: string or null - prep_time: string or null
- cook_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} - 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 "{$safeName}" MUST be the first ingredient; set from_pantry=true for ALL
- steps: array of strings (step text only, no numbers) - steps: array of strings (step text only, no numbers, in {$langName})
- nutrition_note: string or null - nutrition_note: string or null
PROMPT; PROMPT;
@@ -4327,13 +4522,14 @@ REGOLE:
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio). 6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged. 7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed. 8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated.
DISPENSA: DISPENSA:
$ingredientsText $ingredientsText
Rispondi SOLO JSON valido (no markdown): Rispondi SOLO JSON valido (no markdown):
{$promptLanguageRule} {$promptLanguageRule}
{"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":""} {"title":"","meal":"$mealType","persons":$persons,"prep_time":"","cook_time":"","tags":[""],"expiry_note":"","tools_needed":[""],"ingredients":[{"name":"","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["{$promptStepExample}"],"nutrition_note":"","zero_waste_tips":[{"step":0,"scrap":"","tip":""}]}
PROMPT; PROMPT;
$genConfig = [ $genConfig = [
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

+401 -3
View File
@@ -104,11 +104,67 @@ body {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.app-preloader-logo { .app-preloader-logo {
height: 120px; height: 160px;
width: auto; width: auto;
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 2px 8px rgba(0,0,0,0.4)); filter: drop-shadow(0 4px 16px rgba(74,222,128,0.2));
} }
.app-preloader-version {
color: rgba(255,255,255,0.35);
font-size: 0.72rem;
font-family: monospace;
letter-spacing: 0.5px;
margin-top: -8px;
}
/* ── Startup health check list ─────────────────────────────────────── */
.preloader-checks {
display: flex;
flex-direction: column;
gap: 6px;
width: 240px;
max-width: 90vw;
animation: zwFadeIn 0.25s ease;
}
.preloader-check-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.82rem;
color: rgba(255,255,255,0.80);
background: rgba(255,255,255,0.07);
border-radius: 8px;
padding: 5px 10px;
transition: background 0.2s;
}
.preloader-check-row[data-state="ok"] { background: rgba(74,222,128,0.10); color: rgba(255,255,255,0.92); }
.preloader-check-row[data-state="warn"] { background: rgba(251,191,36,0.12); color: rgba(255,255,255,0.92); }
.preloader-check-row[data-state="error"] { background: rgba(239,68,68,0.15); color: #fca5a5; }
.pck-icon { font-size: 1rem; line-height: 1; flex-shrink: 0; }
.pck-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preloader-error-msg {
color: #fca5a5;
background: rgba(239,68,68,0.18);
border: 1px solid rgba(239,68,68,0.4);
border-radius: 10px;
padding: 10px 16px;
font-size: 0.88rem;
text-align: center;
max-width: 280px;
line-height: 1.4;
animation: zwFadeIn 0.3s ease;
}
.preloader-retry-btn {
background: #ef4444;
color: #fff;
border: none;
border-radius: 8px;
padding: 9px 22px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
animation: zwFadeIn 0.3s ease;
}
.preloader-retry-btn:active { opacity: 0.8; }
.header-logo-icon { .header-logo-icon {
height: 28px; height: 28px;
width: auto; width: auto;
@@ -272,6 +328,7 @@ body {
height: 48px; height: 48px;
box-shadow: 0 2px 8px rgba(0,0,0,0.18); box-shadow: 0 2px 8px rgba(0,0,0,0.18);
animation: pulse-scan 2s ease-in-out infinite; animation: pulse-scan 2s ease-in-out infinite;
touch-action: manipulation; /* prevent 300ms delay and double-tap zoom on mobile */
} }
.header-scan-btn:active { .header-scan-btn:active {
background: rgba(255,255,255,0.45); background: rgba(255,255,255,0.45);
@@ -1267,6 +1324,16 @@ body.server-offline .bottom-nav {
color: white; color: white;
} }
#btn-report-bug {
background: #f97316;
color: #fff;
border-color: #ea580c;
}
#btn-report-bug:hover {
background: #ea580c;
border-color: #c2410c;
}
.btn-secondary { .btn-secondary {
background: var(--bg-card); background: var(--bg-card);
color: var(--text); color: var(--text);
@@ -5733,7 +5800,6 @@ body.cooking-mode-active .app-header {
opacity: 0.75; opacity: 0.75;
} }
.alert-item-spoiled .alert-item-name { .alert-item-spoiled .alert-item-name {
text-decoration: line-through;
color: var(--text-light); color: var(--text-light);
} }
@@ -6840,3 +6906,335 @@ body.cooking-mode-active .app-header {
color: #9ca3af; color: #9ca3af;
font-size: 0.8em; font-size: 0.8em;
} }
/* ===== PAGE HEADER ACTION BUTTON (export etc.) ===== */
.page-header-action-btn {
margin-left: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s;
color: var(--primary);
}
.page-header-action-btn:active {
transform: scale(0.95);
opacity: 0.8;
}
/* ===== ZERO-WASTE TIP CARD (cooking mode) ===== */
.cooking-zerowaste-tip {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba(16, 185, 129, 0.10);
border: 1.5px solid rgba(16, 185, 129, 0.35);
border-radius: 12px;
padding: 12px 14px;
margin: 10px 16px 0;
position: relative;
animation: zwFadeIn 0.3s ease;
flex-direction: column;
}
@keyframes zwFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.cooking-zerowaste-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #059669;
}
.cooking-zerowaste-scrap {
font-size: 0.85rem;
font-weight: 700;
color: #065f46;
margin-left: 4px;
}
.cooking-zerowaste-text {
font-size: 0.85rem;
color: var(--text);
margin: 4px 0 0;
line-height: 1.45;
}
.cooking-zerowaste-close {
position: absolute;
top: 8px;
right: 10px;
background: none;
border: none;
font-size: 0.85rem;
cursor: pointer;
color: #6b7280;
padding: 2px 4px;
line-height: 1;
}
.cooking-zerowaste-close:hover { color: #374151; }
[data-theme="dark"] .cooking-zerowaste-tip {
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.25);
}
[data-theme="dark"] .cooking-zerowaste-scrap { color: #6ee7b7; }
[data-theme="dark"] .cooking-zerowaste-label { color: #34d399; }
[data-theme="dark"] .cooking-zerowaste-close { color: #9ca3af; }
/* ===== DARK MODE ===== */
[data-theme="dark"] {
--bg: #0f172a;
--bg-card: #1e293b;
--bg-dark: #020617;
--text: #e2e8f0;
--text-light: #94a3b8;
--text-muted: #64748b;
--text-secondary: #94a3b8;
--border: #334155;
--shadow: 0 2px 8px rgba(0,0,0,0.45);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.6);
color-scheme: dark;
}
[data-theme="dark"] body {
background: var(--bg);
color: var(--text);
}
/* Bottom nav */
[data-theme="dark"] .bottom-nav {
box-shadow: 0 -2px 10px rgba(0,0,0,0.4);
}
/* Location tabs */
[data-theme="dark"] .tab {
background: var(--bg-card);
color: var(--text-light);
}
[data-theme="dark"] .tab.active {
background: var(--primary);
color: #fff;
}
/* Location selector (add/use modal) */
[data-theme="dark"] .location-option {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
[data-theme="dark"] .location-option.selected {
border-color: var(--primary);
background: rgba(45,80,22,0.3);
}
/* Inputs & selects */
[data-theme="dark"] .form-input,
[data-theme="dark"] .form-control,
[data-theme="dark"] input[type="text"],
[data-theme="dark"] input[type="email"],
[data-theme="dark"] input[type="password"],
[data-theme="dark"] input[type="number"],
[data-theme="dark"] textarea,
[data-theme="dark"] select {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] input::placeholder,
[data-theme="dark"] textarea::placeholder {
color: var(--text-muted);
}
/* Buttons */
[data-theme="dark"] .btn-secondary,
[data-theme="dark"] .btn-outline,
[data-theme="dark"] .back-btn,
[data-theme="dark"] .page-header-action-btn {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .btn-outline {
color: var(--primary-light);
border-color: var(--primary-light);
}
/* Inventory items */
[data-theme="dark"] .inventory-item {
background: var(--bg-card);
}
[data-theme="dark"] .inv-location-badge {
background: rgba(45,80,22,0.35);
color: #86efac;
}
/* Shopping items */
[data-theme="dark"] .shopping-item {
background: var(--bg-card) !important;
}
[data-theme="dark"] .shopping-item-tag-menu-container {
background: var(--bg-card);
border-color: var(--border);
}
[data-theme="dark"] .shopping-item-tag-btn {
background: #1e293b;
color: var(--text-light);
border-color: var(--border);
}
[data-theme="dark"] .badge-local-tag {
background: #0c2a4e;
color: #7dd3fc;
}
[data-theme="dark"] .badge-freq-med {
background: #2e1a4a;
color: #c4b5fd;
}
[data-theme="dark"] .badge-freq-low {
background: #1e293b;
color: #94a3b8;
}
/* Settings rows */
[data-theme="dark"] .settings-row {
border-color: var(--border);
}
[data-theme="dark"] .settings-label {
color: var(--text);
}
[data-theme="dark"] .settings-hint {
color: var(--text-muted);
}
/* Toggle switch */
[data-theme="dark"] .toggle-slider {
background: #334155;
}
/* Search bar */
[data-theme="dark"] .search-bar input {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
/* Action modal location selector */
[data-theme="dark"] .action-location-btn {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
/* Scan page */
[data-theme="dark"] .scan-input-row {
background: var(--bg-card);
}
[data-theme="dark"] .scan-result-item {
background: var(--bg-card);
border-color: var(--border);
}
/* Quick access chips */
[data-theme="dark"] .quick-access-chip {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
/* Scan recents */
[data-theme="dark"] .scan-recent-chip {
background: var(--bg-card);
border-color: var(--border);
color: var(--text-light);
}
/* Alert banners */
[data-theme="dark"] .alert-banner {
background: #1e293b;
border-color: #334155;
}
[data-theme="dark"] .alert-banner.banner-expiring {
background: #1c1300;
border-color: #78350f;
}
[data-theme="dark"] .alert-banner.banner-expired {
background: #1f0808;
border-color: #7f1d1d;
}
[data-theme="dark"] .alert-banner.banner-finished {
background: #0f1f0f;
border-color: #166534;
}
[data-theme="dark"] .alert-banner.banner-anomaly {
background: #1a1a2e;
border-color: #4c1d95;
}
/* Recipe dialog */
[data-theme="dark"] .recipe-dialog-content {
background: var(--bg-card);
}
[data-theme="dark"] .recipe-option-btn {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
[data-theme="dark"] .recipe-option-btn.active {
background: rgba(45,80,22,0.4);
border-color: var(--primary-light);
color: var(--primary-light);
}
/* Log rows */
[data-theme="dark"] .log-item {
background: var(--bg-card);
border-color: var(--border);
}
/* Dashboard stat cards */
[data-theme="dark"] .stat-card {
background: var(--bg-card);
}
/* Screensaver */
[data-theme="dark"] .screensaver-overlay {
background: #020617;
}
/* Charts / nutrition */
[data-theme="dark"] .nutrition-chart-bg {
background: var(--bg-card);
}
/* AW badges */
[data-theme="dark"] .aw-badge-rate { background: #2e1a4a; color: #c4b5fd; border-color: #6d28d9; }
[data-theme="dark"] .aw-badge-money { background: #1c1300; color: #fde047; border-color: #78350f; }
[data-theme="dark"] .aw-badge-meals { background: #0f1f0f; color: #4ade80; border-color: #166534; }
[data-theme="dark"] .aw-badge-co2 { background: #0c1f3a; color: #7dd3fc; border-color: #1e3a5f; }
[data-theme="dark"] .aw-badge-wasted{ background: #1f0808; color: #fca5a5; border-color: #7f1d1d; }
[data-theme="dark"] .aw-badge-better{ background: #0f1f0f; color: #4ade80; border-color: #166534; }
/* Chat */
[data-theme="dark"] .chat-input {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .chat-message.user {
background: var(--primary-dark);
}
[data-theme="dark"] .chat-message.bot {
background: var(--bg-card);
}
/* Smart shopping forecast */
[data-theme="dark"] .smart-item {
background: var(--bg-card);
border-color: var(--border);
}
[data-theme="dark"] .smart-filter-btn {
background: var(--bg-card);
color: var(--text-light);
border-color: var(--border);
}
[data-theme="dark"] .smart-filter-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
/* Offline banner */
[data-theme="dark"] #offline-banner {
background: #450a0a;
border-color: #7f1d1d;
}
/* Setup wizard */
[data-theme="dark"] .setup-content {
background: var(--bg-card);
}
[data-theme="dark"] .setup-lang-btn {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .setup-lang-btn.selected {
background: rgba(45,80,22,0.4);
border-color: var(--primary-light);
color: var(--primary-light);
}
/* @media prefers-color-scheme: auto handled in JS */
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+599 -205
View File
File diff suppressed because it is too large Load Diff
-170
View File
@@ -1,170 +0,0 @@
{
"226887def70e33ef73290ebfe75ed4d0": {
"days": 7,
"source": "ai",
"name": "Polpa di pomodoro finissima",
"location": "frigo",
"ts": 1777444819
},
"0ed51c9496aa9edfe38caf41772f54ed": {
"days": 7,
"source": "rule",
"name": "Latte di Montagna",
"location": "frigo",
"ts": 1777444820
},
"2d63d0216a75d46b465150e925d2e7ad": {
"days": 30,
"source": "rule",
"name": "Burro",
"location": "frigo",
"ts": 1777444821
},
"9afdf35c4a256867ef47c32495349eb6": {
"days": 5,
"source": "rule",
"name": "Yaourt Vanille",
"location": "frigo",
"ts": 1777480477
},
"584f57418733a1f2acd29fe2e8816129": {
"days": 5,
"source": "rule",
"name": "Passata di pomodoro",
"location": "frigo",
"ts": 1778133522
},
"baeb7f2021b4bb91c368c9131a61f07c": {
"days": 10,
"source": "rule",
"name": "Formaggio Monte Maria",
"location": "frigo",
"ts": 1778133523
},
"063f2d534407214786d039bb2bffbb93": {
"days": 5,
"source": "rule",
"name": "Carote",
"location": "frigo",
"ts": 1778133524
},
"10a3d07c19bb1f889ebc9293862b4b36": {
"days": 60,
"source": "rule",
"name": "Ovomaltine",
"location": "dispensa",
"ts": 1778419084
},
"0fbad7ccd8b6155c06aaa6b3c17a67d3": {
"days": 365,
"source": "rule",
"name": "Linguine pasta di Gragnano Igp",
"location": "dispensa",
"ts": 1778419084
},
"b4a03e7356e7a0983b9c8af5f3cd8c57": {
"days": 60,
"source": "rule",
"name": "Polpa di pomodoro finissima",
"location": "dispensa",
"ts": 1778419085
},
"b8334ff0febd5c0440c9b24c9f3132ed": {
"days": 180,
"source": "rule",
"name": "Basilico tritato surgelato",
"location": "freezer",
"ts": 1778419086
},
"0cb14384d0ba763ccf12e079d6aa8d34": {
"days": 60,
"source": "rule",
"name": "Salsa Pronta Ciliegini",
"location": "dispensa",
"ts": 1778419086
},
"188634f49edb8b014a46942ee9fad689": {
"days": 180,
"source": "rule",
"name": "Farina Barilla",
"location": "dispensa",
"ts": 1778419204
},
"c8db359d8709c69a95f0e6f68216d220": {
"days": 9999,
"source": "rule",
"name": "Bicarbonato",
"location": "dispensa",
"ts": 1778419205
},
"a6d16a09fd9a6bfbd0a915f05dd71780": {
"days": 7,
"source": "ai",
"name": "Salsa Pronta Ciliegini",
"location": "frigo",
"ts": 1778419205
},
"4f8f1bb04a00e5fc62d7a9cfb21e1796": {
"days": 365,
"source": "rule",
"name": "Riso Chicchi Ricchi Gran Risparmio",
"location": "dispensa",
"ts": 1778419206
},
"e116e4c11084a463f9aaac02e1749fe7": {
"days": 90,
"source": "rule",
"name": "Salsa di soia",
"location": "dispensa",
"ts": 1778419207
},
"b1ad9afd4139b3f225b79af4dae256ce": {
"days": 60,
"source": "rule",
"name": "Tè Al limone",
"location": "dispensa",
"ts": 1778419504
},
"7ff2b7d326dcba52a664cebbf12f78a2": {
"days": 3,
"source": "ai",
"name": "Piselli fini 1\/2 vapore",
"location": "frigo",
"ts": 1778419505
},
"71062dc7ffd82b3ee4f40bad076a7c91": {
"days": 60,
"source": "rule",
"name": "Cioccolato bianco",
"location": "frigo",
"ts": 1778419506
},
"38a0eaea422dfe970eba125494e75981": {
"days": 180,
"source": "rule",
"name": "Zucca a pezzi",
"location": "freezer",
"ts": 1778419506
},
"cde21270e1cd50c431742e49117b225d": {
"days": 7,
"source": "rule",
"name": "Pancetta Dolce",
"location": "frigo",
"ts": 1778419507
},
"9e4189bd3f8cb1121e7389967dd4f74c": {
"days": 180,
"source": "rule",
"name": "Farina di grano tenero tipo rossa",
"location": "dispensa",
"ts": 1778427005
},
"e3472dd051ed13ae18fc96bbebedc1ba": {
"days": 60,
"source": "rule",
"name": "Lievito di birra",
"location": "dispensa",
"ts": 1778427005
}
}
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk" applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = 15 versionCode = 16
versionName = "1.7.14" versionName = "1.7.15"
} }
signingConfigs { signingConfigs {
@@ -43,17 +43,18 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:visibility="gone" /> android:visibility="gone" />
<!-- Settings gear (shown after setup, over WebView) — top-right corner to avoid overlapping modals --> <!-- Settings gear (shown after setup, over WebView) — bottom-right corner so it never
overlaps the webapp header buttons (e.g. the 📷 scan button at top-right) -->
<ImageButton <ImageButton
android:id="@+id/btnSettings" android:id="@+id/btnSettings"
android:layout_width="44dp" android:layout_width="44dp"
android:layout_height="44dp" android:layout_height="44dp"
android:layout_gravity="top|end" android:layout_gravity="bottom|end"
android:layout_marginTop="8dp" android:layout_marginBottom="80dp"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:src="@android:drawable/ic_menu_manage" android:src="@android:drawable/ic_menu_manage"
android:alpha="0.12" android:alpha="0.28"
android:contentDescription="Settings" android:contentDescription="Settings"
android:scaleType="centerInside" android:scaleType="centerInside"
android:visibility="gone" /> android:visibility="gone" />
+197 -149
View File
@@ -11,7 +11,7 @@
<title>EverShelf</title> <title>EverShelf</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png"> <link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260516a"> <link rel="stylesheet" href="assets/css/style.css?v=20260520a">
<!-- QuaggaJS for barcode scanning --> <!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise --> <!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
@@ -53,8 +53,12 @@
<!-- ===== APP PRELOADER (hidden by JS once _initApp completes) ===== --> <!-- ===== APP PRELOADER (hidden by JS once _initApp completes) ===== -->
<div id="app-preloader" aria-hidden="true"> <div id="app-preloader" aria-hidden="true">
<div class="app-preloader-inner"> <div class="app-preloader-inner">
<div class="app-preloader-spinner"></div>
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" /> <img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
<div class="app-preloader-spinner" id="preloader-spinner"></div>
<div id="preloader-checks" class="preloader-checks" 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.20</span>
</div> </div>
</div> </div>
@@ -67,7 +71,7 @@
<!-- Title — left-aligned; grows to fill space --> <!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap"> <div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')"> <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.14</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.20</span>
</h1> </h1>
<!-- Update badge — shown alongside title, never replaces it --> <!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span> <span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -184,13 +188,14 @@
<div class="page-header"> <div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button> <button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2> <h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
</div> </div>
<div class="location-tabs" id="location-tabs"> <div class="location-tabs" id="location-tabs">
<button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button> <button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button>
<button class="tab" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ Dispensa</button> <button class="tab" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
<button class="tab" onclick="filterLocation('frigo')" data-loc="frigo">🧊 Frigo</button> <button class="tab" onclick="filterLocation('frigo')" data-loc="frigo">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
<button class="tab" onclick="filterLocation('freezer')" data-loc="freezer">❄️ Freezer</button> <button class="tab" onclick="filterLocation('freezer')" data-loc="freezer">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
<button class="tab" onclick="filterLocation('altro')" data-loc="altro">📦 Altro</button> <button class="tab" onclick="filterLocation('altro')" data-loc="altro">📦 <span data-i18n="locations.altro">Altro</span></button>
</div> </div>
<div class="search-bar"> <div class="search-bar">
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()" data-i18n-placeholder="inventory.search_placeholder"> <input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()" data-i18n-placeholder="inventory.search_placeholder">
@@ -325,11 +330,11 @@
<div class="action-buttons" id="action-buttons-container"> <div class="action-buttons" id="action-buttons-container">
<button class="btn btn-huge btn-success" onclick="showAddForm()"> <button class="btn btn-huge btn-success" onclick="showAddForm()">
<span class="btn-icon">📥</span> <span class="btn-icon">📥</span>
<span class="btn-text">AGGIUNGI<br><small>in dispensa/frigo</small></span> <span class="btn-text"><span data-i18n="action.add_btn">AGGIUNGI</span><br><small data-i18n="action.add_sub">in dispensa/frigo</small></span>
</button> </button>
<button class="btn btn-huge btn-danger" onclick="showUseForm()"> <button class="btn btn-huge btn-danger" onclick="showUseForm()">
<span class="btn-icon">📤</span> <span class="btn-icon">📤</span>
<span class="btn-text">USA / CONSUMA<br><small>dalla dispensa/frigo</small></span> <span class="btn-text"><span data-i18n="action.use_btn">USA / CONSUMA</span><br><small data-i18n="action.use_sub">dalla dispensa/frigo</small></span>
</button> </button>
</div> </div>
</section> </section>
@@ -337,23 +342,23 @@
<!-- ===== ADD TO INVENTORY FORM ===== --> <!-- ===== ADD TO INVENTORY FORM ===== -->
<section class="page" id="page-add"> <section class="page" id="page-add">
<div class="page-header"> <div class="page-header">
<button class="back-btn" onclick="showPage('action')">← Indietro</button> <button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
<h2>Aggiungi alla Dispensa</h2> <h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
</div> </div>
<div class="product-preview-small" id="add-product-preview"></div> <div class="product-preview-small" id="add-product-preview"></div>
<form class="form" onsubmit="submitAdd(event)"> <form class="form" onsubmit="submitAdd(event)">
<div class="form-group"> <div class="form-group">
<label>📍 Dove lo metti?</label> <label data-i18n="add.location_label">📍 Dove lo metti?</label>
<div class="location-selector"> <div class="location-selector">
<button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ Dispensa</button> <button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 Frigo</button> <button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ Freezer</button> <button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
<button type="button" class="loc-btn" onclick="selectLocation(this, 'altro')">📦 Altro</button> <button type="button" class="loc-btn" onclick="selectLocation(this, 'altro')">📦 <span data-i18n="locations.altro">Altro</span></button>
</div> </div>
<input type="hidden" id="add-location" value="dispensa"> <input type="hidden" id="add-location" value="dispensa">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📦 Quantità</label> <label data-i18n="add.quantity_label">📦 Quantità</label>
<div class="qty-unit-row"> <div class="qty-unit-row">
<div class="qty-control flex-1"> <div class="qty-control flex-1">
<button type="button" class="qty-btn" onclick="adjustAddQty(-1)"></button> <button type="button" class="qty-btn" onclick="adjustAddQty(-1)"></button>
@@ -369,7 +374,7 @@
</div> </div>
<button type="button" id="btn-scale-add" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('add-quantity', function(){ return document.getElementById('add-unit').value; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button> <button type="button" id="btn-scale-add" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('add-quantity', function(){ return document.getElementById('add-unit').value; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button>
<div id="add-conf-size-row" class="conf-size-row" style="display:none"> <div id="add-conf-size-row" class="conf-size-row" style="display:none">
<label class="conf-size-label">📦 Ogni confezione contiene:</label> <label class="conf-size-label" data-i18n="add.conf_size_label">📦 Ogni confezione contiene:</label>
<div class="conf-size-inputs"> <div class="conf-size-inputs">
<input type="number" id="add-conf-size" class="form-input conf-size-input" min="1" step="any" placeholder="es. 300"> <input type="number" id="add-conf-size" class="form-input conf-size-input" min="1" step="any" placeholder="es. 300">
<select id="add-conf-unit" class="form-input conf-size-unit"> <select id="add-conf-unit" class="form-input conf-size-unit">
@@ -382,43 +387,43 @@
</div> </div>
<div class="form-group" id="add-vacuum-group"> <div class="form-group" id="add-vacuum-group">
<label class="toggle-row" onclick="toggleVacuumSealed()"> <label class="toggle-row" onclick="toggleVacuumSealed()">
<span>🫙 Sotto vuoto</span> <span data-i18n="add.vacuum_label">🫙 Sotto vuoto</span>
<span class="toggle-switch" id="add-vacuum-toggle"> <span class="toggle-switch" id="add-vacuum-toggle">
<input type="checkbox" id="add-vacuum-sealed" onchange="onVacuumSealedChange()"> <input type="checkbox" id="add-vacuum-sealed" onchange="onVacuumSealedChange()">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</span> </span>
</label> </label>
<p class="form-hint" id="add-vacuum-hint" style="display:none">La scadenza verrà estesa automaticamente</p> <p class="form-hint" id="add-vacuum-hint" style="display:none" data-i18n="add.vacuum_hint">La scadenza verrà estesa automaticamente</p>
</div> </div>
<div class="form-group" id="add-expiry-section"> <div class="form-group" id="add-expiry-section">
<!-- Populated dynamically by showAddForm() --> <!-- Populated dynamically by showAddForm() -->
</div> </div>
<button type="submit" class="btn btn-large btn-success full-width">✅ Aggiungi</button> <button type="submit" class="btn btn-large btn-success full-width" data-i18n="add.submit">✅ Aggiungi</button>
</form> </form>
</section> </section>
<!-- ===== USE FROM INVENTORY FORM ===== --> <!-- ===== USE FROM INVENTORY FORM ===== -->
<section class="page" id="page-use"> <section class="page" id="page-use">
<div class="page-header"> <div class="page-header">
<button class="back-btn" onclick="showPage('action')">← Indietro</button> <button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
<h2>Usa / Consuma</h2> <h2 data-i18n="use.title">Usa / Consuma</h2>
</div> </div>
<div class="product-preview-small" id="use-product-preview"></div> <div class="product-preview-small" id="use-product-preview"></div>
<div class="use-inventory-info" id="use-inventory-info"></div> <div class="use-inventory-info" id="use-inventory-info"></div>
<div id="use-expiry-hint" style="display:none"></div> <div id="use-expiry-hint" style="display:none"></div>
<form class="form" onsubmit="submitUse(event)"> <form class="form" onsubmit="submitUse(event)">
<div class="form-group" id="use-location-group"> <div class="form-group" id="use-location-group">
<label>📍 Da dove?</label> <label data-i18n="use.location_label">📍 Da dove?</label>
<div class="location-selector" id="use-location-selector"> <div class="location-selector" id="use-location-selector">
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button> <button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ <span data-i18n="locations.dispensa">Dispensa</span></button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'frigo')">🧊 Frigo</button> <button type="button" class="loc-btn" onclick="selectUseLocation(this, 'frigo')">🧊 <span data-i18n="locations.frigo">Frigo</span></button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'freezer')">❄️ Freezer</button> <button type="button" class="loc-btn" onclick="selectUseLocation(this, 'freezer')">❄️ <span data-i18n="locations.freezer">Freezer</span></button>
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'altro')">📦 Altro</button> <button type="button" class="loc-btn" onclick="selectUseLocation(this, 'altro')">📦 <span data-i18n="locations.altro">Altro</span></button>
</div> </div>
<input type="hidden" id="use-location" value="dispensa"> <input type="hidden" id="use-location" value="dispensa">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Quanto hai usato?</label> <label data-i18n="use.quantity_label">Quanto hai usato?</label>
<button type="button" id="btn-scale-use" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('use-quantity', function(){ return _useNormalUnit || 'g'; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button> <button type="button" id="btn-scale-use" class="btn btn-secondary scale-read-btn" style="display:none" onclick="readScaleWeight('use-quantity', function(){ return _useNormalUnit || 'g'; })" data-i18n="scale.read_btn">⚖️ Leggi dalla bilancia</button>
<!-- Live scale weight box (visible when scale connected and unit is g/ml) --> <!-- Live scale weight box (visible when scale connected and unit is g/ml) -->
<div id="scale-live-box" class="scale-live-box" style="display:none"> <div id="scale-live-box" class="scale-live-box" style="display:none">
@@ -431,21 +436,21 @@
</div> </div>
<div class="use-unit-switch" id="use-unit-switch" style="display:none"> <div class="use-unit-switch" id="use-unit-switch" style="display:none">
<button type="button" class="use-unit-btn active" id="use-unit-sub" onclick="switchUseUnit('sub')"></button> <button type="button" class="use-unit-btn active" id="use-unit-sub" onclick="switchUseUnit('sub')"></button>
<button type="button" class="use-unit-btn" id="use-unit-conf" onclick="switchUseUnit('conf')">Confezioni</button> <button type="button" class="use-unit-btn" id="use-unit-conf" onclick="switchUseUnit('conf')" data-i18n="units.boxes">Confezioni</button>
</div> </div>
<div class="use-options"> <div class="use-options">
<button type="button" class="btn btn-large btn-danger full-width use-all-btn" onclick="submitUseAll()"> <button type="button" class="btn btn-large btn-danger full-width use-all-btn" onclick="submitUseAll()" data-i18n="use.use_all">
🗑️ Usato TUTTO / Finito 🗑️ Usato TUTTO / Finito
</button> </button>
<div class="use-partial"> <div class="use-partial">
<p id="use-partial-hint">Oppure specifica la quantità usata:</p> <p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
<div class="qty-control"> <div class="qty-control">
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)"></button> <button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)"></button>
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input" <input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();"> oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button> <button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
</div> </div>
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn">📤 Usa questa quantità</button> <button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
</div> </div>
</div> </div>
</div> </div>
@@ -456,19 +461,19 @@
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== --> <!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
<section class="page" id="page-product-form"> <section class="page" id="page-product-form">
<div class="page-header"> <div class="page-header">
<button class="back-btn" onclick="showPage('scan')">← Indietro</button> <button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
<h2 id="product-form-title">Nuovo Prodotto</h2> <h2 id="product-form-title">Nuovo Prodotto</h2>
</div> </div>
<form class="form" onsubmit="submitProduct(event)"> <form class="form" onsubmit="submitProduct(event)">
<input type="hidden" id="pf-id"> <input type="hidden" id="pf-id">
<div id="pf-ai-fill-row" class="form-group"> <div id="pf-ai-fill-row" class="form-group">
<button type="button" class="btn btn-accent full-width" onclick="captureForAIFormFill()"> <button type="button" class="btn btn-accent full-width" onclick="captureForAIFormFill()" data-i18n="product.ai_fill">
📷 Scatta foto e identifica con AI 📷 Scatta foto e identifica con AI
</button> </button>
<p class="form-hint" style="text-align:center;margin-top:4px">L'AI compilerà automaticamente i campi del prodotto</p> <p class="form-hint" style="text-align:center;margin-top:4px" data-i18n="product.ai_fill_hint">L'AI compilerà automaticamente i campi del prodotto</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🏷️ Nome Prodotto *</label> <label data-i18n="product.name_label">🏷️ Nome Prodotto *</label>
<input type="text" id="pf-name" class="form-input" required placeholder="Es: Latte intero, Pasta penne rigate..." <input type="text" id="pf-name" class="form-input" required placeholder="Es: Latte intero, Pasta penne rigate..."
list="common-products" autocomplete="off"> list="common-products" autocomplete="off">
<datalist id="common-products"> <datalist id="common-products">
@@ -535,7 +540,7 @@
</datalist> </datalist>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🏢 Marca</label> <label data-i18n="product.brand_label">🏢 Marca</label>
<input type="text" id="pf-brand" class="form-input" placeholder="Es: Barilla, Granarolo, Mutti..." <input type="text" id="pf-brand" class="form-input" placeholder="Es: Barilla, Granarolo, Mutti..."
list="common-brands" autocomplete="off"> list="common-brands" autocomplete="off">
<datalist id="common-brands"> <datalist id="common-brands">
@@ -575,9 +580,9 @@
</datalist> </datalist>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📂 Categoria</label> <label data-i18n="product.category_label">📂 Categoria</label>
<select id="pf-category" class="form-input" onchange="onCategoryChange(false)"> <select id="pf-category" class="form-input" onchange="onCategoryChange(false)">
<option value="">-- Seleziona --</option> <option value="" data-i18n="form.select_placeholder">-- Seleziona --</option>
<option value="latticini">🥛 Latticini</option> <option value="latticini">🥛 Latticini</option>
<option value="carne">🥩 Carne</option> <option value="carne">🥩 Carne</option>
<option value="pesce">🐟 Pesce</option> <option value="pesce">🐟 Pesce</option>
@@ -598,21 +603,21 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group flex-1"> <div class="form-group flex-1">
<label>📏 Unità di misura</label> <label data-i18n="product.unit_label">📏 Unità di misura</label>
<select id="pf-unit" class="form-input" onchange="onPfUnitChange()"> <select id="pf-unit" class="form-input" onchange="onPfUnitChange()">
<option value="pz">Pezzi</option> <option value="pz" data-i18n="units.pieces">Pezzi</option>
<option value="g">Grammi</option> <option value="g" data-i18n="units.grams">Grammi</option>
<option value="ml">ml</option> <option value="ml">ml</option>
<option value="conf">Confezione</option> <option value="conf" data-i18n="units.box">Confezione</option>
</select> </select>
</div> </div>
<div class="form-group flex-1"> <div class="form-group flex-1">
<label>🔢 Quantità default</label> <label data-i18n="product.default_qty_label">🔢 Quantità default</label>
<input type="number" id="pf-defqty" class="form-input" value="1" min="0.1" step="any"> <input type="number" id="pf-defqty" class="form-input" value="1" min="0.1" step="any">
</div> </div>
</div> </div>
<div id="pf-conf-size-row" class="conf-size-row" style="display:none"> <div id="pf-conf-size-row" class="conf-size-row" style="display:none">
<label class="conf-size-label">📦 Ogni confezione contiene:</label> <label class="conf-size-label" data-i18n="product.conf_size_label">📦 Ogni confezione contiene:</label>
<div class="conf-size-inputs"> <div class="conf-size-inputs">
<input type="number" id="pf-conf-size" class="form-input conf-size-input" min="1" step="any" placeholder="es. 300"> <input type="number" id="pf-conf-size" class="form-input conf-size-input" min="1" step="any" placeholder="es. 300">
<select id="pf-conf-unit" class="form-input conf-size-unit"> <select id="pf-conf-unit" class="form-input conf-size-unit">
@@ -622,30 +627,30 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📝 Note</label> <label data-i18n="product.notes_label">📝 Note</label>
<textarea id="pf-notes" class="form-input" rows="2" placeholder="Es: senza lattosio, bio, conservare in frigo dopo apertura..."></textarea> <textarea id="pf-notes" class="form-input" rows="2" placeholder="Es: senza lattosio, bio, conservare in frigo dopo apertura..."></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🔖 Barcode</label> <label data-i18n="product.barcode_label">🔖 Barcode</label>
<div class="expiry-input-row"> <div class="expiry-input-row">
<input type="text" id="pf-barcode" class="form-input" placeholder="Codice a barre (se disponibile)" inputmode="numeric"> <input type="text" id="pf-barcode" class="form-input" placeholder="Codice a barre (se disponibile)" inputmode="numeric" data-i18n-placeholder="product.barcode_placeholder">
<button type="button" class="btn btn-accent btn-scan-expiry" id="pf-barcode-scan-btn" onclick="scanBarcodeForForm()" title="Scansiona barcode">📷</button> <button type="button" class="btn btn-accent btn-scan-expiry" id="pf-barcode-scan-btn" onclick="scanBarcodeForForm()" title="Scansiona barcode">📷</button>
</div> </div>
<p class="form-hint" id="pf-barcode-hint" style="display:none">⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!</p> <p class="form-hint" id="pf-barcode-hint" style="display:none" data-i18n="product.barcode_hint">⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!</p>
</div> </div>
<input type="hidden" id="pf-image"> <input type="hidden" id="pf-image">
<div class="product-image-preview" id="pf-image-preview" style="display:none"> <div class="product-image-preview" id="pf-image-preview" style="display:none">
<img id="pf-image-img" src="" alt="Product"> <img id="pf-image-img" src="" alt="Product">
</div> </div>
<button type="submit" class="btn btn-large btn-primary full-width">💾 Salva Prodotto</button> <button type="submit" class="btn btn-large btn-primary full-width" data-i18n="btn.save_product">💾 Salva Prodotto</button>
</form> </form>
</section> </section>
<!-- ===== ALL PRODUCTS PAGE ===== --> <!-- ===== ALL PRODUCTS PAGE ===== -->
<section class="page" id="page-products"> <section class="page" id="page-products">
<div class="page-header"> <div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button> <button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2>📦 Tutti i Prodotti</h2> <h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
</div> </div>
<div class="search-bar"> <div class="search-bar">
<input type="text" id="products-search" placeholder="🔍 Cerca prodotto..." oninput="searchAllProducts()"> <input type="text" id="products-search" placeholder="🔍 Cerca prodotto..." oninput="searchAllProducts()">
@@ -778,8 +783,8 @@
<!-- ===== AI IDENTIFICATION PAGE ===== --> <!-- ===== AI IDENTIFICATION PAGE ===== -->
<section class="page" id="page-ai"> <section class="page" id="page-ai">
<div class="page-header"> <div class="page-header">
<button class="back-btn" onclick="stopScanner(); showPage('scan')">← Indietro</button> <button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
<h2>🤖 Identificazione AI</h2> <h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
</div> </div>
<div class="ai-container"> <div class="ai-container">
<div class="ai-capture" id="ai-capture"> <div class="ai-capture" id="ai-capture">
@@ -790,15 +795,15 @@
<img id="ai-image" src="" alt="Captured"> <img id="ai-image" src="" alt="Captured">
</div> </div>
<div class="ai-actions"> <div class="ai-actions">
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn"> <button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn" data-i18n="ai.capture">
📸 Scatta Foto 📸 Scatta Foto
</button> </button>
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none"> <button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none" data-i18n="ai.retake">
🔄 Riscatta 🔄 Riscatta
</button> </button>
</div> </div>
<div class="ai-result" id="ai-result" style="display:none"></div> <div class="ai-result" id="ai-result" style="display:none"></div>
<p class="scan-hint">Scatta una foto del prodotto e l'AI cercherà di identificarlo</p> <p class="scan-hint" data-i18n="ai.hint">Scatta una foto del prodotto e l'AI cercherà di identificarlo</p>
</div> </div>
</section> </section>
@@ -838,9 +843,9 @@
<h4 data-i18n="settings.gemini.title">🤖 Google Gemini AI</h4> <h4 data-i18n="settings.gemini.title">🤖 Google Gemini AI</h4>
<p class="settings-hint" data-i18n="settings.gemini.hint">Chiave API per identificazione prodotti, scadenze e ricette.</p> <p class="settings-hint" data-i18n="settings.gemini.hint">Chiave API per identificazione prodotti, scadenze e ricette.</p>
<div class="form-group"> <div class="form-group">
<label>API Key Gemini</label> <label data-i18n="settings.gemini.key_label">API Key Gemini</label>
<input type="password" id="setting-gemini-key" class="form-input" placeholder="AIza..."> <input type="password" id="setting-gemini-key" class="form-input" placeholder="AIza...">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-gemini-key')">👁️ Mostra/Nascondi</button> <button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-gemini-key')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div> </div>
</div> </div>
</div> </div>
@@ -850,13 +855,13 @@
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4> <h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p> <p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
<div class="form-group"> <div class="form-group">
<label>📧 Email Bring!</label> <label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com"> <input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🔒 Password Bring!</label> <label data-i18n="settings.bring.password_label">🔒 Password Bring!</label>
<input type="password" id="setting-bring-password" class="form-input" placeholder="Password"> <input type="password" id="setting-bring-password" class="form-input" placeholder="Password">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')">👁️ Mostra/Nascondi</button> <button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div> </div>
</div> </div>
<!-- Price Estimation Settings --> <!-- Price Estimation Settings -->
@@ -927,7 +932,7 @@
<h4 data-i18n="settings.recipe.title">🍳 Preferenze Ricette</h4> <h4 data-i18n="settings.recipe.title">🍳 Preferenze Ricette</h4>
<p class="settings-hint" data-i18n="settings.recipe.hint">Configura le opzioni predefinite per la generazione delle ricette.</p> <p class="settings-hint" data-i18n="settings.recipe.hint">Configura le opzioni predefinite per la generazione delle ricette.</p>
<div class="form-group"> <div class="form-group">
<label>👥 Persone predefinite</label> <label data-i18n="settings.recipe.persons_label">👥 Persone predefinite</label>
<div class="qty-control"> <div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('setting-default-persons', -1)"></button> <button type="button" class="qty-btn" onclick="adjustQty('setting-default-persons', -1)"></button>
<input type="number" id="setting-default-persons" value="1" min="1" max="20" class="qty-input"> <input type="number" id="setting-default-persons" value="1" min="1" max="20" class="qty-input">
@@ -935,18 +940,18 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🎯 Opzioni ricetta predefinite</label> <label data-i18n="settings.recipe.options_label">🎯 Opzioni ricetta predefinite</label>
<div class="recipe-pref-checks"> <div class="recipe-pref-checks">
<label class="checkbox-label"><input type="checkbox" id="setting-pref-veloce"> ⚡ Pasto Veloce</label> <label class="checkbox-label"><input type="checkbox" id="setting-pref-veloce"> <span data-i18n="settings.recipe.fast">⚡ Pasto Veloce</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-pocafame"> 🥗 Poca Fame</label> <label class="checkbox-label"><input type="checkbox" id="setting-pref-pocafame"> <span data-i18n="settings.recipe.light">🥗 Poca Fame</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-scadenze"> ⏰ Priorità Scadenze</label> <label class="checkbox-label"><input type="checkbox" id="setting-pref-scadenze"> <span data-i18n="settings.recipe.expiry">⏰ Priorità Scadenze</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-healthy"> 💚 Extra Salutare</label> <label class="checkbox-label"><input type="checkbox" id="setting-pref-healthy"> <span data-i18n="settings.recipe.healthy">💚 Extra Salutare</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-opened"> 📦 Priorità Cose Aperte</label> <label class="checkbox-label"><input type="checkbox" id="setting-pref-opened"> <span data-i18n="settings.recipe.opened">📦 Priorità Cose Aperte</span></label>
<label class="checkbox-label"><input type="checkbox" id="setting-pref-zerowaste"> ♻️ Zero Sprechi</label> <label class="checkbox-label"><input type="checkbox" id="setting-pref-zerowaste"> <span data-i18n="settings.recipe.zerowaste">♻️ Zero Sprechi</span></label>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🚫 Intolleranze / Restrizioni</label> <label data-i18n="settings.recipe.dietary_label">🚫 Intolleranze / Restrizioni</label>
<textarea id="setting-dietary" class="form-input" rows="2" placeholder="Es: senza glutine, senza lattosio, vegetariano..."></textarea> <textarea id="setting-dietary" class="form-input" rows="2" placeholder="Es: senza glutine, senza lattosio, vegetariano..."></textarea>
</div> </div>
</div> </div>
@@ -958,7 +963,7 @@
<p class="settings-hint" data-i18n="settings.mealplan.hint">Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.</p> <p class="settings-hint" data-i18n="settings.mealplan.hint">Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.</p>
<div class="form-group" style="margin-bottom:10px"> <div class="form-group" style="margin-bottom:10px">
<label class="toggle-row"> <label class="toggle-row">
<span>✅ Attiva piano pasti settimanale</span> <span data-i18n="settings.mealplan.enabled">✅ Attiva piano pasti settimanale</span>
<span class="toggle-switch"> <span class="toggle-switch">
<input type="checkbox" id="setting-meal-plan-enabled" onchange="onMealPlanEnabledChange(this)"> <input type="checkbox" id="setting-meal-plan-enabled" onchange="onMealPlanEnabledChange(this)">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
@@ -969,15 +974,15 @@
<div id="meal-plan-grid" class="mplan-grid"></div> <div id="meal-plan-grid" class="mplan-grid"></div>
<div id="meal-plan-picker" class="mplan-picker" style="display:none"></div> <div id="meal-plan-picker" class="mplan-picker" style="display:none"></div>
<div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap"> <div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap">
<button class="btn btn-small btn-secondary" onclick="resetMealPlan()">↺ Ripristina default</button> <button class="btn btn-small btn-secondary" onclick="resetMealPlan()" data-i18n="settings.mealplan.reset_btn">↺ Ripristina default</button>
</div> </div>
<div class="settings-hint" style="margin-top:10px"> <div class="settings-hint" style="margin-top:10px" data-i18n-html="settings.mealplan.legend">
🌤️ = Pranzo &nbsp;·&nbsp; 🌙 = Cena &nbsp;·&nbsp; Tocca un badge per cambiarlo. 🌤️ = Pranzo &nbsp;·&nbsp; 🌙 = Cena &nbsp;·&nbsp; Tocca un badge per cambiarlo.
</div> </div>
</div> </div>
</div> </div>
<div class="settings-card" id="meal-plan-legend-card"> <div class="settings-card" id="meal-plan-legend-card">
<h4>📋 Tipologie disponibili</h4> <h4 data-i18n="settings.mealplan.types_title">📋 Tipologie disponibili</h4>
<div class="mplan-legend"></div> <div class="mplan-legend"></div>
</div> </div>
</div> </div>
@@ -994,18 +999,18 @@
</div> </div>
</div> </div>
<div class="common-appliances mt-2"> <div class="common-appliances mt-2">
<p class="settings-hint">Aggiungi velocemente:</p> <p class="settings-hint" data-i18n="settings.appliances.quick_title">Aggiungi velocemente:</p>
<div class="appliance-quick-tags"> <div class="appliance-quick-tags">
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Forno')">🔥 Forno</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Forno')" data-i18n="settings.appliances.oven">🔥 Forno</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Microonde')">📡 Microonde</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Microonde')" data-i18n="settings.appliances.microwave">📡 Microonde</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Friggitrice ad aria')">🍟 Friggitrice ad aria</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Friggitrice ad aria')" data-i18n="settings.appliances.air_fryer">🍟 Friggitrice ad aria</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Macchina del pane')">🍞 Macchina pane</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Macchina del pane')" data-i18n="settings.appliances.bread_maker">🍞 Macchina pane</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Bimby/Moulinex Cookeo')">🤖 Bimby/Cookeo</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Bimby/Moulinex Cookeo')" data-i18n="settings.appliances.bimby">🤖 Bimby/Cookeo</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Planetaria')">🌀 Planetaria</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Planetaria')" data-i18n="settings.appliances.mixer">🌀 Planetaria</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Vaporiera')">♨️ Vaporiera</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Vaporiera')" data-i18n="settings.appliances.steamer">♨️ Vaporiera</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Pentola a pressione')">🫕 Pentola pressione</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Pentola a pressione')" data-i18n="settings.appliances.pressure_cooker">🫕 Pentola pressione</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Tostapane')">🍞 Tostapane</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Tostapane')" data-i18n="settings.appliances.toaster">🍞 Tostapane</button>
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Frullatore/Mixer')">🍹 Frullatore</button> <button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Frullatore/Mixer')" data-i18n="settings.appliances.blender">🍹 Frullatore</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1016,35 +1021,35 @@
<h4 data-i18n="settings.camera.title">📷 Fotocamera</h4> <h4 data-i18n="settings.camera.title">📷 Fotocamera</h4>
<p class="settings-hint" data-i18n="settings.camera.hint">Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.</p> <p class="settings-hint" data-i18n="settings.camera.hint">Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.</p>
<div class="form-group"> <div class="form-group">
<label>📸 Fotocamera predefinita</label> <label data-i18n="settings.camera.device_label">📸 Fotocamera predefinita</label>
<select id="setting-camera-facing" class="form-input"> <select id="setting-camera-facing" class="form-input">
<option value="environment">📱 Posteriore (default)</option> <option value="environment" data-i18n="settings.camera.back">📱 Posteriore (default)</option>
<option value="user">🤳 Anteriore</option> <option value="user" data-i18n="settings.camera.front">🤳 Anteriore</option>
</select> </select>
<p class="settings-hint mt-2">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p> <p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()">🔄 Rileva fotocamere</button> <button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Security Tab --> <!-- Security Tab -->
<div class="settings-panel" id="tab-security"> <div class="settings-panel" id="tab-security">
<div class="settings-card"> <div class="settings-card">
<h4>🔑 Token Impostazioni</h4> <h4 data-i18n="settings.security.token_title">🔑 Token Impostazioni</h4>
<p class="settings-hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p> <p class="settings-hint" data-i18n="settings.security.token_hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
<div class="form-group"> <div class="form-group">
<label>Token di accesso</label> <label data-i18n="settings.security.token_label">Token di accesso</label>
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)"> <input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)" data-i18n-placeholder="settings.security.token_placeholder">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')">👁️ Mostra/Nascondi</button> <button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div> </div>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)">🔒 Questo server richiede un token per salvare le impostazioni.</p> <p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)" data-i18n="settings.security.token_required_hint">🔒 Questo server richiede un token per salvare le impostazioni.</p>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h4>🔒 Certificato HTTPS</h4> <h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
<p class="settings-hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p> <p class="settings-hint" data-i18n="settings.security.hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p>
<div class="form-group"> <div class="form-group">
<a href="ca.crt" download="EverShelf_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block">📥 Scarica Certificato CA</a> <a href="ca.crt" download="EverShelf_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block" data-i18n="settings.security.download_btn">📥 Scarica Certificato CA</a>
</div> </div>
<div class="settings-hint" style="margin-top:12px;line-height:1.6"> <div class="settings-hint" style="margin-top:12px;line-height:1.6" data-i18n-html="settings.security.cert_instructions">
<strong>Istruzioni per Chrome (Android):</strong><br> <strong>Istruzioni per Chrome (Android):</strong><br>
1. Scarica il certificato qui sopra<br> 1. Scarica il certificato qui sopra<br>
2. Vai in <em>Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo</em><br> 2. Vai in <em>Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo</em><br>
@@ -1067,7 +1072,7 @@
<p class="settings-hint" data-i18n="settings.tts.hint">Configura la sintesi vocale. Puoi usare la voce offline del browser oppure un endpoint REST esterno (Home Assistant, ecc.).</p> <p class="settings-hint" data-i18n="settings.tts.hint">Configura la sintesi vocale. Puoi usare la voce offline del browser oppure un endpoint REST esterno (Home Assistant, ecc.).</p>
<div class="form-group" style="margin-bottom:10px"> <div class="form-group" style="margin-bottom:10px">
<label class="toggle-row"> <label class="toggle-row">
<span>✅ Attiva TTS</span> <span data-i18n="settings.tts.enabled">✅ Attiva TTS</span>
<span class="toggle-switch"> <span class="toggle-switch">
<input type="checkbox" id="setting-tts-enabled"> <input type="checkbox" id="setting-tts-enabled">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
@@ -1075,31 +1080,31 @@
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>⚙️ Motore TTS</label> <label data-i18n="settings.tts.engine_label">⚙️ Motore TTS</label>
<select id="setting-tts-engine" class="form-input" onchange="onTtsEngineChange(this.value)"> <select id="setting-tts-engine" class="form-input" onchange="onTtsEngineChange(this.value)">
<option value="browser">🔇 Browser (offline, nessuna configurazione)</option> <option value="browser" data-i18n="settings.tts.engine_browser">🔇 Browser (offline, nessuna configurazione)</option>
<option value="server">🌐 Server esterno (Home Assistant, API REST...)</option> <option value="server" data-i18n="settings.tts.engine_server">🌐 Server esterno (Home Assistant, API REST...)</option>
</select> </select>
</div> </div>
<!-- Browser TTS section --> <!-- Browser TTS section -->
<div id="tts-browser-section"> <div id="tts-browser-section">
<div class="form-group"> <div class="form-group">
<label>🗣️ Voce</label> <label data-i18n="settings.tts.voice_label">🗣️ Voce</label>
<div style="display:flex;gap:8px;align-items:center"> <div style="display:flex;gap:8px;align-items:center">
<select id="setting-tts-voice" class="form-input" style="flex:1"> <select id="setting-tts-voice" class="form-input" style="flex:1">
<option value="">— Caricamento voci… —</option> <option value="" data-i18n="settings.tts.voices_loading">— Caricamento voci… —</option>
</select> </select>
<button type="button" class="btn btn-secondary" style="padding:8px 12px;white-space:nowrap;flex-shrink:0" onclick="_initBrowserTtsVoices(document.getElementById('setting-tts-voice').value)"></button> <button type="button" class="btn btn-secondary" style="padding:8px 12px;white-space:nowrap;flex-shrink:0" onclick="_initBrowserTtsVoices(document.getElementById('setting-tts-voice').value)"></button>
</div> </div>
<p class="settings-hint">Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce <strong>Paola</strong> (italiano). Premi ↺ se la lista non si carica.</p> <p class="settings-hint" data-i18n="settings.tts.voices_hint">Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce <strong>Paola</strong> (italiano). Premi ↺ se la lista non si carica.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>⚡ Velocità: <span id="tts-rate-label">1.0</span>×</label> <label><span data-i18n="settings.tts.rate_label">⚡ Velocità</span>: <span id="tts-rate-label">1.0</span>×</label>
<input type="range" id="setting-tts-rate" class="form-input" min="0.5" max="2" step="0.1" value="1" oninput="document.getElementById('tts-rate-label').textContent=parseFloat(this.value).toFixed(1)"> <input type="range" id="setting-tts-rate" class="form-input" min="0.5" max="2" step="0.1" value="1" oninput="document.getElementById('tts-rate-label').textContent=parseFloat(this.value).toFixed(1)">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🎵 Tono: <span id="tts-pitch-label">1.0</span></label> <label><span data-i18n="settings.tts.pitch_label">🎵 Tono</span>: <span id="tts-pitch-label">1.0</span></label>
<input type="range" id="setting-tts-pitch" class="form-input" min="0" max="2" step="0.1" value="1" oninput="document.getElementById('tts-pitch-label').textContent=parseFloat(this.value).toFixed(1)"> <input type="range" id="setting-tts-pitch" class="form-input" min="0" max="2" step="0.1" value="1" oninput="document.getElementById('tts-pitch-label').textContent=parseFloat(this.value).toFixed(1)">
</div> </div>
</div> </div>
@@ -1107,11 +1112,11 @@
<!-- Server TTS section --> <!-- Server TTS section -->
<div id="tts-server-section" style="display:none"> <div id="tts-server-section" style="display:none">
<div class="form-group"> <div class="form-group">
<label>🌐 URL Endpoint</label> <label data-i18n="settings.tts.url_label">🌐 URL Endpoint</label>
<input type="url" id="setting-tts-url" class="form-input" placeholder="https://..."> <input type="url" id="setting-tts-url" class="form-input" placeholder="https://...">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📡 Metodo HTTP</label> <label data-i18n="settings.tts.method_label">📡 Metodo HTTP</label>
<select id="setting-tts-method" class="form-input"> <select id="setting-tts-method" class="form-input">
<option value="POST">POST</option> <option value="POST">POST</option>
<option value="PUT">PUT</option> <option value="PUT">PUT</option>
@@ -1120,30 +1125,30 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🔐 Autenticazione</label> <label data-i18n="settings.tts.auth_label">🔐 Autenticazione</label>
<select id="setting-tts-auth-type" class="form-input" onchange="onTtsAuthTypeChange(this.value)"> <select id="setting-tts-auth-type" class="form-input" onchange="onTtsAuthTypeChange(this.value)">
<option value="bearer">Bearer Token</option> <option value="bearer" data-i18n="settings.tts.auth_bearer">Bearer Token</option>
<option value="header">Header personalizzato</option> <option value="header" data-i18n="settings.tts.auth_custom">Header personalizzato</option>
<option value="none">Nessuna</option> <option value="none" data-i18n="settings.tts.auth_none">Nessuna</option>
</select> </select>
</div> </div>
<div class="form-group" id="tts-token-group"> <div class="form-group" id="tts-token-group">
<label>🔑 Bearer Token</label> <label data-i18n="settings.tts.token_label">🔑 Bearer Token</label>
<input type="password" id="setting-tts-token" class="form-input" placeholder="eyJhbGci..."> <input type="password" id="setting-tts-token" class="form-input" placeholder="eyJhbGci...">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-tts-token')">👁️ Mostra/Nascondi</button> <button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-tts-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
</div> </div>
<div id="tts-custom-header-group" style="display:none"> <div id="tts-custom-header-group" style="display:none">
<div class="form-group"> <div class="form-group">
<label>📋 Nome header</label> <label data-i18n="settings.tts.custom_header_name">📋 Nome header</label>
<input type="text" id="setting-tts-auth-header-name" class="form-input" placeholder="X-API-Key"> <input type="text" id="setting-tts-auth-header-name" class="form-input" placeholder="X-API-Key">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📋 Valore header</label> <label data-i18n="settings.tts.custom_header_value">📋 Valore header</label>
<input type="text" id="setting-tts-auth-header-value" class="form-input" placeholder="..."> <input type="text" id="setting-tts-auth-header-value" class="form-input" placeholder="...">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>📄 Content-Type</label> <label data-i18n="settings.tts.content_type_label">📄 Content-Type</label>
<select id="setting-tts-content-type" class="form-input"> <select id="setting-tts-content-type" class="form-input">
<option value="application/json">application/json</option> <option value="application/json">application/json</option>
<option value="application/x-www-form-urlencoded">application/x-www-form-urlencoded</option> <option value="application/x-www-form-urlencoded">application/x-www-form-urlencoded</option>
@@ -1151,18 +1156,18 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>🗝️ Campo testo nel payload</label> <label data-i18n="settings.tts.payload_key_label">🗝️ Campo testo nel payload</label>
<input type="text" id="setting-tts-payload-key" class="form-input" placeholder="message"> <input type="text" id="setting-tts-payload-key" class="form-input" placeholder="message">
<p class="settings-hint">Nome del campo JSON che conterrà il testo da leggere (es: <code>message</code>, <code>text</code>).</p> <p class="settings-hint">Nome del campo JSON che conterrà il testo da leggere (es: <code>message</code>, <code>text</code>).</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label> Campi extra (JSON)</label> <label data-i18n="settings.tts.extra_fields_label"> Campi extra (JSON)</label>
<textarea id="setting-tts-extra-fields" class="form-input" rows="3" placeholder='{"entity_id": "media_player.living_room"}'></textarea> <textarea id="setting-tts-extra-fields" class="form-input" rows="3" placeholder='{"entity_id": "media_player.living_room"}'></textarea>
<p class="settings-hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p> <p class="settings-hint" data-i18n="settings.tts.extra_fields_hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p>
</div> </div>
</div><!-- /tts-server-section --> </div><!-- /tts-server-section -->
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()">🔊 Invia Test Vocale</button> <button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
<div id="tts-test-status" style="display:none;margin-top:8px"></div> <div id="tts-test-status" style="display:none;margin-top:8px"></div>
</div> </div>
</div> </div>
@@ -1174,14 +1179,14 @@
<!-- Kiosk-mode panel: replace WebSocket config with native reconfigure button --> <!-- Kiosk-mode panel: replace WebSocket config with native reconfigure button -->
<div id="scale-kiosk-panel" style="display:none;background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.25);border-radius:10px;padding:14px;margin-bottom:16px"> <div id="scale-kiosk-panel" style="display:none;background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.25);border-radius:10px;padding:14px;margin-bottom:16px">
<p style="margin:0 0 6px;font-weight:600">📡 Bilancia BLE integrata nel Kiosk</p> <p style="margin:0 0 6px;font-weight:600" data-i18n="settings.scale.kiosk_title">📡 Bilancia BLE integrata nel Kiosk</p>
<p class="settings-hint" style="margin-bottom:12px">La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.</p> <p class="settings-hint" style="margin-bottom:12px" data-i18n="settings.scale.kiosk_hint">La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.</p>
<button class="btn btn-secondary full-width" onclick="_kioskReconfigureScale()">🔄 Riconfigura bilancia BLE</button> <button class="btn btn-secondary full-width" onclick="_kioskReconfigureScale()" data-i18n="settings.scale.kiosk_reconfigure">🔄 Riconfigura bilancia BLE</button>
<!-- shown when kiosk APK is too old to have reconfigureScale() --> <!-- shown when kiosk APK is too old to have reconfigureScale() -->
<div id="kiosk-needs-update-notice" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.35);border-radius:8px;font-size:0.83rem"> <div id="kiosk-needs-update-notice" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.35);border-radius:8px;font-size:0.83rem">
⚠️ Il kiosk installato non supporta questa funzione. <span data-i18n="settings.kiosk.needs_update">⚠️ Il kiosk installato non supporta questa funzione.
Aggiorna l'app kiosk per abilitarla. Aggiorna l'app kiosk per abilitarla.</span>
<a href="https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" target="_blank" rel="noopener noreferrer" style="display:block;margin-top:6px;color:#d97706;font-weight:600;text-decoration:none">📥 Scarica aggiornamento kiosk</a> <a href="https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk" target="_blank" rel="noopener noreferrer" style="display:block;margin-top:6px;color:#d97706;font-weight:600;text-decoration:none" data-i18n="settings.kiosk.download_btn">📥 Scarica aggiornamento kiosk</a>
</div> </div>
</div> </div>
@@ -1229,16 +1234,16 @@
</div> </div>
<div style="text-align:center"> <div style="text-align:center">
<div id="scale-diag-weight" style="font-size:2rem;font-weight:700;line-height:1;letter-spacing:1px">— g</div> <div id="scale-diag-weight" style="font-size:2rem;font-weight:700;line-height:1;letter-spacing:1px">— g</div>
<div style="font-size:0.72rem;color:var(--text-secondary);margin-top:3px">peso in tempo reale</div> <div style="font-size:0.72rem;color:var(--text-secondary);margin-top:3px" data-i18n="settings.scale.live_weight">peso in tempo reale</div>
</div> </div>
<div style="margin-top:10px;display:flex;gap:8px;font-size:0.78rem;color:var(--text-secondary)"> <div style="margin-top:10px;display:flex;gap:8px;font-size:0.78rem;color:var(--text-secondary)">
<span>🔁 Riconnessione: automatica</span> <span data-i18n="settings.scale.auto_reconnect">🔁 Riconnessione: automatica</span>
<span style="margin-left:auto" id="scale-diag-proto"></span> <span style="margin-left:auto" id="scale-diag-proto"></span>
</div> </div>
</div> </div>
<!-- Protocol info --> <!-- Protocol info -->
<div class="settings-hint" style="margin-top:16px;padding:10px;background:var(--bg-secondary,#f8fafc);border-radius:8px"> <div class="settings-hint" style="margin-top:16px;padding:10px;background:var(--bg-secondary,#f8fafc);border-radius:8px" data-i18n-html="settings.scale.ble_protocols">
<p style="margin:0 0 6px;font-weight:600">🔌 Protocolli BLE supportati:</p> <p style="margin:0 0 6px;font-weight:600">🔌 Protocolli BLE supportati:</p>
<ul style="margin:0 0 0 16px;padding:0;font-size:0.8rem"> <ul style="margin:0 0 0 16px;padding:0;font-size:0.8rem">
<li>Bluetooth SIG Weight Scale (0x181D)</li> <li>Bluetooth SIG Weight Scale (0x181D)</li>
@@ -1274,18 +1279,55 @@
</label> </label>
</div> </div>
<div class="form-group" id="screensaver-timeout-row" style="margin-top:10px"> <div class="form-group" id="screensaver-timeout-row" style="margin-top:10px">
<label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)">⏱️ Avvia dopo</label> <label for="setting-screensaver-timeout" style="font-size:0.85rem;font-weight:600;color:var(--text-secondary)" data-i18n="settings.screensaver.start_after">⏱️ Avvia dopo</label>
<select id="setting-screensaver-timeout" class="form-control" style="margin-top:6px;max-width:200px"> <select id="setting-screensaver-timeout" class="form-control" style="margin-top:6px;max-width:200px">
<option value="1">1 minuto</option> <option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
<option value="2">2 minuti</option> <option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
<option value="5" selected>5 minuti</option> <option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
<option value="10">10 minuti</option> <option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
<option value="15">15 minuti</option> <option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
<option value="30">30 minuti</option> <option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
<option value="60">1 ora</option> <option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</option>
</select> </select>
</div> </div>
</div> </div>
<div class="settings-card">
<h4 data-i18n="settings.theme.title">🌙 Tema / Aspetto</h4>
<p class="settings-hint" data-i18n="settings.theme.hint">Scegli il tema dell'interfaccia.</p>
<div class="form-group">
<label data-i18n="settings.theme.label">🌙 Tema</label>
<select id="setting-dark-mode" class="form-input" onchange="_setThemeMode(this.value)">
<option value="off" data-i18n="settings.theme.off">☀️ Chiaro</option>
<option value="auto" selected data-i18n="settings.theme.auto">🔄 Automatico (sistema)</option>
<option value="on" data-i18n="settings.theme.on">🌙 Scuro</option>
</select>
</div>
</div>
<div class="settings-card">
<h4 data-i18n="settings.zerowaste.card_title">♻️ Suggerimenti zero-waste</h4>
<p class="settings-hint" data-i18n="settings.zerowaste.card_hint">Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.</p>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.zerowaste.label">Mostra suggerimenti durante la cottura</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-zerowaste-tips">
<span class="toggle-slider"></span>
</span>
</label>
</div>
</div>
<div class="settings-card">
<h4 data-i18n="export.title">📤 Esporta inventario</h4>
<p class="settings-hint" data-i18n="export.hint">Scarica l'inventario corrente in CSV o apri una versione stampabile (PDF).</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-outline" onclick="exportInventory('csv')" style="flex:1;min-width:120px">
📊 <span data-i18n="export.btn_csv">CSV</span>
</button>
<button class="btn btn-outline" onclick="exportInventory('html')" style="flex:1;min-width:120px">
🖨️ <span data-i18n="export.btn_pdf">PDF / Stampa</span>
</button>
</div>
</div>
</div> </div>
</div> </div>
@@ -1320,11 +1362,11 @@
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
<span style="font-size:1.4rem">📦</span> <span style="font-size:1.4rem">📦</span>
<div> <div>
<p style="margin:0;font-weight:700;font-size:0.9rem">Aggiornamento Kiosk</p> <p style="margin:0;font-weight:700;font-size:0.9rem" data-i18n="settings.kiosk.update_title">Aggiornamento Kiosk</p>
<p class="settings-hint" style="margin:2px 0 0" id="kiosk-update-version-label"></p> <p class="settings-hint" style="margin:2px 0 0" id="kiosk-update-version-label"></p>
</div> </div>
</div> </div>
<button class="btn btn-secondary" style="white-space:nowrap;min-width:120px" id="btn-kiosk-check-update" onclick="_kioskCheckForUpdates()">🔍 Cerca aggiornamenti</button> <button class="btn btn-secondary" style="white-space:nowrap;min-width:120px" id="btn-kiosk-check-update" onclick="_kioskCheckForUpdates()" data-i18n="settings.kiosk.check_updates_btn">🔍 Cerca aggiornamenti</button>
</div> </div>
<div id="kiosk-update-status" style="display:none;padding:10px 12px;border-radius:8px;font-size:0.85rem;line-height:1.4"></div> <div id="kiosk-update-status" style="display:none;padding:10px 12px;border-radius:8px;font-size:0.85rem;line-height:1.4"></div>
<button id="btn-kiosk-install-update" style="display:none;width:100%;margin-top:10px" class="btn btn-accent btn-large" onclick="_kioskInstallUpdate()">⬇️ Installa aggiornamento</button> <button id="btn-kiosk-install-update" style="display:none;width:100%;margin-top:10px" class="btn btn-accent btn-large" onclick="_kioskInstallUpdate()">⬇️ Installa aggiornamento</button>
@@ -1415,7 +1457,7 @@
</button> </button>
<button class="nav-btn" onclick="showPage('settings')" data-page="settings"> <button class="nav-btn" onclick="showPage('settings')" data-page="settings">
<span class="nav-icon">⚙️</span> <span class="nav-icon">⚙️</span>
<span class="nav-label">Config</span> <span class="nav-label" data-i18n="nav.settings">Config</span>
</button> </button>
</nav> </nav>
@@ -1484,8 +1526,8 @@
</div> </div>
<div class="setup-body" id="setup-body"></div> <div class="setup-body" id="setup-body"></div>
<div class="setup-footer"> <div class="setup-footer">
<button class="btn btn-secondary" id="setup-prev" onclick="setupWizardNav(-1)" style="display:none">← Indietro</button> <button class="btn btn-secondary" id="setup-prev" onclick="setupWizardNav(-1)" style="display:none" data-i18n="btn.back">← Indietro</button>
<button class="btn btn-accent" id="setup-next" onclick="setupWizardNav(1)">Avanti →</button> <button class="btn btn-accent" id="setup-next" onclick="setupWizardNav(1)" data-i18n="btn.next">Avanti →</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1552,6 +1594,12 @@
</button> </button>
</div> </div>
<div class="cooking-step-ings" id="cooking-step-ings" style="display:none"></div> <div class="cooking-step-ings" id="cooking-step-ings" style="display:none"></div>
<div id="cooking-zerowaste-tip" class="cooking-zerowaste-tip" style="display:none">
<span class="cooking-zerowaste-label" data-i18n="cooking.zerowaste_label">♻️ Scarto</span>
<span id="cooking-zerowaste-scrap" class="cooking-zerowaste-scrap"></span>
<p id="cooking-zerowaste-text" class="cooking-zerowaste-text"></p>
<button class="cooking-zerowaste-close" onclick="_dismissZeroWasteTip()" aria-label="Chiudi"></button>
</div>
</div> </div>
<div class="cooking-nav"> <div class="cooking-nav">
<button class="cooking-nav-btn cooking-prev-btn" id="cooking-prev" onclick="navigateCookingStep(-1)">◀ Precedente</button> <button class="cooking-nav-btn cooking-prev-btn" id="cooking-prev" onclick="navigateCookingStep(-1)">◀ Precedente</button>
@@ -1559,6 +1607,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260516a"></script> <script src="assets/js/app.js?v=20260520a"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.14", "version": "1.7.20",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",
+118 -20
View File
@@ -9,7 +9,8 @@
"inventory": "Vorrat", "inventory": "Vorrat",
"recipes": "Rezepte", "recipes": "Rezepte",
"shopping": "Einkauf", "shopping": "Einkauf",
"log": "Verlauf" "log": "Verlauf",
"settings": "Einstellungen"
}, },
"btn": { "btn": {
"back": "← Zurück", "back": "← Zurück",
@@ -19,6 +20,8 @@
"add": "✅ Hinzufügen", "add": "✅ Hinzufügen",
"delete": "Löschen", "delete": "Löschen",
"edit": "✏️ Bearbeiten", "edit": "✏️ Bearbeiten",
"use": "Verwenden",
"edit_item": "Bearbeiten",
"search": "🔍 Suchen", "search": "🔍 Suchen",
"go": "✅ Los", "go": "✅ Los",
"toggle_password": "👁️ Anzeigen/Ausblenden", "toggle_password": "👁️ Anzeigen/Ausblenden",
@@ -28,7 +31,12 @@
"restart": "↺ Neustart", "restart": "↺ Neustart",
"reset_default": "↺ Standard wiederherstellen", "reset_default": "↺ Standard wiederherstellen",
"save_info": "💾 Info speichern", "save_info": "💾 Info speichern",
"retry": "🔄 Erneut versuchen" "retry": "🔄 Erneut versuchen",
"yes_short": "Ja",
"no_short": "Nein"
},
"form": {
"select_placeholder": "-- Auswählen --"
}, },
"locations": { "locations": {
"dispensa": "Vorratskammer", "dispensa": "Vorratskammer",
@@ -63,7 +71,9 @@
"pieces": "Stück", "pieces": "Stück",
"grams": "Gramm", "grams": "Gramm",
"box": "Packung", "box": "Packung",
"boxes": "Packungen" "boxes": "Packungen",
"millilitres": "Milliliter",
"from": "von"
}, },
"shopping_sections": { "shopping_sections": {
"frutta_verdura": "Obst & Gemüse", "frutta_verdura": "Obst & Gemüse",
@@ -215,7 +225,7 @@
"throw_btn": "🗑️ ENTSORGEN", "throw_btn": "🗑️ ENTSORGEN",
"throw_sub": "wegwerfen", "throw_sub": "wegwerfen",
"edit_sub": "Ablauf, Ort…", "edit_sub": "Ablauf, Ort…",
"create_recipe_btn": "Rezept damit erstellen" "create_recipe_btn": "Rezept"
}, },
"add": { "add": {
"title": "Zum Vorrat hinzufügen", "title": "Zum Vorrat hinzufügen",
@@ -240,7 +250,9 @@
"scan_expiry_title": "📷 Ablaufdatum scannen", "scan_expiry_title": "📷 Ablaufdatum scannen",
"product_added": "✅ {name} hinzugefügt!{qty}", "product_added": "✅ {name} hinzugefügt!{qty}",
"suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)", "suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)",
"history_badge_tip": "Durchschnitt aus {n} früheren Einträgen" "history_badge_tip": "Durchschnitt aus {n} früheren Einträgen",
"vacuum_question": "Vakuumiert?",
"vacuum_saved": "🔒 Als vakuumiert gespeichert"
}, },
"use": { "use": {
"title": "Verwenden / Verbrauchen", "title": "Verwenden / Verbrauchen",
@@ -312,7 +324,13 @@
"edit_info": "✏️ Informationen bearbeiten", "edit_info": "✏️ Informationen bearbeiten",
"modify_details": "BEARBEITEN\nAblauf, Ort…", "modify_details": "BEARBEITEN\nAblauf, Ort…",
"already_in_pantry": "📋 Bereits im Vorratsschrank", "already_in_pantry": "📋 Bereits im Vorratsschrank",
"no_barcode": "Kein Barcode" "no_barcode": "Kein Barcode",
"unknown_product": "Unbekanntes Produkt",
"edit_name_brand": "Name/Marke bearbeiten",
"weight_label": "Gewicht",
"origin_label": "Herkunft",
"labels_label": "Etiketten",
"select_variant": "Genaue Variante auswählen oder KI-Daten nutzen:"
}, },
"products": { "products": {
"title": "📦 Alle Produkte", "title": "📦 Alle Produkte",
@@ -482,7 +500,8 @@
"undo_success": "↩ Vorgang rückgängig gemacht für {name}", "undo_success": "↩ Vorgang rückgängig gemacht für {name}",
"already_undone": "Vorgang bereits rückgängig gemacht", "already_undone": "Vorgang bereits rückgängig gemacht",
"too_old": "Vorgänge älter als 24 Stunden können nicht rückgängig gemacht werden", "too_old": "Vorgänge älter als 24 Stunden können nicht rückgängig gemacht werden",
"undo_error": "Fehler beim Rückgängigmachen" "undo_error": "Fehler beim Rückgängigmachen",
"recipe_prefix": "Rezept"
}, },
"chat": { "chat": {
"title": "Gemini Chef", "title": "Gemini Chef",
@@ -502,7 +521,8 @@
"transfer_to_recipes": "Zu Rezepten hinzufügen", "transfer_to_recipes": "Zu Rezepten hinzufügen",
"transferring": "Übertrage...", "transferring": "Übertrage...",
"transferred": "Zu Rezepten hinzugefügt!", "transferred": "Zu Rezepten hinzugefügt!",
"open_recipe": "Rezept öffnen" "open_recipe": "Rezept öffnen",
"quick_recipe_prompt": "Schlage mir ein schnelles Rezept FÜR EINE PERSON vor, das die Produkte mit dem nächsten Ablaufdatum verwendet! Ignoriere Tiefkühlprodukte, konzentriere dich auf Kühlschrank und Vorratsschrank."
}, },
"cooking": { "cooking": {
"close": "Schließen", "close": "Schließen",
@@ -519,7 +539,10 @@
"timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!", "timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!",
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!", "recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
"expires_chip": "läuft ab {date}", "expires_chip": "läuft ab {date}",
"finish": "✅ Fertig" "finish": "✅ Fertig",
"step_fallback": "Schritt {n}",
"zerowaste_label": "♻️ Abfall",
"zerowaste_tip_title": "Zero-Waste-Tipp"
}, },
"settings": { "settings": {
"title": "⚙️ Einstellungen", "title": "⚙️ Einstellungen",
@@ -572,8 +595,9 @@
"title": "📅 Wöchentlicher Essensplan", "title": "📅 Wöchentlicher Essensplan",
"hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.", "hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.",
"enabled": "✅ Wöchentlichen Essensplan aktivieren", "enabled": "✅ Wöchentlichen Essensplan aktivieren",
"legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.", "legend": "🌤️ = Mittagessen &nbsp;·&nbsp; 🌙 = Abendessen &nbsp;·&nbsp; Tippe auf ein Badge, um es zu ändern.",
"types_title": "📋 Verfügbare Typen" "types_title": "📋 Verfügbare Typen",
"reset_btn": "↺ Standard wiederherstellen"
}, },
"appliances": { "appliances": {
"title": "🔌 Verfügbare Geräte", "title": "🔌 Verfügbare Geräte",
@@ -627,12 +651,24 @@
"security": { "security": {
"title": "🔒 HTTPS-Zertifikat", "title": "🔒 HTTPS-Zertifikat",
"hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.", "hint": "Wenn der Browser den Fehler \"Verbindung nicht sicher\" (ERR_CERT_AUTHORITY_INVALID) zeigt, installiere das CA-Zertifikat auf dem Gerät.",
"download_btn": "📥 CA-Zertifikat herunterladen" "download_btn": "📥 CA-Zertifikat herunterladen",
"token_title": "🔑 Einstellungs-Token",
"token_label": "Zugriffstoken",
"token_hint": "Falls `SETTINGS_TOKEN` in der Server-`.env` konfiguriert ist, gib hier den Token ein, bevor du die Einstellungen speicherst. Leer lassen, wenn nicht konfiguriert.",
"token_placeholder": "(leer = kein Schutz)",
"token_required_hint": "🔒 Dieser Server benötigt einen Token zum Speichern der Einstellungen.",
"cert_instructions": "<strong>Anleitung für Chrome (Android):</strong><br>1. Zertifikat oben herunterladen<br>2. Gehe zu <em>Einstellungen &rarr; Sicherheit &amp; Datenschutz &rarr; Weitere Sicherheitseinstellungen &rarr; Vom Gerätespeicher installieren</em><br>3. Wähle die heruntergeladene <em>EverShelf_CA.crt</em> Datei<br>4. Wähle \"CA\" und bestätige<br>5. Chrome neu starten<br><br><strong>Anleitung für Chrome (PC):</strong><br>1. Zertifikat oben herunterladen<br>2. Gehe zu <em>chrome://settings/certificates</em> (oder Einstellungen &rarr; Datenschutz und Sicherheit &rarr; Sicherheit &rarr; Zertifikate verwalten)<br>3. Tab \"Zertifizierungsstellen\" &rarr; Importieren &rarr; Datei auswählen<br>4. Häkchen bei \"Dieser Zertifizierungsstelle für die Identifikation von Webseiten vertrauen\"<br>5. Chrome neu starten"
}, },
"tts": { "tts": {
"title": "🔊 Sprache & TTS", "title": "🔊 Sprache & TTS",
"hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.", "hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.",
"enabled": "✅ TTS aktivieren", "enabled": "✅ TTS aktivieren",
"engine_label": "⚙️ TTS-Engine",
"engine_browser": "🔇 Browser (offline, keine Konfiguration erforderlich)",
"engine_server": "🌐 Externer Server (Home Assistant, REST API...)",
"voice_label": "🗣️ Stimme",
"rate_label": "⚡ Geschwindigkeit",
"pitch_label": "🎵 Tonhöhe",
"url_label": "🌐 Endpunkt-URL", "url_label": "🌐 Endpunkt-URL",
"method_label": "📡 HTTP-Methode", "method_label": "📡 HTTP-Methode",
"auth_label": "🔐 Authentifizierung", "auth_label": "🔐 Authentifizierung",
@@ -648,7 +684,14 @@
"extra_fields_label": " Zusätzliche Felder (JSON)", "extra_fields_label": " Zusätzliche Felder (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.", "extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.",
"test_btn": "🔊 Testansage senden" "test_btn": "🔊 Testansage senden",
"voices_loading": "Stimmen werden geladen…",
"voice_not_supported": "Stimme vom Browser nicht unterstützt",
"voices_none": "Keine Stimmen auf diesem Gerät verfügbar",
"voices_hint": "Verfügbare Stimmen hängen vom Betriebssystem und Browser ab. Auf macOS/iOS ist die Stimme Paola (Italienisch) verfügbar. Drücken Sie ↺ wenn die Liste nicht lädt.",
"url_missing": "⚠️ Endpunkt-URL fehlt.",
"test_sending": "⏳ Wird gesendet…",
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat."
}, },
"language": { "language": {
"title": "🌐 Sprache", "title": "🌐 Sprache",
@@ -659,7 +702,15 @@
"screensaver": { "screensaver": {
"label": "Bildschirmschoner aktivieren", "label": "Bildschirmschoner aktivieren",
"card_title": "🌙 Bildschirmschoner", "card_title": "🌙 Bildschirmschoner",
"card_hint": "Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert." "card_hint": "Zeigt nach 5 Minuten Inaktivität eine Uhr mit nützlichen Fakten. Standardmäßig deaktiviert.",
"timeout_1": "1 Minute",
"timeout_2": "2 Minuten",
"timeout_5": "5 Minuten",
"timeout_10": "10 Minuten",
"timeout_15": "15 Minuten",
"timeout_30": "30 Minuten",
"timeout_60": "1 Stunde",
"start_after": "⏱️ Starten nach"
}, },
"scale": { "scale": {
"title": "⚖️ Smart-Waage", "title": "⚖️ Smart-Waage",
@@ -672,7 +723,13 @@
"test_btn": "🔗 Verbindung testen", "test_btn": "🔗 Verbindung testen",
"download_btn": "📥 Android-Gateway herunterladen (APK)", "download_btn": "📥 Android-Gateway herunterladen (APK)",
"download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.", "download_hint": "Android-App als Brücke zwischen BLE-Waage und EverShelf.",
"download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm" "download_sub": "Quellcode: evershelf-scale-gateway/ im Projektstamm",
"live_weight": "Echtzeit-Gewicht",
"auto_reconnect": "🔁 Verbindung: automatisch",
"kiosk_title": "📡 BLE-Waage im Kiosk integriert",
"kiosk_hint": "Die Waage wird direkt vom internen BLE-Gateway des Kiosks verwaltet. Um ein neues Gerät zu koppeln, verwende den Konfigurationsassistenten.",
"kiosk_reconfigure": "🔄 BLE-Waage neu konfigurieren",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Unterstützte BLE-Protokolle:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch &mdash; automatische Heuristik für 100+ Modelle</li></ul>"
}, },
"kiosk": { "kiosk": {
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.", "hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
@@ -682,11 +739,27 @@
"native_hint": "Server-URL, BLE-Waage, Bildschirmschoner und Einrichtungsassistent.", "native_hint": "Server-URL, BLE-Waage, Bildschirmschoner und Einrichtungsassistent.",
"native_btn": "Kiosk-Konfiguration öffnen", "native_btn": "Kiosk-Konfiguration öffnen",
"native_tap_hint": "Zahnrad oben rechts antippen", "native_tap_hint": "Zahnrad oben rechts antippen",
"native_update_hint": "Kiosk-App aktualisieren, um diese Funktion zu nutzen" "native_update_hint": "Kiosk-App aktualisieren, um diese Funktion zu nutzen",
"update_title": "Kiosk-Aktualisierung",
"check_updates_btn": "🔍 Nach Updates suchen",
"needs_update": "⚠️ Das installierte Kiosk unterstützt diese Funktion nicht. Aktualisiere die Kiosk-App, um sie zu aktivieren."
}, },
"saved": "✅ Konfiguration gespeichert!", "saved": "✅ Konfiguration gespeichert!",
"saved_local": "✅ Konfiguration lokal gespeichert", "saved_local": "✅ Konfiguration lokal gespeichert",
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}" "saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}",
"theme": {
"title": "🌙 Erscheinungsbild",
"hint": "Wähle das Interface-Design.",
"label": "🌙 Design",
"off": "☀️ Hell",
"on": "🌙 Dunkel",
"auto": "🔄 Automatisch (System)"
},
"zerowaste": {
"card_title": "♻️ Zero-Waste-Tipps",
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
"label": "Tipps beim Kochen anzeigen"
}
}, },
"expiry": { "expiry": {
"today": "HEUTE", "today": "HEUTE",
@@ -816,7 +889,10 @@
"select_items": "Wähle mindestens ein Produkt aus", "select_items": "Wähle mindestens ein Produkt aus",
"server_offline": "Serververbindung unterbrochen", "server_offline": "Serververbindung unterbrochen",
"server_restored": "Serververbindung wiederhergestellt", "server_restored": "Serververbindung wiederhergestellt",
"server_retry": "Erneut versuchen" "server_retry": "Erneut versuchen",
"unknown": "Unbekannter Fehler",
"prefix": "Fehler",
"no_inventory_entry": "Kein Inventareintrag gefunden"
}, },
"confirm": { "confirm": {
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?", "remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
@@ -832,7 +908,9 @@
"edit": { "edit": {
"title": "{name} bearbeiten", "title": "{name} bearbeiten",
"unknown_hint": "Produktname und Informationen eingeben", "unknown_hint": "Produktname und Informationen eingeben",
"label_name": "🏷️ Produktname" "label_name": "🏷️ Produktname",
"choose_location_title": "Welchen Ort?",
"choose_location_hint": "Wähle den zu bearbeitenden Ort:"
}, },
"screensaver": { "screensaver": {
"recipe_btn": "Rezepte", "recipe_btn": "Rezepte",
@@ -929,7 +1007,8 @@
"thing_rest": "den Rest", "thing_rest": "den Rest",
"stay_btn": "Nein, bleibt in {location}", "stay_btn": "Nein, bleibt in {location}",
"moved_toast": "📦 Offene Packung bewegt nach {location}", "moved_toast": "📦 Offene Packung bewegt nach {location}",
"vacuum_restore": "🫙 Vakuum wiederherstellen" "vacuum_restore": "🫙 Vakuum wiederherstellen",
"vacuum_seal_rest": "🔒 Rest vakuumieren"
}, },
"nova": { "nova": {
"1": "Unverarbeitet", "1": "Unverarbeitet",
@@ -1115,5 +1194,24 @@
"report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.", "report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.",
"changelog": "Changelog", "changelog": "Changelog",
"github": "GitHub-Repository" "github": "GitHub-Repository"
},
"export": {
"title": "Inventar exportieren",
"hint": "Lade das aktuelle Inventar als CSV herunter oder öffne die druckfertige Version (PDF).",
"btn_csv": "CSV herunterladen",
"btn_pdf": "PDF / Drucken",
"btn_title": "Exportieren"
},
"startup": {
"check_php": "PHP",
"check_exts": "PHP-Erweiterungen",
"check_data_dir": "Datenverzeichnis",
"check_db": "Datenbank",
"check_env": "Konfiguration (.env)",
"check_gemini": "Gemini-AI-Schlüssel",
"check_bring": "Bring!-Token",
"critical_error": "Kritischer Fehler: Die App kann nicht gestartet werden. Prüfe die Serverlogs.",
"error_network": "Server nicht erreichbar. Bitte Verbindung prüfen.",
"retry": "Erneut versuchen"
} }
} }
+118 -20
View File
@@ -9,7 +9,8 @@
"inventory": "Pantry", "inventory": "Pantry",
"recipes": "Recipes", "recipes": "Recipes",
"shopping": "Shopping", "shopping": "Shopping",
"log": "Log" "log": "Log",
"settings": "Settings"
}, },
"btn": { "btn": {
"back": "← Back", "back": "← Back",
@@ -19,6 +20,8 @@
"add": "✅ Add", "add": "✅ Add",
"delete": "Delete", "delete": "Delete",
"edit": "✏️ Edit", "edit": "✏️ Edit",
"use": "Use",
"edit_item": "Edit",
"search": "🔍 Search", "search": "🔍 Search",
"go": "✅ Go", "go": "✅ Go",
"toggle_password": "👁️ Show/Hide", "toggle_password": "👁️ Show/Hide",
@@ -28,7 +31,12 @@
"restart": "↺ Restart", "restart": "↺ Restart",
"reset_default": "↺ Reset to default", "reset_default": "↺ Reset to default",
"save_info": "💾 Save information", "save_info": "💾 Save information",
"retry": "🔄 Retry" "retry": "🔄 Retry",
"yes_short": "Yes",
"no_short": "No"
},
"form": {
"select_placeholder": "-- Select --"
}, },
"locations": { "locations": {
"dispensa": "Pantry", "dispensa": "Pantry",
@@ -63,7 +71,9 @@
"pieces": "Pieces", "pieces": "Pieces",
"grams": "Grams", "grams": "Grams",
"box": "Package", "box": "Package",
"boxes": "Packages" "boxes": "Packages",
"millilitres": "Millilitres",
"from": "of"
}, },
"shopping_sections": { "shopping_sections": {
"frutta_verdura": "Fruits & Vegetables", "frutta_verdura": "Fruits & Vegetables",
@@ -215,7 +225,7 @@
"throw_btn": "🗑️ DISCARD", "throw_btn": "🗑️ DISCARD",
"throw_sub": "throw away", "throw_sub": "throw away",
"edit_sub": "expiry, location…", "edit_sub": "expiry, location…",
"create_recipe_btn": "Create a recipe with this" "create_recipe_btn": "Recipe"
}, },
"add": { "add": {
"title": "Add to Pantry", "title": "Add to Pantry",
@@ -240,7 +250,9 @@
"scan_expiry_title": "📷 Scan Expiry Date", "scan_expiry_title": "📷 Scan Expiry Date",
"product_added": "✅ {name} added!{qty}", "product_added": "✅ {name} added!{qty}",
"suffix_freezer_vacuum": "(freezer + vacuum sealed)", "suffix_freezer_vacuum": "(freezer + vacuum sealed)",
"history_badge_tip": "Average from {n} previous entries" "history_badge_tip": "Average from {n} previous entries",
"vacuum_question": "Vacuum sealed?",
"vacuum_saved": "🔒 Vacuum sealed!"
}, },
"use": { "use": {
"title": "Use / Consume", "title": "Use / Consume",
@@ -312,7 +324,13 @@
"edit_info": "✏️ Edit information", "edit_info": "✏️ Edit information",
"modify_details": "EDIT\nexpiry, location…", "modify_details": "EDIT\nexpiry, location…",
"already_in_pantry": "📋 Already in pantry", "already_in_pantry": "📋 Already in pantry",
"no_barcode": "No barcode" "no_barcode": "No barcode",
"unknown_product": "Unrecognized product",
"edit_name_brand": "Edit name/brand",
"weight_label": "Weight",
"origin_label": "Origin",
"labels_label": "Labels",
"select_variant": "Select the exact variant or use AI data:"
}, },
"products": { "products": {
"title": "📦 All Products", "title": "📦 All Products",
@@ -482,7 +500,8 @@
"undo_success": "↩ Operation undone for {name}", "undo_success": "↩ Operation undone for {name}",
"already_undone": "Operation already undone", "already_undone": "Operation already undone",
"too_old": "Cannot undo operations older than 24 hours", "too_old": "Cannot undo operations older than 24 hours",
"undo_error": "Error during undo" "undo_error": "Error during undo",
"recipe_prefix": "Recipe"
}, },
"chat": { "chat": {
"title": "Gemini Chef", "title": "Gemini Chef",
@@ -502,7 +521,8 @@
"transfer_to_recipes": "Transfer to Recipes", "transfer_to_recipes": "Transfer to Recipes",
"transferring": "Transferring...", "transferring": "Transferring...",
"transferred": "Added to Recipes!", "transferred": "Added to Recipes!",
"open_recipe": "Open recipe" "open_recipe": "Open recipe",
"quick_recipe_prompt": "Suggest a quick recipe FOR ONE PERSON using the products that expire first! Ignore freezer items, focus on fridge and pantry."
}, },
"cooking": { "cooking": {
"close": "Close", "close": "Close",
@@ -519,7 +539,10 @@
"timer_warning_tts": "Heads up! {label}: 10 seconds left!", "timer_warning_tts": "Heads up! {label}: 10 seconds left!",
"recipe_done_tts": "Recipe complete! Enjoy your meal!", "recipe_done_tts": "Recipe complete! Enjoy your meal!",
"expires_chip": "exp. {date}", "expires_chip": "exp. {date}",
"finish": "✅ Finish" "finish": "✅ Finish",
"step_fallback": "Step {n}",
"zerowaste_label": "♻️ Scrap",
"zerowaste_tip_title": "Zero-waste tip"
}, },
"settings": { "settings": {
"title": "⚙️ Settings", "title": "⚙️ Settings",
@@ -572,8 +595,9 @@
"title": "📅 Weekly Meal Plan", "title": "📅 Weekly Meal Plan",
"hint": "Set the meal type for each day. It will be used as a guide in recipe generation.", "hint": "Set the meal type for each day. It will be used as a guide in recipe generation.",
"enabled": "✅ Enable weekly meal plan", "enabled": "✅ Enable weekly meal plan",
"legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.", "legend": "🌤️ = Lunch &nbsp;·&nbsp; 🌙 = Dinner &nbsp;·&nbsp; Tap a badge to change it.",
"types_title": "📋 Available types" "types_title": "📋 Available types",
"reset_btn": "↺ Restore defaults"
}, },
"appliances": { "appliances": {
"title": "🔌 Available Appliances", "title": "🔌 Available Appliances",
@@ -627,12 +651,24 @@
"security": { "security": {
"title": "🔒 HTTPS Certificate", "title": "🔒 HTTPS Certificate",
"hint": "If the browser shows the error \"Your connection is not private\" (ERR_CERT_AUTHORITY_INVALID), you need to install the CA certificate on the device.", "hint": "If the browser shows the error \"Your connection is not private\" (ERR_CERT_AUTHORITY_INVALID), you need to install the CA certificate on the device.",
"download_btn": "📥 Download CA Certificate" "download_btn": "📥 Download CA Certificate",
"token_title": "🔑 Settings Token",
"token_label": "Access token",
"token_hint": "If `SETTINGS_TOKEN` is configured in the server's `.env`, enter the token here before saving settings. Leave empty if not configured.",
"token_placeholder": "(empty = no protection)",
"token_required_hint": "🔒 This server requires a token to save settings.",
"cert_instructions": "<strong>Instructions for Chrome (Android):</strong><br>1. Download the certificate above<br>2. Go to <em>Settings &rarr; Security &amp; Privacy &rarr; More security settings &rarr; Install from device storage</em><br>3. Select the downloaded <em>EverShelf_CA.crt</em> file<br>4. Choose \"CA\" and confirm<br>5. Restart Chrome<br><br><strong>Instructions for Chrome (PC):</strong><br>1. Download the certificate above<br>2. Go to <em>chrome://settings/certificates</em> (or Settings &rarr; Privacy and security &rarr; Security &rarr; Manage certificates)<br>3. Tab \"Authorities\" &rarr; Import &rarr; select the file<br>4. Check \"Trust this certificate for identifying websites\"<br>5. Restart Chrome"
}, },
"tts": { "tts": {
"title": "🔊 Voice & TTS", "title": "🔊 Voice & TTS",
"hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.", "hint": "Configure text-to-speech via any external REST API. Recipe steps and expired timers will be sent to the configured endpoint.",
"enabled": "✅ Enable TTS", "enabled": "✅ Enable TTS",
"engine_label": "⚙️ TTS Engine",
"engine_browser": "🔇 Browser (offline, no configuration required)",
"engine_server": "🌐 External server (Home Assistant, REST API...)",
"voice_label": "🗣️ Voice",
"rate_label": "⚡ Speed",
"pitch_label": "🎵 Pitch",
"url_label": "🌐 Endpoint URL", "url_label": "🌐 Endpoint URL",
"method_label": "📡 HTTP Method", "method_label": "📡 HTTP Method",
"auth_label": "🔐 Authentication", "auth_label": "🔐 Authentication",
@@ -648,7 +684,14 @@
"extra_fields_label": " Extra fields (JSON)", "extra_fields_label": " Extra fields (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.", "extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.",
"test_btn": "🔊 Send Test Voice" "test_btn": "🔊 Send Test Voice",
"voices_loading": "Loading voices…",
"voice_not_supported": "Voice not supported by this browser",
"voices_none": "No voices available on this device",
"voices_hint": "Available voices depend on the OS and browser. On macOS/iOS the Paola (Italian) voice is available. Press ↺ if the list does not load.",
"url_missing": "⚠️ Endpoint URL missing.",
"test_sending": "⏳ Sending…",
"test_ok": "✅ Response {code} — check that the speaker has spoken."
}, },
"language": { "language": {
"title": "🌐 Language", "title": "🌐 Language",
@@ -659,7 +702,15 @@
"screensaver": { "screensaver": {
"label": "Enable screensaver", "label": "Enable screensaver",
"card_title": "🌙 Screensaver", "card_title": "🌙 Screensaver",
"card_hint": "Shows a clock with useful facts after 5 minutes of inactivity. Disabled by default." "card_hint": "Shows a clock with useful facts after 5 minutes of inactivity. Disabled by default.",
"timeout_1": "1 minute",
"timeout_2": "2 minutes",
"timeout_5": "5 minutes",
"timeout_10": "10 minutes",
"timeout_15": "15 minutes",
"timeout_30": "30 minutes",
"timeout_60": "1 hour",
"start_after": "⏱️ Start after"
}, },
"scale": { "scale": {
"title": "⚖️ Smart Scale", "title": "⚖️ Smart Scale",
@@ -672,7 +723,13 @@
"test_btn": "🔗 Test connection", "test_btn": "🔗 Test connection",
"download_btn": "📥 Download Android Gateway (APK)", "download_btn": "📥 Download Android Gateway (APK)",
"download_hint": "Android app that bridges your BLE scale and EverShelf.", "download_hint": "Android app that bridges your BLE scale and EverShelf.",
"download_sub": "Source: evershelf-scale-gateway/ in the project root" "download_sub": "Source: evershelf-scale-gateway/ in the project root",
"live_weight": "real-time weight",
"auto_reconnect": "🔁 Reconnect: automatic",
"kiosk_title": "📡 BLE Scale integrated in Kiosk",
"kiosk_hint": "The scale is directly managed by the internal BLE Gateway of the kiosk. To pair a new device, use the configuration wizard.",
"kiosk_reconfigure": "🔄 Reconfigure BLE Scale",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Supported BLE protocols:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic &mdash; automatic heuristic for 100+ models</li></ul>"
}, },
"kiosk": { "kiosk": {
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.", "hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
@@ -682,11 +739,27 @@
"native_hint": "Server URL, BLE scale, screensaver and setup wizard.", "native_hint": "Server URL, BLE scale, screensaver and setup wizard.",
"native_btn": "Open kiosk configuration", "native_btn": "Open kiosk configuration",
"native_tap_hint": "Tap the gear button at the top right", "native_tap_hint": "Tap the gear button at the top right",
"native_update_hint": "Update the kiosk app to use this feature" "native_update_hint": "Update the kiosk app to use this feature",
"update_title": "Kiosk Update",
"check_updates_btn": "🔍 Check for updates",
"needs_update": "⚠️ The installed kiosk does not support this feature. Update the kiosk app to enable it."
}, },
"saved": "✅ Configuration saved!", "saved": "✅ Configuration saved!",
"saved_local": "✅ Configuration saved locally", "saved_local": "✅ Configuration saved locally",
"saved_local_error": "⚠️ Saved locally, server error: {error}" "saved_local_error": "⚠️ Saved locally, server error: {error}",
"theme": {
"title": "🌙 Appearance",
"hint": "Choose the interface theme.",
"label": "🌙 Theme",
"off": "☀️ Light",
"on": "🌙 Dark",
"auto": "🔄 Auto (system)"
},
"zerowaste": {
"card_title": "♻️ Zero-waste tips",
"card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.",
"label": "Show tips during cooking"
}
}, },
"expiry": { "expiry": {
"today": "TODAY", "today": "TODAY",
@@ -816,7 +889,10 @@
"select_items": "Select at least one product", "select_items": "Select at least one product",
"server_offline": "Server connection lost", "server_offline": "Server connection lost",
"server_restored": "Server connection restored", "server_restored": "Server connection restored",
"server_retry": "Retry" "server_retry": "Retry",
"unknown": "Unknown error",
"prefix": "Error",
"no_inventory_entry": "No inventory entry found"
}, },
"confirm": { "confirm": {
"remove_item": "Do you really want to remove this product from inventory?", "remove_item": "Do you really want to remove this product from inventory?",
@@ -832,7 +908,9 @@
"edit": { "edit": {
"title": "Edit {name}", "title": "Edit {name}",
"unknown_hint": "Enter the product name and information", "unknown_hint": "Enter the product name and information",
"label_name": "🏷️ Product name" "label_name": "🏷️ Product name",
"choose_location_title": "Which location?",
"choose_location_hint": "Choose the location to edit:"
}, },
"screensaver": { "screensaver": {
"recipe_btn": "Recipes", "recipe_btn": "Recipes",
@@ -929,7 +1007,8 @@
"thing_rest": "rest", "thing_rest": "rest",
"stay_btn": "No, stay in {location}", "stay_btn": "No, stay in {location}",
"moved_toast": "📦 Opened package moved to {location}", "moved_toast": "📦 Opened package moved to {location}",
"vacuum_restore": "🫙 Restore vacuum sealed" "vacuum_restore": "🫙 Restore vacuum sealed",
"vacuum_seal_rest": "🔒 Vacuum seal the rest"
}, },
"nova": { "nova": {
"1": "Unprocessed", "1": "Unprocessed",
@@ -1115,5 +1194,24 @@
"report_bug_error": "Could not send the report. Check your connection.", "report_bug_error": "Could not send the report. Check your connection.",
"changelog": "Changelog", "changelog": "Changelog",
"github": "GitHub Repository" "github": "GitHub Repository"
},
"export": {
"title": "Export inventory",
"hint": "Download the current inventory as CSV or open a print-ready version (PDF).",
"btn_csv": "Download CSV",
"btn_pdf": "PDF / Print",
"btn_title": "Export"
},
"startup": {
"check_php": "PHP",
"check_exts": "PHP extensions",
"check_data_dir": "Data directory",
"check_db": "Database",
"check_env": "Configuration (.env)",
"check_gemini": "Gemini AI key",
"check_bring": "Bring! token",
"critical_error": "Critical error: the app cannot start. Check your server logs.",
"error_network": "Cannot reach the server. Check your connection.",
"retry": "Retry"
} }
} }
+1217
View File
File diff suppressed because it is too large Load Diff
+1217
View File
File diff suppressed because it is too large Load Diff
+118 -20
View File
@@ -9,7 +9,8 @@
"inventory": "Dispensa", "inventory": "Dispensa",
"recipes": "Ricette", "recipes": "Ricette",
"shopping": "Spesa", "shopping": "Spesa",
"log": "Storico" "log": "Storico",
"settings": "Config"
}, },
"btn": { "btn": {
"back": "← Indietro", "back": "← Indietro",
@@ -19,6 +20,8 @@
"add": "✅ Aggiungi", "add": "✅ Aggiungi",
"delete": "Elimina", "delete": "Elimina",
"edit": "✏️ Modifica", "edit": "✏️ Modifica",
"use": "Usa",
"edit_item": "Modifica",
"search": "🔍 Cerca", "search": "🔍 Cerca",
"go": "✅ Vai", "go": "✅ Vai",
"toggle_password": "👁️ Mostra/Nascondi", "toggle_password": "👁️ Mostra/Nascondi",
@@ -28,7 +31,12 @@
"restart": "↺ Ricomincia", "restart": "↺ Ricomincia",
"reset_default": "↺ Ripristina default", "reset_default": "↺ Ripristina default",
"save_info": "💾 Salva informazioni", "save_info": "💾 Salva informazioni",
"retry": "🔄 Riprova" "retry": "🔄 Riprova",
"yes_short": "Sì",
"no_short": "No"
},
"form": {
"select_placeholder": "-- Seleziona --"
}, },
"locations": { "locations": {
"dispensa": "Dispensa", "dispensa": "Dispensa",
@@ -63,7 +71,9 @@
"pieces": "Pezzi", "pieces": "Pezzi",
"grams": "Grammi", "grams": "Grammi",
"box": "Confezione", "box": "Confezione",
"boxes": "Confezioni" "boxes": "Confezioni",
"millilitres": "Millilitri",
"from": "da"
}, },
"shopping_sections": { "shopping_sections": {
"frutta_verdura": "Frutta & Verdura", "frutta_verdura": "Frutta & Verdura",
@@ -215,7 +225,7 @@
"throw_btn": "🗑️ BUTTA", "throw_btn": "🗑️ BUTTA",
"throw_sub": "butta il prodotto", "throw_sub": "butta il prodotto",
"edit_sub": "scadenza, luogo…", "edit_sub": "scadenza, luogo…",
"create_recipe_btn": "Crea una ricetta con questo" "create_recipe_btn": "Ricetta"
}, },
"add": { "add": {
"title": "Aggiungi alla Dispensa", "title": "Aggiungi alla Dispensa",
@@ -240,7 +250,9 @@
"scan_expiry_title": "📷 Scansiona Data Scadenza", "scan_expiry_title": "📷 Scansiona Data Scadenza",
"product_added": "✅ {name} aggiunto!{qty}", "product_added": "✅ {name} aggiunto!{qty}",
"suffix_freezer_vacuum": "(freezer + sotto vuoto)", "suffix_freezer_vacuum": "(freezer + sotto vuoto)",
"history_badge_tip": "Media da {n} inserimenti precedenti" "history_badge_tip": "Media da {n} inserimenti precedenti",
"vacuum_question": "Messo sotto vuoto?",
"vacuum_saved": "🔒 Sotto vuoto registrato"
}, },
"use": { "use": {
"title": "Usa / Consuma", "title": "Usa / Consuma",
@@ -312,7 +324,13 @@
"edit_info": "✏️ Modifica informazioni", "edit_info": "✏️ Modifica informazioni",
"modify_details": "MODIFICA\nscadenza, luogo…", "modify_details": "MODIFICA\nscadenza, luogo…",
"already_in_pantry": "📋 Già in dispensa", "already_in_pantry": "📋 Già in dispensa",
"no_barcode": "Senza barcode" "no_barcode": "Senza barcode",
"unknown_product": "Prodotto non riconosciuto",
"edit_name_brand": "Modifica nome/marca",
"weight_label": "Peso",
"origin_label": "Origine",
"labels_label": "Etichette",
"select_variant": "Seleziona la variante esatta o usa i dati AI:"
}, },
"products": { "products": {
"title": "📦 Tutti i Prodotti", "title": "📦 Tutti i Prodotti",
@@ -482,7 +500,8 @@
"undo_success": "↩ Operazione annullata per {name}", "undo_success": "↩ Operazione annullata per {name}",
"already_undone": "Operazione già annullata", "already_undone": "Operazione già annullata",
"too_old": "Non è possibile annullare operazioni più vecchie di 24 ore", "too_old": "Non è possibile annullare operazioni più vecchie di 24 ore",
"undo_error": "Errore durante l'annullamento" "undo_error": "Errore durante l'annullamento",
"recipe_prefix": "Ricetta"
}, },
"chat": { "chat": {
"title": "Gemini Chef", "title": "Gemini Chef",
@@ -502,7 +521,8 @@
"transfer_to_recipes": "Trasferisci a Ricette", "transfer_to_recipes": "Trasferisci a Ricette",
"transferring": "Trasferimento in corso...", "transferring": "Trasferimento in corso...",
"transferred": "Aggiunta alle Ricette!", "transferred": "Aggiunta alle Ricette!",
"open_recipe": "Apri la ricetta" "open_recipe": "Apri la ricetta",
"quick_recipe_prompt": "Suggeriscimi una ricetta veloce PER UNA PERSONA usando i prodotti che scadono prima! Ignora i prodotti in freezer, concentrati su frigo e dispensa."
}, },
"cooking": { "cooking": {
"close": "Chiudi", "close": "Chiudi",
@@ -519,7 +539,10 @@
"timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!", "timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!",
"recipe_done_tts": "Ricetta completata! Buon appetito!", "recipe_done_tts": "Ricetta completata! Buon appetito!",
"expires_chip": "scade {date}", "expires_chip": "scade {date}",
"finish": "✅ Fine" "finish": "✅ Fine",
"step_fallback": "Passo {n}",
"zerowaste_label": "♻️ Scarto",
"zerowaste_tip_title": "Consiglio anti-spreco"
}, },
"settings": { "settings": {
"title": "⚙️ Configurazione", "title": "⚙️ Configurazione",
@@ -572,8 +595,9 @@
"title": "📅 Piano Pasti Settimanale", "title": "📅 Piano Pasti Settimanale",
"hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.", "hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.",
"enabled": "✅ Attiva piano pasti settimanale", "enabled": "✅ Attiva piano pasti settimanale",
"legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.", "legend": "🌤️ = Pranzo &nbsp;·&nbsp; 🌙 = Cena &nbsp;·&nbsp; Tocca un badge per cambiarlo.",
"types_title": "📋 Tipologie disponibili" "types_title": "📋 Tipologie disponibili",
"reset_btn": "↺ Ripristina default"
}, },
"appliances": { "appliances": {
"title": "🔌 Elettrodomestici Disponibili", "title": "🔌 Elettrodomestici Disponibili",
@@ -627,12 +651,24 @@
"security": { "security": {
"title": "🔒 Certificato HTTPS", "title": "🔒 Certificato HTTPS",
"hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.", "hint": "Se il browser mostra l'errore \"La connessione non è privata\" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.",
"download_btn": "📥 Scarica Certificato CA" "download_btn": "📥 Scarica Certificato CA",
"token_title": "🔑 Token Impostazioni",
"token_label": "Token di accesso",
"token_hint": "Se `SETTINGS_TOKEN` è configurato nel `.env` server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.",
"token_placeholder": "(vuoto = nessuna protezione)",
"token_required_hint": "🔒 Questo server richiede un token per salvare le impostazioni.",
"cert_instructions": "<strong>Istruzioni per Chrome (Android):</strong><br>1. Scarica il certificato qui sopra<br>2. Vai in <em>Impostazioni &rarr; Sicurezza e privacy &rarr; Altre impostazioni di sicurezza &rarr; Installa da archivio dispositivo</em><br>3. Seleziona il file <em>EverShelf_CA.crt</em> scaricato<br>4. Scegli \"CA\" e conferma<br>5. Riavvia Chrome<br><br><strong>Istruzioni per Chrome (PC):</strong><br>1. Scarica il certificato qui sopra<br>2. Vai in <em>chrome://settings/certificates</em> (o Impostazioni &rarr; Privacy e sicurezza &rarr; Sicurezza &rarr; Gestisci certificati)<br>3. Tab \"Autorità\" &rarr; Importa &rarr; seleziona il file<br>4. Spunta \"Considera attendibile per identificare siti web\"<br>5. Riavvia Chrome"
}, },
"tts": { "tts": {
"title": "🔊 Voce & TTS", "title": "🔊 Voce & TTS",
"hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.", "hint": "Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.",
"enabled": "✅ Attiva TTS", "enabled": "✅ Attiva TTS",
"engine_label": "⚙️ Motore TTS",
"engine_browser": "🔇 Browser (offline, nessuna configurazione)",
"engine_server": "🌐 Server esterno (Home Assistant, API REST...)",
"voice_label": "🗣️ Voce",
"rate_label": "⚡ Velocità",
"pitch_label": "🎵 Tono",
"url_label": "🌐 URL Endpoint", "url_label": "🌐 URL Endpoint",
"method_label": "📡 Metodo HTTP", "method_label": "📡 Metodo HTTP",
"auth_label": "🔐 Autenticazione", "auth_label": "🔐 Autenticazione",
@@ -648,7 +684,14 @@
"extra_fields_label": " Campi extra (JSON)", "extra_fields_label": " Campi extra (JSON)",
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}", "extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
"extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.", "extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.",
"test_btn": "🔊 Invia Test Vocale" "test_btn": "🔊 Invia Test Vocale",
"voices_loading": "Caricamento voci…",
"voice_not_supported": "Voce non supportata dal browser",
"voices_none": "Nessuna voce disponibile su questo dispositivo",
"voices_hint": "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.",
"url_missing": "⚠️ URL endpoint mancante.",
"test_sending": "⏳ Invio in corso…",
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato."
}, },
"language": { "language": {
"title": "🌐 Lingua / Language", "title": "🌐 Lingua / Language",
@@ -659,7 +702,15 @@
"screensaver": { "screensaver": {
"label": "Attiva salvaschermo", "label": "Attiva salvaschermo",
"card_title": "🌙 Salvaschermo", "card_title": "🌙 Salvaschermo",
"card_hint": "Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato." "card_hint": "Mostra un orologio con fatti utili dopo 5 minuti di inattività. Di default è disattivato.",
"timeout_1": "1 minuto",
"timeout_2": "2 minuti",
"timeout_5": "5 minuti",
"timeout_10": "10 minuti",
"timeout_15": "15 minuti",
"timeout_30": "30 minuti",
"timeout_60": "1 ora",
"start_after": "⏱️ Avvia dopo"
}, },
"scale": { "scale": {
"title": "⚖️ Bilancia Smart", "title": "⚖️ Bilancia Smart",
@@ -672,7 +723,13 @@
"test_btn": "🔗 Testa connessione", "test_btn": "🔗 Testa connessione",
"download_btn": "📥 Scarica Gateway Android (APK)", "download_btn": "📥 Scarica Gateway Android (APK)",
"download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.", "download_hint": "App Android che fa da ponte tra la bilancia BLE e questo sito.",
"download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto" "download_sub": "Sorgente: evershelf-scale-gateway/ nella root del progetto",
"live_weight": "peso in tempo reale",
"auto_reconnect": "🔁 Riconnessione: automatica",
"kiosk_title": "📡 Bilancia BLE integrata nel Kiosk",
"kiosk_hint": "La bilancia è gestita direttamente dal Gateway BLE interno al kiosk. Per abbinare un nuovo dispositivo usa il wizard di configurazione.",
"kiosk_reconfigure": "🔄 Riconfigura bilancia BLE",
"ble_protocols": "<p style=\"margin:0 0 6px;font-weight:600\">🔌 Protocolli BLE supportati:</p><ul style=\"margin:0 0 0 16px;padding:0;font-size:0.8rem\"><li>Bluetooth SIG Weight Scale (0x181D)</li><li>Bluetooth SIG Body Composition (0x181B) &mdash; peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico &mdash; heuristica automatica su 100+ modelli</li></ul>"
}, },
"kiosk": { "kiosk": {
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.", "hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
@@ -682,11 +739,27 @@
"native_hint": "URL server, bilancia BLE, salvaschermo e setup wizard.", "native_hint": "URL server, bilancia BLE, salvaschermo e setup wizard.",
"native_btn": "Apri configurazione kiosk", "native_btn": "Apri configurazione kiosk",
"native_tap_hint": "Tocca la rotella in alto a destra", "native_tap_hint": "Tocca la rotella in alto a destra",
"native_update_hint": "Aggiorna l'app kiosk per usare questa funzione" "native_update_hint": "Aggiorna l'app kiosk per usare questa funzione",
"update_title": "Aggiornamento Kiosk",
"check_updates_btn": "🔍 Cerca aggiornamenti",
"needs_update": "⚠️ Il kiosk installato non supporta questa funzione. Aggiorna l'app kiosk per abilitarla."
}, },
"saved": "✅ Configurazione salvata!", "saved": "✅ Configurazione salvata!",
"saved_local": "✅ Configurazione salvata localmente", "saved_local": "✅ Configurazione salvata localmente",
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}" "saved_local_error": "⚠️ Salvato localmente, errore server: {error}",
"theme": {
"title": "🌙 Tema / Aspetto",
"hint": "Scegli il tema dell interfaccia.",
"label": "🌙 Tema",
"off": "☀️ Chiaro",
"on": "🌙 Scuro",
"auto": "🔄 Automatico (sistema)"
},
"zerowaste": {
"card_title": "♻️ Suggerimenti zero-waste",
"card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.",
"label": "Mostra suggerimenti durante la cottura"
}
}, },
"expiry": { "expiry": {
"today": "OGGI", "today": "OGGI",
@@ -816,7 +889,10 @@
"select_items": "Seleziona almeno un prodotto", "select_items": "Seleziona almeno un prodotto",
"server_offline": "Connessione al server persa", "server_offline": "Connessione al server persa",
"server_restored": "Connessione al server ripristinata", "server_restored": "Connessione al server ripristinata",
"server_retry": "Riprova" "server_retry": "Riprova",
"unknown": "Errore sconosciuto",
"prefix": "Errore",
"no_inventory_entry": "Nessuna voce di inventario trovata"
}, },
"confirm": { "confirm": {
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?", "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
@@ -832,7 +908,9 @@
"edit": { "edit": {
"title": "Modifica {name}", "title": "Modifica {name}",
"unknown_hint": "Inserisci il nome e le informazioni del prodotto", "unknown_hint": "Inserisci il nome e le informazioni del prodotto",
"label_name": "🏷️ Nome prodotto" "label_name": "🏷️ Nome prodotto",
"choose_location_title": "Quale modifica?",
"choose_location_hint": "Scegli la posizione da modificare:"
}, },
"screensaver": { "screensaver": {
"recipe_btn": "Ricette", "recipe_btn": "Ricette",
@@ -929,7 +1007,8 @@
"thing_rest": "il resto", "thing_rest": "il resto",
"stay_btn": "No, resta in {location}", "stay_btn": "No, resta in {location}",
"moved_toast": "📦 Confezione aperta spostata in {location}", "moved_toast": "📦 Confezione aperta spostata in {location}",
"vacuum_restore": "🫙 Torna sotto vuoto" "vacuum_restore": "🫙 Torna sotto vuoto",
"vacuum_seal_rest": "🔒 Metti sotto vuoto il resto"
}, },
"nova": { "nova": {
"1": "Non trasformato", "1": "Non trasformato",
@@ -1115,5 +1194,24 @@
"report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.", "report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.",
"changelog": "Changelog", "changelog": "Changelog",
"github": "Repository GitHub" "github": "Repository GitHub"
},
"export": {
"title": "Esporta inventario",
"hint": "Scarica l inventario corrente in CSV o apri la versione stampabile (PDF).",
"btn_csv": "Scarica CSV",
"btn_pdf": "PDF / Stampa",
"btn_title": "Esporta"
},
"startup": {
"check_php": "PHP",
"check_exts": "Estensioni PHP",
"check_data_dir": "Cartella dati",
"check_db": "Database",
"check_env": "Configurazione (.env)",
"check_gemini": "Chiave Gemini AI",
"check_bring": "Token Bring!",
"critical_error": "Errore critico: impossibile avviare l'app. Controlla i log del server.",
"error_network": "Impossibile contattare il server. Controlla la connessione.",
"retry": "Riprova"
} }
} }