Compare commits

...

19 Commits

Author SHA1 Message Date
dadaloop82 66b75b1537 Merge develop: v1.2.0 - EverShelf rebrand
Merging all changes from develop into main for v1.2.0 release:
- Project renamed from Dispensa Manager to EverShelf
- Contact email: evershelfproject@gmail.com
- All localStorage keys migrated: dispensa_* -> evershelf_*
- SQLite DB renamed: dispensa.db -> evershelf.db
- Docker/Apache resources renamed to evershelf
- Version badge added to app header
- App name updated in all translations (it, en, de)
- Asset version strings bumped to bust browser cache
- JS file truncation fix (safe Python-based replacements)
- CHANGELOG, manifest.json, OpenAPI spec updated for v1.2.0
2026-04-13 10:21:28 +00:00
dadaloop82 874a242149 release: v1.2.0 - EverShelf rebrand + version badge
- Add v1.2.0 version badge to app header
- Update CHANGELOG with v1.2.0 entries
- Bump manifest.json version to 1.2.0
- Bump OpenAPI spec version to 1.2.0
2026-04-13 10:21:16 +00:00
dadaloop82 a1873d3f81 fix: bump asset versions to bust browser cache (20260413a)
After JS truncation and recovery, browsers may have cached the broken app.js.
Bumping ?v= query strings forces fresh download of app.js and style.css.
2026-04-13 10:18:56 +00:00
dadaloop82 c52a91e779 fix: restore app.js after sed truncation, update nav title to EverShelf
- Recover app.js from pre-rebrand commit and re-apply all substitutions safely
- Fix all localStorage keys: dispensa_* -> evershelf_* (settings, setup, lang)
- Fix TTS test strings: 'Dispensa Manager' -> 'EverShelf'
- Set nav.title to EverShelf in it/en/de translations and HTML fallback
- Fix JS syntax error (Unexpected end of input) that broke the site
- CI JavaScript Lint should now pass
2026-04-13 10:14:40 +00:00
dadaloop82 20f734d54a rebrand: rename project from Dispensa Manager to EverShelf
- Update app name across all files (manifest, index.html, README, docs)
- Update contact email to evershelfproject@gmail.com
- Rename Docker service/container/volume to evershelf
- Rename localStorage keys: dispensa_* → evershelf_*
- Rename SQLite DB reference: dispensa.db → evershelf.db
- Update SSH remote to dadaloop82/EverShelf
- Update Apache conf file name to evershelf.conf
- Update CI workflow Docker image/container names
- Update cron job example path
- Add data/dispensa.db to .gitignore to prevent accidental commit
2026-04-13 10:09:33 +00:00
dadaloop82 2ea0c68f2e Merge develop: offline browser TTS 2026-04-10 10:19:02 +00:00
dadaloop82 da962581c0 feat: offline browser TTS engine with voice selector
Add Web Speech API as alternative TTS engine (fully offline, no config needed).
- Engine selector in settings: 'browser' (offline) or 'server' (HTTP endpoint)
- Voice picker populated from speechSynthesis.getVoices(), Italian voices first
- Auto-selects Paola voice on macOS/iOS if available
- Rate and pitch sliders (0.5x-2x, 0-2)
- testTTS() and speakCookingStep() branch on selected engine
- Existing users with tts_url keep 'server' as default engine
2026-04-10 10:19:02 +00:00
dadaloop82 499552e4df Merge develop: fix wizard password check 2026-04-10 06:57:46 +00:00
dadaloop82 4b5979333e fix: use bring_password_set and gemini_key_set flags from server API
The server never exposes bring_password in plaintext (only bring_password_set).
Fix wizard to check the boolean flag instead of the empty string.
2026-04-10 06:57:46 +00:00
dadaloop82 c9b3eb01cc Merge develop: fix wizard server settings check 2026-04-10 06:55:40 +00:00
dadaloop82 82f147d8d5 fix: check server-side credentials before showing setup wizard steps
Bring! and Gemini keys stored in .env are now fetched from the server
before deciding which wizard steps to show. This prevents the wizard
from prompting for credentials that are already configured server-side.
2026-04-10 06:55:40 +00:00
dadaloop82 38866e3daf Merge develop: smart setup wizard 2026-04-10 06:53:03 +00:00
dadaloop82 ef654b9dfc feat: smart setup wizard - only prompts for missing settings
The wizard now detects which specific settings are missing and shows
only those steps. Existing configurations are preserved. If a future
feature adds a new required setting, it will automatically prompt for
just that one.
2026-04-10 06:53:03 +00:00
dadaloop82 0b863b1cad Merge develop: update roadmap 2026-04-10 06:08:32 +00:00
dadaloop82 d75b889d8e docs: update roadmap with completed features 2026-04-10 06:08:32 +00:00
dadaloop82 d66bdc146c Merge develop: fix Docker build 2026-04-10 06:07:16 +00:00
dadaloop82 faaae1eede fix: add libonig-dev dependency for mbstring in Dockerfile 2026-04-10 06:07:16 +00:00
dadaloop82 52efa719d3 Merge develop: v1.1.0 release 2026-04-10 06:03:22 +00:00
dadaloop82 d13f744aea feat: v1.1.0 - Docker, i18n, setup wizard, rate limiting, OpenAPI
New features:
- Docker support (Dockerfile + docker-compose.yml)
- GitHub Actions CI pipeline (PHP lint, JS lint, Docker build, i18n validation)
- Internationalization system with 3 languages (it, en, de) and 347 translation keys
- First-run setup wizard (4-step configuration)
- File-based API rate limiting (120/15/5 req/min tiers)
- OpenAPI 3.1.0 specification for all 43 API endpoints
- CONTRIBUTING.md with translation and development guide
- Screenshots directory placeholder

Modified:
- README.md: Docker badges, install instructions, translations section
- api/index.php: rate limiting middleware
- assets/js/app.js: i18n system, setup wizard, t() function
- assets/css/style.css: setup wizard styles
- index.html: data-i18n attributes, setup wizard overlay, language settings
- .gitignore: rate_limits exclusion
2026-04-10 06:03:11 +00:00
22 changed files with 3250 additions and 152 deletions
+34
View File
@@ -0,0 +1,34 @@
# Docker runtime files
data/evershelf.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
+1 -1
View File
@@ -1,4 +1,4 @@
# Dispensa Manager - Configuration
# EverShelf - Configuration
# Copy this file to .env and fill in your values
# cp .env.example .env
+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 evershelf-test .
- name: Test container starts
run: |
docker run -d --name test-evershelf -p 8080:80 evershelf-test
sleep 5
curl -f http://localhost:8080/ || exit 1
docker stop test-evershelf
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 ✓')
"
+2
View File
@@ -2,6 +2,7 @@
.env
# Data directory (user-specific runtime data)
data/evershelf.db
data/dispensa.db
data/*.db-wal
data/*.db-shm
@@ -12,6 +13,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
+21 -1
View File
@@ -1,10 +1,30 @@
# Changelog
All notable changes to Dispensa Manager will be documented in this file.
All notable changes to EverShelf will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2026-04-13
### Changed
- **Project renamed** from "Dispensa Manager" to **EverShelf**
- Contact email updated to `evershelfproject@gmail.com`
- Docker service, container, and volume renamed to `evershelf`
- SQLite database renamed from `dispensa.db` to `evershelf.db`
- All localStorage keys migrated: `dispensa_*``evershelf_*`
- Apache config file renamed to `evershelf.conf`
- CI workflow Docker image/container names updated
- App name updated in all translations (it, en, de)
- Navigation title updated to EverShelf across all languages
### Added
- Version badge (`v1.2.0`) in the app header
### Fixed
- JS file truncation caused by `sed` in-place edit on large files
- Browser cache invalidation via bumped asset version strings (`?v=20260413a`)
## [1.0.0] - 2026-04-10
### Added
+141
View File
@@ -0,0 +1,141 @@
# Contributing to EverShelf
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/EverShelf.git
cd EverShelf
```
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": "EverShelf",
"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 evershelf-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).
+42
View File
@@ -0,0 +1,42 @@
FROM php:8.2-apache
# Install required PHP extensions
RUN apt-get update && apt-get install -y \
libsqlite3-dev \
libcurl4-openssl-dev \
libonig-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/evershelf.conf \
&& a2enconf evershelf
# Expose port 80
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
CMD ["apache2-foreground"]
+56 -16
View File
@@ -1,14 +1,18 @@
# 🏠 Dispensa Manager
# 🏠 EverShelf
> **Self-hosted pantry management system** — Track your food inventory, scan barcodes, get AI-powered recipe suggestions, and reduce waste.
[![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,10 +67,29 @@
### Installation
#### Option A: Docker (recommended)
```bash
# 1. Clone the repository
git clone https://github.com/dadaloop82/dispensa.git
cd dispensa
git clone https://github.com/dadaloop82/EverShelf.git
cd EverShelf
# 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/EverShelf.git
cd EverShelf
# 2. Create configuration file
cp .env.example .env
@@ -104,7 +127,7 @@ TTS_ENABLED=true
The app works out of the box with Apache if placed in the web root or a subdirectory. Make sure `mod_rewrite` is enabled and `AllowOverride All` is set.
```apache
<Directory /var/www/html/dispensa>
<Directory /var/www/html/evershelf>
AllowOverride All
Require all granted
</Directory>
@@ -119,7 +142,7 @@ The app works out of the box with Apache if placed in the web root or a subdirec
server {
listen 80;
server_name your-server.local;
root /var/www/html/dispensa;
root /var/www/html/evershelf;
index index.html;
location /api/ {
@@ -153,7 +176,7 @@ Set up a cron job for smart shopping predictions:
```bash
# Run every 5 minutes
*/5 * * * * php /path/to/dispensa/api/cron_smart_shopping.php >> /path/to/dispensa/data/cron.log 2>&1
*/5 * * * * php /path/to/evershelf/api/cron_smart_shopping.php >> /path/to/evershelf/data/cron.log 2>&1
```
### Backup (Optional)
@@ -162,7 +185,7 @@ The included `backup.sh` creates local daily backups of your database:
```bash
# Run daily at 3 AM
0 3 * * * /path/to/dispensa/backup.sh
0 3 * * * /path/to/evershelf/backup.sh
```
---
@@ -170,7 +193,7 @@ The included `backup.sh` creates local daily backups of your database:
## 🏗️ Architecture
```
dispensa/
evershelf/
├── index.html # Single-page application (SPA)
├── manifest.json # PWA manifest
├── .env.example # Configuration template
@@ -188,7 +211,7 @@ dispensa/
│ └── img/ # Static images
└── data/ # Runtime data (gitignored)
├── dispensa.db # SQLite database (auto-created)
├── evershelf.db # SQLite database (auto-created)
├── backups/ # Local DB backups
└── *.json # Token/cache files
```
@@ -232,7 +255,7 @@ dispensa/
```bash
# Run PHP's built-in server for local development
php -S localhost:8080 -t /path/to/dispensa
php -S localhost:8080 -t /path/to/evershelf
# Check PHP syntax
php -l api/index.php
@@ -245,19 +268,36 @@ The application uses no build tools — edit files directly and refresh.
## 📋 Roadmap
- [ ] Multi-language support (i18n)
- [x] Multi-language support (i18n) — 3 languages (it/en/de), 347 keys
- [ ] User authentication / multi-user support
- [ ] Docker container for easy deployment
- [ ] REST API documentation (OpenAPI/Swagger)
- [x] Docker container for easy deployment — see [Dockerfile](Dockerfile) + [docker-compose.yml](docker-compose.yml)
- [x] REST API documentation (OpenAPI/Swagger) — see [docs/openapi.yaml](docs/openapi.yaml)
- [x] First-run setup wizard — 4-step guided configuration
- [x] API rate limiting — file-based, 3 tiers (120/15/5 req/min)
- [x] CI/CD pipeline — GitHub Actions (lint, Docker build, translation validation)
- [ ] 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`)
@@ -275,6 +315,6 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
## 👨‍💻 Author
**Stimpfl Daniel** — [dadaloop82@gmail.com](mailto:dadaloop82@gmail.com)
**Stimpfl Daniel** — [evershelfproject@gmail.com](mailto:evershelfproject@gmail.com)
- GitHub: [@dadaloop82](https://github.com/dadaloop82)
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* Cron: pre-compute smart shopping list and save to cache.
* Install with: crontab -e
* *\/5 * * * * php /var/www/html/dispensa/api/cron_smart_shopping.php >> /var/www/html/dispensa/data/cron.log 2>&1
* *\/5 * * * * php /var/www/html/evershelf/api/cron_smart_shopping.php >> /var/www/html/evershelf/data/cron.log 2>&1
*/
// Only allow CLI execution — block HTTP access
+3 -3
View File
@@ -1,13 +1,13 @@
<?php
/**
* Dispensa Manager - Database initialization, schema, and migrations.
* EverShelf - Database initialization, schema, and migrations.
* Uses SQLite with WAL journal mode for concurrent read/write performance.
*
* @author Stimpfl Daniel <dadaloop82@gmail.com>
* @author Stimpfl Daniel <evershelfproject@gmail.com>
* @license MIT
*/
define('DB_PATH', __DIR__ . '/../data/dispensa.db');
define('DB_PATH', __DIR__ . '/../data/evershelf.db');
function getDB(): PDO {
$isNew = !file_exists(DB_PATH);
+70 -2
View File
@@ -1,10 +1,10 @@
<?php
/**
* Dispensa Manager - Main API Router
* EverShelf - Main API Router
* Handles all CRUD operations for products, inventory, shopping lists,
* AI-powered features (Gemini), and third-party integrations (Bring!, DupliClick).
*
* @author Stimpfl Daniel <dadaloop82@gmail.com>
* @author Stimpfl Daniel <evershelfproject@gmail.com>
* @license MIT
*/
@@ -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) {
+122 -2
View File
@@ -1,8 +1,8 @@
/**
* Dispensa Manager - UI Styles
* EverShelf - UI Styles
* Mobile-first PWA design with CSS custom properties.
*
* @author Stimpfl Daniel <dadaloop82@gmail.com>
* @author Stimpfl Daniel <evershelfproject@gmail.com>
* @license MIT
*/
@@ -81,6 +81,16 @@ body {
font-size: 1.3rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: baseline;
gap: 6px;
}
.header-version {
font-size: 0.6rem;
font-weight: 400;
opacity: 0.7;
letter-spacing: 0.03em;
}
.header-btn {
@@ -5072,3 +5082,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) |
```
+497 -48
View File
@@ -1,9 +1,9 @@
/**
* Dispensa Manager - Main Application JS
* EverShelf - Main Application JS
* Complete pantry management with barcode scanning, AI identification,
* Bring! shopping list integration, recipe generation, and TTS cooking mode.
*
* @author Stimpfl Daniel <dadaloop82@gmail.com>
* @author Stimpfl Daniel <evershelfproject@gmail.com>
* @license MIT
*/
@@ -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('evershelf_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('evershelf_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('evershelf_lang', lang);
location.reload();
}
const LOCATIONS = {
'dispensa': { icon: '🗄️', label: 'Dispensa' },
'frigo': { icon: '🧊', label: 'Frigo' },
@@ -611,7 +728,7 @@ let _settingsDirty = false;
function getSettings() {
if (!_settingsCache) {
try {
_settingsCache = JSON.parse(localStorage.getItem('dispensa_settings') || '{}');
_settingsCache = JSON.parse(localStorage.getItem('evershelf_settings') || '{}');
} catch(e) { _settingsCache = {}; }
}
const s = _settingsCache;
@@ -629,7 +746,7 @@ function getSettings() {
function saveSettingsToStorage(settings) {
_settingsCache = settings;
localStorage.setItem('dispensa_settings', JSON.stringify(settings));
localStorage.setItem('evershelf_settings', JSON.stringify(settings));
// Persist to DB
_settingsDirty = true;
_debouncedSyncSettings();
@@ -681,7 +798,7 @@ async function syncSettingsFromDB() {
if (db[key] !== undefined) s[key] = db[key];
}
_settingsCache = s;
localStorage.setItem('dispensa_settings', JSON.stringify(s));
localStorage.setItem('evershelf_settings', JSON.stringify(s));
}
if (res.settings.review_confirmed) {
_reviewConfirmedCache = res.settings.review_confirmed;
@@ -733,11 +850,23 @@ async function loadSettingsUI() {
s.tts_auth_type = s.tts_auth_type || 'bearer';
s.tts_content_type = s.tts_content_type || 'application/json';
s.tts_enabled = s.tts_enabled !== undefined ? s.tts_enabled : false;
// Default engine: 'server' if a URL was already configured, else 'browser'
if (!s.tts_engine) s.tts_engine = s.tts_url ? 'server' : 'browser';
s.tts_voice = s.tts_voice || '';
s.tts_rate = s.tts_rate !== undefined ? s.tts_rate : 1;
s.tts_pitch = s.tts_pitch !== undefined ? s.tts_pitch : 1;
s._tts_initialized = true;
saveSettingsToStorage(s);
}
const ttsEnabledEl = document.getElementById('setting-tts-enabled');
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true;
const ttsEngineEl = document.getElementById('setting-tts-engine');
if (ttsEngineEl) { ttsEngineEl.value = s.tts_engine || 'browser'; onTtsEngineChange(ttsEngineEl.value); }
const ttsRateEl = document.getElementById('setting-tts-rate');
if (ttsRateEl) { ttsRateEl.value = s.tts_rate || 1; document.getElementById('tts-rate-label').textContent = parseFloat(s.tts_rate || 1).toFixed(1); }
const ttsPitchEl = document.getElementById('setting-tts-pitch');
if (ttsPitchEl) { ttsPitchEl.value = s.tts_pitch || 1; document.getElementById('tts-pitch-label').textContent = parseFloat(s.tts_pitch || 1).toFixed(1); }
_initBrowserTtsVoices(s.tts_voice || '');
const ttsUrlEl = document.getElementById('setting-tts-url');
if (ttsUrlEl) ttsUrlEl.value = s.tts_url || '';
const ttsMethEl = document.getElementById('setting-tts-method');
@@ -822,21 +951,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);
@@ -876,6 +1005,14 @@ async function saveSettings() {
if (ttsEnabledEl) s.tts_enabled = ttsEnabledEl.checked;
const ttsUrlEl2 = document.getElementById('setting-tts-url');
if (ttsUrlEl2) s.tts_url = ttsUrlEl2.value.trim();
const ttsEngineEl2 = document.getElementById('setting-tts-engine');
if (ttsEngineEl2) s.tts_engine = ttsEngineEl2.value;
const ttsVoiceEl2 = document.getElementById('setting-tts-voice');
if (ttsVoiceEl2) s.tts_voice = ttsVoiceEl2.value;
const ttsRateEl2 = document.getElementById('setting-tts-rate');
if (ttsRateEl2) s.tts_rate = parseFloat(ttsRateEl2.value) || 1;
const ttsPitchEl2 = document.getElementById('setting-tts-pitch');
if (ttsPitchEl2) s.tts_pitch = parseFloat(ttsPitchEl2.value) || 1;
const ttsMethEl2 = document.getElementById('setting-tts-method');
if (ttsMethEl2) s.tts_method = ttsMethEl2.value;
const ttsAuthTypeEl2 = document.getElementById('setting-tts-auth-type');
@@ -1364,7 +1501,7 @@ function confirmReviewItem(inventoryId) {
}
}, 300);
}
showToast('✓ Quantità confermata', 'success');
showToast(t('toast.quantity_confirmed'), 'success');
}
function editReviewItem(inventoryId, productId) {
@@ -1747,7 +1884,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 +1892,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 +1916,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 +2496,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 +2514,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 +2539,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 +2643,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 +2931,7 @@ async function submitProduct(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3270,7 +3407,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 +3508,7 @@ async function throwAll() {
}
} catch(e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3396,7 +3533,7 @@ async function throwPartial() {
}
} catch(e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3412,7 +3549,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 +3583,7 @@ async function saveEditedProductInfo() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3915,7 +4052,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 +4065,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 +4098,7 @@ async function submitAdd(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4384,7 +4521,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 +4649,7 @@ async function submitUseAll() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4556,7 +4693,7 @@ async function submitUse(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4770,12 +4907,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 +4941,7 @@ async function saveAIProductDirect() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -5062,7 +5199,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 +5800,7 @@ async function addSmartToBring() {
}
} catch (e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -6191,7 +6328,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 +6379,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 +6465,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 +7323,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;
}
@@ -7480,8 +7617,12 @@ async function speakCookingStep(text) {
const s = getSettings();
if (!s.tts_enabled) return;
try {
const req = _buildTtsRequest(text, s);
await _ttsViaProxy(req);
if ((s.tts_engine || 'browser') === 'browser') {
_speakBrowser(text);
} else {
const req = _buildTtsRequest(text, s);
await _ttsViaProxy(req);
}
} catch(e) { /* silent — TTS is non-critical */ }
}
@@ -7499,9 +7640,87 @@ function onTtsAuthTypeChange(type) {
if (headerGroup) headerGroup.style.display = type === 'header' ? '' : 'none';
}
function onTtsEngineChange(engine) {
const browserSect = document.getElementById('tts-browser-section');
const serverSect = document.getElementById('tts-server-section');
if (browserSect) browserSect.style.display = engine === 'browser' ? '' : 'none';
if (serverSect) serverSect.style.display = engine === 'server' ? '' : 'none';
}
/** Populate voice selector from Web Speech API. Called on settings load and on voiceschanged. */
function _initBrowserTtsVoices(selectedVoice) {
const sel = document.getElementById('setting-tts-voice');
if (!sel || !window.speechSynthesis) return;
const populate = () => {
const voices = window.speechSynthesis.getVoices();
if (!voices.length) return;
// Italian voices first, then others
const it = voices.filter(v => v.lang.startsWith('it'));
const others = voices.filter(v => !v.lang.startsWith('it'));
const sorted = [...it, ...others];
sel.innerHTML = sorted.map(v =>
`<option value="${v.name}" ${v.name === selectedVoice ? 'selected' : ''}>${v.name} (${v.lang})${v.localService ? '' : ' ☁️'}</option>`
).join('');
// Auto-select Paola if no preference and it exists
if (!selectedVoice) {
const paola = sorted.find(v => v.name === 'Paola');
const firstIt = sorted.find(v => v.lang.startsWith('it'));
if (paola) sel.value = paola.name;
else if (firstIt) sel.value = firstIt.name;
}
};
populate();
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = populate;
}
}
/** Speak text using the browser Web Speech API (offline). */
function _speakBrowser(text) {
if (!window.speechSynthesis) return;
window.speechSynthesis.cancel();
const s = getSettings();
const utt = new SpeechSynthesisUtterance(text);
utt.rate = parseFloat(s.tts_rate) || 1;
utt.pitch = parseFloat(s.tts_pitch) || 1;
const voices = window.speechSynthesis.getVoices();
const preferred = voices.find(v => v.name === s.tts_voice);
if (preferred) {
utt.voice = preferred;
utt.lang = preferred.lang;
} else {
utt.lang = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-US' : 'it-IT';
}
window.speechSynthesis.speak(utt);
}
async function testTTS() {
const statusEl = document.getElementById('tts-test-status');
// Build settings from current form values (before saving)
const enabled = document.getElementById('setting-tts-enabled')?.checked;
if (!enabled) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ TTS non attivo — attiva il toggle prima di testare.'; }
return;
}
const engine = document.getElementById('setting-tts-engine')?.value || 'browser';
if (engine === 'browser') {
if (!window.speechSynthesis) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Web Speech API non supportata da questo browser.'; }
return;
}
// Temporarily apply form values for the test
const s = getSettings();
const voiceName = document.getElementById('setting-tts-voice')?.value;
s.tts_voice = voiceName || s.tts_voice;
s.tts_rate = parseFloat(document.getElementById('setting-tts-rate')?.value) || 1;
s.tts_pitch = parseFloat(document.getElementById('setting-tts-pitch')?.value) || 1;
saveSettingsToStorage(s);
_speakBrowser('Test vocale EverShelf. La sintesi vocale funziona correttamente.');
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Riproduzione in corso — controlla l\'audio del dispositivo.'; }
return;
}
// Server engine
let extraFields = {};
try { extraFields = JSON.parse((document.getElementById('setting-tts-extra-fields')?.value || '{}').trim() || '{}'); } catch(e) { /* ignore */ }
const formSettings = {
@@ -7515,18 +7734,13 @@ async function testTTS() {
tts_payload_key: (document.getElementById('setting-tts-payload-key')?.value || '').trim() || 'message',
tts_extra_fields: document.getElementById('setting-tts-extra-fields')?.value || ''
};
const enabled = document.getElementById('setting-tts-enabled')?.checked;
if (!enabled) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ TTS non attivo — attiva il toggle prima di testare.'; }
return;
}
if (!formSettings.tts_url) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ URL endpoint mancante.'; }
return;
}
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Invio in corso…'; }
try {
const req = _buildTtsRequest('Test vocale Dispensa Manager', formSettings);
const req = _buildTtsRequest('Test vocale EverShelf', formSettings);
const res = await _ttsViaProxy(req);
const data = await res.json().catch(() => ({}));
const httpCode = data.status || res.status;
@@ -7921,7 +8135,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 +8908,241 @@ 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;
let _setupPendingSteps = [];
const _setupData = { lang: _currentLang, gemini_key: '', bring_email: '', bring_password: '' };
/**
* Returns indices of setup steps that still need configuration.
* Accepts optional serverSettings fetched from the API so server-side
* credentials (stored in .env) are also considered.
*/
function _getMissingSetupSteps(serverSettings) {
const missing = [];
const s = getSettings();
const srv = serverSettings || {};
// Step 0 — language: missing only if never set at all (fresh install)
if (!localStorage.getItem('evershelf_lang') && !localStorage.getItem('evershelf_setup_done')) {
missing.push(0);
}
// Step 1 — Gemini API key (check both localStorage and server .env)
if (!s.gemini_key && !srv.gemini_key && !srv.gemini_key_set) missing.push(1);
// Step 2 — Bring! credentials (check both localStorage and server .env)
if ((!s.bring_email && !srv.bring_email) || (!s.bring_password && !srv.bring_password_set)) missing.push(2);
// Note: step 3 (done screen) gets appended automatically when there are missing steps
return missing;
}
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(pendingSteps) {
_setupPendingSteps = pendingSteps || _getMissingSetupSteps();
if (_setupPendingSteps.length === 0) return;
// Append the "done" step (3) at the end
_setupPendingSteps.push(3);
_setupStep = 0;
// Pre-fill _setupData from existing settings so we don't lose them
const s = getSettings();
if (s.gemini_key) _setupData.gemini_key = s.gemini_key;
if (s.bring_email) _setupData.bring_email = s.bring_email;
if (s.bring_password) _setupData.bring_password = s.bring_password;
document.getElementById('setup-wizard').style.display = '';
_renderSetupStep();
}
function _renderSetupStep() {
const allSteps = _setupSteps();
const totalPending = _setupPendingSteps.length;
const realIndex = _setupPendingSteps[_setupStep];
const step = allSteps[realIndex];
// Progress dots (based on pending steps only)
const dotsHtml = _setupPendingSteps.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 === totalPending - 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() {
const realIndex = _setupPendingSteps[_setupStep];
if (realIndex === 1) {
const el = document.getElementById('setup-gemini-key');
if (el) _setupData.gemini_key = el.value.trim();
} else if (realIndex === 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 totalPending = _setupPendingSteps.length;
const realIndex = _setupPendingSteps[_setupStep];
if (dir === 1 && _setupStep === totalPending - 1) {
_finishSetup();
return;
}
// If language changed, apply it
if (realIndex === 0 && dir === 1 && _setupData.lang !== _currentLang) {
localStorage.setItem('evershelf_lang', _setupData.lang);
localStorage.setItem('evershelf_setup_step', String(_setupStep + 1));
localStorage.setItem('evershelf_setup_pending', JSON.stringify(_setupPendingSteps));
localStorage.setItem('evershelf_setup_data', JSON.stringify(_setupData));
location.reload();
return;
}
_setupStep = Math.max(0, Math.min(totalPending - 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('evershelf_setup_done', '1');
localStorage.removeItem('evershelf_setup_step');
localStorage.removeItem('evershelf_setup_data');
document.getElementById('setup-wizard').style.display = 'none';
}
async function _initApp() {
// Check for setup wizard resume (after language change)
const resumeStep = localStorage.getItem('evershelf_setup_step');
const resumeData = localStorage.getItem('evershelf_setup_data');
const resumePending = localStorage.getItem('evershelf_setup_pending');
if (resumeStep && resumePending) {
try { Object.assign(_setupData, JSON.parse(resumeData)); } catch(e) {}
try { _setupPendingSteps = JSON.parse(resumePending); } catch(e) {}
_setupStep = parseInt(resumeStep) || 0;
localStorage.removeItem('evershelf_setup_step');
localStorage.removeItem('evershelf_setup_data');
localStorage.removeItem('evershelf_setup_pending');
document.getElementById('setup-wizard').style.display = '';
_renderSetupStep();
} else {
// Fetch server settings first so .env credentials (Bring!, Gemini)
// are taken into account before deciding which wizard steps to show.
let serverSettings = {};
try { serverSettings = await api('get_settings'); } catch(e) {}
const missing = _getMissingSetupSteps(serverSettings);
if (missing.length > 0) {
showSetupWizard(missing);
}
}
// Migrate old session-based flags to time-based
if (sessionStorage.getItem('_autoAddedCritical')) {
sessionStorage.removeItem('_autoAddedCritical');
@@ -8738,7 +9187,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:
+5 -5
View File
@@ -1,23 +1,23 @@
#!/bin/bash
# Daily backup of Dispensa database (local only)
# Daily backup of EverShelf database (local only)
# The database is NOT pushed to remote repositories.
# Runs via cron: creates a local timestamped backup copy
#
# Example crontab entry:
# 0 3 * * * /var/www/html/dispensa/backup.sh
# 0 3 * * * /var/www/html/evershelf/backup.sh
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKUP_DIR="${INSTALL_DIR}/data/backups"
mkdir -p "$BACKUP_DIR"
DB_FILE="${INSTALL_DIR}/data/dispensa.db"
DB_FILE="${INSTALL_DIR}/data/evershelf.db"
if [ ! -f "$DB_FILE" ]; then
exit 0
fi
DATE=$(date '+%Y-%m-%d_%H%M')
cp "$DB_FILE" "${BACKUP_DIR}/dispensa_${DATE}.db"
cp "$DB_FILE" "${BACKUP_DIR}/evershelf_${DATE}.db"
# Keep only the last 7 backups
ls -t "${BACKUP_DIR}"/dispensa_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
ls -t "${BACKUP_DIR}"/evershelf_*.db 2>/dev/null | tail -n +8 | xargs -r rm --
+18
View File
@@ -0,0 +1,18 @@
services:
evershelf:
build: .
container_name: evershelf
ports:
- "8080:80"
volumes:
# Persist database and runtime data
- evershelf_data:/var/www/html/data
# Mount your local .env configuration
- ./.env:/var/www/html/.env:ro
restart: unless-stopped
environment:
- TZ=Europe/Rome
volumes:
evershelf_data:
driver: local
+691
View File
@@ -0,0 +1,691 @@
openapi: "3.1.0"
info:
title: EverShelf API
description: |
REST API for EverShelf — 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.2.0"
contact:
name: Stimpfl Daniel
email: evershelfproject@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
+130 -70
View File
@@ -8,10 +8,10 @@
<meta name="theme-color" content="#2d5016">
<meta name="author" content="Stimpfl Daniel">
<meta name="description" content="Self-hosted pantry manager with barcode scanning, AI identification, and shopping list integration.">
<title>Dispensa Manager</title>
<title>EverShelf</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
<link rel="stylesheet" href="assets/css/style.css?v=20260329a">
<link rel="stylesheet" href="assets/css/style.css?v=20260413a">
<!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
</head>
@@ -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">🏠 EverShelf<span class="header-version">v1.2.0</span></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">
@@ -845,13 +846,13 @@
<h4>🔒 Certificato HTTPS</h4>
<p class="settings-hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p>
<div class="form-group">
<a href="ca.crt" download="Dispensa_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block">📥 Scarica Certificato CA</a>
<a href="ca.crt" download="EverShelf_CA.crt" class="btn btn-large btn-accent full-width" style="text-align:center;text-decoration:none;display:block">📥 Scarica Certificato CA</a>
</div>
<div class="settings-hint" style="margin-top:12px;line-height:1.6">
<strong>Istruzioni per Chrome (Android):</strong><br>
1. Scarica il certificato qui sopra<br>
2. Vai in <em>Impostazioni → Sicurezza e privacy → Altre impostazioni di sicurezza → Installa da archivio dispositivo</em><br>
3. Seleziona il file <em>Dispensa_CA.crt</em> scaricato<br>
3. Seleziona il file <em>EverShelf_CA.crt</em> scaricato<br>
4. Scegli "CA" e conferma<br>
5. Riavvia Chrome<br><br>
<strong>Istruzioni per Chrome (PC):</strong><br>
@@ -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. Puoi usare la voce offline del browser oppure un endpoint REST esterno (Home Assistant, ecc.).</p>
<div class="form-group" style="margin-bottom:10px">
<label class="toggle-row">
<span>✅ Attiva TTS</span>
@@ -877,6 +878,35 @@
</span>
</label>
</div>
<div class="form-group">
<label>⚙️ Motore TTS</label>
<select id="setting-tts-engine" class="form-input" onchange="onTtsEngineChange(this.value)">
<option value="browser">🔇 Browser (offline, nessuna configurazione)</option>
<option value="server">🌐 Server esterno (Home Assistant, API REST...)</option>
</select>
</div>
<!-- Browser TTS section -->
<div id="tts-browser-section">
<div class="form-group">
<label>🗣️ Voce</label>
<select id="setting-tts-voice" class="form-input">
<option value="">— Caricamento voci… —</option>
</select>
<p class="settings-hint">Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce <strong>Paola</strong> (italiano).</p>
</div>
<div class="form-group">
<label>⚡ Velocità: <span id="tts-rate-label">1.0</span>×</label>
<input type="range" id="setting-tts-rate" class="form-input" min="0.5" max="2" step="0.1" value="1" oninput="document.getElementById('tts-rate-label').textContent=parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<label>🎵 Tono: <span id="tts-pitch-label">1.0</span></label>
<input type="range" id="setting-tts-pitch" class="form-input" min="0" max="2" step="0.1" value="1" oninput="document.getElementById('tts-pitch-label').textContent=parseFloat(this.value).toFixed(1)">
</div>
</div>
<!-- Server TTS section -->
<div id="tts-server-section" style="display:none">
<div class="form-group">
<label>🌐 URL Endpoint</label>
<input type="url" id="setting-tts-url" class="form-input" placeholder="https://...">
@@ -931,12 +961,27 @@
<textarea id="setting-tts-extra-fields" class="form-input" rows="3" placeholder='{"entity_id": "media_player.living_room"}'></textarea>
<p class="settings-hint">Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.</p>
</div>
</div><!-- /tts-server-section -->
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()">🔊 Invia Test Vocale</button>
<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 +991,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 +1023,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 +1105,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>🏠 EverShelf</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 -->
@@ -1121,6 +1181,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260330c"></script>
<script src="assets/js/app.js?v=20260413a"></script>
</body>
</html>
+4 -3
View File
@@ -1,8 +1,9 @@
{
"name": "Dispensa Manager",
"short_name": "Dispensa",
"name": "EverShelf",
"short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode",
"start_url": "/dispensa/",
"version": "1.2.0",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
"theme_color": "#2d5016",
+431
View File
@@ -0,0 +1,431 @@
{
"app": {
"name": "EverShelf",
"loading": "Laden..."
},
"nav": {
"title": "🏠 EverShelf",
"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": "EverShelf",
"loading": "Loading..."
},
"nav": {
"title": "🏠 EverShelf",
"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": "EverShelf",
"loading": "Caricamento..."
},
"nav": {
"title": "🏠 EverShelf",
"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"
}
}