Merge develop: v1.1.0 release

This commit is contained in:
dadaloop82
2026-04-10 06:03:22 +00:00
16 changed files with 2993 additions and 102 deletions
+34
View File
@@ -0,0 +1,34 @@
# Docker runtime files
data/dispensa.db
data/*.db-wal
data/*.db-shm
data/backups/
data/cron.log
data/smart_shopping_cache.json
data/bring_token.json
data/bring_catalog.json
data/dupliclick_token.json
data/client_debug.log
data/*.crt
data/*.pem
# Config (mounted as volume)
.env
# Git
.git
.gitignore
# Docs (not needed in container)
README.md
CHANGELOG.md
CONTRIBUTING.md
LICENSE
docs/
*.md
# IDE
.idea/
.vscode/
*.swp
*.swo
+88
View File
@@ -0,0 +1,88 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint-php:
name: PHP Syntax Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo_sqlite, curl, mbstring
- name: Check PHP syntax
run: |
find api/ -name '*.php' -exec php -l {} \;
lint-js:
name: JavaScript Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check JS syntax
run: |
node -c assets/js/app.js
docker-build:
name: Docker Build Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t dispensa-test .
- name: Test container starts
run: |
docker run -d --name test-dispensa -p 8080:80 dispensa-test
sleep 5
curl -f http://localhost:8080/ || exit 1
docker stop test-dispensa
validate-translations:
name: Validate Translation Files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate JSON syntax
run: |
for f in translations/*.json; do
echo "Checking $f..."
python3 -c "import json; json.load(open('$f'))" || exit 1
done
echo "All translation files valid."
- name: Check translation completeness
run: |
python3 -c "
import json, sys
base = json.load(open('translations/it.json'))
base_keys = set(base.keys())
ok = True
import glob
for f in glob.glob('translations/*.json'):
if 'it.json' in f:
continue
lang = json.load(open(f))
lang_keys = set(lang.keys())
missing = base_keys - lang_keys
if missing:
print(f'{f}: {len(missing)} missing keys')
for k in sorted(missing)[:10]:
print(f' - {k}')
if len(missing) > 10:
print(f' ... and {len(missing)-10} more')
else:
print(f'{f}: complete ✓')
"
+1
View File
@@ -12,6 +12,7 @@ data/bring_token.json
data/bring_catalog.json
data/dupliclick_token.json
data/client_debug.log
data/rate_limits/
# SSL certificates (local only)
data/*.crt
+141
View File
@@ -0,0 +1,141 @@
# Contributing to Dispensa Manager
Thank you for your interest in contributing! This guide will help you get started.
## 🚀 Getting Started
1. **Fork** the repository
2. **Clone** your fork:
```bash
git clone https://github.com/YOUR_USERNAME/dispensa.git
cd dispensa
```
3. **Create a branch** from `develop`:
```bash
git checkout develop
git checkout -b feature/your-feature-name
```
4. **Set up** your development environment:
```bash
cp .env.example .env
# Edit .env with your API keys
php -S localhost:8080
```
## 📐 Project Structure
```
├── index.html # Single-page app (all HTML)
├── api/
│ ├── index.php # API router + all endpoint functions
│ ├── database.php # SQLite schema + migrations
│ └── cron_smart_shopping.php
├── assets/
│ ├── js/app.js # All application JavaScript
│ └── css/style.css # All styles
├── translations/ # i18n translation files
│ ├── it.json # Italian (base language)
│ ├── en.json # English
│ └── ...
└── data/ # Runtime data (gitignored)
```
## 🌍 Contributing Translations
Translations are one of the easiest ways to contribute! Each language is a single JSON file in the `translations/` directory.
### Adding a new language
1. Copy `translations/it.json` (the base language)
2. Rename it to your language code (e.g., `fr.json`, `de.json`, `es.json`)
3. Translate all the values (keep the keys unchanged)
4. Submit a Pull Request
### Translation file format
```json
{
"app.title": "Dispensa Manager",
"nav.dashboard": "Dashboard",
"nav.inventory": "Inventario",
...
}
```
**Rules:**
- Keys are in English, dot-separated (`section.key`)
- Values are the translated strings
- Keep `{0}`, `{1}` placeholders — they are filled dynamically
- Don't translate brand names (Bring!, Gemini, etc.)
- The CI pipeline will check your file for missing keys
### Language codes
Use [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) two-letter codes:
`it`, `en`, `de`, `fr`, `es`, `pt`, `nl`, `pl`, `ru`, `ja`, `zh`, `ko`, etc.
## 🔧 Development Guidelines
### Code Style
- **PHP**: PSR-12 compatible, use type hints where practical
- **JavaScript**: No build tools, vanilla ES6+, single-file architecture
- **CSS**: Mobile-first, use CSS custom properties from `:root`
- **Comments**: English only, concise
### Commits
- Use descriptive commit messages
- Reference issue numbers when applicable: `Fix #42: barcode scanner timeout`
- Keep commits focused on a single change
### Branching
- `main` — stable releases only
- `develop` — active development (PRs target here)
- `feature/*` — new features
- `fix/*` — bug fixes
- `i18n/*` — translation contributions
## 🧪 Testing
Before submitting a PR:
```bash
# Check PHP syntax
php -l api/index.php
php -l api/database.php
# Check JS syntax
node -c assets/js/app.js
# Validate translation files
python3 -c "import json; json.load(open('translations/it.json'))"
# Test Docker build
docker build -t dispensa-test .
```
## 📝 Pull Request Process
1. Ensure your code passes all CI checks
2. Update `CHANGELOG.md` if applicable
3. Target the `develop` branch
4. Provide a clear description of your changes
5. Link any related issues
## 🐛 Reporting Bugs
Open an issue with:
- Steps to reproduce
- Expected vs. actual behavior
- Browser/device information
- Screenshots if applicable
## 💡 Feature Requests
Open an issue with the `enhancement` label. Describe:
- The problem you're trying to solve
- Your proposed solution
- Any alternatives you've considered
## 📄 License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
+41
View File
@@ -0,0 +1,41 @@
FROM php:8.2-apache
# Install required PHP extensions
RUN apt-get update && apt-get install -y \
libsqlite3-dev \
libcurl4-openssl-dev \
&& docker-php-ext-install pdo_sqlite curl mbstring \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Enable Apache mod_rewrite
RUN a2enmod rewrite
# Set working directory
WORKDIR /var/www/html
# Copy application files
COPY . /var/www/html/
# Create data directory with proper permissions
RUN mkdir -p /var/www/html/data/backups \
&& chown -R www-data:www-data /var/www/html/data \
&& chmod -R 775 /var/www/html/data
# Create .env from example if it doesn't exist (will be overridden by volume mount)
RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.env || true
# Apache configuration: serve from app root
RUN echo '<Directory /var/www/html>\n\
AllowOverride All\n\
Require all granted\n\
</Directory>' > /etc/apache2/conf-available/dispensa.conf \
&& a2enconf dispensa
# Expose port 80
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
CMD ["apache2-foreground"]
+40 -3
View File
@@ -5,10 +5,14 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![PHP](https://img.shields.io/badge/PHP-8.0+-blue.svg)](https://www.php.net/)
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE-orange.svg)](translations/)
<!--
<p align="center">
<img src="assets/img/screenshot-dashboard.png" alt="Dashboard Screenshot" width="320" />
<img src="assets/img/screenshots/dashboard.png" alt="Dashboard Screenshot" width="320" />
</p>
-->
---
@@ -63,6 +67,25 @@
### Installation
#### Option A: Docker (recommended)
```bash
# 1. Clone the repository
git clone https://github.com/dadaloop82/dispensa.git
cd dispensa
# 2. Create configuration file
cp .env.example .env
nano .env
# 3. Start with Docker Compose
docker compose up -d
# → Open http://localhost:8080
```
#### Option B: Manual
```bash
# 1. Clone the repository
git clone https://github.com/dadaloop82/dispensa.git
@@ -248,16 +271,30 @@ The application uses no build tools — edit files directly and refresh.
- [ ] Multi-language support (i18n)
- [ ] User authentication / multi-user support
- [ ] Docker container for easy deployment
- [ ] REST API documentation (OpenAPI/Swagger)
- [x] REST API documentation (OpenAPI/Swagger) — see [docs/openapi.yaml](docs/openapi.yaml)
- [ ] Offline mode with service worker
- [ ] Export/import inventory data
- [ ] Notification system (Telegram, email) for expiring products
---
## 🌐 Translations
The app supports multiple languages via JSON translation files in the `translations/` folder.
| Language | Status |
|----------|--------|
| 🇮🇹 Italian (it) | ✅ Complete (base) |
| 🇬🇧 English (en) | ✅ Complete |
| 🇩🇪 German (de) | ✅ Complete |
**Want to add your language?** See the [Translation Guide](CONTRIBUTING.md#-adding-translations) — just copy `translations/it.json`, translate the values, and submit a PR!
---
## 🤝 Contributing
Contributions are welcome! Please:
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/my-feature`)
+68
View File
@@ -52,6 +52,74 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}
// ===== RATE LIMITING =====
/**
* Simple file-based rate limiter.
* Limits: 120 req/min general, 15 req/min for AI endpoints, 5 req/min for login.
*/
function checkRateLimit(string $action): void {
$rateLimitDir = __DIR__ . '/../data/rate_limits';
if (!is_dir($rateLimitDir)) {
mkdir($rateLimitDir, 0755, true);
}
// Determine limit based on action
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
$loginActions = ['dupliclick_login'];
if (in_array($action, $aiActions)) {
$limit = 15;
$window = 60;
$bucket = 'ai';
} elseif (in_array($action, $loginActions)) {
$limit = 5;
$window = 60;
$bucket = 'login';
} else {
$limit = 120;
$window = 60;
$bucket = 'general';
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$file = $rateLimitDir . '/' . md5($ip . '_' . $bucket) . '.json';
// Clean up old rate limit files periodically (1% chance per request)
if (mt_rand(1, 100) === 1) {
foreach (glob($rateLimitDir . '/*.json') as $f) {
if (filemtime($f) < time() - 300) @unlink($f);
}
}
$now = time();
$data = [];
if (file_exists($file)) {
$raw = @file_get_contents($file);
if ($raw) $data = json_decode($raw, true) ?: [];
}
// Remove entries outside the window
$data = array_values(array_filter($data, function($ts) use ($now, $window) {
return $ts > $now - $window;
}));
if (count($data) >= $limit) {
http_response_code(429);
header('Retry-After: ' . $window);
echo json_encode(['error' => 'Too many requests. Please try again later.']);
exit;
}
$data[] = $now;
@file_put_contents($file, json_encode($data), LOCK_EX);
}
// Apply rate limiting
$rateLimitAction = $_GET['action'] ?? '';
if ($rateLimitAction) {
checkRateLimit($rateLimitAction);
}
try {
$db = getDB();
} catch (Exception $e) {
+110
View File
@@ -5072,3 +5072,113 @@ body {
background: rgba(255,255,255,0.25);
transform: scale(0.92);
}
/* ===== SETUP WIZARD ===== */
.setup-wizard-content {
max-width: 480px;
width: 95%;
margin: auto;
border-radius: 20px;
overflow: hidden;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.setup-header {
background: var(--primary);
color: #fff;
padding: 24px 20px 16px;
text-align: center;
}
.setup-header h2 {
margin: 0 0 12px;
font-size: 1.5rem;
}
.setup-progress {
display: flex;
gap: 8px;
justify-content: center;
}
.setup-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255,255,255,0.3);
transition: background 0.3s, transform 0.3s;
}
.setup-dot.active {
background: #fff;
transform: scale(1.3);
}
.setup-dot.done {
background: var(--success-light);
}
.setup-body {
padding: 24px 20px;
overflow-y: auto;
flex: 1;
}
.setup-body h3 {
margin: 0 0 8px;
font-size: 1.2rem;
}
.setup-body p {
color: #666;
margin: 0 0 16px;
font-size: 0.9rem;
line-height: 1.5;
}
.setup-body .form-group {
margin-bottom: 16px;
}
.setup-body .form-input {
width: 100%;
box-sizing: border-box;
}
.setup-lang-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
margin: 12px 0;
}
.setup-lang-btn {
padding: 14px 12px;
border: 2px solid #e2e8f0;
border-radius: 12px;
background: #fff;
cursor: pointer;
text-align: center;
font-size: 1rem;
transition: border-color 0.2s, background 0.2s;
}
.setup-lang-btn.selected {
border-color: var(--primary);
background: rgba(45,80,22,0.08);
}
.setup-lang-btn:hover {
border-color: var(--primary-light);
}
.setup-footer {
padding: 16px 20px;
display: flex;
justify-content: space-between;
border-top: 1px solid #e2e8f0;
gap: 12px;
}
.setup-footer .btn {
flex: 1;
padding: 12px;
font-size: 1rem;
}
.setup-skip-link {
display: block;
text-align: center;
color: #999;
font-size: 0.8rem;
margin-top: 12px;
cursor: pointer;
text-decoration: underline;
}
.setup-skip-link:hover {
color: #666;
}
+31
View File
@@ -0,0 +1,31 @@
# Screenshots
Add screenshots here to showcase the app in the README.
## Recommended screenshots
Take screenshots of these pages for the README `Screenshots` section:
1. **dashboard.png** — Main dashboard with stats cards and expiry alerts
2. **inventory.png** — Inventory list with product cards
3. **scan.png** — Barcode scanning page
4. **recipe.png** — Generated recipe with cooking mode
5. **shopping.png** — Shopping list with smart predictions
6. **chat.png** — Gemini Chef AI conversation
7. **settings.png** — Settings page
8. **setup.png** — First-run setup wizard
## How to add
1. Take screenshots on a mobile device or using Chrome DevTools device emulation
2. Recommended size: 375×812 (iPhone X viewport)
3. Save as PNG with descriptive names
4. Update the README.md `## Screenshots` section to reference them:
```markdown
## Screenshots
| Dashboard | Inventory | Scan |
|:-:|:-:|:-:|
| ![Dashboard](assets/img/screenshots/dashboard.png) | ![Inventory](assets/img/screenshots/inventory.png) | ![Scan](assets/img/screenshots/scan.png) |
```
+343 -34
View File
@@ -57,6 +57,123 @@ window.addEventListener('unhandledrejection', function(e) {
// ===== CONFIGURATION =====
const API_BASE = 'api/index.php';
// ===== i18n TRANSLATION SYSTEM =====
let _i18nStrings = null; // current language translations (flat)
let _i18nFallback = null; // Italian fallback (flat)
let _currentLang = localStorage.getItem('dispensa_lang') || navigator.language?.slice(0, 2) || 'it';
const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch' };
if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'it';
// Flatten nested JSON: { a: { b: "x" } } → { "a.b": "x" }
function _flattenI18n(obj, prefix = '') {
const result = {};
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
Object.assign(result, _flattenI18n(v, key));
} else {
result[key] = v;
}
}
return result;
}
// Translation function: t('toast.thrown_away', {name: 'Latte'})
function t(key, params) {
let str = (_i18nStrings && _i18nStrings[key]) || (_i18nFallback && _i18nFallback[key]) || key;
if (params) {
for (const [k, v] of Object.entries(params)) {
str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
}
}
return str;
}
// Load translations from JSON files
async function loadTranslations(lang) {
lang = lang || _currentLang;
try {
// Always load Italian as fallback
if (!_i18nFallback) {
const fbRes = await fetch(`translations/it.json?v=${Date.now()}`);
if (fbRes.ok) _i18nFallback = _flattenI18n(await fbRes.json());
}
if (lang === 'it') {
_i18nStrings = _i18nFallback;
} else {
const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${Date.now()}`);
if (res.ok) _i18nStrings = _flattenI18n(await res.json());
else _i18nStrings = _i18nFallback;
}
_currentLang = lang;
localStorage.setItem('dispensa_lang', lang);
_applyI18nToLabels();
translatePage();
} catch (e) {
console.warn('i18n: Failed to load translations for', lang, e);
_i18nStrings = _i18nFallback;
}
}
// Update LOCATIONS / SHOPPING_SECTIONS labels from translations
function _applyI18nToLabels() {
if (!_i18nStrings) return;
for (const key of Object.keys(LOCATIONS)) {
const tKey = `locations.${key}`;
if (_i18nStrings[tKey]) LOCATIONS[key].label = _i18nStrings[tKey];
}
for (const sec of SHOPPING_SECTIONS) {
const tKey = `shopping_sections.${sec.key}`;
if (_i18nStrings[tKey]) sec.label = _i18nStrings[tKey];
}
}
// Translate all elements with data-i18n attributes
function translatePage() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (key) el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-html]').forEach(el => {
const key = el.getAttribute('data-i18n-html');
if (key) el.innerHTML = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (key) el.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (key) el.title = t(key);
});
// Update HTML lang attribute
document.documentElement.lang = _currentLang;
// Populate language selector if present
_populateLanguageSelector();
}
// Populate the language selector dropdown
function _populateLanguageSelector() {
const sel = document.getElementById('setting-language');
if (!sel) return;
sel.innerHTML = '';
for (const [code, name] of Object.entries(_SUPPORTED_LANGS)) {
const opt = document.createElement('option');
opt.value = code;
opt.textContent = name;
if (code === _currentLang) opt.selected = true;
sel.appendChild(opt);
}
}
// Change language and reload the page
function changeLanguage(lang) {
if (lang === _currentLang) return;
localStorage.setItem('dispensa_lang', lang);
location.reload();
}
const LOCATIONS = {
'dispensa': { icon: '🗄️', label: 'Dispensa' },
'frigo': { icon: '🧊', label: 'Frigo' },
@@ -822,21 +939,21 @@ function addAppliance() {
const s = getSettings();
if (!s.appliances) s.appliances = [];
if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) {
showToast('Elettrodomestico già presente', 'error');
showToast(t('error.appliance_exists'), 'error');
return;
}
s.appliances.push(name);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
input.value = '';
showToast('Elettrodomestico aggiunto', 'success');
showToast(t('toast.appliance_added'), 'success');
}
function addApplianceQuick(name) {
const s = getSettings();
if (!s.appliances) s.appliances = [];
if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) {
showToast('Già presente', 'error');
showToast(t('error.already_exists'), 'error');
return;
}
s.appliances.push(name);
@@ -1364,7 +1481,7 @@ function confirmReviewItem(inventoryId) {
}
}, 300);
}
showToast('✓ Quantità confermata', 'success');
showToast(t('toast.quantity_confirmed'), 'success');
}
function editReviewItem(inventoryId, productId) {
@@ -1747,7 +1864,7 @@ async function quickUse(productId, location) {
} catch (err) {
showLoading(false);
console.error('quickUse error:', err);
showToast('Errore nel caricamento del prodotto', 'error');
showToast(t('error.loading'), 'error');
}
}
@@ -1755,7 +1872,7 @@ async function deleteInventoryItem(id) {
if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast('Prodotto rimosso', 'success');
showToast(t('toast.product_removed'), 'success');
refreshCurrentPage();
}
}
@@ -1779,7 +1896,7 @@ function editInventoryItem(id) {
const item = currentInventory.find(i => i.id === id);
if (!item) {
closeModal();
showToast('Prodotto non trovato', 'error');
showToast(t('error.not_found'), 'error');
return;
}
@@ -2359,7 +2476,7 @@ function submitManualBarcode() {
const input = document.getElementById('manual-barcode-input');
const barcode = (input.value || '').trim();
if (!barcode) {
showToast('Inserisci un codice a barre', 'error');
showToast(t('error.barcode_empty'), 'error');
input.focus();
return;
}
@@ -2377,7 +2494,7 @@ async function submitQuickName() {
const input = document.getElementById('quick-product-name');
const name = (input.value || '').trim();
if (!name || name.length < 2) {
showToast('Scrivi almeno 2 caratteri', 'error');
showToast(t('error.min_chars'), 'error');
input.focus();
return;
}
@@ -2402,7 +2519,7 @@ async function submitQuickName() {
} catch (err) {
showLoading(false);
console.error('Quick name search error:', err);
showToast('Errore nella ricerca', 'error');
showToast(t('error.search_short'), 'error');
}
}
@@ -2506,7 +2623,7 @@ async function createQuickProduct(name) {
} catch (err) {
showLoading(false);
console.error('Quick product creation error:', err);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -2794,7 +2911,7 @@ async function submitProduct(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3270,7 +3387,7 @@ async function deleteActionInventoryItem(id) {
if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast('Prodotto rimosso', 'success');
showToast(t('toast.product_removed'), 'success');
showProductAction(); // Refresh the action page
}
}
@@ -3371,7 +3488,7 @@ async function throwAll() {
}
} catch(e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3396,7 +3513,7 @@ async function throwPartial() {
}
} catch(e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3412,7 +3529,7 @@ function toggleActionEdit() {
async function saveEditedProductInfo() {
const name = (document.getElementById('edit-action-name')?.value || '').trim();
if (!name) {
showToast('Inserisci il nome del prodotto', 'error');
showToast(t('product.name_required'), 'error');
document.getElementById('edit-action-name')?.focus();
return;
}
@@ -3446,7 +3563,7 @@ async function saveEditedProductInfo() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3915,7 +4032,7 @@ async function submitAdd(e) {
}
showToast(`${currentProduct.name} aggiunto!${qtyInfo}`, 'success');
if (result.removed_from_bring) {
setTimeout(() => showToast('🛒 Rimosso dalla lista della spesa', 'info'), 1500);
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
} else if (shoppingItems.length > 0 && shoppingListUUID) {
// PHP matching may have missed the item (custom name / no catalog match) —
// try a client-side fuzzy remove using the already-loaded shoppingItems
@@ -3928,7 +4045,7 @@ async function submitAdd(e) {
}).then(r => {
if (r && r.success) {
shoppingItems = shoppingItems.filter(i => i !== match);
setTimeout(() => showToast('🛒 Rimosso dalla lista della spesa', 'info'), 1500);
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
}
}).catch(() => {});
}
@@ -3961,7 +4078,7 @@ async function submitAdd(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4384,7 +4501,7 @@ async function addLowStockToBring(productName) {
if (data.success && data.added > 0) {
showToast('🛒 Aggiunto alla lista della spesa!', 'success');
} else if (data.success && data.skipped > 0) {
showToast('️ Già nella lista della spesa', 'info');
showToast(t('shopping.already_in_list_short'), 'info');
}
} catch (e) {
showToast('Errore nell\'aggiunta a Bring!', 'error');
@@ -4512,7 +4629,7 @@ async function submitUseAll() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4556,7 +4673,7 @@ async function submitUse(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4770,12 +4887,12 @@ async function selectAIMatch(idx) {
showProductAction();
} else {
showLoading(false);
showToast('Errore nel salvataggio', 'error');
showToast(t('error.save'), 'error');
}
} catch (err) {
showLoading(false);
console.error('AI match select error:', err);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4804,7 +4921,7 @@ async function saveAIProductDirect() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -5062,7 +5179,7 @@ async function selectProductForAction(productId) {
showProductAction();
} else {
showLoading(false);
showToast('Prodotto non trovato', 'error');
showToast(t('error.not_found'), 'error');
}
} catch (err) {
showLoading(false);
@@ -5663,7 +5780,7 @@ async function addSmartToBring() {
}
} catch (e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -6191,7 +6308,7 @@ async function removeBringItem(idx) {
if (data.success) {
shoppingItems.splice(idx, 1);
renderShoppingItems();
showToast('Rimosso dalla lista', 'success');
showToast(t('toast.removed_from_list_short'), 'success');
logOperation('bring_manual_remove', { name: item.name });
// Update dashboard shopping count
loadShoppingCount();
@@ -6242,7 +6359,7 @@ async function generateSuggestions() {
btn.disabled = false;
btn.innerHTML = '🤖 Suggerisci cosa comprare';
console.error('Suggestion error:', err);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -6328,7 +6445,7 @@ async function addSelectedSuggestions() {
showToast(data.error || 'Errore', 'error');
}
} catch (err) {
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
btn.disabled = false;
@@ -7186,7 +7303,7 @@ async function submitRecipeUse(useAll) {
console.error('Recipe use error:', err);
btn.disabled = false;
btn.textContent = '📦 Usa';
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
_recipeUseContext = null;
}
@@ -7921,7 +8038,7 @@ async function generateRecipe() {
console.error('Recipe error:', err);
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -8694,6 +8811,198 @@ function initInactivityWatcher() {
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
// Load translations first, then initialize the app
loadTranslations(_currentLang).then(() => {
_initApp();
}).catch(() => {
_initApp(); // fallback: initialize even if translations fail
});
});
// ===== SETUP WIZARD =====
let _setupStep = 0;
const _setupData = { lang: _currentLang, gemini_key: '', bring_email: '', bring_password: '' };
function _isFirstRun() {
return !localStorage.getItem('dispensa_setup_done');
}
function _setupSteps() {
return [
{
title: '🌐 ' + t('settings.language.label'),
desc: t('settings.language.hint'),
render: () => {
let html = '<div class="setup-lang-grid">';
for (const [code, name] of Object.entries(_SUPPORTED_LANGS)) {
const sel = code === _setupData.lang ? ' selected' : '';
html += `<button class="setup-lang-btn${sel}" onclick="_setupSelectLang('${code}')">${name}</button>`;
}
html += '</div>';
return html;
}
},
{
title: '🤖 Google Gemini AI',
desc: t('settings.gemini.hint'),
render: () => `
<div class="form-group">
<label>${t('settings.gemini.key_label')}</label>
<input type="text" id="setup-gemini-key" class="form-input" placeholder="AIza..." value="${_setupData.gemini_key}">
<p style="color:#999;font-size:0.8rem;margin-top:8px">
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener"> Get a free API key from Google AI Studio</a>
</p>
</div>
<span class="setup-skip-link" onclick="_setupSkipStep()">${t('btn.cancel')} ${_currentLang === 'it' ? 'configura dopo' : 'configure later'}</span>
`
},
{
title: '🛒 Bring! Shopping List',
desc: t('settings.bring.hint'),
render: () => `
<div class="form-group">
<label>${t('settings.bring.email_label')}</label>
<input type="email" id="setup-bring-email" class="form-input" placeholder="email@example.com" value="${_setupData.bring_email}">
</div>
<div class="form-group">
<label>${t('settings.bring.password_label')}</label>
<input type="password" id="setup-bring-password" class="form-input" placeholder="Password" value="${_setupData.bring_password}">
</div>
<span class="setup-skip-link" onclick="_setupSkipStep()">${t('btn.cancel')} ${_currentLang === 'it' ? 'configura dopo' : 'configure later'}</span>
`
},
{
title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : 'All set!'),
desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.'
: _currentLang === 'de' ? 'Die Konfiguration ist abgeschlossen. Du kannst diese Einstellungen jederzeit ändern.'
: 'Setup is complete. You can always change these settings from the Settings page.',
render: () => {
let summary = '<div style="text-align:center;font-size:2.5rem;margin:12px 0">🎉</div>';
return summary;
}
}
];
}
function showSetupWizard() {
_setupStep = 0;
document.getElementById('setup-wizard').style.display = '';
_renderSetupStep();
}
function _renderSetupStep() {
const steps = _setupSteps();
const step = steps[_setupStep];
// Progress dots
const dotsHtml = steps.map((_, i) => {
let cls = 'setup-dot';
if (i < _setupStep) cls += ' done';
if (i === _setupStep) cls += ' active';
return `<div class="${cls}"></div>`;
}).join('');
document.getElementById('setup-progress').innerHTML = dotsHtml;
// Body
document.getElementById('setup-body').innerHTML = `<h3>${step.title}</h3><p>${step.desc}</p>${step.render()}`;
// Buttons
const prevBtn = document.getElementById('setup-prev');
const nextBtn = document.getElementById('setup-next');
prevBtn.style.display = _setupStep > 0 ? '' : 'none';
prevBtn.textContent = t('btn.back');
if (_setupStep === steps.length - 1) {
nextBtn.textContent = _currentLang === 'it' ? '🚀 Inizia!' : _currentLang === 'de' ? '🚀 Los geht\'s!' : '🚀 Start!';
} else {
nextBtn.textContent = _currentLang === 'it' ? 'Avanti →' : _currentLang === 'de' ? 'Weiter →' : 'Next →';
}
}
function _setupSelectLang(lang) {
_setupData.lang = lang;
document.querySelectorAll('.setup-lang-btn').forEach(b => b.classList.remove('selected'));
event.target.classList.add('selected');
}
function _setupSkipStep() {
_setupStep++;
_renderSetupStep();
}
function _setupCollectCurrent() {
if (_setupStep === 1) {
const el = document.getElementById('setup-gemini-key');
if (el) _setupData.gemini_key = el.value.trim();
} else if (_setupStep === 2) {
const email = document.getElementById('setup-bring-email');
const pass = document.getElementById('setup-bring-password');
if (email) _setupData.bring_email = email.value.trim();
if (pass) _setupData.bring_password = pass.value.trim();
}
}
function setupWizardNav(dir) {
_setupCollectCurrent();
const steps = _setupSteps();
if (dir === 1 && _setupStep === steps.length - 1) {
// Finish wizard
_finishSetup();
return;
}
// If language changed, apply it
if (_setupStep === 0 && dir === 1 && _setupData.lang !== _currentLang) {
localStorage.setItem('dispensa_lang', _setupData.lang);
localStorage.setItem('dispensa_setup_step', '1');
localStorage.setItem('dispensa_setup_data', JSON.stringify(_setupData));
location.reload();
return;
}
_setupStep = Math.max(0, Math.min(steps.length - 1, _setupStep + dir));
_renderSetupStep();
}
async function _finishSetup() {
// Save settings
const s = getSettings();
if (_setupData.gemini_key) s.gemini_key = _setupData.gemini_key;
if (_setupData.bring_email) s.bring_email = _setupData.bring_email;
if (_setupData.bring_password) s.bring_password = _setupData.bring_password;
saveSettingsToStorage(s);
// Save server-side settings (.env)
try {
await api('save_settings', {}, 'POST', {
gemini_key: _setupData.gemini_key,
bring_email: _setupData.bring_email,
bring_password: _setupData.bring_password
});
} catch(e) { /* will work locally */ }
localStorage.setItem('dispensa_setup_done', '1');
localStorage.removeItem('dispensa_setup_step');
localStorage.removeItem('dispensa_setup_data');
document.getElementById('setup-wizard').style.display = 'none';
}
function _initApp() {
// Check for setup wizard resume (after language change)
const resumeStep = localStorage.getItem('dispensa_setup_step');
const resumeData = localStorage.getItem('dispensa_setup_data');
if (resumeStep) {
try { Object.assign(_setupData, JSON.parse(resumeData)); } catch(e) {}
_setupStep = parseInt(resumeStep) || 0;
localStorage.removeItem('dispensa_setup_step');
localStorage.removeItem('dispensa_setup_data');
document.getElementById('setup-wizard').style.display = '';
_renderSetupStep();
} else if (_isFirstRun()) {
showSetupWizard();
}
// Migrate old session-based flags to time-based
if (sessionStorage.getItem('_autoAddedCritical')) {
sessionStorage.removeItem('_autoAddedCritical');
@@ -8738,7 +9047,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Silent background sync: update urgency specs on Bring and add missing critical items
// Runs once at startup (time-gated: max every 10 min) without affecting the UI
_backgroundBringSync();
});
}
/**
* Background sync at startup:
+18
View File
@@ -0,0 +1,18 @@
services:
dispensa:
build: .
container_name: dispensa
ports:
- "8080:80"
volumes:
# Persist database and runtime data
- dispensa_data:/var/www/html/data
# Mount your local .env configuration
- ./.env:/var/www/html/.env:ro
restart: unless-stopped
environment:
- TZ=Europe/Rome
volumes:
dispensa_data:
driver: local
+691
View File
@@ -0,0 +1,691 @@
openapi: "3.1.0"
info:
title: Dispensa Manager API
description: |
REST API for Dispensa Manager — a self-hosted pantry management system.
All endpoints use the query parameter `action` to determine the operation.
**Base URL:** `api/index.php?action={action_name}`
Rate limits apply:
- General: 120 requests/minute
- AI endpoints: 15 requests/minute
- Login endpoints: 5 requests/minute
version: "1.0.0"
contact:
name: Stimpfl Daniel
email: dadaloop82@gmail.com
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: /api
description: Local server
paths:
/index.php?action=search_barcode:
get:
summary: Search product by barcode in local database
tags: [Products]
parameters:
- name: barcode
in: query
required: true
schema:
type: string
responses:
"200":
description: Product found
content:
application/json:
schema:
$ref: "#/components/schemas/Product"
"404":
description: Product not found
/index.php?action=lookup_barcode:
get:
summary: Lookup barcode on Open Food Facts
tags: [Products]
parameters:
- name: barcode
in: query
required: true
schema:
type: string
responses:
"200":
description: Product data from Open Food Facts
/index.php?action=product_save:
post:
summary: Create or update a product
tags: [Products]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ProductInput"
responses:
"200":
description: Product saved
/index.php?action=product_get:
get:
summary: Get product by ID
tags: [Products]
parameters:
- name: id
in: query
required: true
schema:
type: integer
responses:
"200":
description: Product details
content:
application/json:
schema:
$ref: "#/components/schemas/Product"
/index.php?action=product_delete:
post:
summary: Delete a product
tags: [Products]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id:
type: integer
responses:
"200":
description: Product deleted
/index.php?action=products_list:
get:
summary: List all products
tags: [Products]
responses:
"200":
description: Array of all products
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Product"
/index.php?action=products_search:
get:
summary: Search products by name
tags: [Products]
parameters:
- name: q
in: query
required: true
schema:
type: string
responses:
"200":
description: Matching products
/index.php?action=inventory_list:
get:
summary: List all inventory items
tags: [Inventory]
responses:
"200":
description: Inventory items grouped by product
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/InventoryItem"
/index.php?action=inventory_add:
post:
summary: Add item to inventory
tags: [Inventory]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [product_id, quantity, location]
properties:
product_id:
type: integer
quantity:
type: number
location:
type: string
enum: [dispensa, frigo, freezer, altro]
expiry_date:
type: string
format: date
conf_size:
type: number
vacuum:
type: boolean
responses:
"200":
description: Item added to inventory
/index.php?action=inventory_use:
post:
summary: Use/consume items from inventory
tags: [Inventory]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [product_id, quantity, location]
properties:
product_id:
type: integer
quantity:
type: number
location:
type: string
use_all:
type: boolean
responses:
"200":
description: Item consumed
/index.php?action=inventory_update:
post:
summary: Update an inventory entry
tags: [Inventory]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id:
type: integer
quantity:
type: number
location:
type: string
expiry_date:
type: string
format: date
responses:
"200":
description: Inventory entry updated
/index.php?action=inventory_delete:
post:
summary: Remove an inventory entry
tags: [Inventory]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id:
type: integer
responses:
"200":
description: Inventory entry removed
/index.php?action=inventory_summary:
get:
summary: Get inventory summary (counts per location)
tags: [Inventory]
responses:
"200":
description: Summary object with counts
/index.php?action=transactions_list:
get:
summary: List operations log
tags: [Log]
parameters:
- name: limit
in: query
schema:
type: integer
default: 50
- name: offset
in: query
schema:
type: integer
default: 0
responses:
"200":
description: Array of transactions
/index.php?action=stats:
get:
summary: Get waste/consumption statistics
tags: [Log]
responses:
"200":
description: Statistics for the last 30 days
/index.php?action=gemini_expiry:
post:
summary: Use AI to read expiry date from image
tags: [AI / Gemini]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
image:
type: string
description: Base64-encoded image
responses:
"200":
description: Parsed expiry date
/index.php?action=generate_recipe:
post:
summary: Generate a recipe based on available ingredients
tags: [AI / Gemini]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
persons:
type: integer
preferences:
type: object
responses:
"200":
description: Generated recipe
/index.php?action=gemini_identify:
post:
summary: Identify a product from photo using AI
tags: [AI / Gemini]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
image:
type: string
description: Base64-encoded image
responses:
"200":
description: Identified product data
/index.php?action=gemini_chat:
post:
summary: Chat with Gemini AI kitchen assistant
tags: [AI / Gemini]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
message:
type: string
history:
type: array
items:
type: object
responses:
"200":
description: AI response
/index.php?action=bring_list:
get:
summary: Get Bring! shopping list
tags: [Bring! Integration]
responses:
"200":
description: Shopping list items
/index.php?action=bring_add:
post:
summary: Add item to Bring! shopping list
tags: [Bring! Integration]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
spec:
type: string
responses:
"200":
description: Item added
/index.php?action=bring_remove:
post:
summary: Remove item from Bring! shopping list
tags: [Bring! Integration]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
responses:
"200":
description: Item removed
/index.php?action=bring_suggest:
post:
summary: Get AI shopping suggestions
tags: [Bring! Integration]
responses:
"200":
description: AI-generated shopping suggestions
/index.php?action=smart_shopping:
get:
summary: Get smart shopping predictions
tags: [Bring! Integration]
responses:
"200":
description: Predicted shopping needs
/index.php?action=save_settings:
post:
summary: Save server-side settings (.env values)
tags: [Settings]
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
"200":
description: Settings saved
/index.php?action=get_settings:
get:
summary: Get server settings (masked passwords)
tags: [Settings]
responses:
"200":
description: Current settings
/index.php?action=app_settings_get:
get:
summary: Get application settings from database
tags: [Settings]
responses:
"200":
description: App settings object
/index.php?action=app_settings_save:
post:
summary: Save application settings to database
tags: [Settings]
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
"200":
description: App settings saved
/index.php?action=recipes_list:
get:
summary: List saved recipes
tags: [Recipes]
responses:
"200":
description: Array of saved recipes
/index.php?action=recipes_save:
post:
summary: Save a recipe
tags: [Recipes]
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
"200":
description: Recipe saved
/index.php?action=recipes_delete:
post:
summary: Delete a recipe
tags: [Recipes]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id:
type: integer
responses:
"200":
description: Recipe deleted
/index.php?action=dupliclick_login:
post:
summary: Login to DupliClick (online shopping)
tags: [DupliClick]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
email:
type: string
password:
type: string
responses:
"200":
description: Login successful
/index.php?action=dupliclick_search:
get:
summary: Search DupliClick product catalog
tags: [DupliClick]
parameters:
- name: q
in: query
required: true
schema:
type: string
responses:
"200":
description: Search results
/index.php?action=tts_proxy:
post:
summary: Proxy TTS request to external endpoint
tags: [TTS]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
text:
type: string
responses:
"200":
description: TTS request sent
/index.php?action=expiry_history:
get:
summary: Get expiry scan history for a product
tags: [Inventory]
parameters:
- name: product_id
in: query
required: true
schema:
type: integer
responses:
"200":
description: Expiry history entries
components:
schemas:
Product:
type: object
properties:
id:
type: integer
name:
type: string
brand:
type: string
barcode:
type: string
category:
type: string
enum:
- latticini
- carne
- pesce
- frutta
- verdura
- pasta
- pane
- surgelati
- bevande
- condimenti
- snack
- conserve
- cereali
- igiene
- pulizia
- altro
unit:
type: string
enum: [pz, conf, g, ml]
default_quantity:
type: number
package_size:
type: number
package_unit:
type: string
notes:
type: string
created_at:
type: string
format: date-time
ProductInput:
type: object
required: [name]
properties:
id:
type: integer
description: If provided, updates existing product
name:
type: string
brand:
type: string
barcode:
type: string
category:
type: string
unit:
type: string
default_quantity:
type: number
package_size:
type: number
InventoryItem:
type: object
properties:
id:
type: integer
product_id:
type: integer
product_name:
type: string
quantity:
type: number
location:
type: string
expiry_date:
type: string
format: date
opened:
type: boolean
created_at:
type: string
format: date-time
responses:
TooManyRequests:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
content:
application/json:
schema:
type: object
properties:
error:
type: string
tags:
- name: Products
description: Product catalog management
- name: Inventory
description: Inventory tracking (stock in/out)
- name: Log
description: Operations log and statistics
- name: AI / Gemini
description: Google Gemini AI integration (identification, recipes, chat)
- name: Bring! Integration
description: Bring! shopping list integration
- name: Recipes
description: Recipe storage
- name: Settings
description: Application and server settings
- name: DupliClick
description: DupliClick online shopping integration
- name: TTS
description: Text-to-Speech proxy
+94 -65
View File
@@ -20,12 +20,12 @@
<!-- Top Header -->
<header class="app-header">
<div class="header-content">
<h1 class="header-title" onclick="showPage('dashboard')">🏠 Dispensa</h1>
<h1 class="header-title" onclick="showPage('dashboard')" data-i18n="nav.title">🏠 Dispensa</h1>
<div class="header-actions">
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini">
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title">
<svg class="gemini-icon" viewBox="0 0 24 24" width="28" height="28" fill="white"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
</button>
<button class="header-scan-btn" id="btn-header-scan" title="Scansiona prodotto (tieni premuto per modalità spesa)">
<button class="header-scan-btn" id="btn-header-scan" title="Scansiona prodotto (tieni premuto per modalità spesa)" data-i18n-title="scan.hint">
📷
</button>
</div>
@@ -41,22 +41,22 @@
<div class="stat-card" onclick="showPage('inventory', 'dispensa')">
<span class="stat-icon">🗄️</span>
<span class="stat-value" id="stat-dispensa">0</span>
<span class="stat-label">Dispensa</span>
<span class="stat-label" data-i18n="locations.dispensa">Dispensa</span>
</div>
<div class="stat-card" onclick="showPage('inventory', 'frigo')">
<span class="stat-icon">🧊</span>
<span class="stat-value" id="stat-frigo">0</span>
<span class="stat-label">Frigo</span>
<span class="stat-label" data-i18n="locations.frigo">Frigo</span>
</div>
<div class="stat-card" onclick="showPage('inventory', 'freezer')">
<span class="stat-icon">❄️</span>
<span class="stat-value" id="stat-freezer">0</span>
<span class="stat-label">Freezer</span>
<span class="stat-label" data-i18n="locations.freezer">Freezer</span>
</div>
<div class="stat-card" onclick="showPage('shopping')">
<span class="stat-icon">🛒</span>
<span class="stat-value" id="stat-spesa">-</span>
<span class="stat-label">Spesa</span>
<span class="stat-label" data-i18n="nav.shopping">Spesa</span>
<span class="stat-urgent" id="stat-urgent" style="display:none"></span>
</div>
</div>
@@ -65,39 +65,39 @@
<div class="quick-recipe-bar" id="quick-recipe-bar" style="display:none">
<button class="btn-quick-recipe" onclick="quickRecipeSuggestion()">
<span>🍳</span>
<span class="quick-recipe-text">Ricetta veloce con prodotti in scadenza</span>
<span class="quick-recipe-text" data-i18n="dashboard.quick_recipe">🍳 Ricetta veloce con prodotti in scadenza</span>
<span></span>
</button>
</div>
<!-- Alert for expired items (on top) -->
<div class="alert-section alert-danger" id="alert-expired" style="display:none">
<h3>🚫 Scaduti</h3>
<h3 data-i18n="dashboard.expired_title">🚫 Scaduti</h3>
<div id="expired-list"></div>
</div>
<!-- Alert for soonest expiring items -->
<div class="alert-section" id="alert-expiring" style="display:none">
<h3>⏰ Prossime Scadenze</h3>
<h3 data-i18n="dashboard.expiring_title">⏰ Prossime Scadenze</h3>
<div id="expiring-list"></div>
</div>
<!-- Waste vs consumption mini chart -->
<div class="waste-chart-section" id="waste-chart-section" style="display:none">
<h3>📊 Ultimi 30 giorni</h3>
<h3 data-i18n="dashboard.stats_period">📊 Ultimi 30 giorni</h3>
<div class="waste-chart-bar" id="waste-chart-bar"></div>
<div class="waste-chart-legend" id="waste-chart-legend"></div>
</div>
<!-- Opened (partially used) products -->
<div class="alert-section alert-opened" id="alert-opened" style="display:none">
<h3>📦 Prodotti Aperti</h3>
<h3 data-i18n="dashboard.opened_title">📦 Prodotti Aperti</h3>
<div id="opened-list"></div>
</div>
<!-- Review suspicious quantities -->
<div class="alert-section alert-review" id="alert-review" style="display:none">
<h3>🔍 Da revisionare</h3>
<p class="review-hint">Quantità che sembrano anomale. Conferma se corrette o modifica.</p>
<h3 data-i18n="dashboard.review_title">🔍 Da revisionare</h3>
<p class="review-hint" data-i18n="dashboard.review_hint">Quantità che sembrano anomale. Conferma se corrette o modifica.</p>
<div id="review-list"></div>
</div>
@@ -106,18 +106,18 @@
<!-- ===== INVENTORY LIST ===== -->
<section class="page" id="page-inventory">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
<h2 id="inventory-title">Dispensa</h2>
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
</div>
<div class="location-tabs" id="location-tabs">
<button class="tab active" onclick="filterLocation('')" data-loc="">Tutti</button>
<button class="tab active" onclick="filterLocation('')" data-loc="" data-i18n="inventory.filter_all">Tutti</button>
<button class="tab" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ Dispensa</button>
<button class="tab" onclick="filterLocation('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>
</div>
<div class="search-bar">
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()">
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()" data-i18n-placeholder="inventory.search_placeholder">
</div>
<div class="inventory-list" id="inventory-list"></div>
</section>
@@ -125,15 +125,15 @@
<!-- ===== SCAN PAGE ===== -->
<section class="page" id="page-scan">
<div class="page-header">
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')">← Indietro</button>
<h2>Scansiona Prodotto</h2>
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="scan.title">Scansiona Prodotto</h2>
</div>
<div class="spesa-mode-banner" id="spesa-mode-banner" style="display:none">
<div class="spesa-banner-left">
<span>🛒 Modalità Spesa</span>
<span data-i18n="scan.mode_shopping">🛒 Modalità Spesa</span>
<span class="spesa-stat"></span>
</div>
<button class="btn btn-small" onclick="endSpesaMode()">✅ Fine spesa</button>
<button class="btn btn-small" onclick="endSpesaMode()" data-i18n="scan.mode_shopping_end">✅ Fine spesa</button>
</div>
<div class="scan-container">
<div class="scanner-viewport" id="scanner-viewport">
@@ -147,7 +147,7 @@
<div class="scan-result" id="scan-result" style="display:none"></div>
<div class="barcode-manual-entry">
<div class="barcode-input-row">
<input type="text" id="manual-barcode-input" class="form-input" placeholder="Inserisci codice a barre..." inputmode="numeric" pattern="[0-9]*" onkeydown="if(event.key==='Enter')submitManualBarcode()">
<input type="text" id="manual-barcode-input" class="form-input" placeholder="Inserisci codice a barre..." inputmode="numeric" pattern="[0-9]*" onkeydown="if(event.key==='Enter')submitManualBarcode()" data-i18n-placeholder="scan.barcode_placeholder">
<button class="btn btn-primary" onclick="submitManualBarcode()">🔍 Cerca</button>
</div>
</div>
@@ -504,11 +504,11 @@
<!-- ===== RECIPE PAGE ===== -->
<section class="page" id="page-recipe">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
<h2>🍳 Ricette</h2>
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
</div>
<div class="recipe-page-container">
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()">
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()" data-i18n="recipes.generate">
✨ Genera nuova ricetta
</button>
<div id="recipe-archive" class="recipe-archive"></div>
@@ -518,12 +518,12 @@
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
<section class="page" id="page-shopping">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
<h2>🛒 Lista della Spesa</h2>
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
</div>
<div class="shopping-container">
<div class="bring-status" id="bring-status">
<div class="bring-loading">Connessione a Bring!...</div>
<div class="bring-loading" data-i18n="shopping.bring_loading">Connessione a Bring!...</div>
</div>
<!-- Tab navigation -->
@@ -541,7 +541,7 @@
<!-- Price total banner -->
<div class="spesa-total-banner" id="spesa-total-banner" style="display:none">
<div class="spesa-total-row">
<span class="spesa-total-label">💰 Totale stimato</span>
<span class="spesa-total-label" data-i18n="shopping.total_label">💰 Totale stimato</span>
<span class="spesa-total-value" id="spesa-total-value">€ 0,00</span>
</div>
<div class="spesa-total-detail" id="spesa-total-detail"></div>
@@ -642,10 +642,10 @@
<!-- Log Page -->
<section id="page-log" class="page">
<div class="page-header">
<h2>📒 Log Operazioni</h2>
<h2 data-i18n="log.title">📒 Log Operazioni</h2>
</div>
<div id="log-list" class="log-list"></div>
<button class="btn btn-secondary full-width mt-2" id="log-load-more" style="display:none" onclick="loadLog(true)">
<button class="btn btn-secondary full-width mt-2" id="log-load-more" style="display:none" onclick="loadLog(true)" data-i18n="btn.load_more">
Carica altri...
</button>
</section>
@@ -653,8 +653,8 @@
<!-- ===== SETTINGS PAGE ===== -->
<section class="page" id="page-settings">
<div class="page-header">
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
<h2>⚙️ Configurazione</h2>
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
</div>
<div class="settings-tabs">
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
@@ -665,14 +665,15 @@
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-spesa')" data-tab="tab-spesa" title="Spesa Online">🛍️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)">🔊</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-language')" data-tab="tab-language" title="Lingua" data-i18n-title="settings.tab_language">🌐</button>
</div>
<div class="settings-panels">
<!-- API Keys Tab -->
<div class="settings-panel active" id="tab-api">
<div class="settings-card">
<h4>🤖 Google Gemini AI</h4>
<p class="settings-hint">Chiave API per identificazione prodotti, scadenze e ricette.</p>
<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>
<input type="password" id="setting-gemini-key" class="form-input" placeholder="AIza...">
@@ -683,8 +684,8 @@
<!-- Bring! Tab -->
<div class="settings-panel" id="tab-bring">
<div class="settings-card">
<h4>🛒 Bring! Shopping List</h4>
<p class="settings-hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
<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>
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
@@ -699,8 +700,8 @@
<!-- Recipe Tab -->
<div class="settings-panel" id="tab-recipe">
<div class="settings-card">
<h4>🍳 Preferenze Ricette</h4>
<p class="settings-hint">Configura le opzioni predefinite per la generazione delle ricette.</p>
<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>
<div class="qty-control">
@@ -729,8 +730,8 @@
<!-- Weekly Meal Plan Tab -->
<div class="settings-panel" id="tab-mealplan">
<div class="settings-card">
<h4>📅 Piano Pasti Settimanale</h4>
<p class="settings-hint">Imposta la tipologia di pasto per ogni giorno. Sarà usata come guida nella generazione delle ricette.</p>
<h4 data-i18n="settings.mealplan.title">📅 Piano Pasti Settimanale</h4>
<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>
@@ -759,8 +760,8 @@
<!-- Appliances Tab -->
<div class="settings-panel" id="tab-appliances">
<div class="settings-card">
<h4>🔌 Elettrodomestici Disponibili</h4>
<p class="settings-hint">Indica gli elettrodomestici che hai a disposizione. Saranno considerati nella generazione delle ricette.</p>
<h4 data-i18n="settings.appliances.title">🔌 Elettrodomestici Disponibili</h4>
<p class="settings-hint" data-i18n="settings.appliances.hint">Indica gli elettrodomestici che hai a disposizione. Saranno considerati nella generazione delle ricette.</p>
<div class="appliances-list" id="appliances-list"></div>
<div class="form-group mt-2">
<div class="barcode-input-row">
@@ -826,8 +827,8 @@
<!-- Camera Tab -->
<div class="settings-panel" id="tab-camera">
<div class="settings-card">
<h4>📷 Fotocamera</h4>
<p class="settings-hint">Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.</p>
<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>
<select id="setting-camera-facing" class="form-input">
@@ -866,8 +867,8 @@
<!-- TTS Tab -->
<div class="settings-panel" id="tab-tts">
<div class="settings-card">
<h4>🔊 Voce & TTS</h4>
<p class="settings-hint">Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.</p>
<h4 data-i18n="settings.tts.title">🔊 Voce & TTS</h4>
<p class="settings-hint" data-i18n="settings.tts.hint">Configura la sintesi vocale tramite qualsiasi API REST esterna. I passi della ricetta e i timer scaduti verranno inviati all'endpoint configurato.</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span>✅ Attiva TTS</span>
@@ -935,8 +936,21 @@
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
</div>
</div>
<!-- Language Tab -->
<div class="settings-panel" id="tab-language">
<div class="settings-card">
<h4 data-i18n="settings.language.title">🌐 Lingua / Language</h4>
<p class="settings-hint" data-i18n="settings.language.hint">Seleziona la lingua dell'interfaccia. Select the interface language.</p>
<div class="form-group">
<label data-i18n="settings.language.label">🌐 Lingua</label>
<select id="setting-language" class="form-input" onchange="changeLanguage(this.value)">
</select>
<p class="settings-hint mt-2" data-i18n="settings.language.restart_notice">La pagina verrà ricaricata per applicare la nuova lingua.</p>
</div>
</div>
</div>
</div>
<button class="btn btn-large btn-success full-width mt-2" onclick="saveSettings()">💾 Salva Configurazione</button>
<button class="btn btn-large btn-success full-width mt-2" onclick="saveSettings()" data-i18n="btn.save_config">💾 Salva Configurazione</button>
<div id="settings-status" class="settings-status" style="display:none"></div>
</section>
@@ -946,25 +960,25 @@
<div class="chat-header-bar">
<div class="chat-header-info">
<svg class="gemini-icon-sm" viewBox="0 0 24 24" width="22" height="22" fill="#6366f1"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
<span class="chat-title">Gemini Chef</span>
<span class="chat-title" data-i18n="chat.title">Gemini Chef</span>
</div>
<button class="btn-chat-clear" onclick="clearChat()" title="Nuova conversazione">🗑️</button>
<button class="btn-chat-clear" onclick="clearChat()" title="Nuova conversazione" data-i18n-title="chat.clear">🗑️</button>
</div>
<div class="chat-messages" id="chat-messages">
<div class="chat-welcome">
<svg class="gemini-icon-lg" viewBox="0 0 24 24" width="48" height="48" fill="#6366f1"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
<h3>Ciao! Sono il tuo assistente cucina</h3>
<p>Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!</p>
<h3 data-i18n="chat.welcome">Ciao! Sono il tuo assistente cucina</h3>
<p data-i18n="chat.welcome_desc">Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!</p>
<div class="chat-suggestions">
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa posso preparare per uno spuntino veloce?')">🍿 Spuntino veloce</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Fammi un succo o frullato con quello che ho')">🥤 Succo/Frullato</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Ho fame ma voglio qualcosa di leggero')">🥗 Qualcosa di leggero</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa sta per scadere e come posso usarlo?')">⏰ Usa le scadenze</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa posso preparare per uno spuntino veloce?')" data-i18n="chat.suggestion_snack">🍿 Spuntino veloce</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Fammi un succo o frullato con quello che ho')" data-i18n="chat.suggestion_juice">🥤 Succo/Frullato</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Ho fame ma voglio qualcosa di leggero')" data-i18n="chat.suggestion_light">🥗 Qualcosa di leggero</button>
<button class="chat-suggestion" onclick="sendChatSuggestion('Cosa sta per scadere e come posso usarlo?')" data-i18n="chat.suggestion_expiry">⏰ Usa le scadenze</button>
</div>
</div>
</div>
<div class="chat-input-bar">
<input type="text" id="chat-input" class="chat-input" placeholder="Chiedi qualcosa..." onkeydown="if(event.key==='Enter')sendChatMessage()">
<input type="text" id="chat-input" class="chat-input" placeholder="Chiedi qualcosa..." onkeydown="if(event.key==='Enter')sendChatMessage()" data-i18n-placeholder="chat.placeholder">
<button class="btn-chat-send" id="btn-chat-send" onclick="sendChatMessage()">
<svg viewBox="0 0 24 24" width="22" height="22" fill="white"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
@@ -978,23 +992,23 @@
<nav class="bottom-nav">
<button class="nav-btn" onclick="showPage('dashboard')" data-page="dashboard">
<span class="nav-icon">🏠</span>
<span class="nav-label">Home</span>
<span class="nav-label" data-i18n="nav.home">Home</span>
</button>
<button class="nav-btn" onclick="showPage('inventory', '')" data-page="inventory">
<span class="nav-icon">📋</span>
<span class="nav-label">Dispensa</span>
<span class="nav-label" data-i18n="nav.inventory">Dispensa</span>
</button>
<button class="nav-btn" onclick="showPage('recipe')" data-page="recipe">
<span class="nav-icon">🍳</span>
<span class="nav-label">Ricette</span>
<span class="nav-label" data-i18n="nav.recipes">Ricette</span>
</button>
<button class="nav-btn" onclick="showPage('shopping')" data-page="shopping">
<span class="nav-icon">🛒</span>
<span class="nav-label">Spesa</span>
<span class="nav-label" data-i18n="nav.shopping">Spesa</span>
</button>
<button class="nav-btn" onclick="showPage('log')" data-page="log">
<span class="nav-icon">📒</span>
<span class="nav-label">Log</span>
<span class="nav-label" data-i18n="nav.log">Log</span>
</button>
<button class="nav-btn" onclick="showPage('settings')" data-page="settings">
<span class="nav-icon">⚙️</span>
@@ -1060,13 +1074,28 @@
</div>
</div>
<!-- Setup Wizard (first-run) -->
<div class="modal-overlay" id="setup-wizard" style="display:none">
<div class="modal-content setup-wizard-content" onclick="event.stopPropagation()">
<div class="setup-header">
<h2>🏠 Dispensa Manager</h2>
<div class="setup-progress" id="setup-progress"></div>
</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>
</div>
</div>
</div>
<!-- Toast notification -->
<div class="toast" id="toast"></div>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loading" style="display:none">
<div class="loading-spinner"></div>
<p>Caricamento...</p>
<p data-i18n="app.loading">Caricamento...</p>
</div>
<!-- Modal for product details from inventory -->
+431
View File
@@ -0,0 +1,431 @@
{
"app": {
"name": "Dispensa Manager",
"loading": "Laden..."
},
"nav": {
"title": "🏠 Vorratskammer",
"home": "Home",
"inventory": "Vorrat",
"recipes": "Rezepte",
"shopping": "Einkauf",
"log": "Log"
},
"btn": {
"back": "← Zurück",
"save": "💾 Speichern",
"cancel": "✕ Abbrechen",
"close": "Schließen",
"add": "✅ Hinzufügen",
"delete": "Löschen",
"edit": "✏️ Bearbeiten",
"search": "🔍 Suchen",
"go": "✅ Los",
"toggle_password": "👁️ Anzeigen/Ausblenden",
"load_more": "Mehr laden...",
"save_config": "💾 Konfiguration speichern",
"save_product": "💾 Produkt speichern",
"restart": "↺ Neustart",
"reset_default": "↺ Standard wiederherstellen"
},
"locations": {
"dispensa": "Vorratskammer",
"frigo": "Kühlschrank",
"freezer": "Gefrierschrank",
"altro": "Sonstiges"
},
"categories": {
"latticini": "Milchprodukte",
"carne": "Fleisch",
"pesce": "Fisch",
"frutta": "Obst",
"verdura": "Gemüse",
"pasta": "Pasta & Reis",
"pane": "Brot & Backwaren",
"surgelati": "Tiefkühl",
"bevande": "Getränke",
"condimenti": "Gewürze",
"snack": "Snacks & Süßes",
"conserve": "Konserven",
"cereali": "Getreide & Hülsenfrüchte",
"igiene": "Hygiene",
"pulizia": "Reinigung",
"altro": "Sonstiges",
"select": "-- Auswählen --"
},
"units": {
"pz": "Stk",
"conf": "Pkg",
"g": "g",
"ml": "ml",
"pieces": "Stück",
"grams": "Gramm",
"box": "Packung",
"boxes": "Packungen"
},
"shopping_sections": {
"frutta_verdura": "Obst & Gemüse",
"carne_pesce": "Fleisch & Fisch",
"latticini": "Milchprodukte & Frisches",
"pane_dolci": "Brot & Süßes",
"pasta": "Pasta & Getreide",
"conserve": "Konserven & Soßen",
"surgelati": "Tiefkühl",
"bevande": "Getränke",
"pulizia_igiene": "Reinigung & Hygiene",
"altro": "Sonstiges"
},
"dashboard": {
"expired_title": "🚫 Abgelaufen",
"expiring_title": "⏰ Bald ablaufend",
"stats_period": "📊 Letzte 30 Tage",
"opened_title": "📦 Geöffnete Produkte",
"review_title": "🔍 Zu prüfen",
"review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.",
"quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten"
},
"inventory": {
"title": "Vorrat",
"filter_all": "Alle",
"search_placeholder": "🔍 Produkt suchen...",
"empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!",
"no_items_found": "Keine Bestandseinträge gefunden"
},
"scan": {
"title": "Produkt scannen",
"mode_shopping": "🛒 Einkaufsmodus",
"mode_shopping_end": "✅ Einkauf beenden",
"zoom": "Zoom",
"barcode_placeholder": "Barcode eingeben...",
"quick_name_divider": "oder Name eingeben",
"quick_name_placeholder": "z.B.: Äpfel, Zucchini, Brot...",
"manual_entry": "✏️ Manuelle Eingabe",
"ai_identify": "🤖 Mit KI identifizieren",
"hint": "Barcode scannen, Produktname eingeben oder KI zur Identifikation nutzen",
"debug_toggle": "🐛 Debug Log",
"barcode_acquired": "🔖 Barcode gescannt: {code}",
"scan_barcode": "🔖 Barcode scannen"
},
"action": {
"title": "Was möchtest du tun?",
"add_btn": "📥 HINZUFÜGEN",
"add_sub": "in Vorrat/Kühlschrank",
"use_btn": "📤 VERWENDEN / VERBRAUCHEN",
"use_sub": "aus Vorrat/Kühlschrank"
},
"add": {
"title": "Zum Vorrat hinzufügen",
"location_label": "📍 Wohin?",
"quantity_label": "📦 Menge",
"conf_size_label": "📦 Jede Packung enthält:",
"conf_size_placeholder": "z.B. 300",
"vacuum_label": "🫙 Vakuumiert",
"vacuum_hint": "Ablaufdatum wird automatisch verlängert",
"submit": "✅ Hinzufügen"
},
"use": {
"title": "Verwenden / Verbrauchen",
"location_label": "📍 Woher?",
"quantity_label": "Wie viel hast du benutzt?",
"partial_hint": "Oder genaue Menge angeben:",
"use_all": "🗑️ ALLES verwendet / Aufgebraucht",
"submit": "📤 Diese Menge verwenden",
"available": "📦 Verfügbar:",
"not_in_inventory": "⚠️ Produkt nicht im Bestand.",
"expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!"
},
"product": {
"title_new": "Neues Produkt",
"title_edit": "Produkt bearbeiten",
"ai_fill": "📷 Foto machen und mit KI identifizieren",
"ai_fill_hint": "KI füllt die Produktfelder automatisch aus",
"name_label": "🏷️ Produktname *",
"name_placeholder": "z.B.: Vollmilch, Penne Nudeln...",
"brand_label": "🏢 Marke",
"brand_placeholder": "z.B.: Barilla, Müller, Knorr...",
"category_label": "📂 Kategorie",
"unit_label": "📏 Maßeinheit",
"default_qty_label": "🔢 Standardmenge",
"conf_size_label": "📦 Jede Packung enthält:",
"conf_size_placeholder": "z.B. 300",
"notes_label": "📝 Notizen",
"notes_placeholder": "z.B.: laktosefrei, bio, nach dem Öffnen im Kühlschrank aufbewahren...",
"barcode_label": "🔖 Barcode",
"barcode_placeholder": "Barcode (falls vorhanden)",
"barcode_hint": "⚠️ Barcode hinzufügen, damit du beim nächsten Einkauf nur scannen musst!",
"submit": "💾 Produkt speichern",
"name_required": "Produktname eingeben",
"conf_size_required": "Packungsinhalt angeben",
"expiry_estimated": "Geschätztes Ablaufdatum:",
"scan_expiry": "Ablaufdatum scannen",
"expiry_hint": "📝 Du kannst das Datum ändern oder mit der Kamera scannen",
"add_batch": "📦 + Charge mit anderem Ablaufdatum",
"package_info": "📦 Packung: {info}",
"edit_catalog": "⚙️ Produktinfo bearbeiten (Name, Marke, Kategorie…)",
"not_recognized": "⚠️ Produkt nicht erkannt",
"edit_info": "✏️ Informationen bearbeiten",
"modify_details": "BEARBEITEN\nAblauf, Ort…"
},
"products": {
"title": "📦 Alle Produkte",
"search_placeholder": "🔍 Produkt suchen...",
"empty": "Keine Produkte in der Datenbank.\nScanne ein Produkt zum Starten!",
"no_category": "Keine Produkte in dieser Kategorie"
},
"recipes": {
"title": "🍳 Rezepte",
"generate": "✨ Neues Rezept generieren"
},
"shopping": {
"title": "🛒 Einkaufsliste",
"bring_loading": "Verbindung zu Bring!...",
"tab_to_buy": "🛍️ Zu kaufen",
"tab_forecast": "🧠 Vorhersage",
"total_label": "💰 Geschätzter Gesamtbetrag",
"section_to_buy": "🛍️ Zu kaufen",
"suggestions_title": "💡 KI-Vorschläge",
"suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen",
"search_prices": "🔍 Alle Preise suchen",
"suggest_btn": "🤖 Einkaufsvorschläge",
"smart_title": "🧠 Intelligente Vorhersagen",
"smart_empty": "Keine Vorhersagen verfügbar.<br>Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.",
"smart_filter_all": "Alle",
"smart_filter_critical": "🔴 Dringend",
"smart_filter_high": "🟠 Bald",
"smart_filter_medium": "🟡 Planen",
"smart_filter_low": "🟢 Vorhersage",
"smart_add": "🛒 Ausgewählte zu Bring! hinzufügen",
"empty": "Einkaufsliste leer!\nNutze den Button unten, um Vorschläge zu generieren.",
"already_in_list": "🛒 \"{name}\" ist bereits in der Einkaufsliste",
"already_in_list_short": "️ Bereits in der Einkaufsliste",
"add_prompt": "Möchtest du es zur Einkaufsliste hinzufügen?",
"smart_already": "📊 Intelligenter Einkauf sagt bereits {name} voraus",
"all_searched": "Alle Produkte wurden bereits gesucht. Nutze 🔄 für einzelne Suchen.",
"search_complete": "Suche abgeschlossen: {count} Produkte",
"removed_sufficient": "🧹 {removed} Produkt(e) mit ausreichendem Bestand von der Liste entfernt"
},
"ai": {
"title": "🤖 KI-Identifikation",
"capture": "📸 Foto aufnehmen",
"retake": "🔄 Neu aufnehmen",
"hint": "Mache ein Foto des Produkts und die KI versucht es zu identifizieren",
"identifying": "🤖 Identifiziere Produkt...",
"no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\n<small>Füge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.</small>",
"fields_filled": "✅ Felder von KI ausgefüllt"
},
"log": {
"title": "📒 Operationslog"
},
"chat": {
"title": "Gemini Chef",
"welcome": "Hallo! Ich bin dein Küchenassistent",
"welcome_desc": "Frag mich, dir einen Saft, einen Snack, ein schnelles Gericht zu machen... Ich kenne deinen Vorrat, deine Geräte und deine Vorlieben!",
"suggestion_snack": "🍿 Schneller Snack",
"suggestion_juice": "🥤 Saft/Smoothie",
"suggestion_light": "🥗 Etwas Leichtes",
"suggestion_expiry": "⏰ Ablaufende nutzen",
"clear": "Neues Gespräch",
"placeholder": "Frag etwas..."
},
"cooking": {
"close": "Schließen",
"tts_btn": "Vorlesen",
"restart": "↺ Neustart",
"replay": "🔊 Nochmal",
"timer": "⏱️ {time} · Timer",
"prev": "◀ Zurück",
"next": "Weiter ▶"
},
"settings": {
"title": "⚙️ Einstellungen",
"tab_api": "API Keys",
"tab_bring": "Bring!",
"tab_recipe": "Rezepte",
"tab_mealplan": "Wochenplan",
"tab_appliances": "Geräte",
"tab_spesa": "Online-Einkauf",
"tab_camera": "Kamera",
"tab_security": "Sicherheit",
"tab_tts": "Sprache (TTS)",
"tab_language": "Sprache",
"gemini": {
"title": "🤖 Google Gemini AI",
"hint": "API-Schlüssel für Produkterkennung, Ablaufdaten und Rezepte.",
"key_label": "Gemini API Key"
},
"bring": {
"title": "🛒 Bring! Einkaufsliste",
"hint": "Zugangsdaten für die Bring! Einkaufslisten-Integration.",
"email_label": "📧 Bring! E-Mail",
"password_label": "🔒 Bring! Passwort"
},
"recipe": {
"title": "🍳 Rezept-Einstellungen",
"hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.",
"persons_label": "👥 Standard-Portionen",
"options_label": "🎯 Standard-Rezeptoptionen",
"fast": "⚡ Schnelles Gericht",
"light": "🥗 Leichte Mahlzeit",
"expiry": "⏰ Ablauf-Priorität",
"healthy": "💚 Extra Gesund",
"opened": "📦 Offene Produkte zuerst",
"zerowaste": "♻️ Keine Verschwendung",
"dietary_label": "🚫 Unverträglichkeiten / Einschränkungen",
"dietary_placeholder": "z.B.: glutenfrei, laktosefrei, vegetarisch..."
},
"mealplan": {
"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"
},
"appliances": {
"title": "🔌 Verfügbare Geräte",
"hint": "Gib an, welche Geräte du hast. Sie werden bei der Rezeptgenerierung berücksichtigt.",
"new_placeholder": "z.B.: Brotbackmaschine, Thermomix, Heißluftfritteuse...",
"quick_title": "Schnell hinzufügen:",
"oven": "🔥 Backofen",
"microwave": "📡 Mikrowelle",
"air_fryer": "🍟 Heißluftfritteuse",
"bread_maker": "🍞 Brotbackmaschine",
"bimby": "🤖 Thermomix/Cookeo",
"mixer": "🌀 Küchenmaschine",
"steamer": "♨️ Dampfgarer",
"pressure_cooker": "🫕 Schnellkochtopf",
"toaster": "🍞 Toaster",
"blender": "🍹 Mixer",
"empty": "Keine Geräte hinzugefügt"
},
"spesa": {
"title": "🛍️ Online-Einkauf",
"hint": "Online-Einkaufsanbieter konfigurieren.",
"provider_label": "🏪 Anbieter",
"email_label": "📧 E-Mail",
"password_label": "🔒 Passwort",
"login_btn": "🔐 Anmelden",
"ai_prompt_label": "🤖 KI-Produktauswahl Prompt",
"ai_prompt_placeholder": "Anweisungen für die KI bei der Auswahl zwischen mehreren Produkten...",
"ai_prompt_hint": "Die KI verwendet diesen Prompt zur Auswahl des passendsten Produkts. Leer lassen für Standardverhalten.",
"configure_first": "Konfiguriere zuerst den Online-Einkauf in den Einstellungen"
},
"camera": {
"title": "📷 Kamera",
"hint": "Wähle die Kamera für Barcode-Scanning und KI-Identifikation.",
"device_label": "📸 Standardkamera",
"back": "📱 Rückkamera (Standard)",
"front": "🤳 Frontkamera",
"devices_hint": "Bei mehreren Kameras kannst du nach Freigabe der Berechtigungen eine bestimmte aus der Liste oben wählen.",
"detect_btn": "🔄 Kameras erkennen"
},
"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"
},
"tts": {
"title": "🔊 Sprache & TTS",
"hint": "Sprachsynthese über externe REST-API konfigurieren. Rezeptschritte und abgelaufene Timer werden an den Endpunkt gesendet.",
"enabled": "✅ TTS aktivieren",
"url_label": "🌐 Endpunkt-URL",
"method_label": "📡 HTTP-Methode",
"auth_label": "🔐 Authentifizierung",
"auth_bearer": "Bearer Token",
"auth_custom": "Benutzerdefinierter Header",
"auth_none": "Keine",
"token_label": "🔑 Bearer Token",
"custom_header_name": "📋 Header-Name",
"custom_header_value": "📋 Header-Wert",
"content_type_label": "📄 Content-Type",
"payload_key_label": "🗝️ Textfeld im Payload",
"payload_key_hint": "Name des JSON-Feldes für den zu lesenden Text (z.B.: message, text).",
"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"
},
"language": {
"title": "🌐 Sprache",
"hint": "Wähle die Sprache der Benutzeroberfläche.",
"label": "🌐 Sprache",
"restart_notice": "Die Seite wird neu geladen, um die neue Sprache anzuwenden."
},
"saved": "✅ Konfiguration gespeichert!",
"saved_local": "✅ Konfiguration lokal gespeichert",
"saved_local_error": "⚠️ Lokal gespeichert, Serverfehler: {error}"
},
"expiry": {
"today": "HEUTE",
"tomorrow": "Morgen",
"days": "{days} Tage",
"expired_days": "Seit {days}T",
"expired_yesterday": "Seit gestern",
"expired_today": "Heute"
},
"status": {
"ok": "OK",
"check": "Prüfen",
"discard": "Entsorgen"
},
"toast": {
"product_saved": "Produkt gespeichert!",
"product_created": "Produkt erstellt!",
"product_updated": "✅ Produkt aktualisiert!",
"product_removed": "Produkt entfernt",
"updated": "Aktualisiert!",
"quantity_confirmed": "✓ Menge bestätigt",
"added_to_inventory": "✅ {name} hinzugefügt!",
"removed_from_list": "✅ {name} von der Liste entfernt!",
"removed_from_list_short": "Von der Liste entfernt",
"added_to_shopping": "🛒 Zur Einkaufsliste hinzugefügt!",
"removed_from_shopping": "🛒 Von der Einkaufsliste entfernt",
"finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt",
"thrown_away": "🗑️ {name} weggeworfen!",
"thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen",
"appliance_added": "Gerät hinzugefügt",
"item_added": "{name} hinzugefügt"
},
"error": {
"generic": "Fehler",
"loading": "Fehler beim Laden des Produkts",
"not_found": "Produkt nicht gefunden",
"not_found_manual": "Produkt nicht gefunden. Manuell eingeben.",
"search": "Suchfehler. Nochmal versuchen.",
"search_short": "Suchfehler",
"save": "Fehler beim Speichern",
"connection": "Verbindungsfehler",
"camera": "Kamera nicht verfügbar",
"bring_add": "Fehler beim Hinzufügen zu Bring!",
"bring_connection": "Bring! Verbindungsfehler",
"identification": "Identifikationsfehler",
"barcode_empty": "Barcode eingeben",
"barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)",
"min_chars": "Mindestens 2 Zeichen eingeben",
"not_in_inventory": "Produkt nicht im Bestand",
"appliance_exists": "Gerät bereits vorhanden",
"already_exists": "Bereits vorhanden"
},
"confirm": {
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?"
},
"edit": {
"title": "{name} bearbeiten"
},
"screensaver": {
"recipe_btn": "Rezepte",
"scan_btn": "Produkt scannen"
},
"days": {
"mon": "Montag",
"tue": "Dienstag",
"wed": "Mittwoch",
"thu": "Donnerstag",
"fri": "Freitag",
"sat": "Samstag",
"sun": "Sonntag"
},
"meal_types": {
"lunch": "Mittagessen",
"dinner": "Abendessen"
}
}
+431
View File
@@ -0,0 +1,431 @@
{
"app": {
"name": "Dispensa Manager",
"loading": "Loading..."
},
"nav": {
"title": "🏠 Pantry",
"home": "Home",
"inventory": "Pantry",
"recipes": "Recipes",
"shopping": "Shopping",
"log": "Log"
},
"btn": {
"back": "← Back",
"save": "💾 Save",
"cancel": "✕ Cancel",
"close": "Close",
"add": "✅ Add",
"delete": "Delete",
"edit": "✏️ Edit",
"search": "🔍 Search",
"go": "✅ Go",
"toggle_password": "👁️ Show/Hide",
"load_more": "Load more...",
"save_config": "💾 Save Configuration",
"save_product": "💾 Save Product",
"restart": "↺ Restart",
"reset_default": "↺ Reset to default"
},
"locations": {
"dispensa": "Pantry",
"frigo": "Fridge",
"freezer": "Freezer",
"altro": "Other"
},
"categories": {
"latticini": "Dairy",
"carne": "Meat",
"pesce": "Fish",
"frutta": "Fruit",
"verdura": "Vegetables",
"pasta": "Pasta & Rice",
"pane": "Bread & Bakery",
"surgelati": "Frozen",
"bevande": "Beverages",
"condimenti": "Condiments",
"snack": "Snacks & Sweets",
"conserve": "Canned Goods",
"cereali": "Cereals & Legumes",
"igiene": "Hygiene",
"pulizia": "Household",
"altro": "Other",
"select": "-- Select --"
},
"units": {
"pz": "pcs",
"conf": "pkg",
"g": "g",
"ml": "ml",
"pieces": "Pieces",
"grams": "Grams",
"box": "Package",
"boxes": "Packages"
},
"shopping_sections": {
"frutta_verdura": "Fruits & Vegetables",
"carne_pesce": "Meat & Fish",
"latticini": "Dairy & Fresh",
"pane_dolci": "Bread & Sweets",
"pasta": "Pasta & Cereals",
"conserve": "Canned & Sauces",
"surgelati": "Frozen",
"bevande": "Beverages",
"pulizia_igiene": "Cleaning & Hygiene",
"altro": "Other"
},
"dashboard": {
"expired_title": "🚫 Expired",
"expiring_title": "⏰ Expiring Soon",
"stats_period": "📊 Last 30 days",
"opened_title": "📦 Opened Products",
"review_title": "🔍 To Review",
"review_hint": "Quantities that seem unusual. Confirm if correct or modify.",
"quick_recipe": "🍳 Quick recipe with expiring products"
},
"inventory": {
"title": "Pantry",
"filter_all": "All",
"search_placeholder": "🔍 Search product...",
"empty": "No products here.\nScan a product to add it!",
"no_items_found": "No inventory items found"
},
"scan": {
"title": "Scan Product",
"mode_shopping": "🛒 Shopping Mode",
"mode_shopping_end": "✅ End shopping",
"zoom": "Zoom",
"barcode_placeholder": "Enter barcode...",
"quick_name_divider": "or type the name",
"quick_name_placeholder": "E.g.: Apples, Zucchini, Bread...",
"manual_entry": "✏️ Manual Entry",
"ai_identify": "🤖 Identify with AI",
"hint": "Scan the barcode, type the product name, or use AI to identify it",
"debug_toggle": "🐛 Debug Log",
"barcode_acquired": "🔖 Barcode scanned: {code}",
"scan_barcode": "🔖 Scan Barcode"
},
"action": {
"title": "What do you want to do?",
"add_btn": "📥 ADD",
"add_sub": "to pantry/fridge",
"use_btn": "📤 USE / CONSUME",
"use_sub": "from pantry/fridge"
},
"add": {
"title": "Add to Pantry",
"location_label": "📍 Where do you put it?",
"quantity_label": "📦 Quantity",
"conf_size_label": "📦 Each package contains:",
"conf_size_placeholder": "e.g. 300",
"vacuum_label": "🫙 Vacuum sealed",
"vacuum_hint": "Expiry date will be extended automatically",
"submit": "✅ Add"
},
"use": {
"title": "Use / Consume",
"location_label": "📍 From where?",
"quantity_label": "How much did you use?",
"partial_hint": "Or specify the quantity used:",
"use_all": "🗑️ Used ALL / Finished",
"submit": "📤 Use this quantity",
"available": "📦 Available:",
"not_in_inventory": "⚠️ Product not in inventory.",
"expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!"
},
"product": {
"title_new": "New Product",
"title_edit": "Edit Product",
"ai_fill": "📷 Take photo and identify with AI",
"ai_fill_hint": "AI will automatically fill in the product fields",
"name_label": "🏷️ Product Name *",
"name_placeholder": "E.g.: Whole milk, Penne pasta...",
"brand_label": "🏢 Brand",
"brand_placeholder": "E.g.: Barilla, Granarolo, Mutti...",
"category_label": "📂 Category",
"unit_label": "📏 Unit of measure",
"default_qty_label": "🔢 Default quantity",
"conf_size_label": "📦 Each package contains:",
"conf_size_placeholder": "e.g. 300",
"notes_label": "📝 Notes",
"notes_placeholder": "E.g.: lactose free, organic, store in fridge after opening...",
"barcode_label": "🔖 Barcode",
"barcode_placeholder": "Barcode (if available)",
"barcode_hint": "⚠️ Add the barcode so next time you just need to scan it!",
"submit": "💾 Save Product",
"name_required": "Enter the product name",
"conf_size_required": "Specify the package content",
"expiry_estimated": "Estimated expiry:",
"scan_expiry": "Scan expiry date",
"expiry_hint": "📝 You can edit the date or scan it with the camera",
"add_batch": "📦 + Batch with different expiry",
"package_info": "📦 Package: {info}",
"edit_catalog": "⚙️ Edit product info (name, brand, category…)",
"not_recognized": "⚠️ Product not recognized",
"edit_info": "✏️ Edit information",
"modify_details": "EDIT\nexpiry, location…"
},
"products": {
"title": "📦 All Products",
"search_placeholder": "🔍 Search product...",
"empty": "No products in database.\nScan a product to get started!",
"no_category": "No products in this category"
},
"recipes": {
"title": "🍳 Recipes",
"generate": "✨ Generate new recipe"
},
"shopping": {
"title": "🛒 Shopping List",
"bring_loading": "Connecting to Bring!...",
"tab_to_buy": "🛍️ To buy",
"tab_forecast": "🧠 Forecast",
"total_label": "💰 Estimated total",
"section_to_buy": "🛍️ To buy",
"suggestions_title": "💡 AI Suggestions",
"suggestions_add": "✅ Add selected to Bring!",
"search_prices": "🔍 Search all prices",
"suggest_btn": "🤖 Suggest what to buy",
"smart_title": "🧠 Smart Predictions",
"smart_empty": "No predictions available.<br>Add products to your pantry to receive smart predictions.",
"smart_filter_all": "All",
"smart_filter_critical": "🔴 Urgent",
"smart_filter_high": "🟠 Soon",
"smart_filter_medium": "🟡 Plan",
"smart_filter_low": "🟢 Forecast",
"smart_add": "🛒 Add selected to Bring!",
"empty": "Shopping list empty!\nUse the button below to generate suggestions.",
"already_in_list": "🛒 \"{name}\" is already in the shopping list",
"already_in_list_short": "️ Already in the shopping list",
"add_prompt": "Do you want to add it to the shopping list?",
"smart_already": "📊 Smart shopping already predicts {name}",
"all_searched": "All products have already been searched. Use 🔄 to search individual ones.",
"search_complete": "Search complete: {count} products",
"removed_sufficient": "🧹 {removed} product(s) with sufficient stock removed from the list"
},
"ai": {
"title": "🤖 AI Identification",
"capture": "📸 Take Photo",
"retake": "🔄 Retake",
"hint": "Take a photo of the product and AI will try to identify it",
"identifying": "🤖 Identifying product...",
"no_api_key": "⚠️ Gemini API key not configured.\n<small>Add GEMINI_API_KEY to the .env file on the server.</small>",
"fields_filled": "✅ Fields filled by AI"
},
"log": {
"title": "📒 Operations Log"
},
"chat": {
"title": "Gemini Chef",
"welcome": "Hi! I'm your kitchen assistant",
"welcome_desc": "Ask me to make you a juice, a snack, a quick dish... I know your pantry, your appliances and your preferences!",
"suggestion_snack": "🍿 Quick snack",
"suggestion_juice": "🥤 Juice/Smoothie",
"suggestion_light": "🥗 Something light",
"suggestion_expiry": "⏰ Use expiring items",
"clear": "New conversation",
"placeholder": "Ask something..."
},
"cooking": {
"close": "Close",
"tts_btn": "Read aloud",
"restart": "↺ Restart",
"replay": "🔊 Replay",
"timer": "⏱️ {time} · Timer",
"prev": "◀ Previous",
"next": "Next ▶"
},
"settings": {
"title": "⚙️ Settings",
"tab_api": "API Keys",
"tab_bring": "Bring!",
"tab_recipe": "Recipes",
"tab_mealplan": "Weekly Plan",
"tab_appliances": "Appliances",
"tab_spesa": "Online Shopping",
"tab_camera": "Camera",
"tab_security": "Security",
"tab_tts": "Voice (TTS)",
"tab_language": "Language",
"gemini": {
"title": "🤖 Google Gemini AI",
"hint": "API key for product identification, expiry dates and recipes.",
"key_label": "Gemini API Key"
},
"bring": {
"title": "🛒 Bring! Shopping List",
"hint": "Credentials for the Bring! shopping list integration.",
"email_label": "📧 Bring! Email",
"password_label": "🔒 Bring! Password"
},
"recipe": {
"title": "🍳 Recipe Preferences",
"hint": "Configure the default options for recipe generation.",
"persons_label": "👥 Default servings",
"options_label": "🎯 Default recipe options",
"fast": "⚡ Quick Meal",
"light": "🥗 Light Meal",
"expiry": "⏰ Expiry Priority",
"healthy": "💚 Extra Healthy",
"opened": "📦 Open Items Priority",
"zerowaste": "♻️ Zero Waste",
"dietary_label": "🚫 Intolerances / Restrictions",
"dietary_placeholder": "E.g.: gluten free, lactose free, vegetarian..."
},
"mealplan": {
"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"
},
"appliances": {
"title": "🔌 Available Appliances",
"hint": "Indicate the appliances you have. They will be considered in recipe generation.",
"new_placeholder": "E.g.: Bread machine, Thermomix, Air fryer...",
"quick_title": "Quick add:",
"oven": "🔥 Oven",
"microwave": "📡 Microwave",
"air_fryer": "🍟 Air fryer",
"bread_maker": "🍞 Bread maker",
"bimby": "🤖 Thermomix/Cookeo",
"mixer": "🌀 Stand mixer",
"steamer": "♨️ Steamer",
"pressure_cooker": "🫕 Pressure cooker",
"toaster": "🍞 Toaster",
"blender": "🍹 Blender",
"empty": "No appliances added"
},
"spesa": {
"title": "🛍️ Online Shopping",
"hint": "Configure the online shopping provider.",
"provider_label": "🏪 Provider",
"email_label": "📧 Email",
"password_label": "🔒 Password",
"login_btn": "🔐 Login",
"ai_prompt_label": "🤖 AI product selection prompt",
"ai_prompt_placeholder": "Instructions for AI when choosing between multiple products...",
"ai_prompt_hint": "AI uses this prompt to choose the most appropriate product from results. Leave empty for default behavior.",
"configure_first": "Configure Online Shopping in settings first"
},
"camera": {
"title": "📷 Camera",
"hint": "Choose which camera to use for barcode scanning and AI identification.",
"device_label": "📸 Default camera",
"back": "📱 Rear (default)",
"front": "🤳 Front",
"devices_hint": "If you have multiple cameras, you can select a specific one from the list above after granting permissions.",
"detect_btn": "🔄 Detect cameras"
},
"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"
},
"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",
"url_label": "🌐 Endpoint URL",
"method_label": "📡 HTTP Method",
"auth_label": "🔐 Authentication",
"auth_bearer": "Bearer Token",
"auth_custom": "Custom Header",
"auth_none": "None",
"token_label": "🔑 Bearer Token",
"custom_header_name": "📋 Header name",
"custom_header_value": "📋 Header value",
"content_type_label": "📄 Content-Type",
"payload_key_label": "🗝️ Text field in payload",
"payload_key_hint": "Name of the JSON field that will contain the text to read (e.g.: message, text).",
"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"
},
"language": {
"title": "🌐 Language",
"hint": "Select the interface language.",
"label": "🌐 Language",
"restart_notice": "The page will reload to apply the new language."
},
"saved": "✅ Configuration saved!",
"saved_local": "✅ Configuration saved locally",
"saved_local_error": "⚠️ Saved locally, server error: {error}"
},
"expiry": {
"today": "TODAY",
"tomorrow": "Tomorrow",
"days": "{days} days",
"expired_days": "{days}d ago",
"expired_yesterday": "Yesterday",
"expired_today": "Today"
},
"status": {
"ok": "OK",
"check": "Check",
"discard": "Discard"
},
"toast": {
"product_saved": "Product saved!",
"product_created": "Product created!",
"product_updated": "✅ Product updated!",
"product_removed": "Product removed",
"updated": "Updated!",
"quantity_confirmed": "✓ Quantity confirmed",
"added_to_inventory": "✅ {name} added!",
"removed_from_list": "✅ {name} removed from the list!",
"removed_from_list_short": "Removed from the list",
"added_to_shopping": "🛒 Added to the shopping list!",
"removed_from_shopping": "🛒 Removed from the shopping list",
"finished_to_bring": "🛒 Product finished → added to Bring!",
"thrown_away": "🗑️ {name} thrown away!",
"thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}",
"appliance_added": "Appliance added",
"item_added": "{name} added"
},
"error": {
"generic": "Error",
"loading": "Error loading product",
"not_found": "Product not found",
"not_found_manual": "Product not found. Enter it manually.",
"search": "Search error. Try again.",
"search_short": "Search error",
"save": "Error saving",
"connection": "Connection error",
"camera": "Cannot access camera",
"bring_add": "Error adding to Bring!",
"bring_connection": "Bring! connection error",
"identification": "Identification error",
"barcode_empty": "Enter a barcode",
"barcode_format": "Barcode must contain only numbers (4-14 digits)",
"min_chars": "Type at least 2 characters",
"not_in_inventory": "Product not in inventory",
"appliance_exists": "Appliance already exists",
"already_exists": "Already exists"
},
"confirm": {
"remove_item": "Do you really want to remove this product from inventory?"
},
"edit": {
"title": "Edit {name}"
},
"screensaver": {
"recipe_btn": "Recipes",
"scan_btn": "Scan product"
},
"days": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
},
"meal_types": {
"lunch": "Lunch",
"dinner": "Dinner"
}
}
+431
View File
@@ -0,0 +1,431 @@
{
"app": {
"name": "Dispensa Manager",
"loading": "Caricamento..."
},
"nav": {
"title": "🏠 Dispensa",
"home": "Home",
"inventory": "Dispensa",
"recipes": "Ricette",
"shopping": "Spesa",
"log": "Log"
},
"btn": {
"back": "← Indietro",
"save": "💾 Salva",
"cancel": "✕ Annulla",
"close": "Chiudi",
"add": "✅ Aggiungi",
"delete": "Elimina",
"edit": "✏️ Modifica",
"search": "🔍 Cerca",
"go": "✅ Vai",
"toggle_password": "👁️ Mostra/Nascondi",
"load_more": "Carica altri...",
"save_config": "💾 Salva Configurazione",
"save_product": "💾 Salva Prodotto",
"restart": "↺ Ricomincia",
"reset_default": "↺ Ripristina default"
},
"locations": {
"dispensa": "Dispensa",
"frigo": "Frigo",
"freezer": "Freezer",
"altro": "Altro"
},
"categories": {
"latticini": "Latticini",
"carne": "Carne",
"pesce": "Pesce",
"frutta": "Frutta",
"verdura": "Verdura",
"pasta": "Pasta & Riso",
"pane": "Pane & Forno",
"surgelati": "Surgelati",
"bevande": "Bevande",
"condimenti": "Condimenti",
"snack": "Snack & Dolci",
"conserve": "Conserve",
"cereali": "Cereali & Legumi",
"igiene": "Igiene",
"pulizia": "Pulizia Casa",
"altro": "Altro",
"select": "-- Seleziona --"
},
"units": {
"pz": "pz",
"conf": "conf",
"g": "g",
"ml": "ml",
"pieces": "Pezzi",
"grams": "Grammi",
"box": "Confezione",
"boxes": "Confezioni"
},
"shopping_sections": {
"frutta_verdura": "Frutta & Verdura",
"carne_pesce": "Carne & Pesce",
"latticini": "Latticini & Fresco",
"pane_dolci": "Pane & Dolci",
"pasta": "Pasta & Cereali",
"conserve": "Conserve & Salse",
"surgelati": "Surgelati",
"bevande": "Bevande",
"pulizia_igiene": "Pulizia & Igiene",
"altro": "Altro"
},
"dashboard": {
"expired_title": "🚫 Scaduti",
"expiring_title": "⏰ Prossime Scadenze",
"stats_period": "📊 Ultimi 30 giorni",
"opened_title": "📦 Prodotti Aperti",
"review_title": "🔍 Da revisionare",
"review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.",
"quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza"
},
"inventory": {
"title": "Dispensa",
"filter_all": "Tutti",
"search_placeholder": "🔍 Cerca prodotto...",
"empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!",
"no_items_found": "Nessuna voce di inventario trovata"
},
"scan": {
"title": "Scansiona Prodotto",
"mode_shopping": "🛒 Modalità Spesa",
"mode_shopping_end": "✅ Fine spesa",
"zoom": "Zoom",
"barcode_placeholder": "Inserisci codice a barre...",
"quick_name_divider": "oppure scrivi il nome",
"quick_name_placeholder": "Es: Mele, Zucchine, Pane...",
"manual_entry": "✏️ Inserimento Manuale",
"ai_identify": "🤖 Identifica con AI",
"hint": "Scansiona il barcode, scrivi il nome del prodotto, oppure usa l'AI per identificarlo",
"debug_toggle": "🐛 Debug Log",
"barcode_acquired": "🔖 Barcode acquisito: {code}",
"scan_barcode": "🔖 Scansiona Barcode"
},
"action": {
"title": "Cosa vuoi fare?",
"add_btn": "📥 AGGIUNGI",
"add_sub": "in dispensa/frigo",
"use_btn": "📤 USA / CONSUMA",
"use_sub": "dalla dispensa/frigo"
},
"add": {
"title": "Aggiungi alla Dispensa",
"location_label": "📍 Dove lo metti?",
"quantity_label": "📦 Quantità",
"conf_size_label": "📦 Ogni confezione contiene:",
"conf_size_placeholder": "es. 300",
"vacuum_label": "🫙 Sotto vuoto",
"vacuum_hint": "La scadenza verrà estesa automaticamente",
"submit": "✅ Aggiungi"
},
"use": {
"title": "Usa / Consuma",
"location_label": "📍 Da dove?",
"quantity_label": "Quanto hai usato?",
"partial_hint": "Oppure specifica la quantità usata:",
"use_all": "🗑️ Usato TUTTO / Finito",
"submit": "📤 Usa questa quantità",
"available": "📦 Disponibile:",
"not_in_inventory": "⚠️ Prodotto non presente nell'inventario.",
"expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!"
},
"product": {
"title_new": "Nuovo Prodotto",
"title_edit": "Modifica Prodotto",
"ai_fill": "📷 Scatta foto e identifica con AI",
"ai_fill_hint": "L'AI compilerà automaticamente i campi del prodotto",
"name_label": "🏷️ Nome Prodotto *",
"name_placeholder": "Es: Latte intero, Pasta penne rigate...",
"brand_label": "🏢 Marca",
"brand_placeholder": "Es: Barilla, Granarolo, Mutti...",
"category_label": "📂 Categoria",
"unit_label": "📏 Unità di misura",
"default_qty_label": "🔢 Quantità default",
"conf_size_label": "📦 Ogni confezione contiene:",
"conf_size_placeholder": "es. 300",
"notes_label": "📝 Note",
"notes_placeholder": "Es: senza lattosio, bio, conservare in frigo dopo apertura...",
"barcode_label": "🔖 Barcode",
"barcode_placeholder": "Codice a barre (se disponibile)",
"barcode_hint": "⚠️ Aggiungi il barcode così al prossimo acquisto basta scansionarlo!",
"submit": "💾 Salva Prodotto",
"name_required": "Inserisci il nome del prodotto",
"conf_size_required": "Specifica il contenuto di ogni confezione",
"expiry_estimated": "Scadenza stimata:",
"scan_expiry": "Scansiona data scadenza",
"expiry_hint": "📝 Puoi modificare la data o scansionarla con la fotocamera",
"add_batch": "📦 + Lotto con scadenza diversa",
"package_info": "📦 Confezione: {info}",
"edit_catalog": "⚙️ Modifica scheda prodotto (nome, marca, categoria…)",
"not_recognized": "⚠️ Prodotto non riconosciuto",
"edit_info": "✏️ Modifica informazioni",
"modify_details": "MODIFICA\nscadenza, luogo…"
},
"products": {
"title": "📦 Tutti i Prodotti",
"search_placeholder": "🔍 Cerca prodotto...",
"empty": "Nessun prodotto nel database.\nScansiona un prodotto per iniziare!",
"no_category": "Nessun prodotto in questa categoria"
},
"recipes": {
"title": "🍳 Ricette",
"generate": "✨ Genera nuova ricetta"
},
"shopping": {
"title": "🛒 Lista della Spesa",
"bring_loading": "Connessione a Bring!...",
"tab_to_buy": "🛍️ Da comprare",
"tab_forecast": "🧠 In previsione",
"total_label": "💰 Totale stimato",
"section_to_buy": "🛍️ Da comprare",
"suggestions_title": "💡 Suggerimenti AI",
"suggestions_add": "✅ Aggiungi selezionati a Bring!",
"search_prices": "🔍 Cerca tutti i prezzi",
"suggest_btn": "🤖 Suggerisci cosa comprare",
"smart_title": "🧠 Previsioni intelligenti",
"smart_empty": "Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.",
"smart_filter_all": "Tutti",
"smart_filter_critical": "🔴 Urgenti",
"smart_filter_high": "🟠 Presto",
"smart_filter_medium": "🟡 Pianifica",
"smart_filter_low": "🟢 Previsione",
"smart_add": "🛒 Aggiungi selezionati a Bring!",
"empty": "Lista della spesa vuota!\nUsa il pulsante sotto per generare suggerimenti.",
"already_in_list": "🛒 \"{name}\" già nella lista della spesa",
"already_in_list_short": "️ Già nella lista della spesa",
"add_prompt": "Vuoi aggiungerlo alla lista della spesa?",
"smart_already": "📊 La spesa intelligente prevede già {name}",
"all_searched": "Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.",
"search_complete": "Ricerca completata: {count} prodotti",
"removed_sufficient": "🧹 {removed} prodotto/i con scorte sufficienti rimosso/i dalla lista"
},
"ai": {
"title": "🤖 Identificazione AI",
"capture": "📸 Scatta Foto",
"retake": "🔄 Riscatta",
"hint": "Scatta una foto del prodotto e l'AI cercherà di identificarlo",
"identifying": "🤖 Identifico il prodotto...",
"no_api_key": "⚠️ Chiave API Gemini non configurata.\n<small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small>",
"fields_filled": "✅ Campi compilati dall'AI"
},
"log": {
"title": "📒 Log Operazioni"
},
"chat": {
"title": "Gemini Chef",
"welcome": "Ciao! Sono il tuo assistente cucina",
"welcome_desc": "Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!",
"suggestion_snack": "🍿 Spuntino veloce",
"suggestion_juice": "🥤 Succo/Frullato",
"suggestion_light": "🥗 Qualcosa di leggero",
"suggestion_expiry": "⏰ Usa le scadenze",
"clear": "Nuova conversazione",
"placeholder": "Chiedi qualcosa..."
},
"cooking": {
"close": "Chiudi",
"tts_btn": "Leggi ad alta voce",
"restart": "↺ Ricomincia",
"replay": "🔊 Rileggi",
"timer": "⏱️ {time} · Timer",
"prev": "◀ Precedente",
"next": "Successivo ▶"
},
"settings": {
"title": "⚙️ Configurazione",
"tab_api": "API Keys",
"tab_bring": "Bring!",
"tab_recipe": "Ricette",
"tab_mealplan": "Piano Settimanale",
"tab_appliances": "Elettrodomestici",
"tab_spesa": "Spesa Online",
"tab_camera": "Fotocamera",
"tab_security": "Sicurezza",
"tab_tts": "Voce (TTS)",
"tab_language": "Lingua",
"gemini": {
"title": "🤖 Google Gemini AI",
"hint": "Chiave API per identificazione prodotti, scadenze e ricette.",
"key_label": "API Key Gemini"
},
"bring": {
"title": "🛒 Bring! Shopping List",
"hint": "Credenziali per l'integrazione con la lista della spesa Bring!",
"email_label": "📧 Email Bring!",
"password_label": "🔒 Password Bring!"
},
"recipe": {
"title": "🍳 Preferenze Ricette",
"hint": "Configura le opzioni predefinite per la generazione delle ricette.",
"persons_label": "👥 Persone predefinite",
"options_label": "🎯 Opzioni ricetta predefinite",
"fast": "⚡ Pasto Veloce",
"light": "🥗 Poca Fame",
"expiry": "⏰ Priorità Scadenze",
"healthy": "💚 Extra Salutare",
"opened": "📦 Priorità Cose Aperte",
"zerowaste": "♻️ Zero Sprechi",
"dietary_label": "🚫 Intolleranze / Restrizioni",
"dietary_placeholder": "Es: senza glutine, senza lattosio, vegetariano..."
},
"mealplan": {
"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"
},
"appliances": {
"title": "🔌 Elettrodomestici Disponibili",
"hint": "Indica gli elettrodomestici che hai a disposizione. Saranno considerati nella generazione delle ricette.",
"new_placeholder": "Es: Macchina del pane, Bimby, Friggitrice ad aria...",
"quick_title": "Aggiungi velocemente:",
"oven": "🔥 Forno",
"microwave": "📡 Microonde",
"air_fryer": "🍟 Friggitrice ad aria",
"bread_maker": "🍞 Macchina pane",
"bimby": "🤖 Bimby/Cookeo",
"mixer": "🌀 Planetaria",
"steamer": "♨️ Vaporiera",
"pressure_cooker": "🫕 Pentola pressione",
"toaster": "🍞 Tostapane",
"blender": "🍹 Frullatore",
"empty": "Nessun elettrodomestico aggiunto"
},
"spesa": {
"title": "🛍️ Spesa Online",
"hint": "Configura il provider per la spesa online.",
"provider_label": "🏪 Provider",
"email_label": "📧 Email",
"password_label": "🔒 Password",
"login_btn": "🔐 Accedi",
"ai_prompt_label": "🤖 Prompt AI selezione prodotto",
"ai_prompt_placeholder": "Istruzioni per l'AI quando deve scegliere tra più prodotti...",
"ai_prompt_hint": "L'AI usa questo prompt per scegliere il prodotto più appropriato tra i risultati. Lascia vuoto per il comportamento predefinito.",
"configure_first": "Configura prima la Spesa Online nelle impostazioni"
},
"camera": {
"title": "📷 Fotocamera",
"hint": "Scegli quale fotocamera utilizzare per la scansione barcode e l'identificazione AI.",
"device_label": "📸 Fotocamera predefinita",
"back": "📱 Posteriore (default)",
"front": "🤳 Anteriore",
"devices_hint": "Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.",
"detect_btn": "🔄 Rileva fotocamere"
},
"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"
},
"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",
"url_label": "🌐 URL Endpoint",
"method_label": "📡 Metodo HTTP",
"auth_label": "🔐 Autenticazione",
"auth_bearer": "Bearer Token",
"auth_custom": "Header personalizzato",
"auth_none": "Nessuna",
"token_label": "🔑 Bearer Token",
"custom_header_name": "📋 Nome header",
"custom_header_value": "📋 Valore header",
"content_type_label": "📄 Content-Type",
"payload_key_label": "🗝️ Campo testo nel payload",
"payload_key_hint": "Nome del campo JSON che conterrà il testo da leggere (es: message, text).",
"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"
},
"language": {
"title": "🌐 Lingua / Language",
"hint": "Seleziona la lingua dell'interfaccia. Select the interface language.",
"label": "🌐 Lingua",
"restart_notice": "La pagina verrà ricaricata per applicare la nuova lingua."
},
"saved": "✅ Configurazione salvata!",
"saved_local": "✅ Configurazione salvata localmente",
"saved_local_error": "⚠️ Salvato localmente, errore server: {error}"
},
"expiry": {
"today": "OGGI",
"tomorrow": "Domani",
"days": "{days} giorni",
"expired_days": "Da {days}g",
"expired_yesterday": "Da ieri",
"expired_today": "Oggi"
},
"status": {
"ok": "OK",
"check": "Controlla",
"discard": "Buttare"
},
"toast": {
"product_saved": "Prodotto salvato!",
"product_created": "Prodotto creato!",
"product_updated": "✅ Prodotto aggiornato!",
"product_removed": "Prodotto rimosso",
"updated": "Aggiornato!",
"quantity_confirmed": "✓ Quantità confermata",
"added_to_inventory": "✅ {name} aggiunto!",
"removed_from_list": "✅ {name} rimosso dalla lista!",
"removed_from_list_short": "Rimosso dalla lista",
"added_to_shopping": "🛒 Aggiunto alla lista della spesa!",
"removed_from_shopping": "🛒 Rimosso dalla lista della spesa",
"finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!",
"thrown_away": "🗑️ {name} buttato!",
"thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}",
"appliance_added": "Elettrodomestico aggiunto",
"item_added": "{name} aggiunto"
},
"error": {
"generic": "Errore",
"loading": "Errore nel caricamento del prodotto",
"not_found": "Prodotto non trovato",
"not_found_manual": "Prodotto non trovato. Inseriscilo manualmente.",
"search": "Errore nella ricerca. Riprova.",
"search_short": "Errore nella ricerca",
"save": "Errore nel salvataggio",
"connection": "Errore di connessione",
"camera": "Impossibile accedere alla fotocamera",
"bring_add": "Errore nell'aggiunta a Bring!",
"bring_connection": "Errore connessione Bring!",
"identification": "Errore nell'identificazione",
"barcode_empty": "Inserisci un codice a barre",
"barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)",
"min_chars": "Scrivi almeno 2 caratteri",
"not_in_inventory": "Prodotto non nell'inventario",
"appliance_exists": "Elettrodomestico già presente",
"already_exists": "Già presente"
},
"confirm": {
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?"
},
"edit": {
"title": "Modifica {name}"
},
"screensaver": {
"recipe_btn": "Ricette",
"scan_btn": "Scansiona prodotto"
},
"days": {
"mon": "Lunedì",
"tue": "Martedì",
"wed": "Mercoledì",
"thu": "Giovedì",
"fri": "Venerdì",
"sat": "Sabato",
"sun": "Domenica"
},
"meal_types": {
"lunch": "Pranzo",
"dinner": "Cena"
}
}