Compare commits
22 Commits
kiosk-1.7.13
...
v1.7.15
| Author | SHA1 | Date | |
|---|---|---|---|
| abbc2772ff | |||
| 473d3f59a4 | |||
| e7ae5c90c7 | |||
| 195c3d3bfa | |||
| 85ba22c7c8 | |||
| 698eb721f2 | |||
| 45dc79e5b7 | |||
| 8508993441 | |||
| a3147d704e | |||
| 834d8efab4 | |||
| 8894a5a2c7 | |||
| 5f4c29bd5a | |||
| 460875430b | |||
| 8a596cb7d8 | |||
| 99b8953ccf | |||
| c87d7d2cde | |||
| 424fc7bbe3 | |||
| 61a2372caa | |||
| ad9be3b705 | |||
| bd8dc0501a | |||
| c9a6f8ec42 | |||
| 0afdf60d38 |
@@ -5,6 +5,38 @@ All notable changes to EverShelf will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased] — Ideas & Roadmap
|
||||
|
||||
> Ideas collected during development. No priority or date implied.
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.15] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- **Full i18n audit** — Comprehensive sweep of all user-visible strings in `app.js` and `index.html`. 25+ new translation keys added across `it.json`, `en.json`, `de.json`, covering: vacuum toast, TTS voice controls, timer step labels, product note labels, error messages, expiry form, barcode hint, category select placeholder, cooking step fallback, `form.select_placeholder`, `btn.yes_short`/`no_short`, `add.vacuum_question`, `add.vacuum_saved`, `move.vacuum_seal_rest`, `cooking.step_fallback`, `error.prefix`/`unknown`, `product.select_variant`, and more.
|
||||
- **Splash screen redesign** — Logo displayed prominently, spinner below, app version shown at the bottom; version label injected dynamically at boot time so it never gets out of sync. Minimum 3-second display duration enforced: `_splashStart` is recorded before `DOMContentLoaded`; the fade-out is delayed by the remaining time if the app loads faster than 3 s.
|
||||
- **Demo GIF in README** — `assets/img/demo.gif` (processed at 2× speed, ~36 s) added to the `## 📸 Screenshots` section.
|
||||
- **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`.
|
||||
|
||||
### Fixed
|
||||
- **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`).
|
||||
- **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal.
|
||||
- **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`).
|
||||
- **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`.
|
||||
- **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README.
|
||||
|
||||
## [1.7.14] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- **In-app bug report form** — "Segnala un problema" now opens a modal form instead of redirecting to GitHub. Users can select type (Bug / Feature / Question), write title and description, optionally add reproduction steps. A GitHub issue is created directly with labels and app metadata attached.
|
||||
|
||||
### Fixed
|
||||
- **Kiosk settings button** — "Apri configurazione kiosk" in webapp settings was showing a toast asking to tap a gear icon that no longer exists. Now calls `openNativeSettings()` bridge directly (opens Android SettingsActivity). Fallback for old APKs shows a proper "update the kiosk app" hint.
|
||||
- **False update badge** — `manifest.json` version was `1.7.12` while the app header showed `v1.7.13`, causing the server to report an older deployed version and triggering a spurious update notification.
|
||||
- **Kiosk settings gear disappeared** — Race condition where Kotlin's `onPageFinished` injects `#_kiosk_overlay` before JS runs; JS found the element already present and returned early without ever restoring the native gear button. Fixed: JS no longer hides the native gear on load; `closeModal()` restores it with `setNativeSettingsVisible(true)`.
|
||||
- **`openNativeSettings()` fragile typeof check** — Android `@JavascriptInterface` methods are not always detected as `'function'` by typeof; replaced with try/catch.
|
||||
|
||||
## [1.7.13] - 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
@@ -45,7 +45,7 @@
|
||||
- **Expiry tracking** — Automatic shelf-life estimation based on product type and storage
|
||||
- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section)
|
||||
- **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items
|
||||
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("La quantità è giusta (2 pz)")
|
||||
- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("Quantity is correct (2 pcs)")
|
||||
|
||||
### 🤖 AI-Powered (Google Gemini)
|
||||
- **Expiry date reading** — Photograph a label and extract the expiry date automatically
|
||||
@@ -55,13 +55,13 @@
|
||||
- **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated
|
||||
- **Smart chat assistant** — Ask questions about your inventory, get cooking tips
|
||||
- **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip
|
||||
- **Anomaly explanation** — "🤖 Spiega" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||
- **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do
|
||||
- **Model fallback** — All AI endpoints try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically
|
||||
- **Graceful no-key state** — When no Gemini key is configured, AI entry points show a friendly message; the header button is visually greyed with an amber dot
|
||||
|
||||
### 🛒 Shopping List
|
||||
- **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app
|
||||
- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Panna da cucina") rather than brand, keeping the Bring! list clean and consolidated
|
||||
- **Generic shopping names** — Products are grouped by type (e.g. "Milk", "Cold cuts", "Cooking cream") rather than brand, keeping the Bring! list clean and consolidated
|
||||
- **Smart predictions** — Know what you'll need before you run out
|
||||
- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed
|
||||
- **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration** — Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load)
|
||||
@@ -72,7 +72,7 @@
|
||||
- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled
|
||||
- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode
|
||||
- **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up
|
||||
- **Recipe completion** — "Buon appetito!" spoken when the last step is confirmed
|
||||
- **Recipe completion** — "Bon appétit!" announced via TTS when the last step is confirmed
|
||||
- **Built-in timer** — Automatic timer suggestions based on recipe instructions
|
||||
- **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
- **Expiry alerts** — Visual warnings for expired and soon-to-expire items
|
||||
- **Opened products panel** — Tracks partially-used items; expiry is recalculated from the opening date using AI (Gemini) + per-category rule fallback; whole sealed packages always keep their original manufacturer expiry; conf items with mixed whole + fractional units are shown as two separate entries
|
||||
- **Freezer shelf-life** — Granular per-product estimates (USDA/EFSA): fish 120 d, poultry 270 d, whole red-meat cuts 365 d, mince 120 d, vegetables/fruit 270 d, generic 180 d; AI + cache still take priority over rules
|
||||
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action
|
||||
- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and a discard action as the primary action
|
||||
- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner; icon, colour and title adapt to the actual safety level (✅ green for safe, 👀 amber to check, 🚫 red for danger); high-risk items get a prominent discard action
|
||||
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
|
||||
- **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit
|
||||
@@ -427,6 +427,12 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
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!
|
||||
|
||||
+107
-17
@@ -438,6 +438,10 @@ try {
|
||||
reportError();
|
||||
break;
|
||||
|
||||
case 'report_bug':
|
||||
reportBugManual();
|
||||
break;
|
||||
|
||||
case 'check_update':
|
||||
checkUpdate();
|
||||
break;
|
||||
@@ -2865,6 +2869,8 @@ function geminiChat(PDO $db): void {
|
||||
$history = $input['history'] ?? [];
|
||||
$appliances = $input['appliances'] ?? [];
|
||||
$dietaryRestrictions = $input['dietary_restrictions'] ?? '';
|
||||
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
|
||||
$langName = recipeLangName($lang);
|
||||
|
||||
if (empty($message)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Messaggio vuoto']);
|
||||
@@ -2912,27 +2918,29 @@ function geminiChat(PDO $db): void {
|
||||
|
||||
$dietaryText = '';
|
||||
if (!empty($dietaryRestrictions)) {
|
||||
$dietaryText = "\nRestrizioni alimentari dell'utente: {$dietaryRestrictions}. Rispetta SEMPRE queste restrizioni.";
|
||||
$dietaryText = "\nUser dietary restrictions: {$dietaryRestrictions}. Always respect these restrictions.";
|
||||
}
|
||||
|
||||
$langName = recipeLangName($lang);
|
||||
$systemPrompt = <<<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}
|
||||
{$appliancesText}{$dietaryText}
|
||||
|
||||
REGOLE:
|
||||
1. Rispondi SEMPRE in italiano, in modo colloquiale e amichevole
|
||||
2. Usa SOLO gli ingredienti dalla dispensa dell'utente (più acqua, sale, pepe, olio che si presumono sempre disponibili)
|
||||
3. Dai priorità agli ingredienti in scadenza
|
||||
4. Sii conciso: non fare liste chilometriche, vai al sodo
|
||||
5. Se l'utente chiede una ricetta o preparazione, dai istruzioni chiare con quantità
|
||||
6. Se non ci sono ingredienti adatti per la richiesta, dillo onestamente e suggerisci alternative
|
||||
7. Puoi suggerire combinazioni creative
|
||||
8. Quando menzioni quantità, usa le stesse unità di misura della dispensa
|
||||
9. Ricorda il contesto della conversazione precedente
|
||||
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)
|
||||
RULES:
|
||||
1. Always respond in {$langName}
|
||||
2. Use ONLY ingredients from the user's pantry (plus water, salt, pepper, oil which are assumed always available)
|
||||
3. Prioritize ingredients that expire soon
|
||||
4. Be concise: no lengthy lists, get to the point
|
||||
5. If the user asks for a recipe or preparation, give clear instructions with quantities
|
||||
6. If there are no suitable ingredients for the request, say so honestly and suggest alternatives
|
||||
7. You can suggest creative combinations
|
||||
8. When mentioning quantities, use the same units as in the pantry
|
||||
9. Remember the context of the previous conversation
|
||||
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;
|
||||
|
||||
// Build conversation for Gemini
|
||||
@@ -3806,6 +3814,8 @@ function recipeFromIngredient(PDO $db): void {
|
||||
echo json_encode(['success' => false, 'error' => 'empty_ingredient']);
|
||||
return;
|
||||
}
|
||||
$lang = recipeNormalizeLang($input['lang'] ?? 'it');
|
||||
$langName = recipeLangName($lang);
|
||||
|
||||
// Fetch inventory (same as generateRecipe)
|
||||
$stmt = $db->query("
|
||||
@@ -3821,18 +3831,18 @@ function recipeFromIngredient(PDO $db): void {
|
||||
$safeName = htmlspecialchars($ingredientName, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$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.
|
||||
|
||||
Fields:
|
||||
- title: string (Italian recipe name)
|
||||
- title: string (recipe name in {$langName})
|
||||
- meal: null (do NOT categorize)
|
||||
- persons: 2
|
||||
- prep_time: string or null
|
||||
- cook_time: string or null
|
||||
- ingredients: array of {"name":"...","qty":"...","qty_number":0.0,"unit":"g|ml|pz|conf|kg|l","from_pantry":true}
|
||||
— "{$safeName}" MUST be the first ingredient; set from_pantry=true for ALL
|
||||
- steps: array of strings (step text only, no numbers)
|
||||
- steps: array of strings (step text only, no numbers, in {$langName})
|
||||
- nutrition_note: string or null
|
||||
PROMPT;
|
||||
|
||||
@@ -6897,6 +6907,86 @@ function reportError(): void {
|
||||
echo json_encode(['ok' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/?action=report_bug
|
||||
*
|
||||
* Manual bug/feature/question report submitted by the user via the in-app form.
|
||||
* Creates a GitHub issue directly with the provided title and description.
|
||||
*
|
||||
* Expected JSON body:
|
||||
* type string 'bug'|'feature'|'question'
|
||||
* title string Issue title (required, max 150 chars)
|
||||
* description string Main description (required, max 3000 chars)
|
||||
* steps string? Steps to reproduce (optional, max 2000 chars)
|
||||
* lang string? UI language the user is running
|
||||
* url string? Page URL
|
||||
* user_agent string? Navigator UA
|
||||
* version string? App version
|
||||
*/
|
||||
function reportBugManual(): void {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
|
||||
$allowedTypes = ['bug', 'feature', 'question'];
|
||||
$type = in_array($input['type'] ?? '', $allowedTypes, true) ? $input['type'] : 'bug';
|
||||
$title = substr(trim($input['title'] ?? ''), 0, 150);
|
||||
$desc = substr(trim($input['description'] ?? ''), 0, 3000);
|
||||
$steps = substr(trim($input['steps'] ?? ''), 0, 2000);
|
||||
$ua = substr(trim($input['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? '')), 0, 300);
|
||||
$url = substr(trim($input['url'] ?? ''), 0, 300);
|
||||
$ver = substr(trim($input['version'] ?? ''), 0, 50);
|
||||
$lang = preg_replace('/[^a-z\-]/', '', strtolower($input['lang'] ?? 'it'));
|
||||
|
||||
if (empty($title) || empty($desc)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'title and description required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$token = _ghToken();
|
||||
if (!$token) {
|
||||
// No GitHub token configured — log locally and return ok so the UX is not broken
|
||||
_appendErrorLog('pwa', 'manual_report', $title, $desc, $url, $ua, ['type' => $type, 'version' => $ver, 'lang' => $lang]);
|
||||
echo json_encode(['ok' => true, 'issue' => null]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Labels: always 'user-report' + type-specific label
|
||||
$labelMap = [
|
||||
'bug' => ['bug', 'user-report'],
|
||||
'feature' => ['enhancement', 'user-report'],
|
||||
'question' => ['question', 'user-report'],
|
||||
];
|
||||
$labels = $labelMap[$type];
|
||||
|
||||
$typeEmoji = ['bug' => '🐛', 'feature' => '💡', 'question' => '❓'][$type];
|
||||
$ts = date('Y-m-d H:i:s T');
|
||||
|
||||
$body = "## {$typeEmoji} User Report\n\n";
|
||||
$body .= "**Description:**\n{$desc}\n\n";
|
||||
if ($steps) {
|
||||
$body .= "**Steps to reproduce:**\n{$steps}\n\n";
|
||||
}
|
||||
$body .= "---\n";
|
||||
$body .= "**Version:** `{$ver}` \n";
|
||||
$body .= "**Language:** `{$lang}` \n";
|
||||
if ($url) $body .= "**URL:** `{$url}` \n";
|
||||
if ($ua) $body .= "**User-Agent:** `{$ua}` \n";
|
||||
$body .= "**Reported at:** {$ts}\n\n";
|
||||
$body .= "_This issue was submitted via the in-app bug report form._";
|
||||
|
||||
$res = _githubRequest($token, 'POST',
|
||||
'https://api.github.com/repos/' . GH_REPO . '/issues',
|
||||
['title' => $title, 'body' => $body, 'labels' => $labels]
|
||||
);
|
||||
|
||||
$issueNum = $res['body']['number'] ?? null;
|
||||
$issueUrl = $res['body']['html_url'] ?? null;
|
||||
if ($issueNum) {
|
||||
echo json_encode(['ok' => true, 'issue' => $issueNum, 'url' => $issueUrl]);
|
||||
} else {
|
||||
echo json_encode(['ok' => false, 'error' => 'github_api_error']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append to data/error_reports.log (local safety net, max 500 KB)
|
||||
*/
|
||||
|
||||
+49
-2
@@ -104,10 +104,17 @@ body {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.app-preloader-logo {
|
||||
height: 120px;
|
||||
height: 160px;
|
||||
width: auto;
|
||||
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;
|
||||
}
|
||||
.header-logo-icon {
|
||||
height: 28px;
|
||||
@@ -1267,6 +1274,16 @@ body.server-offline .bottom-nav {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#btn-report-bug {
|
||||
background: #f97316;
|
||||
color: #fff;
|
||||
border-color: #ea580c;
|
||||
}
|
||||
#btn-report-bug:hover {
|
||||
background: #ea580c;
|
||||
border-color: #c2410c;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
@@ -3103,6 +3120,36 @@ body.server-offline .bottom-nav {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Bug report form ── */
|
||||
.bug-type-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bug-type-pill {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 7px 10px;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.bug-type-pill.active {
|
||||
border-color: var(--primary, #2d5016);
|
||||
background: var(--primary, #2d5016);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bug-type-pill:not(.active):hover {
|
||||
border-color: var(--primary, #2d5016);
|
||||
color: var(--primary, #2d5016);
|
||||
}
|
||||
|
||||
.modal-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
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 |
+237
-144
@@ -2100,51 +2100,124 @@ async function _loadAboutSection() {
|
||||
* Manually triggered bug report from the About section in Settings.
|
||||
* Collects basic info and submits via the existing report_error endpoint.
|
||||
*/
|
||||
async function reportBugManual() {
|
||||
const btn = document.getElementById('btn-report-bug');
|
||||
const statusEl = document.getElementById('report-bug-status');
|
||||
if (!btn || !statusEl) return;
|
||||
function reportBugManual() {
|
||||
const mc = document.getElementById('modal-content');
|
||||
if (!mc) return;
|
||||
|
||||
btn.disabled = true;
|
||||
mc.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>${t('about.report_bug_modal_title')}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding:16px 20px 20px">
|
||||
<div style="margin-bottom:14px">
|
||||
<div class="bug-type-pills">
|
||||
<button type="button" class="bug-type-pill active" data-btype="bug">🐛 ${t('about.report_type_bug')}</button>
|
||||
<button type="button" class="bug-type-pill" data-btype="feature">💡 ${t('about.report_type_feature')}</button>
|
||||
<button type="button" class="bug-type-pill" data-btype="question">❓ ${t('about.report_type_question')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<label class="settings-label" style="display:block;margin-bottom:4px">${t('about.report_field_title')} *</label>
|
||||
<input type="text" id="bug-form-title" maxlength="150" autocomplete="off"
|
||||
placeholder="${t('about.report_field_title_ph')}"
|
||||
style="width:100%;box-sizing:border-box;padding:9px 11px;border:1.5px solid #cbd5e1;border-radius:8px;font-size:0.95rem;background:#fff;color:#1e293b;outline:none">
|
||||
</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<label class="settings-label" style="display:block;margin-bottom:4px">${t('about.report_field_desc')} *</label>
|
||||
<textarea id="bug-form-desc" rows="4" maxlength="3000"
|
||||
placeholder="${t('about.report_field_desc_ph')}"
|
||||
style="width:100%;box-sizing:border-box;padding:9px 11px;border:1.5px solid #cbd5e1;border-radius:8px;font-size:0.95rem;resize:vertical;background:#fff;color:#1e293b;outline:none;font-family:inherit"></textarea>
|
||||
</div>
|
||||
<div id="bug-form-steps-group" style="margin-bottom:12px">
|
||||
<label class="settings-label" style="display:block;margin-bottom:4px">${t('about.report_field_steps')}</label>
|
||||
<textarea id="bug-form-steps" rows="3" maxlength="2000"
|
||||
placeholder="${t('about.report_field_steps_ph')}"
|
||||
style="width:100%;box-sizing:border-box;padding:9px 11px;border:1.5px solid #cbd5e1;border-radius:8px;font-size:0.95rem;resize:vertical;background:#fff;color:#1e293b;outline:none;font-family:inherit"></textarea>
|
||||
</div>
|
||||
<p class="settings-hint" style="margin:0 0 14px;font-size:0.78rem">
|
||||
${t('about.report_auto_info').replace('{version}', _loadedVersion || '—').replace('{lang}', _currentLang || '—')}
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">${t('btn.cancel')}</button>
|
||||
<button type="button" class="btn btn-primary" id="bug-form-submit" onclick="_submitBugReport()">
|
||||
${t('about.report_send_btn')}
|
||||
</button>
|
||||
</div>
|
||||
<div id="bug-form-status" style="display:none;margin-top:10px;text-align:center;font-size:0.88rem;padding:8px;border-radius:6px"></div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
|
||||
// Pill click: switch type, show/hide steps field
|
||||
mc.querySelectorAll('.bug-type-pill').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
mc.querySelectorAll('.bug-type-pill').forEach(p => p.classList.remove('active'));
|
||||
pill.classList.add('active');
|
||||
const stepsGroup = document.getElementById('bug-form-steps-group');
|
||||
if (stepsGroup) stepsGroup.style.display = (pill.dataset.btype === 'bug') ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _submitBugReport() {
|
||||
const submitBtn = document.getElementById('bug-form-submit');
|
||||
const statusEl = document.getElementById('bug-form-status');
|
||||
const titleEl = document.getElementById('bug-form-title');
|
||||
const descEl = document.getElementById('bug-form-desc');
|
||||
const stepsEl = document.getElementById('bug-form-steps');
|
||||
const activePill = document.querySelector('#modal-content .bug-type-pill.active');
|
||||
|
||||
const title = titleEl?.value.trim() || '';
|
||||
const desc = descEl?.value.trim() || '';
|
||||
const steps = stepsEl?.value.trim() || '';
|
||||
const type = activePill?.dataset.btype || 'bug';
|
||||
|
||||
// Inline validation
|
||||
if (!title) {
|
||||
titleEl.style.borderColor = '#dc2626';
|
||||
titleEl.focus();
|
||||
return;
|
||||
}
|
||||
if (!desc) {
|
||||
descEl.style.borderColor = '#dc2626';
|
||||
descEl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
statusEl.style.display = '';
|
||||
statusEl.style.background = '#f1f5f9';
|
||||
statusEl.style.color = '#64748b';
|
||||
statusEl.textContent = t('about.report_bug_sending');
|
||||
|
||||
const manifest = await fetch('manifest.json?_=' + Date.now()).then(r => r.json()).catch(() => ({}));
|
||||
|
||||
try {
|
||||
const res = await fetch(API_BASE + '?action=report_error', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: 'pwa',
|
||||
type: 'manual_report',
|
||||
message: 'Manual bug report submitted from Settings → About',
|
||||
stack: '',
|
||||
url: location.href,
|
||||
const res = await api('report_bug', null, 'POST', {
|
||||
type,
|
||||
title,
|
||||
description: desc,
|
||||
steps,
|
||||
user_agent: navigator.userAgent,
|
||||
version: manifest.version || '',
|
||||
context: {
|
||||
lang: _currentLang,
|
||||
online: navigator.onLine,
|
||||
version_guard_bypass: true,
|
||||
}
|
||||
})
|
||||
url: location.href,
|
||||
version: _loadedVersion || '',
|
||||
lang: _currentLang || 'it',
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.ok) {
|
||||
|
||||
if (res.ok) {
|
||||
statusEl.style.background = '#dcfce7';
|
||||
statusEl.style.color = '#15803d';
|
||||
statusEl.textContent = t('about.report_bug_sent');
|
||||
// Open GitHub issues so user can add details
|
||||
setTimeout(() => window.open('https://github.com/dadaloop82/EverShelf/issues', '_blank', 'noopener'), 800);
|
||||
const issueRef = res.issue ? ` (#${res.issue})` : '';
|
||||
statusEl.textContent = t('about.report_bug_sent') + issueRef;
|
||||
submitBtn.style.display = 'none';
|
||||
setTimeout(() => closeModal(), 3500);
|
||||
} else {
|
||||
throw new Error(json.error || 'error');
|
||||
throw new Error(res.error || 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
statusEl.style.background = '#fee2e2';
|
||||
statusEl.style.color = '#dc2626';
|
||||
statusEl.textContent = t('about.report_bug_error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2337,6 +2410,9 @@ async function loadSettingsUI() {
|
||||
// Show kiosk self-update panel
|
||||
const updatePanel = document.getElementById('kiosk-update-panel');
|
||||
if (updatePanel) updatePanel.style.display = '';
|
||||
// Show kiosk native settings shortcut panel
|
||||
const nativePanel = document.getElementById('kiosk-native-settings-panel');
|
||||
if (nativePanel) nativePanel.style.display = '';
|
||||
}
|
||||
|
||||
// Populate About section version
|
||||
@@ -2356,6 +2432,19 @@ function _kioskReconfigureScale() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kiosk: open native SettingsActivity (server URL, BLE, screensaver) ──
|
||||
function _openKioskNativeSettings() {
|
||||
if (typeof _kioskBridge === 'undefined') return;
|
||||
// Use try/catch directly: Android @JavascriptInterface methods are not always
|
||||
// detected as 'function' by typeof, so we just call and catch if unavailable.
|
||||
try {
|
||||
_kioskBridge.openNativeSettings();
|
||||
} catch(e) {
|
||||
// Older APK without openNativeSettings bridge — inform user to update
|
||||
showToast(t('settings.kiosk.native_update_hint') || 'Aggiorna l\'app kiosk per usare questa funzione', 'warning', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kiosk: manual update check ────────────────────────────────────────
|
||||
let _kioskPendingApkUrl = '';
|
||||
|
||||
@@ -2374,7 +2463,7 @@ window._kioskUpdateResult = function(result) {
|
||||
status.style.background = 'rgba(239,68,68,0.1)';
|
||||
status.style.border = '1px solid rgba(239,68,68,0.3)';
|
||||
status.style.color = '';
|
||||
status.innerHTML = `❌ Errore: ${result.error}`;
|
||||
status.innerHTML = `❌ ${t('error.prefix')}: ${result.error}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2466,6 +2555,10 @@ function _injectKioskOverlay() {
|
||||
const appHeader = document.querySelector('.app-header');
|
||||
if (appHeader) appHeader.classList.add('kiosk-mode');
|
||||
|
||||
const btnStyle = 'background:rgba(255,255,255,0.2);border:none;color:#fff;width:34px;height:34px;border-radius:50%;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;';
|
||||
|
||||
// If the Kotlin onPageFinished already injected #_kiosk_overlay (with only ✕ and ↻),
|
||||
// nothing more to do — the native Android btnSettings (top-right) opens SettingsActivity.
|
||||
if (document.getElementById('_kiosk_overlay')) return;
|
||||
|
||||
const headerLeft = document.getElementById('header-left');
|
||||
@@ -2475,8 +2568,6 @@ function _injectKioskOverlay() {
|
||||
wrap.id = '_kiosk_overlay';
|
||||
wrap.style.cssText = 'display:flex;gap:6px;align-items:center;';
|
||||
|
||||
const btnStyle = 'background:rgba(255,255,255,0.2);border:none;color:#fff;width:34px;height:34px;border-radius:50%;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;';
|
||||
|
||||
// Exit button
|
||||
const exitBtn = document.createElement('button');
|
||||
exitBtn.id = '_kiosk_exit_btn';
|
||||
@@ -2499,24 +2590,13 @@ function _injectKioskOverlay() {
|
||||
_kioskBridge.hardReload();
|
||||
});
|
||||
|
||||
// Settings button — replaces the native Android settings button
|
||||
const settBtn = document.createElement('button');
|
||||
settBtn.id = '_kiosk_settings_btn';
|
||||
settBtn.textContent = '\u2699\uFE0F';
|
||||
settBtn.title = t('settings.title') || 'Settings';
|
||||
settBtn.style.cssText = btnStyle.replace('font-size:15px', 'font-size:16px');
|
||||
settBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showPage('settings');
|
||||
});
|
||||
// NOTE: No ⚙️ button here — the native Android settings button (top-right, injected by
|
||||
// Kotlin) opens SettingsActivity (server URL, BLE scale, screensaver). Do NOT call
|
||||
// setNativeSettingsVisible(false) — that would hide the only way to reconfigure the kiosk.
|
||||
|
||||
wrap.appendChild(exitBtn);
|
||||
wrap.appendChild(refBtn);
|
||||
wrap.appendChild(settBtn);
|
||||
headerLeft.appendChild(wrap);
|
||||
|
||||
// Permanently hide the native Android settings button — replaced by the web overlay button above.
|
||||
try { _kioskBridge.setNativeSettingsVisible(false); } catch(_) {}
|
||||
}
|
||||
|
||||
function renderAppliances(appliances) {
|
||||
@@ -3354,7 +3434,7 @@ function _renderNutritionSection(inventory) {
|
||||
<div class="nutr-leg-row">
|
||||
<span class="nutr-leg-dot" style="background:${s.color}"></span>
|
||||
<span class="nutr-leg-icon">${s.icon}</span>
|
||||
<span class="nutr-leg-name">${s.cat}</span>
|
||||
<span class="nutr-leg-name">${t('categories.' + s.cat) || s.cat}</span>
|
||||
<span class="nutr-leg-pct">${s.pct}%</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
@@ -3574,7 +3654,7 @@ async function loadDashboard() {
|
||||
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
|
||||
const qty = parseFloat(item.quantity);
|
||||
const pkgSize = parseFloat(item.default_quantity);
|
||||
const unitLabels = { 'ml': 'ml', 'g': 'g', 'pz': 'pz' };
|
||||
const unitLabels = { 'ml': 'ml', 'g': 'g', 'pz': t('units.pz') };
|
||||
let qtyText = '';
|
||||
|
||||
if (item.unit === 'conf') {
|
||||
@@ -3586,13 +3666,13 @@ async function loadDashboard() {
|
||||
// Only show remainder if it rounds to at least 1 unit
|
||||
const remainderText = remainderAmt >= 0.5 ? formatSubRemainder(remainderAmt, pkgUnit) : '';
|
||||
if (wholeConf > 0 && remainderText) {
|
||||
qtyText = `${wholeConf} conf${pkgLabel ? ` (da ${pkgSize}${pkgLabel})` : ''} + ${remainderText}`;
|
||||
qtyText = `${wholeConf} ${t('units.conf') || 'conf'}${pkgLabel ? ` (${t('units.from') || 'da'} ${pkgSize}${pkgLabel})` : ''} + ${remainderText}`;
|
||||
} else if (wholeConf > 0) {
|
||||
qtyText = `${wholeConf} conf${pkgLabel ? ` (da ${pkgSize}${pkgLabel})` : ''}`;
|
||||
qtyText = `${wholeConf} ${t('units.conf') || 'conf'}${pkgLabel ? ` (${t('units.from') || 'da'} ${pkgSize}${pkgLabel})` : ''}`;
|
||||
} else if (remainderText) {
|
||||
qtyText = remainderAmt >= 1 ? remainderText : t('inventory.qty_trace') || '< 1' + (pkgLabel || '');
|
||||
} else {
|
||||
qtyText = `${qty} conf`;
|
||||
qtyText = `${Math.round(qty * 10) / 10} ${t('units.conf') || 'conf'}`;
|
||||
}
|
||||
} else {
|
||||
const unitLabel = unitLabels[item.unit] || item.unit || '';
|
||||
@@ -3690,7 +3770,7 @@ function quickRecipeSuggestion() {
|
||||
// Navigate to chat and auto-send a prompt about expiring products
|
||||
showPage('chat');
|
||||
setTimeout(() => {
|
||||
document.getElementById('chat-input').value = 'Suggeriscimi una ricetta veloce PER UNA PERSONA usando i prodotti che scadono prima! Ignora i prodotti in freezer (hanno scadenze molto lunghe), concentrati su frigo e dispensa.';
|
||||
document.getElementById('chat-input').value = t('chat.quick_recipe_prompt') || 'Suggeriscimi una ricetta veloce PER UNA PERSONA usando i prodotti che scadono prima! Ignora i prodotti in freezer (hanno scadenze molto lunghe), concentrati su frigo e dispensa.';
|
||||
sendChatMessage();
|
||||
}, 500);
|
||||
}
|
||||
@@ -4268,7 +4348,7 @@ async function explainBannerAnomaly() {
|
||||
}
|
||||
} catch (e) {
|
||||
detailEl.innerHTML = originalHtml;
|
||||
showToast('Errore AI', 'error');
|
||||
showToast(t('error.generic'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4352,7 +4432,7 @@ function bannerFinishAll() {
|
||||
showToast(t('toast.finished_all').replace('{name}', item.name), 'success');
|
||||
showLowStockBringPrompt(res, () => loadDashboard());
|
||||
} else {
|
||||
showToast(res.error || 'Errore', 'error');
|
||||
showToast(res.error || t('error.generic'), 'error');
|
||||
}
|
||||
}).catch(() => showToast(t('error.connection'), 'error'));
|
||||
}
|
||||
@@ -4557,8 +4637,8 @@ function _pzFractionLabel(n) {
|
||||
function formatQuantity(qty, unit, defaultQty, packageUnit) {
|
||||
if (!qty && qty !== 0) return '';
|
||||
const n = parseFloat(qty);
|
||||
const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' };
|
||||
const label = unitLabels[unit] || unit || 'pz';
|
||||
const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.conf') };
|
||||
const label = unitLabels[unit] || unit || t('units.pz');
|
||||
|
||||
// Special handling for conf with partial packages
|
||||
if (unit === 'conf' && packageUnit && defaultQty > 0) {
|
||||
@@ -4567,11 +4647,11 @@ function formatQuantity(qty, unit, defaultQty, packageUnit) {
|
||||
const fractionalConf = Math.round((n - wholeConf) * 1000) / 1000;
|
||||
|
||||
if (fractionalConf < 0.01) {
|
||||
return `${wholeConf} conf <span class="conf-size-info">(da ${defaultQty}${pkgLabel})</span>`;
|
||||
return `${wholeConf} ${t('units.conf') || 'conf'} <span class="conf-size-info">(${t('units.from') || 'da'} ${defaultQty}${pkgLabel})</span>`;
|
||||
}
|
||||
const remainderText = formatSubRemainder(fractionalConf * defaultQty, packageUnit);
|
||||
if (wholeConf > 0) {
|
||||
return `${wholeConf} conf <span class="conf-size-info">(da ${defaultQty}${pkgLabel})</span> + ${remainderText}`;
|
||||
return `${wholeConf} ${t('units.conf') || 'conf'} <span class="conf-size-info">(${t('units.from') || 'da'} ${defaultQty}${pkgLabel})</span> + ${remainderText}`;
|
||||
}
|
||||
return remainderText;
|
||||
}
|
||||
@@ -4587,8 +4667,8 @@ function formatQuantity(qty, unit, defaultQty, packageUnit) {
|
||||
// Returns { mainQty: '10', unitLabel: 'conf', packageDetail: 'da 36g', fraction: '¼' }
|
||||
function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
|
||||
const n = parseFloat(qty) || 0;
|
||||
const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' };
|
||||
const label = unitLabels[unit] || unit || 'pz';
|
||||
const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.conf') };
|
||||
const label = unitLabels[unit] || unit || t('units.pz');
|
||||
|
||||
// Special handling for conf with partial packages
|
||||
if (unit === 'conf' && packageUnit && defaultQty > 0) {
|
||||
@@ -4597,11 +4677,11 @@ function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
|
||||
const fractionalConf = Math.round((n - wholeConf) * 1000) / 1000;
|
||||
|
||||
if (fractionalConf < 0.01) {
|
||||
return { mainQty: `${wholeConf}`, unitLabel: 'conf', packageDetail: `da ${defaultQty}${pkgLabel}`, fraction: '' };
|
||||
return { mainQty: `${wholeConf}`, unitLabel: t('units.conf') || 'conf', packageDetail: `${t('units.from') || 'da'} ${defaultQty}${pkgLabel}`, fraction: '' };
|
||||
}
|
||||
const remainderText = formatSubRemainder(fractionalConf * defaultQty, packageUnit);
|
||||
if (wholeConf > 0) {
|
||||
return { mainQty: `${wholeConf}`, unitLabel: 'conf', packageDetail: `da ${defaultQty}${pkgLabel}`, fraction: `+ ${remainderText}` };
|
||||
return { mainQty: `${wholeConf}`, unitLabel: t('units.conf') || 'conf', packageDetail: `${t('units.from') || 'da'} ${defaultQty}${pkgLabel}`, fraction: `+ ${remainderText}` };
|
||||
}
|
||||
return { mainQty: remainderText, unitLabel: '', packageDetail: '', fraction: '' };
|
||||
}
|
||||
@@ -4898,9 +4978,9 @@ function showItemDetail(inventoryId, productId) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-danger flex-1" onclick="quickUse(${item.product_id}, '${item.location}')">📤 Usa</button>
|
||||
<button class="btn btn-primary flex-1" onclick="editInventoryItem(${inventoryId})">✏️ Modifica</button>
|
||||
<button class="btn btn-accent flex-1" onclick="closeModal();generateRecipeForIngredient(${JSON.stringify(item.name)})">🍳 Ricetta</button>
|
||||
<button class="btn btn-danger flex-1" onclick="quickUse(${item.product_id}, '${item.location}')">📤 ${t('btn.use')}</button>
|
||||
<button class="btn btn-primary flex-1" onclick="editInventoryItem(${inventoryId})">✏️ ${t('btn.edit_item')}</button>
|
||||
<button class="btn btn-accent flex-1" data-name="${escapeHtml(item.name)}" onclick="closeModal();generateRecipeForIngredient(this.dataset.name)">🍳 ${t('action.create_recipe_btn')}</button>
|
||||
<button class="btn btn-secondary" onclick="deleteInventoryItem(${inventoryId})" style="padding:12px">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -4910,8 +4990,8 @@ function showItemDetail(inventoryId, productId) {
|
||||
function closeModal() {
|
||||
document.getElementById('modal-overlay').style.display = 'none';
|
||||
clearMoveModalTimer();
|
||||
// Native kiosk settings button is permanently replaced by the web overlay button — keep hidden.
|
||||
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {}
|
||||
// Restore the native kiosk settings button when the modal closes.
|
||||
try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(true); } catch (_) {}
|
||||
_cancelScaleAutoConfirm(false);
|
||||
_scaleRecipeAutoFillPaused = false;
|
||||
_scaleUserDismissed = false;
|
||||
@@ -5011,7 +5091,7 @@ function editInventoryItem(id) {
|
||||
// Rebuild modal content for editing (don't close and reopen - just replace content)
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>Modifica ${escapeHtml(item.name)}</h3>
|
||||
<h3>${t('edit.title').replace('{name}', escapeHtml(item.name))}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<form class="form" onsubmit="submitEditInventory(event, ${id}, ${item.product_id})">
|
||||
@@ -5032,15 +5112,15 @@ function editInventoryItem(id) {
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>📏 Unità di misura</label>
|
||||
<label>${t('product.unit_label')}</label>
|
||||
<select id="edit-unit" class="form-input" onchange="onEditUnitChange()">
|
||||
${['pz','g','ml','conf'].map(u => `<option value="${u}" ${(item.unit||'pz') === u ? 'selected' : ''}>${u === 'pz' ? 'pz (pezzi)' : u === 'g' ? 'g (grammi)' : u === 'ml' ? 'ml (millilitri)' : u === 'conf' ? 'conf (confezioni)' : u}</option>`).join('')}
|
||||
${['pz','g','ml','conf'].map(u => `<option value="${u}" ${(item.unit||'pz') === u ? 'selected' : ''}>${u === 'pz' ? 'pz (' + t('units.pieces') + ')' : u === 'g' ? 'g (' + t('units.grams') + ')' : u === 'ml' ? 'ml (' + t('units.millilitres') + ')' : u === 'conf' ? 'conf (' + t('units.boxes') + ')' : u}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="edit-conf-size-group" style="display:${isConf ? 'block' : 'none'}">
|
||||
<label>📦 Ogni confezione contiene:</label>
|
||||
<label>${t('product.conf_size_label')}</label>
|
||||
<div class="conf-size-inputs">
|
||||
<input type="number" id="edit-conf-size" class="form-input conf-size-input" min="1" step="any" value="${confSizeVal}" placeholder="es. 300">
|
||||
<input type="number" id="edit-conf-size" class="form-input conf-size-input" min="1" step="any" value="${confSizeVal}" placeholder="${t('product.conf_size_placeholder')}">
|
||||
<select id="edit-conf-unit" class="form-input conf-size-unit">
|
||||
${['g','ml'].map(u => `<option value="${u}" ${confUnitVal === u ? 'selected' : ''}>${u}</option>`).join('')}
|
||||
</select>
|
||||
@@ -5103,7 +5183,7 @@ async function submitEditInventory(e, id, productId) {
|
||||
|
||||
await api('inventory_update', {}, 'POST', payload);
|
||||
closeModal();
|
||||
showToast('Aggiornato!', 'success');
|
||||
showToast(t('toast.updated'), 'success');
|
||||
if (_bannerEditPending) {
|
||||
_bannerEditPending = false;
|
||||
// Mark the item as confirmed so it does NOT reappear in the banner
|
||||
@@ -5581,12 +5661,12 @@ async function onBarcodeDetected(barcode) {
|
||||
|
||||
// Build rich notes with all available info
|
||||
const notesParts = [];
|
||||
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
|
||||
if (p.quantity_info) notesParts.push(`${t('product.weight_label')}: ${p.quantity_info}`);
|
||||
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
|
||||
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
|
||||
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
|
||||
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
|
||||
if (p.labels) notesParts.push(`Etichette: ${p.labels}`);
|
||||
if (p.origin) notesParts.push(`${t('product.origin_label')}: ${p.origin}`);
|
||||
if (p.labels) notesParts.push(`${t('product.labels_label')}: ${p.labels}`);
|
||||
|
||||
// Save to local DB
|
||||
const saveResult = await api('product_save', {}, 'POST', {
|
||||
@@ -6227,7 +6307,7 @@ function showProductAction() {
|
||||
${currentProduct.weight_info ? `<p style="font-size:0.85rem;color:var(--text-light)">⚖️ ${escapeHtml(currentProduct.weight_info)}</p>` : ''}
|
||||
${currentProduct.barcode ? `<p style="font-size:0.75rem;color:var(--text-muted)">📊 ${currentProduct.barcode}</p>` : ''}
|
||||
</div>
|
||||
<button type="button" class="btn-edit-inline" onclick="toggleActionEdit()" title="Modifica nome/marca">✏️</button>
|
||||
<button type="button" class="btn-edit-inline" onclick="toggleActionEdit()" title="${t('product.edit_name_brand')}">✏️</button>
|
||||
`;
|
||||
|
||||
// Check if product needs editing (unknown name, missing info)
|
||||
@@ -6251,7 +6331,7 @@ function showProductAction() {
|
||||
|
||||
editInfoEl.innerHTML = `
|
||||
<div class="edit-unknown-card ${isUnknown ? 'highlight' : ''}">
|
||||
<h4>${isUnknown ? '⚠️ Prodotto non riconosciuto' : '✏️ Modifica informazioni'}</h4>
|
||||
<h4>${isUnknown ? '⚠️ ' + t('product.unknown_product') : '✏️ ' + t('product.edit_info')}</h4>
|
||||
${isUnknown ? '<p class="edit-unknown-hint">Inserisci il nome e le informazioni del prodotto</p>' : ''}
|
||||
<div class="edit-unknown-form">
|
||||
<div class="form-group">
|
||||
@@ -6259,13 +6339,13 @@ function showProductAction() {
|
||||
<input type="text" id="edit-action-name" class="form-input" value="${escapeHtml(isUnknown ? '' : currentProduct.name)}" placeholder="Es: Latte intero, Pasta penne..." required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>🏪 Marca</label>
|
||||
<label>${t('product.brand_label')}</label>
|
||||
<input type="text" id="edit-action-brand" class="form-input" value="${escapeHtml(currentProduct.brand || '')}" placeholder="Es: Barilla, Mulino Bianco...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>📂 Categoria</label>
|
||||
<label>${t('product.category_label')}</label>
|
||||
<select id="edit-action-category" class="form-input">
|
||||
<option value="">-- Seleziona --</option>
|
||||
<option value="">${t('form.select_placeholder')}</option>
|
||||
${categoryOptions}
|
||||
</select>
|
||||
</div>
|
||||
@@ -6365,7 +6445,7 @@ function showProductAction() {
|
||||
<span class="btn-icon">✏️</span>
|
||||
<span class="btn-text">${t('product.modify_details')}<br><small>${t('action.edit_sub')}</small></span>
|
||||
</button>
|
||||
<button class="btn btn-recipe-from-ingredient" onclick="generateRecipeForIngredient(${JSON.stringify(currentProduct.name)})">
|
||||
<button class="btn btn-recipe-from-ingredient" data-name="${escapeHtml(currentProduct.name)}" onclick="generateRecipeForIngredient(this.dataset.name)">
|
||||
👨🍳 ${t('action.create_recipe_btn') || 'Crea una ricetta'}
|
||||
</button>
|
||||
`;
|
||||
@@ -6377,7 +6457,7 @@ function showProductAction() {
|
||||
catalogLink.style.cssText = 'text-align:center;margin-top:6px';
|
||||
btnsContainer.after(catalogLink);
|
||||
}
|
||||
catalogLink.innerHTML = `<button type="button" class="btn-link-small" onclick="editProductFromAction()">⚙️ Modifica scheda prodotto (nome, marca, categoria…)</button>`;
|
||||
catalogLink.innerHTML = `<button type="button" class="btn-link-small" onclick="editProductFromAction()">${t('product.edit_catalog')}</button>`;
|
||||
} else {
|
||||
// Product NOT in inventory - show only AGGIUNGI
|
||||
statusBar.style.display = 'none';
|
||||
@@ -6481,7 +6561,7 @@ function editProductFromAction() {
|
||||
function openInventoryEdit() {
|
||||
const items = _actionInventoryItems;
|
||||
if (!items || items.length === 0) {
|
||||
showToast('Nessuna voce di inventario trovata', 'error');
|
||||
showToast(t('error.no_inventory_entry') || 'Nessuna voce di inventario trovata', 'error');
|
||||
return;
|
||||
}
|
||||
if (items.length === 1) {
|
||||
@@ -6492,10 +6572,10 @@ function openInventoryEdit() {
|
||||
const contentEl = document.getElementById('modal-content');
|
||||
contentEl.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>✏️ Quale modifica?</h3>
|
||||
<h3>✏️ ${t('edit.choose_location_title')}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<p style="font-size:0.9rem;color:var(--text-muted);margin:0 0 12px">Scegli la posizione da modificare:</p>
|
||||
<p style="font-size:0.9rem;color:var(--text-muted);margin:0 0 12px">${t('edit.choose_location_hint')}</p>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
${items.map(inv => {
|
||||
const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location };
|
||||
@@ -6530,7 +6610,7 @@ function editActionInventoryItem(inventoryId) {
|
||||
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>Modifica ${escapeHtml(item.name || currentProduct.name)}</h3>
|
||||
<h3>${t('edit.title').replace('{name}', escapeHtml(item.name || currentProduct.name))}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<form class="form" onsubmit="submitActionEditInventory(event, ${inventoryId}, ${item.product_id})">
|
||||
@@ -6545,13 +6625,13 @@ function editActionInventoryItem(inventoryId) {
|
||||
<div class="form-group">
|
||||
<label>${t('product.unit_label')}</label>
|
||||
<select id="action-edit-unit" class="form-input" onchange="onActionEditUnitChange()">
|
||||
${['pz','g','ml','conf'].map(u => `<option value="${u}" ${(item.unit||'pz') === u ? 'selected' : ''}>${u === 'pz' ? 'pz (pezzi)' : u === 'g' ? 'g (grammi)' : u === 'ml' ? 'ml (millilitri)' : u === 'conf' ? 'conf (confezioni)' : u}</option>`).join('')}
|
||||
${['pz','g','ml','conf'].map(u => `<option value="${u}" ${(item.unit||'pz') === u ? 'selected' : ''}>${u === 'pz' ? 'pz (' + t('units.pieces') + ')' : u === 'g' ? 'g (' + t('units.grams') + ')' : u === 'ml' ? 'ml (' + t('units.millilitres') + ')' : u === 'conf' ? 'conf (' + t('units.boxes') + ')' : u}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="action-edit-conf-group" style="display:${isConf ? 'block' : 'none'}">
|
||||
<label>📦 Ogni confezione contiene:</label>
|
||||
<label>${t('product.conf_size_label')}</label>
|
||||
<div class="conf-size-inputs">
|
||||
<input type="number" id="action-edit-conf-size" class="form-input conf-size-input" min="1" step="any" value="${confSizeVal}" placeholder="es. 300">
|
||||
<input type="number" id="action-edit-conf-size" class="form-input conf-size-input" min="1" step="any" value="${confSizeVal}" placeholder="${t('product.conf_size_placeholder')}">
|
||||
<select id="action-edit-conf-unit" class="form-input conf-size-unit">
|
||||
${['g','ml'].map(u => `<option value="${u}" ${confUnitVal === u ? 'selected' : ''}>${u}</option>`).join('')}
|
||||
</select>
|
||||
@@ -6615,7 +6695,7 @@ async function submitActionEditInventory(e, id, productId) {
|
||||
|
||||
await api('inventory_update', {}, 'POST', payload);
|
||||
closeModal();
|
||||
showToast('Aggiornato!', 'success');
|
||||
showToast(t('toast.updated'), 'success');
|
||||
showProductAction(); // Refresh the action page
|
||||
}
|
||||
|
||||
@@ -6794,7 +6874,7 @@ async function throwAll() {
|
||||
showToast(t('toast.thrown_away', { name: currentProduct.name }), 'success');
|
||||
showPage('dashboard');
|
||||
} else {
|
||||
showToast(result.error || 'Errore', 'error');
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
showLoading(false);
|
||||
@@ -6822,7 +6902,7 @@ async function throwPartial() {
|
||||
showToast(t('toast.thrown_away_partial', { qty, unit: currentProduct.unit || 'pz', name: currentProduct.name }), 'success');
|
||||
showPage('dashboard');
|
||||
} else {
|
||||
showToast(result.error || 'Errore', 'error');
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
showLoading(false);
|
||||
@@ -7040,7 +7120,7 @@ function recalculateAddExpiry() {
|
||||
if (window._historyExpiryDays) suffix = ' (da storico)';
|
||||
else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum');
|
||||
else if (loc === 'freezer') suffix = ' (freezer)';
|
||||
else if (isVacuum) suffix = ' (sotto vuoto)';
|
||||
else if (isVacuum) suffix = ' ' + t('add.suffix_vacuum');
|
||||
|
||||
const expiryInput = document.getElementById('add-expiry');
|
||||
const estimateEl = document.querySelector('.expiry-estimate-label');
|
||||
@@ -7288,12 +7368,12 @@ function selectPurchaseType(btn, type) {
|
||||
} else {
|
||||
detailDiv.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>📅 Quando scade?</label>
|
||||
<label>${t('inventory.label_expiry')}</label>
|
||||
<div class="expiry-input-row">
|
||||
<input type="date" id="add-expiry" class="form-input" value="">
|
||||
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="${t('add.scan_expiry_title')}">📷</button>
|
||||
</div>
|
||||
<p class="form-hint">Inserisci la data di scadenza o scansionala</p>
|
||||
<p class="form-hint">${t('add.expiry_hint')}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t('add.remaining_label')}</label>
|
||||
@@ -7414,7 +7494,7 @@ async function submitAdd(e) {
|
||||
let qtyInfo = '';
|
||||
if (result.total_qty) {
|
||||
const u = result.unit || 'pz';
|
||||
const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' };
|
||||
const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.conf') };
|
||||
const uLabel = unitLabels[u] || u;
|
||||
if (u === 'conf' && result.package_unit && result.default_quantity > 0) {
|
||||
const pkgLabel = unitLabels[result.package_unit] || result.package_unit;
|
||||
@@ -7467,7 +7547,7 @@ async function submitAdd(e) {
|
||||
window._addExtraBatches = [];
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || 'Errore', 'error');
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showLoading(false);
|
||||
@@ -8002,9 +8082,9 @@ function _showVacuumPrompt(openedId, wasVacuumSealed) {
|
||||
'width:calc(100% - 32px)', 'box-sizing:border-box', 'overflow:hidden'
|
||||
].join(';');
|
||||
bar.innerHTML = `
|
||||
<span style="flex:1;font-size:0.9rem;line-height:1.3">🔒 Messo <b>sotto vuoto</b>?</span>
|
||||
<button id="_vac-yes" style="background:#22c55e;color:#fff;border:none;border-radius:8px;padding:7px 14px;font-weight:700;cursor:pointer;white-space:nowrap">Sì</button>
|
||||
<button id="_vac-no" style="background:#475569;color:#fff;border:none;border-radius:8px;padding:7px 14px;font-weight:700;cursor:pointer;white-space:nowrap">No</button>
|
||||
<span style="flex:1;font-size:0.9rem;line-height:1.3">${t('add.vacuum_question')}</span>
|
||||
<button id="_vac-yes" style="background:#22c55e;color:#fff;border:none;border-radius:8px;padding:7px 14px;font-weight:700;cursor:pointer;white-space:nowrap">${t('btn.yes_short')}</button>
|
||||
<button id="_vac-no" style="background:#475569;color:#fff;border:none;border-radius:8px;padding:7px 14px;font-weight:700;cursor:pointer;white-space:nowrap">${t('btn.no_short')}</button>
|
||||
<div id="_vac-bar" style="position:absolute;bottom:0;left:0;height:3px;background:#60a5fa;border-radius:0;width:100%"></div>
|
||||
`;
|
||||
document.body.appendChild(bar);
|
||||
@@ -8020,7 +8100,7 @@ function _showVacuumPrompt(openedId, wasVacuumSealed) {
|
||||
if (rafH) cancelAnimationFrame(rafH);
|
||||
bar.remove();
|
||||
api('inventory_update', {}, 'POST', { id: openedId, vacuum_sealed: vacuum ? 1 : 0 })
|
||||
.then(() => { if (vacuum) showToast('🔒 Sotto vuoto registrato', 'success'); })
|
||||
.then(() => { if (vacuum) showToast(t('add.vacuum_saved'), 'success'); })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -8242,7 +8322,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
|
||||
const vacuumRow = `
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
|
||||
<input type="checkbox" id="move-vacuum-check" ${wasVacuum ? 'checked' : ''}>
|
||||
<span>🔒 Metti <b>sotto vuoto</b> il resto${wasVacuum ? ' (era già sigillato)' : ''}</span>
|
||||
<span>${t('move.vacuum_seal_rest')}${wasVacuum ? ' ' + t('move.was_sealed') : ''}</span>
|
||||
</label>`;
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
@@ -8274,7 +8354,7 @@ async function _saveVacuumAndStay(openedId) {
|
||||
const isVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
|
||||
try {
|
||||
await api('inventory_update', {}, 'POST', { id: openedId, vacuum_sealed: isVacuum });
|
||||
if (isVacuum) showToast('🔒 Sotto vuoto registrato', 'success');
|
||||
if (isVacuum) showToast(t('add.vacuum_saved'), 'success');
|
||||
} catch (_) {}
|
||||
}
|
||||
showPage('dashboard');
|
||||
@@ -8368,7 +8448,7 @@ async function _doSubmitUseAll() {
|
||||
}
|
||||
showLowStockBringPrompt(result, () => showPage('dashboard'));
|
||||
} else {
|
||||
showToast(result.error || 'Errore', 'error');
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showLoading(false);
|
||||
@@ -8454,7 +8534,7 @@ async function _submitUseAllAt(location, isOpenedOnly) {
|
||||
}
|
||||
showLowStockBringPrompt(result, () => showPage('dashboard'));
|
||||
} else {
|
||||
showToast(result.error || 'Errore', 'error');
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showLoading(false);
|
||||
@@ -8523,7 +8603,7 @@ async function submitUse(e) {
|
||||
} else if (result.duplicate) {
|
||||
// Silently ignore: this was a scale double-trigger, not a real error
|
||||
} else {
|
||||
showToast(result.error || 'Errore', 'error');
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showLoading(false);
|
||||
@@ -8613,7 +8693,7 @@ async function analyzeWithAI() {
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error === 'no_api_key') {
|
||||
resultDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
|
||||
resultDiv.innerHTML = `<p style="color:var(--warning)">${t('ai.no_api_key').replace(/\n/g, '<br>')}</p>`;
|
||||
} else if (/resource.?exhaust|quota|rate.?limit/i.test(result.error || '')) {
|
||||
resultDiv.innerHTML = `<p style="color:var(--warning)">⏳ ${t('error.ai_quota')}</p>
|
||||
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">${t('btn.retry')}</button>`;
|
||||
@@ -8750,11 +8830,11 @@ async function selectAIMatch(idx) {
|
||||
const detected = detectUnitAndQuantity(p.quantity_info);
|
||||
|
||||
const notesParts = [];
|
||||
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
|
||||
if (p.quantity_info) notesParts.push(`${t('product.weight_label')}: ${p.quantity_info}`);
|
||||
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
|
||||
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
|
||||
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
|
||||
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
|
||||
if (p.origin) notesParts.push(`${t('product.origin_label')}: ${p.origin}`);
|
||||
|
||||
const saveResult = await api('product_save', {}, 'POST', {
|
||||
barcode: match.barcode,
|
||||
@@ -8960,7 +9040,7 @@ async function _pfAiAnalyze(base64) {
|
||||
html += `</div>`;
|
||||
|
||||
if (matches.length > 0) {
|
||||
html += `<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:6px">Seleziona la variante esatta o usa i dati AI:</p>`;
|
||||
html += `<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:6px">${t('product.select_variant')}</p>`;
|
||||
html += `<div class="ai-matches-list" style="max-height:160px;overflow-y:auto;margin-bottom:10px">`;
|
||||
matches.forEach((m, idx) => {
|
||||
html += `<div class="ai-match-item" onclick="_pfAiFillFromMatch(${idx})">`;
|
||||
@@ -9104,7 +9184,7 @@ async function selectProductForAction(productId) {
|
||||
}
|
||||
} catch (err) {
|
||||
showLoading(false);
|
||||
showToast('Errore', 'error');
|
||||
showToast(t('error.generic'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10084,10 +10164,10 @@ async function migrateBringNames(btn) {
|
||||
showToast(t('shopping.names_already_updated'), 'info');
|
||||
}
|
||||
} else {
|
||||
if (statusEl) statusEl.textContent = '❌ ' + (data.error || 'Errore');
|
||||
if (statusEl) statusEl.textContent = '❌ ' + (data.error || t('error.unknown'));
|
||||
}
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.textContent = '❌ Errore di connessione';
|
||||
if (statusEl) statusEl.textContent = '❌ ' + t('scale.error_connect');
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
@@ -10144,7 +10224,7 @@ async function addSmartToBring() {
|
||||
// Reload to refresh badges
|
||||
loadShoppingList();
|
||||
} else {
|
||||
showToast(result.error || 'Errore', 'error');
|
||||
showToast(result.error || t('error.generic'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showLoading(false);
|
||||
@@ -10917,7 +10997,7 @@ async function analyzeExpiryImage(dataUrl) {
|
||||
// Close modal after delay
|
||||
setTimeout(() => closeExpiryScanner(), 1500);
|
||||
} else if (result.error === 'no_api_key') {
|
||||
statusDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
|
||||
statusDiv.innerHTML = `<p style="color:var(--warning)">${t('ai.no_api_key').replace(/\n/g, '<br>')}</p>`;
|
||||
} else {
|
||||
statusDiv.innerHTML = `<p style="color:var(--danger)">❌ Non riesco a leggere la data. ${result.raw_text ? '<br><small>Letto: ' + escapeHtml(result.raw_text) + '</small>' : ''}</p>
|
||||
<button class="btn btn-secondary" onclick="retakeExpiry()" style="margin-top:8px">${t('btn.retry')}</button>`;
|
||||
@@ -10991,7 +11071,7 @@ const LOG_PAGE_SIZE = 50;
|
||||
async function loadLog(more = false) {
|
||||
if (!more) {
|
||||
_logOffset = 0;
|
||||
document.getElementById('log-list').innerHTML = '<p style="text-align:center;color:var(--text-muted)">Caricamento...</p>';
|
||||
document.getElementById('log-list').innerHTML = '<p style="text-align:center;color:var(--text-muted)">' + t('loading') + '</p>';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -11642,7 +11722,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
||||
|
||||
// Build quantity controls
|
||||
let qtySection = '';
|
||||
let defaultQtyValue = qtyNumber;
|
||||
let defaultQtyValue = Math.round(qtyNumber * 10) / 10;
|
||||
|
||||
if (isConf) {
|
||||
const totalConf = items.reduce((s, i) => s + parseFloat(i.quantity), 0);
|
||||
@@ -11653,7 +11733,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
||||
|
||||
// qtyNumber from recipe is in sub-units (g, ml)
|
||||
const step = getSubUnitStep(pkgUnit);
|
||||
defaultQtyValue = qtyNumber;
|
||||
defaultQtyValue = (pkgUnit === 'g' || pkgUnit === 'ml') ? Math.round(qtyNumber) : Math.round(qtyNumber * 10) / 10;
|
||||
|
||||
qtySection = `
|
||||
<div class="use-unit-switch" style="display:flex;margin-bottom:8px">
|
||||
@@ -11669,7 +11749,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
||||
</div>`;
|
||||
} else {
|
||||
_recipeUseNormalUnit = unit;
|
||||
const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml' };
|
||||
const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml' };
|
||||
const unitLabel = unitLabels[unit] || unit;
|
||||
const inputMin = '0.1';
|
||||
qtySection = `
|
||||
@@ -11881,7 +11961,7 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)
|
||||
const vacuumRow = `
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
|
||||
<input type="checkbox" id="move-vacuum-check" ${wasVacuum ? 'checked' : ''}>
|
||||
<span>${wasVacuum ? t('move.vacuum_restore') : '🔒 Metti <b>sotto vuoto</b> il resto'}</span>
|
||||
<span>${wasVacuum ? t('move.vacuum_restore') : t('move.vacuum_seal_rest')}</span>
|
||||
</label>`;
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
@@ -11996,7 +12076,7 @@ function renderRecipe(r) {
|
||||
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
|
||||
(r.ingredients || []).forEach((ing, idx) => {
|
||||
if (ing.from_pantry && ing.product_id) {
|
||||
const qtyNum = ing.qty_number || 0;
|
||||
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
|
||||
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
|
||||
const alreadyUsed = ing.used === true;
|
||||
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}">`;
|
||||
@@ -12358,7 +12438,7 @@ function renderCookingStep() {
|
||||
const cookingLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
||||
ingsEl.innerHTML = ings.map(ing => {
|
||||
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
|
||||
const qtyNum = ing.qty_number || 0;
|
||||
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
|
||||
// Build info chips: brand, location, expiry
|
||||
const chips = [];
|
||||
if (ing.brand) chips.push(`<span class="cooking-ing-chip">${escapeHtml(ing.brand)}</span>`);
|
||||
@@ -12486,12 +12566,12 @@ function _initBrowserTtsVoices(selectedVoice) {
|
||||
}
|
||||
|
||||
if (!window.speechSynthesis) {
|
||||
sel.innerHTML = '<option value="">— Voce non supportata dal browser —</option>';
|
||||
sel.innerHTML = `<option value="">— ${t('settings.tts.voice_not_supported')} —</option>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset to loading state each time (settings page may be re-opened)
|
||||
sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
|
||||
sel.innerHTML = `<option value="">— ${t('settings.tts.voices_loading')} —</option>`;
|
||||
|
||||
const populate = () => {
|
||||
let voices = [];
|
||||
@@ -12531,7 +12611,7 @@ function _initBrowserTtsVoices(selectedVoice) {
|
||||
} else if (tries >= 50) { // 50 × 200ms = 10s
|
||||
clearInterval(interval);
|
||||
if (!window.speechSynthesis.getVoices().length) {
|
||||
sel.innerHTML = '<option value="">— Nessuna voce disponibile su questo dispositivo —</option>';
|
||||
sel.innerHTML = `<option value="">— ${t('settings.tts.voices_none')} —</option>`;
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
@@ -12617,23 +12697,23 @@ async function testTTS() {
|
||||
tts_extra_fields: document.getElementById('setting-tts-extra-fields')?.value || ''
|
||||
};
|
||||
if (!formSettings.tts_url) {
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ URL endpoint mancante.'; }
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = t('settings.tts.url_missing'); }
|
||||
return;
|
||||
}
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Invio in corso…'; }
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = t('settings.tts.test_sending'); }
|
||||
try {
|
||||
const req = _buildTtsRequest('Test vocale EverShelf', formSettings);
|
||||
const res = await _ttsViaProxy(req);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const httpCode = data.status || res.status;
|
||||
if (res.ok && httpCode >= 200 && httpCode < 300) {
|
||||
if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ Risposta ${httpCode} — controlla che l'altoparlante abbia parlato.`; }
|
||||
if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = t('settings.tts.test_ok').replace('{code}', httpCode); }
|
||||
} else {
|
||||
const errDetail = data.error || data.body || res.statusText;
|
||||
if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `⚠️ HTTP ${httpCode}: ${errDetail}`; }
|
||||
}
|
||||
} catch(e) {
|
||||
if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ Errore: ${e.message}`; }
|
||||
if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${t('error.prefix')}: ${e.message}`; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12745,7 +12825,7 @@ function _extractTimerLabel(text, stepNum) {
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!beforeTime) return `Passo ${stepNum + 1}`;
|
||||
if (!beforeTime) return t('cooking.step_fallback').replace('{n}', stepNum + 1);
|
||||
|
||||
const actionRules = [
|
||||
{ re: /\b(rosolatur\w*|rosola\w*|soffrigg\w*)\b/i, label: 'Rosolatura' },
|
||||
@@ -12795,11 +12875,11 @@ function _extractTimerLabel(text, stepNum) {
|
||||
.filter(w => w.length > 2 && !/^\d+$/.test(w) && !fillers.has(w) && !applianceWords.has(w))
|
||||
.slice(0, 3)
|
||||
.join(' ');
|
||||
label = fallback || `Passo ${stepNum + 1}`;
|
||||
label = fallback || t('cooking.step_fallback').replace('{n}', stepNum + 1);
|
||||
}
|
||||
|
||||
label = label.replace(/\s+/g, ' ').trim();
|
||||
if (!label) return `Passo ${stepNum + 1}`;
|
||||
if (!label) return t('cooking.step_fallback').replace('{n}', stepNum + 1);
|
||||
|
||||
// Keep timer chips compact and readable.
|
||||
const maxLen = 30;
|
||||
@@ -13314,7 +13394,7 @@ async function chatTransferToRecipes(btn, replyText) {
|
||||
});
|
||||
if (!result || !result.success || !result.recipe) {
|
||||
resetBtn();
|
||||
showToast('⚠️ ' + (result?.error || t('error.generic') || 'Errore'), 'error');
|
||||
showToast('⚠️ ' + (result?.error || t('error.generic') || t('error.generic')), 'error');
|
||||
return;
|
||||
}
|
||||
const recipe = result.recipe;
|
||||
@@ -13337,7 +13417,7 @@ async function chatTransferToRecipes(btn, replyText) {
|
||||
} catch (err) {
|
||||
console.error('[chatTransferToRecipes]', err);
|
||||
resetBtn();
|
||||
showToast('⚠️ ' + (err.message || t('error.connection') || 'Errore di connessione'), 'error');
|
||||
showToast('⚠️ ' + (err.message || t('error.connection')), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13350,10 +13430,10 @@ async function generateRecipeForIngredient(ingredientName) {
|
||||
const loadingMsg = document.getElementById('recipe-loading-msg');
|
||||
if (loadingMsg) loadingMsg.textContent = '👨🍳 ' + (t('recipes.loading_msg') || 'Sto preparando la ricetta...');
|
||||
try {
|
||||
const result = await api('recipe_from_ingredient', {}, 'POST', { ingredient: ingredientName });
|
||||
const result = await api('recipe_from_ingredient', {}, 'POST', { ingredient: ingredientName, lang: _currentLang });
|
||||
if (!result || !result.success || !result.recipe) {
|
||||
document.getElementById('recipe-overlay').style.display = 'none';
|
||||
showToast('⚠️ ' + (result?.error || t('error.generic') || 'Errore'), 'error');
|
||||
showToast('⚠️ ' + (result?.error || t('error.generic') || t('error.generic')), 'error');
|
||||
return;
|
||||
}
|
||||
const recipe = result.recipe;
|
||||
@@ -13367,7 +13447,7 @@ async function generateRecipeForIngredient(ingredientName) {
|
||||
} catch (err) {
|
||||
console.error('[generateRecipeForIngredient]', err);
|
||||
document.getElementById('recipe-overlay').style.display = 'none';
|
||||
showToast('⚠️ ' + (t('error.connection') || 'Errore di connessione'), 'error');
|
||||
showToast('⚠️ ' + t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13401,7 +13481,8 @@ async function sendChatMessage() {
|
||||
message: text,
|
||||
history: chatHistory.slice(0, -1).slice(-20), // last 20 messages for context
|
||||
appliances: settings.appliances || [],
|
||||
dietary_restrictions: settings.dietary_restrictions || ''
|
||||
dietary_restrictions: settings.dietary_restrictions || '',
|
||||
lang: _currentLang
|
||||
});
|
||||
|
||||
// Remove typing indicator
|
||||
@@ -13422,7 +13503,7 @@ async function sendChatMessage() {
|
||||
scrollChatBottom();
|
||||
}
|
||||
} else {
|
||||
const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta');
|
||||
const errMsg = result.error === 'no_api_key' ? t('error.no_api_key') : (result.error || t('error.generic'));
|
||||
appendChatBubble('gemini', `⚠️ ${escapeHtml(errMsg)}`);
|
||||
}
|
||||
} catch(err) {
|
||||
@@ -13727,7 +13808,7 @@ function _renderScreensaverNutrition() {
|
||||
<div class="ss-pie3d" id="ss-pie-main" style="--pie-bg:${gradient}"></div>
|
||||
<div class="ss-nutr-chart-label">${t('nutrition.products_n').replace('{n}', total)}</div>
|
||||
<div class="ss-nutr-legend">
|
||||
${top4.map(s => `<div class="ss-leg-row"><span style="background:${s.color}" class="ss-leg-dot"></span><span>${s.icon} ${s.cat}</span><span class="ss-leg-pct">${s.pct}%</span></div>`).join('')}
|
||||
${top4.map(s => `<div class="ss-leg-row"><span style="background:${s.color}" class="ss-leg-dot"></span><span>${s.icon} ${t('categories.' + s.cat) || s.cat}</span><span class="ss-leg-pct">${s.pct}%</span></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Score donuts -->
|
||||
@@ -14244,6 +14325,7 @@ function initInactivityWatcher() {
|
||||
}
|
||||
|
||||
// ===== INITIALIZATION =====
|
||||
const _splashStart = Date.now();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load translations first, then initialize the app
|
||||
loadTranslations(_currentLang).then(() => {
|
||||
@@ -14572,11 +14654,22 @@ async function _initApp() {
|
||||
startHeartbeat();
|
||||
_injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView)
|
||||
|
||||
// Hide preloader once the dashboard is rendered
|
||||
// Sync version label in preloader (in case HTML is stale)
|
||||
const preloaderVer = document.getElementById('preloader-version');
|
||||
if (preloaderVer) {
|
||||
const ver = document.querySelector('.header-version')?.textContent?.trim() || '';
|
||||
if (ver) preloaderVer.textContent = ver;
|
||||
}
|
||||
|
||||
// Hide preloader — enforce minimum 3 s splash regardless of load speed
|
||||
const preloader = document.getElementById('app-preloader');
|
||||
if (preloader) {
|
||||
const elapsed = Date.now() - _splashStart;
|
||||
const minDelay = Math.max(0, 3000 - elapsed);
|
||||
setTimeout(() => {
|
||||
preloader.classList.add('fade-out');
|
||||
setTimeout(() => preloader.remove(), 380);
|
||||
}, minDelay);
|
||||
}
|
||||
|
||||
// Defer update check: fire 6 s after app is ready so it doesn't compete
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 14
|
||||
versionName = "1.7.13"
|
||||
versionCode = 15
|
||||
versionName = "1.7.14"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -515,6 +515,17 @@ class KioskActivity : AppCompatActivity() {
|
||||
btnSettings.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Open the native SettingsActivity from the webapp settings page.
|
||||
* Allows configuring server URL, BLE scale and screensaver without
|
||||
* the user having to find the native gear button.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun openNativeSettings() {
|
||||
runOnUiThread {
|
||||
startActivity(Intent(this@KioskActivity, SettingsActivity::class.java))
|
||||
}
|
||||
}
|
||||
}, "_kioskBridge")
|
||||
|
||||
val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# Build trigger: versionName 1.7.13 fix (8d87494)
|
||||
# Build trigger: TTS bridge fix (95389eb)
|
||||
# Build trigger: v1.7.14 with openNativeSettings fix (834d8ef)
|
||||
|
||||
+163
-151
@@ -11,7 +11,7 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260513a">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260516b">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<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 -->
|
||||
@@ -53,8 +53,9 @@
|
||||
<!-- ===== APP PRELOADER (hidden by JS once _initApp completes) ===== -->
|
||||
<div id="app-preloader" aria-hidden="true">
|
||||
<div class="app-preloader-inner">
|
||||
<div class="app-preloader-spinner"></div>
|
||||
<img src="assets/img/logo/logo.png" alt="EverShelf" class="app-preloader-logo" />
|
||||
<div class="app-preloader-spinner"></div>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.15</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +68,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.13</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.15</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -187,10 +188,10 @@
|
||||
</div>
|
||||
<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" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ Dispensa</button>
|
||||
<button class="tab" onclick="filterLocation('frigo')" data-loc="frigo">🧊 Frigo</button>
|
||||
<button class="tab" onclick="filterLocation('freezer')" data-loc="freezer">❄️ Freezer</button>
|
||||
<button class="tab" onclick="filterLocation('altro')" data-loc="altro">📦 Altro</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">🧊 <span data-i18n="locations.frigo">Frigo</span></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">📦 <span data-i18n="locations.altro">Altro</span></button>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()" data-i18n-placeholder="inventory.search_placeholder">
|
||||
@@ -325,11 +326,11 @@
|
||||
<div class="action-buttons" id="action-buttons-container">
|
||||
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
||||
<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 class="btn btn-huge btn-danger" onclick="showUseForm()">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -337,23 +338,23 @@
|
||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-add">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')">← Indietro</button>
|
||||
<h2>Aggiungi alla Dispensa</h2>
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="add-product-preview"></div>
|
||||
<form class="form" onsubmit="submitAdd(event)">
|
||||
<div class="form-group">
|
||||
<label>📍 Dove lo metti?</label>
|
||||
<label data-i18n="add.location_label">📍 Dove lo metti?</label>
|
||||
<div class="location-selector">
|
||||
<button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ Dispensa</button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 Frigo</button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ Freezer</button>
|
||||
<button type="button" class="loc-btn" onclick="selectLocation(this, 'altro')">📦 Altro</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')">🧊 <span data-i18n="locations.frigo">Frigo</span></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')">📦 <span data-i18n="locations.altro">Altro</span></button>
|
||||
</div>
|
||||
<input type="hidden" id="add-location" value="dispensa">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>📦 Quantità</label>
|
||||
<label data-i18n="add.quantity_label">📦 Quantità</label>
|
||||
<div class="qty-unit-row">
|
||||
<div class="qty-control flex-1">
|
||||
<button type="button" class="qty-btn" onclick="adjustAddQty(-1)">−</button>
|
||||
@@ -369,7 +370,7 @@
|
||||
</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>
|
||||
<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">
|
||||
<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">
|
||||
@@ -382,43 +383,43 @@
|
||||
</div>
|
||||
<div class="form-group" id="add-vacuum-group">
|
||||
<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">
|
||||
<input type="checkbox" id="add-vacuum-sealed" onchange="onVacuumSealedChange()">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</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 class="form-group" id="add-expiry-section">
|
||||
<!-- Populated dynamically by showAddForm() -->
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-use">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')">← Indietro</button>
|
||||
<h2>Usa / Consuma</h2>
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="use-product-preview"></div>
|
||||
<div class="use-inventory-info" id="use-inventory-info"></div>
|
||||
<div id="use-expiry-hint" style="display:none"></div>
|
||||
<form class="form" onsubmit="submitUse(event)">
|
||||
<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">
|
||||
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'frigo')">🧊 Frigo</button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'freezer')">❄️ Freezer</button>
|
||||
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'altro')">📦 Altro</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')">🧊 <span data-i18n="locations.frigo">Frigo</span></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')">📦 <span data-i18n="locations.altro">Altro</span></button>
|
||||
</div>
|
||||
<input type="hidden" id="use-location" value="dispensa">
|
||||
</div>
|
||||
<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>
|
||||
<!-- 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">
|
||||
@@ -431,21 +432,21 @@
|
||||
</div>
|
||||
<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" 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 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
|
||||
</button>
|
||||
<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">
|
||||
<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"
|
||||
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
||||
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
||||
</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>
|
||||
@@ -456,19 +457,19 @@
|
||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||
<section class="page" id="page-product-form">
|
||||
<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>
|
||||
</div>
|
||||
<form class="form" onsubmit="submitProduct(event)">
|
||||
<input type="hidden" id="pf-id">
|
||||
<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
|
||||
</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 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..."
|
||||
list="common-products" autocomplete="off">
|
||||
<datalist id="common-products">
|
||||
@@ -535,7 +536,7 @@
|
||||
</datalist>
|
||||
</div>
|
||||
<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..."
|
||||
list="common-brands" autocomplete="off">
|
||||
<datalist id="common-brands">
|
||||
@@ -575,9 +576,9 @@
|
||||
</datalist>
|
||||
</div>
|
||||
<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)">
|
||||
<option value="">-- Seleziona --</option>
|
||||
<option value="" data-i18n="form.select_placeholder">-- Seleziona --</option>
|
||||
<option value="latticini">🥛 Latticini</option>
|
||||
<option value="carne">🥩 Carne</option>
|
||||
<option value="pesce">🐟 Pesce</option>
|
||||
@@ -598,21 +599,21 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<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()">
|
||||
<option value="pz">Pezzi</option>
|
||||
<option value="g">Grammi</option>
|
||||
<option value="pz" data-i18n="units.pieces">Pezzi</option>
|
||||
<option value="g" data-i18n="units.grams">Grammi</option>
|
||||
<option value="ml">ml</option>
|
||||
<option value="conf">Confezione</option>
|
||||
<option value="conf" data-i18n="units.box">Confezione</option>
|
||||
</select>
|
||||
</div>
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
@@ -622,30 +623,30 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>🔖 Barcode</label>
|
||||
<label data-i18n="product.barcode_label">🔖 Barcode</label>
|
||||
<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>
|
||||
</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>
|
||||
<input type="hidden" id="pf-image">
|
||||
<div class="product-image-preview" id="pf-image-preview" style="display:none">
|
||||
<img id="pf-image-img" src="" alt="Product">
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||
<section class="page" id="page-products">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
|
||||
<h2>📦 Tutti i Prodotti</h2>
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="products-search" placeholder="🔍 Cerca prodotto..." oninput="searchAllProducts()">
|
||||
@@ -778,8 +779,8 @@
|
||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||
<section class="page" id="page-ai">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')">← Indietro</button>
|
||||
<h2>🤖 Identificazione AI</h2>
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||
</div>
|
||||
<div class="ai-container">
|
||||
<div class="ai-capture" id="ai-capture">
|
||||
@@ -790,15 +791,15 @@
|
||||
<img id="ai-image" src="" alt="Captured">
|
||||
</div>
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -838,9 +839,9 @@
|
||||
<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>
|
||||
<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...">
|
||||
<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>
|
||||
@@ -850,13 +851,13 @@
|
||||
<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>
|
||||
<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">
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<!-- Price Estimation Settings -->
|
||||
@@ -927,7 +928,7 @@
|
||||
<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>
|
||||
<div class="form-group">
|
||||
<label>👥 Persone predefinite</label>
|
||||
<label data-i18n="settings.recipe.persons_label">👥 Persone predefinite</label>
|
||||
<div class="qty-control">
|
||||
<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">
|
||||
@@ -935,18 +936,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<label class="checkbox-label"><input type="checkbox" id="setting-pref-veloce"> ⚡ Pasto Veloce</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-scadenze"> ⏰ Priorità Scadenze</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-opened"> 📦 Priorità Cose Aperte</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-veloce"> <span data-i18n="settings.recipe.fast">⚡ Pasto Veloce</span></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"> <span data-i18n="settings.recipe.expiry">⏰ Priorità Scadenze</span></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"> <span data-i18n="settings.recipe.opened">📦 Priorità Cose Aperte</span></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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -958,7 +959,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>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<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">
|
||||
<input type="checkbox" id="setting-meal-plan-enabled" onchange="onMealPlanEnabledChange(this)">
|
||||
<span class="toggle-slider"></span>
|
||||
@@ -969,15 +970,15 @@
|
||||
<div id="meal-plan-grid" class="mplan-grid"></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">
|
||||
<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 class="settings-hint" style="margin-top:10px">
|
||||
<div class="settings-hint" style="margin-top:10px" data-i18n-html="settings.mealplan.legend">
|
||||
🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -994,18 +995,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Forno')">🔥 Forno</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Microonde')">📡 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('Macchina del pane')">🍞 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('Planetaria')">🌀 Planetaria</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Vaporiera')">♨️ 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('Tostapane')">🍞 Tostapane</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="addApplianceQuick('Frullatore/Mixer')">🍹 Frullatore</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')" data-i18n="settings.appliances.microwave">📡 Microonde</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')" data-i18n="settings.appliances.bread_maker">🍞 Macchina pane</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')" data-i18n="settings.appliances.mixer">🌀 Planetaria</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')" data-i18n="settings.appliances.pressure_cooker">🫕 Pentola pressione</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')" data-i18n="settings.appliances.blender">🍹 Frullatore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1016,35 +1017,35 @@
|
||||
<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>
|
||||
<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">
|
||||
<option value="environment">📱 Posteriore (default)</option>
|
||||
<option value="user">🤳 Anteriore</option>
|
||||
<option value="environment" data-i18n="settings.camera.back">📱 Posteriore (default)</option>
|
||||
<option value="user" data-i18n="settings.camera.front">🤳 Anteriore</option>
|
||||
</select>
|
||||
<p class="settings-hint mt-2">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>
|
||||
<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()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Security Tab -->
|
||||
<div class="settings-panel" id="tab-security">
|
||||
<div class="settings-card">
|
||||
<h4>🔑 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>
|
||||
<h4 data-i18n="settings.security.token_title">🔑 Token Impostazioni</h4>
|
||||
<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">
|
||||
<label>Token di accesso</label>
|
||||
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')">👁️ Mostra/Nascondi</button>
|
||||
<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)" data-i18n-placeholder="settings.security.token_placeholder">
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
</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 class="settings-card">
|
||||
<h4>🔒 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>
|
||||
<h4 data-i18n="settings.security.title">🔒 Certificato HTTPS</h4>
|
||||
<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">
|
||||
<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 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>
|
||||
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>
|
||||
@@ -1067,7 +1068,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>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label class="toggle-row">
|
||||
<span>✅ Attiva TTS</span>
|
||||
<span data-i18n="settings.tts.enabled">✅ Attiva TTS</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-tts-enabled">
|
||||
<span class="toggle-slider"></span>
|
||||
@@ -1075,31 +1076,31 @@
|
||||
</label>
|
||||
</div>
|
||||
<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)">
|
||||
<option value="browser">🔇 Browser (offline, nessuna configurazione)</option>
|
||||
<option value="server">🌐 Server esterno (Home Assistant, API REST...)</option>
|
||||
<option value="browser" data-i18n="settings.tts.engine_browser">🔇 Browser (offline, nessuna configurazione)</option>
|
||||
<option value="server" data-i18n="settings.tts.engine_server">🌐 Server esterno (Home Assistant, API REST...)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Browser TTS section -->
|
||||
<div id="tts-browser-section">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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 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)">
|
||||
</div>
|
||||
<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)">
|
||||
</div>
|
||||
</div>
|
||||
@@ -1107,11 +1108,11 @@
|
||||
<!-- Server TTS section -->
|
||||
<div id="tts-server-section" style="display:none">
|
||||
<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://...">
|
||||
</div>
|
||||
<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">
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
@@ -1120,30 +1121,30 @@
|
||||
</select>
|
||||
</div>
|
||||
<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)">
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="header">Header personalizzato</option>
|
||||
<option value="none">Nessuna</option>
|
||||
<option value="bearer" data-i18n="settings.tts.auth_bearer">Bearer Token</option>
|
||||
<option value="header" data-i18n="settings.tts.auth_custom">Header personalizzato</option>
|
||||
<option value="none" data-i18n="settings.tts.auth_none">Nessuna</option>
|
||||
</select>
|
||||
</div>
|
||||
<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...">
|
||||
<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 id="tts-custom-header-group" style="display:none">
|
||||
<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">
|
||||
</div>
|
||||
<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="...">
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<option value="application/json">application/json</option>
|
||||
<option value="application/x-www-form-urlencoded">application/x-www-form-urlencoded</option>
|
||||
@@ -1151,18 +1152,18 @@
|
||||
</select>
|
||||
</div>
|
||||
<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">
|
||||
<p class="settings-hint">Nome del campo JSON che conterrà il testo da leggere (es: <code>message</code>, <code>text</code>).</p>
|
||||
</div>
|
||||
<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>
|
||||
<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><!-- /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>
|
||||
</div>
|
||||
@@ -1174,14 +1175,14 @@
|
||||
|
||||
<!-- 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">
|
||||
<p style="margin:0 0 6px;font-weight:600">📡 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>
|
||||
<button class="btn btn-secondary full-width" onclick="_kioskReconfigureScale()">🔄 Riconfigura bilancia BLE</button>
|
||||
<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" 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()" data-i18n="settings.scale.kiosk_reconfigure">🔄 Riconfigura bilancia BLE</button>
|
||||
<!-- 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">
|
||||
⚠️ Il kiosk installato non supporta questa funzione.
|
||||
Aggiorna l'app kiosk per abilitarla.
|
||||
<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>
|
||||
<span data-i18n="settings.kiosk.needs_update">⚠️ Il kiosk installato non supporta questa funzione.
|
||||
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" data-i18n="settings.kiosk.download_btn">📥 Scarica aggiornamento kiosk</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1229,16 +1230,16 @@
|
||||
</div>
|
||||
<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 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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<ul style="margin:0 0 0 16px;padding:0;font-size:0.8rem">
|
||||
<li>Bluetooth SIG Weight Scale (0x181D)</li>
|
||||
@@ -1274,15 +1275,15 @@
|
||||
</label>
|
||||
</div>
|
||||
<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">
|
||||
<option value="1">1 minuto</option>
|
||||
<option value="2">2 minuti</option>
|
||||
<option value="5" selected>5 minuti</option>
|
||||
<option value="10">10 minuti</option>
|
||||
<option value="15">15 minuti</option>
|
||||
<option value="30">30 minuti</option>
|
||||
<option value="60">1 ora</option>
|
||||
<option value="1" data-i18n="settings.screensaver.timeout_1">1 minuto</option>
|
||||
<option value="2" data-i18n="settings.screensaver.timeout_2">2 minuti</option>
|
||||
<option value="5" selected data-i18n="settings.screensaver.timeout_5">5 minuti</option>
|
||||
<option value="10" data-i18n="settings.screensaver.timeout_10">10 minuti</option>
|
||||
<option value="15" data-i18n="settings.screensaver.timeout_15">15 minuti</option>
|
||||
<option value="30" data-i18n="settings.screensaver.timeout_30">30 minuti</option>
|
||||
<option value="60" data-i18n="settings.screensaver.timeout_60">1 ora</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1302,17 +1303,29 @@
|
||||
<p class="settings-hint" style="margin-top:8px" data-i18n="settings.kiosk.download_sub">Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: <code>evershelf-kiosk/</code></p>
|
||||
</div>
|
||||
|
||||
<!-- Kiosk native settings panel (visible only inside kiosk WebView) -->
|
||||
<div id="kiosk-native-settings-panel" style="display:none;background:rgba(99,102,241,0.06);border:1.5px solid rgba(99,102,241,0.2);border-radius:12px;padding:16px;margin-top:16px">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
||||
<span style="font-size:1.4rem">🖥️</span>
|
||||
<div>
|
||||
<p style="margin:0;font-weight:700;font-size:0.9rem" data-i18n="settings.kiosk.native_title">Configurazione Kiosk</p>
|
||||
<p class="settings-hint" style="margin:2px 0 0" data-i18n="settings.kiosk.native_hint">URL server, bilancia BLE, salvaschermo e setup wizard.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary full-width" onclick="_openKioskNativeSettings()">⚙️ <span data-i18n="settings.kiosk.native_btn">Apri configurazione kiosk</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Kiosk self-update panel (visible only inside kiosk WebView) -->
|
||||
<div id="kiosk-update-panel" style="display:none;background:rgba(16,185,129,0.06);border:1.5px solid rgba(16,185,129,0.2);border-radius:12px;padding:16px;margin-top:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:1.4rem">📦</span>
|
||||
<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>
|
||||
</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 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>
|
||||
@@ -1332,7 +1345,7 @@
|
||||
<button class="btn btn-outline full-width" onclick="reportBugManual()" id="btn-report-bug">
|
||||
🐛 <span data-i18n="about.report_bug">Segnala un problema</span>
|
||||
</button>
|
||||
<p class="settings-hint" style="text-align:center;margin:0" data-i18n="about.report_bug_hint">Qualcosa non funziona? Apri una segnalazione su GitHub.</p>
|
||||
<p class="settings-hint" style="text-align:center;margin:0" data-i18n="about.report_bug_hint">Qualcosa non funziona? Inviaci una segnalazione direttamente dall'app.</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<a class="btn btn-outline full-width" style="text-decoration:none;text-align:center"
|
||||
href="https://github.com/dadaloop82/EverShelf/blob/main/CHANGELOG.md"
|
||||
@@ -1342,7 +1355,6 @@
|
||||
target="_blank" rel="noopener" data-i18n="about.github">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="report-bug-status" style="display:none;margin-top:8px;text-align:center;font-size:0.85rem"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1404,7 +1416,7 @@
|
||||
</button>
|
||||
<button class="nav-btn" onclick="showPage('settings')" data-page="settings">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-label">Config</span>
|
||||
<span class="nav-label" data-i18n="nav.settings">Config</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -1473,8 +1485,8 @@
|
||||
</div>
|
||||
<div class="setup-body" id="setup-body"></div>
|
||||
<div class="setup-footer">
|
||||
<button class="btn btn-secondary" id="setup-prev" onclick="setupWizardNav(-1)" style="display:none">← Indietro</button>
|
||||
<button class="btn btn-accent" id="setup-next" onclick="setupWizardNav(1)">Avanti →</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)" data-i18n="btn.next">Avanti →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1548,6 +1560,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260513a"></script>
|
||||
<script src="assets/js/app.js?v=20260516b"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.12",
|
||||
"version": "1.7.15",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+99
-19
@@ -9,7 +9,8 @@
|
||||
"inventory": "Vorrat",
|
||||
"recipes": "Rezepte",
|
||||
"shopping": "Einkauf",
|
||||
"log": "Verlauf"
|
||||
"log": "Verlauf",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Zurück",
|
||||
@@ -19,6 +20,8 @@
|
||||
"add": "✅ Hinzufügen",
|
||||
"delete": "Löschen",
|
||||
"edit": "✏️ Bearbeiten",
|
||||
"use": "Verwenden",
|
||||
"edit_item": "Bearbeiten",
|
||||
"search": "🔍 Suchen",
|
||||
"go": "✅ Los",
|
||||
"toggle_password": "👁️ Anzeigen/Ausblenden",
|
||||
@@ -28,7 +31,12 @@
|
||||
"restart": "↺ Neustart",
|
||||
"reset_default": "↺ Standard wiederherstellen",
|
||||
"save_info": "💾 Info speichern",
|
||||
"retry": "🔄 Erneut versuchen"
|
||||
"retry": "🔄 Erneut versuchen",
|
||||
"yes_short": "Ja",
|
||||
"no_short": "Nein"
|
||||
},
|
||||
"form": {
|
||||
"select_placeholder": "-- Auswählen --"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Vorratskammer",
|
||||
@@ -63,7 +71,9 @@
|
||||
"pieces": "Stück",
|
||||
"grams": "Gramm",
|
||||
"box": "Packung",
|
||||
"boxes": "Packungen"
|
||||
"boxes": "Packungen",
|
||||
"millilitres": "Milliliter",
|
||||
"from": "von"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Obst & Gemüse",
|
||||
@@ -215,7 +225,7 @@
|
||||
"throw_btn": "🗑️ ENTSORGEN",
|
||||
"throw_sub": "wegwerfen",
|
||||
"edit_sub": "Ablauf, Ort…",
|
||||
"create_recipe_btn": "Rezept damit erstellen"
|
||||
"create_recipe_btn": "Rezept"
|
||||
},
|
||||
"add": {
|
||||
"title": "Zum Vorrat hinzufügen",
|
||||
@@ -240,7 +250,9 @@
|
||||
"scan_expiry_title": "📷 Ablaufdatum scannen",
|
||||
"product_added": "✅ {name} hinzugefügt!{qty}",
|
||||
"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": {
|
||||
"title": "Verwenden / Verbrauchen",
|
||||
@@ -312,7 +324,13 @@
|
||||
"edit_info": "✏️ Informationen bearbeiten",
|
||||
"modify_details": "BEARBEITEN\nAblauf, Ort…",
|
||||
"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": {
|
||||
"title": "📦 Alle Produkte",
|
||||
@@ -502,7 +520,8 @@
|
||||
"transfer_to_recipes": "Zu Rezepten hinzufügen",
|
||||
"transferring": "Übertrage...",
|
||||
"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": {
|
||||
"close": "Schließen",
|
||||
@@ -519,7 +538,8 @@
|
||||
"timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!",
|
||||
"recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!",
|
||||
"expires_chip": "läuft ab {date}",
|
||||
"finish": "✅ Fertig"
|
||||
"finish": "✅ Fertig",
|
||||
"step_fallback": "Schritt {n}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Einstellungen",
|
||||
@@ -572,8 +592,9 @@
|
||||
"title": "📅 Wöchentlicher Essensplan",
|
||||
"hint": "Lege die Mahlzeitenart für jeden Tag fest. Wird als Leitfaden bei der Rezeptgenerierung verwendet.",
|
||||
"enabled": "✅ Wöchentlichen Essensplan aktivieren",
|
||||
"legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.",
|
||||
"types_title": "📋 Verfügbare Typen"
|
||||
"legend": "🌤️ = Mittagessen · 🌙 = Abendessen · Tippe auf ein Badge, um es zu ändern.",
|
||||
"types_title": "📋 Verfügbare Typen",
|
||||
"reset_btn": "↺ Standard wiederherstellen"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Verfügbare Geräte",
|
||||
@@ -627,12 +648,24 @@
|
||||
"security": {
|
||||
"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.",
|
||||
"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 → Sicherheit & Datenschutz → Weitere Sicherheitseinstellungen → 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 → Datenschutz und Sicherheit → Sicherheit → Zertifikate verwalten)<br>3. Tab \"Zertifizierungsstellen\" → Importieren → Datei auswählen<br>4. Häkchen bei \"Dieser Zertifizierungsstelle für die Identifikation von Webseiten vertrauen\"<br>5. Chrome neu starten"
|
||||
},
|
||||
"tts": {
|
||||
"title": "🔊 Sprache & TTS",
|
||||
"hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.",
|
||||
"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",
|
||||
"method_label": "📡 HTTP-Methode",
|
||||
"auth_label": "🔐 Authentifizierung",
|
||||
@@ -648,7 +681,14 @@
|
||||
"extra_fields_label": "➕ Zusätzliche Felder (JSON)",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "🌐 Sprache",
|
||||
@@ -659,7 +699,15 @@
|
||||
"screensaver": {
|
||||
"label": "Bildschirmschoner aktivieren",
|
||||
"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": {
|
||||
"title": "⚖️ Smart-Waage",
|
||||
@@ -672,12 +720,26 @@
|
||||
"test_btn": "🔗 Verbindung testen",
|
||||
"download_btn": "📥 Android-Gateway herunterladen (APK)",
|
||||
"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) — Gewicht, Fett, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generisch — automatische Heuristik für 100+ Modelle</li></ul>"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Verwandeln Sie ein Android-Tablet in ein EverShelf-Panel mit integriertem BLE-Waagen-Gateway.",
|
||||
"download_btn": "📥 EverShelf Kiosk herunterladen (APK)",
|
||||
"download_sub": "Vollbild-Kioskmodus + integriertes Waagen-Gateway. Quellcode: evershelf-kiosk/"
|
||||
"download_sub": "Vollbild-Kioskmodus + integriertes Waagen-Gateway. Quellcode: evershelf-kiosk/",
|
||||
"native_title": "Kiosk-Konfiguration",
|
||||
"native_hint": "Server-URL, BLE-Waage, Bildschirmschoner und Einrichtungsassistent.",
|
||||
"native_btn": "Kiosk-Konfiguration öffnen",
|
||||
"native_tap_hint": "Zahnrad oben rechts antippen",
|
||||
"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_local": "✅ Konfiguration lokal gespeichert",
|
||||
@@ -811,7 +873,10 @@
|
||||
"select_items": "Wähle mindestens ein Produkt aus",
|
||||
"server_offline": "Serververbindung unterbrochen",
|
||||
"server_restored": "Serververbindung wiederhergestellt",
|
||||
"server_retry": "Erneut versuchen"
|
||||
"server_retry": "Erneut versuchen",
|
||||
"unknown": "Unbekannter Fehler",
|
||||
"prefix": "Fehler",
|
||||
"no_inventory_entry": "Kein Inventareintrag gefunden"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
|
||||
@@ -827,7 +892,9 @@
|
||||
"edit": {
|
||||
"title": "{name} bearbeiten",
|
||||
"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": {
|
||||
"recipe_btn": "Rezepte",
|
||||
@@ -924,7 +991,8 @@
|
||||
"thing_rest": "den Rest",
|
||||
"stay_btn": "Nein, bleibt in {location}",
|
||||
"moved_toast": "📦 Offene Packung bewegt nach {location}",
|
||||
"vacuum_restore": "🫙 Vakuum wiederherstellen"
|
||||
"vacuum_restore": "🫙 Vakuum wiederherstellen",
|
||||
"vacuum_seal_rest": "🔒 Rest vakuumieren"
|
||||
},
|
||||
"nova": {
|
||||
"1": "Unverarbeitet",
|
||||
@@ -1092,7 +1160,19 @@
|
||||
"title": "Über",
|
||||
"version": "Version",
|
||||
"report_bug": "Fehler melden",
|
||||
"report_bug_hint": "Etwas funktioniert nicht? Öffne ein Issue auf GitHub.",
|
||||
"report_bug_hint": "Etwas funktioniert nicht? Sende uns direkt aus der App eine Meldung.",
|
||||
"report_bug_modal_title": "Fehler melden",
|
||||
"report_type_bug": "Fehler",
|
||||
"report_type_feature": "Funktion",
|
||||
"report_type_question": "Frage",
|
||||
"report_field_title": "Titel",
|
||||
"report_field_title_ph": "Kurze Beschreibung des Problems",
|
||||
"report_field_desc": "Beschreibung",
|
||||
"report_field_desc_ph": "Problem detailliert beschreiben…",
|
||||
"report_field_steps": "Schritte zum Reproduzieren (optional)",
|
||||
"report_field_steps_ph": "1. Gehe zu…\n2. Tippe auf…\n3. Fehler erscheint…",
|
||||
"report_auto_info": "Automatisch beigefügt: Version {version}, Sprache {lang}.",
|
||||
"report_send_btn": "Bericht senden",
|
||||
"report_bug_sending": "Wird gesendet…",
|
||||
"report_bug_sent": "Bericht gesendet — danke!",
|
||||
"report_bug_error": "Bericht konnte nicht gesendet werden. Verbindung prüfen.",
|
||||
|
||||
+101
-20
@@ -9,7 +9,8 @@
|
||||
"inventory": "Pantry",
|
||||
"recipes": "Recipes",
|
||||
"shopping": "Shopping",
|
||||
"log": "Log"
|
||||
"log": "Log",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Back",
|
||||
@@ -19,6 +20,8 @@
|
||||
"add": "✅ Add",
|
||||
"delete": "Delete",
|
||||
"edit": "✏️ Edit",
|
||||
"use": "Use",
|
||||
"edit_item": "Edit",
|
||||
"search": "🔍 Search",
|
||||
"go": "✅ Go",
|
||||
"toggle_password": "👁️ Show/Hide",
|
||||
@@ -28,7 +31,12 @@
|
||||
"restart": "↺ Restart",
|
||||
"reset_default": "↺ Reset to default",
|
||||
"save_info": "💾 Save information",
|
||||
"retry": "🔄 Retry"
|
||||
"retry": "🔄 Retry",
|
||||
"yes_short": "Yes",
|
||||
"no_short": "No"
|
||||
},
|
||||
"form": {
|
||||
"select_placeholder": "-- Select --"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Pantry",
|
||||
@@ -63,7 +71,9 @@
|
||||
"pieces": "Pieces",
|
||||
"grams": "Grams",
|
||||
"box": "Package",
|
||||
"boxes": "Packages"
|
||||
"boxes": "Packages",
|
||||
"millilitres": "Millilitres",
|
||||
"from": "of"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Fruits & Vegetables",
|
||||
@@ -215,7 +225,7 @@
|
||||
"throw_btn": "🗑️ DISCARD",
|
||||
"throw_sub": "throw away",
|
||||
"edit_sub": "expiry, location…",
|
||||
"create_recipe_btn": "Create a recipe with this"
|
||||
"create_recipe_btn": "Recipe"
|
||||
},
|
||||
"add": {
|
||||
"title": "Add to Pantry",
|
||||
@@ -240,7 +250,9 @@
|
||||
"scan_expiry_title": "📷 Scan Expiry Date",
|
||||
"product_added": "✅ {name} added!{qty}",
|
||||
"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": {
|
||||
"title": "Use / Consume",
|
||||
@@ -312,7 +324,13 @@
|
||||
"edit_info": "✏️ Edit information",
|
||||
"modify_details": "EDIT\nexpiry, location…",
|
||||
"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": {
|
||||
"title": "📦 All Products",
|
||||
@@ -482,7 +500,8 @@
|
||||
"undo_success": "↩ Operation undone for {name}",
|
||||
"already_undone": "Operation already undone",
|
||||
"too_old": "Cannot undo operations older than 24 hours",
|
||||
"undo_error": "Error during undo"
|
||||
"undo_error": "Error during undo",
|
||||
"recipe_prefix": "Recipe"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Gemini Chef",
|
||||
@@ -502,7 +521,8 @@
|
||||
"transfer_to_recipes": "Transfer to Recipes",
|
||||
"transferring": "Transferring...",
|
||||
"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": {
|
||||
"close": "Close",
|
||||
@@ -519,7 +539,8 @@
|
||||
"timer_warning_tts": "Heads up! {label}: 10 seconds left!",
|
||||
"recipe_done_tts": "Recipe complete! Enjoy your meal!",
|
||||
"expires_chip": "exp. {date}",
|
||||
"finish": "✅ Finish"
|
||||
"finish": "✅ Finish",
|
||||
"step_fallback": "Step {n}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Settings",
|
||||
@@ -572,8 +593,9 @@
|
||||
"title": "📅 Weekly Meal Plan",
|
||||
"hint": "Set the meal type for each day. It will be used as a guide in recipe generation.",
|
||||
"enabled": "✅ Enable weekly meal plan",
|
||||
"legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.",
|
||||
"types_title": "📋 Available types"
|
||||
"legend": "🌤️ = Lunch · 🌙 = Dinner · Tap a badge to change it.",
|
||||
"types_title": "📋 Available types",
|
||||
"reset_btn": "↺ Restore defaults"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Available Appliances",
|
||||
@@ -627,12 +649,24 @@
|
||||
"security": {
|
||||
"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.",
|
||||
"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 → Security & Privacy → More security settings → 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 → Privacy and security → Security → Manage certificates)<br>3. Tab \"Authorities\" → Import → select the file<br>4. Check \"Trust this certificate for identifying websites\"<br>5. Restart Chrome"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
"method_label": "📡 HTTP Method",
|
||||
"auth_label": "🔐 Authentication",
|
||||
@@ -648,7 +682,14 @@
|
||||
"extra_fields_label": "➕ Extra fields (JSON)",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "🌐 Language",
|
||||
@@ -659,7 +700,15 @@
|
||||
"screensaver": {
|
||||
"label": "Enable 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": {
|
||||
"title": "⚖️ Smart Scale",
|
||||
@@ -672,12 +721,26 @@
|
||||
"test_btn": "🔗 Test connection",
|
||||
"download_btn": "📥 Download Android Gateway (APK)",
|
||||
"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) — weight, fat, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generic — automatic heuristic for 100+ models</li></ul>"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Turn an Android tablet into an always-on EverShelf panel with built-in BLE scale gateway.",
|
||||
"download_btn": "📥 Download EverShelf Kiosk (APK)",
|
||||
"download_sub": "Full-screen kiosk mode + integrated scale gateway. Source: evershelf-kiosk/"
|
||||
"download_sub": "Full-screen kiosk mode + integrated scale gateway. Source: evershelf-kiosk/",
|
||||
"native_title": "Kiosk Configuration",
|
||||
"native_hint": "Server URL, BLE scale, screensaver and setup wizard.",
|
||||
"native_btn": "Open kiosk configuration",
|
||||
"native_tap_hint": "Tap the gear button at the top right",
|
||||
"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_local": "✅ Configuration saved locally",
|
||||
@@ -811,7 +874,10 @@
|
||||
"select_items": "Select at least one product",
|
||||
"server_offline": "Server connection lost",
|
||||
"server_restored": "Server connection restored",
|
||||
"server_retry": "Retry"
|
||||
"server_retry": "Retry",
|
||||
"unknown": "Unknown error",
|
||||
"prefix": "Error",
|
||||
"no_inventory_entry": "No inventory entry found"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Do you really want to remove this product from inventory?",
|
||||
@@ -827,7 +893,9 @@
|
||||
"edit": {
|
||||
"title": "Edit {name}",
|
||||
"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": {
|
||||
"recipe_btn": "Recipes",
|
||||
@@ -924,7 +992,8 @@
|
||||
"thing_rest": "rest",
|
||||
"stay_btn": "No, stay in {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": {
|
||||
"1": "Unprocessed",
|
||||
@@ -1092,7 +1161,19 @@
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"report_bug": "Report a Bug",
|
||||
"report_bug_hint": "Something not working? Open an issue on GitHub.",
|
||||
"report_bug_hint": "Something not working? Send us a report directly from the app.",
|
||||
"report_bug_modal_title": "Report a Bug",
|
||||
"report_type_bug": "Bug",
|
||||
"report_type_feature": "Feature",
|
||||
"report_type_question": "Question",
|
||||
"report_field_title": "Title",
|
||||
"report_field_title_ph": "Brief description of the issue",
|
||||
"report_field_desc": "Description",
|
||||
"report_field_desc_ph": "Describe the issue in detail…",
|
||||
"report_field_steps": "Steps to reproduce (optional)",
|
||||
"report_field_steps_ph": "1. Go to…\n2. Tap…\n3. See the error…",
|
||||
"report_auto_info": "Automatically attached: version {version}, language {lang}.",
|
||||
"report_send_btn": "Send report",
|
||||
"report_bug_sending": "Sending…",
|
||||
"report_bug_sent": "Report sent — thank you!",
|
||||
"report_bug_error": "Could not send the report. Check your connection.",
|
||||
|
||||
+101
-20
@@ -9,7 +9,8 @@
|
||||
"inventory": "Dispensa",
|
||||
"recipes": "Ricette",
|
||||
"shopping": "Spesa",
|
||||
"log": "Storico"
|
||||
"log": "Storico",
|
||||
"settings": "Config"
|
||||
},
|
||||
"btn": {
|
||||
"back": "← Indietro",
|
||||
@@ -19,6 +20,8 @@
|
||||
"add": "✅ Aggiungi",
|
||||
"delete": "Elimina",
|
||||
"edit": "✏️ Modifica",
|
||||
"use": "Usa",
|
||||
"edit_item": "Modifica",
|
||||
"search": "🔍 Cerca",
|
||||
"go": "✅ Vai",
|
||||
"toggle_password": "👁️ Mostra/Nascondi",
|
||||
@@ -28,7 +31,12 @@
|
||||
"restart": "↺ Ricomincia",
|
||||
"reset_default": "↺ Ripristina default",
|
||||
"save_info": "💾 Salva informazioni",
|
||||
"retry": "🔄 Riprova"
|
||||
"retry": "🔄 Riprova",
|
||||
"yes_short": "Sì",
|
||||
"no_short": "No"
|
||||
},
|
||||
"form": {
|
||||
"select_placeholder": "-- Seleziona --"
|
||||
},
|
||||
"locations": {
|
||||
"dispensa": "Dispensa",
|
||||
@@ -63,7 +71,9 @@
|
||||
"pieces": "Pezzi",
|
||||
"grams": "Grammi",
|
||||
"box": "Confezione",
|
||||
"boxes": "Confezioni"
|
||||
"boxes": "Confezioni",
|
||||
"millilitres": "Millilitri",
|
||||
"from": "da"
|
||||
},
|
||||
"shopping_sections": {
|
||||
"frutta_verdura": "Frutta & Verdura",
|
||||
@@ -215,7 +225,7 @@
|
||||
"throw_btn": "🗑️ BUTTA",
|
||||
"throw_sub": "butta il prodotto",
|
||||
"edit_sub": "scadenza, luogo…",
|
||||
"create_recipe_btn": "Crea una ricetta con questo"
|
||||
"create_recipe_btn": "Ricetta"
|
||||
},
|
||||
"add": {
|
||||
"title": "Aggiungi alla Dispensa",
|
||||
@@ -240,7 +250,9 @@
|
||||
"scan_expiry_title": "📷 Scansiona Data Scadenza",
|
||||
"product_added": "✅ {name} aggiunto!{qty}",
|
||||
"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": {
|
||||
"title": "Usa / Consuma",
|
||||
@@ -312,7 +324,13 @@
|
||||
"edit_info": "✏️ Modifica informazioni",
|
||||
"modify_details": "MODIFICA\nscadenza, luogo…",
|
||||
"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": {
|
||||
"title": "📦 Tutti i Prodotti",
|
||||
@@ -482,7 +500,8 @@
|
||||
"undo_success": "↩ Operazione annullata per {name}",
|
||||
"already_undone": "Operazione già annullata",
|
||||
"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": {
|
||||
"title": "Gemini Chef",
|
||||
@@ -502,7 +521,8 @@
|
||||
"transfer_to_recipes": "Trasferisci a Ricette",
|
||||
"transferring": "Trasferimento in corso...",
|
||||
"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": {
|
||||
"close": "Chiudi",
|
||||
@@ -519,7 +539,8 @@
|
||||
"timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!",
|
||||
"recipe_done_tts": "Ricetta completata! Buon appetito!",
|
||||
"expires_chip": "scade {date}",
|
||||
"finish": "✅ Fine"
|
||||
"finish": "✅ Fine",
|
||||
"step_fallback": "Passo {n}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "⚙️ Configurazione",
|
||||
@@ -572,8 +593,9 @@
|
||||
"title": "📅 Piano Pasti Settimanale",
|
||||
"hint": "Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.",
|
||||
"enabled": "✅ Attiva piano pasti settimanale",
|
||||
"legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.",
|
||||
"types_title": "📋 Tipologie disponibili"
|
||||
"legend": "🌤️ = Pranzo · 🌙 = Cena · Tocca un badge per cambiarlo.",
|
||||
"types_title": "📋 Tipologie disponibili",
|
||||
"reset_btn": "↺ Ripristina default"
|
||||
},
|
||||
"appliances": {
|
||||
"title": "🔌 Elettrodomestici Disponibili",
|
||||
@@ -627,12 +649,24 @@
|
||||
"security": {
|
||||
"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.",
|
||||
"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 → Sicurezza e privacy → Altre impostazioni di sicurezza → 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 → Privacy e sicurezza → Sicurezza → Gestisci certificati)<br>3. Tab \"Autorità\" → Importa → seleziona il file<br>4. Spunta \"Considera attendibile per identificare siti web\"<br>5. Riavvia Chrome"
|
||||
},
|
||||
"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.",
|
||||
"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",
|
||||
"method_label": "📡 Metodo HTTP",
|
||||
"auth_label": "🔐 Autenticazione",
|
||||
@@ -648,7 +682,14 @@
|
||||
"extra_fields_label": "➕ Campi extra (JSON)",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "🌐 Lingua / Language",
|
||||
@@ -659,7 +700,15 @@
|
||||
"screensaver": {
|
||||
"label": "Attiva 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": {
|
||||
"title": "⚖️ Bilancia Smart",
|
||||
@@ -672,12 +721,26 @@
|
||||
"test_btn": "🔗 Testa connessione",
|
||||
"download_btn": "📥 Scarica Gateway Android (APK)",
|
||||
"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) — peso, grasso, BMI</li><li>Xiaomi Mi Body Composition Scale 2</li><li>Generico — heuristica automatica su 100+ modelli</li></ul>"
|
||||
},
|
||||
"kiosk": {
|
||||
"hint": "Trasforma un tablet Android in un pannello EverShelf sempre acceso, con bilancia BLE integrata.",
|
||||
"download_btn": "📥 Scarica EverShelf Kiosk (APK)",
|
||||
"download_sub": "Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: evershelf-kiosk/"
|
||||
"download_sub": "Modalità kiosk full-screen + gateway bilancia integrato. Sorgente: evershelf-kiosk/",
|
||||
"native_title": "Configurazione Kiosk",
|
||||
"native_hint": "URL server, bilancia BLE, salvaschermo e setup wizard.",
|
||||
"native_btn": "Apri configurazione kiosk",
|
||||
"native_tap_hint": "Tocca la rotella in alto a destra",
|
||||
"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_local": "✅ Configurazione salvata localmente",
|
||||
@@ -811,7 +874,10 @@
|
||||
"select_items": "Seleziona almeno un prodotto",
|
||||
"server_offline": "Connessione al server persa",
|
||||
"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": {
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
@@ -827,7 +893,9 @@
|
||||
"edit": {
|
||||
"title": "Modifica {name}",
|
||||
"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": {
|
||||
"recipe_btn": "Ricette",
|
||||
@@ -924,7 +992,8 @@
|
||||
"thing_rest": "il resto",
|
||||
"stay_btn": "No, resta 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": {
|
||||
"1": "Non trasformato",
|
||||
@@ -1092,7 +1161,19 @@
|
||||
"title": "Informazioni",
|
||||
"version": "Versione",
|
||||
"report_bug": "Segnala un problema",
|
||||
"report_bug_hint": "Qualcosa non funziona? Apri una segnalazione su GitHub.",
|
||||
"report_bug_hint": "Qualcosa non funziona? Inviaci una segnalazione direttamente dall'app.",
|
||||
"report_bug_modal_title": "Segnala un problema",
|
||||
"report_type_bug": "Bug",
|
||||
"report_type_feature": "Funzionalità",
|
||||
"report_type_question": "Domanda",
|
||||
"report_field_title": "Titolo",
|
||||
"report_field_title_ph": "Breve descrizione del problema",
|
||||
"report_field_desc": "Descrizione",
|
||||
"report_field_desc_ph": "Descrivi il problema in dettaglio…",
|
||||
"report_field_steps": "Passi per riprodurlo (opzionale)",
|
||||
"report_field_steps_ph": "1. Vai su…\n2. Tocca…\n3. Vedi l'errore…",
|
||||
"report_auto_info": "Saranno allegati automaticamente: versione {version}, lingua {lang}.",
|
||||
"report_send_btn": "Invia segnalazione",
|
||||
"report_bug_sending": "Invio in corso…",
|
||||
"report_bug_sent": "Segnalazione inviata — grazie!",
|
||||
"report_bug_error": "Impossibile inviare la segnalazione. Controlla la connessione.",
|
||||
|
||||
Reference in New Issue
Block a user