chore: auto-merge develop → main
Triggered by: da62647 feat: v1.7.9 — category badges, category search, AI guards
This commit is contained in:
@@ -5,6 +5,23 @@ All notable changes to EverShelf will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.9] - 2026-05-11
|
||||
|
||||
### Added
|
||||
- **Category badge on inventory items** — Every product in the inventory now displays a macro-category badge (icon + label) next to the location badge. Badges showing `altro` are asynchronously refined via the new `guess_category` AI endpoint (Gemini + `data/category_ai_cache.json` cache) so the correct category appears automatically after the page loads.
|
||||
- **Category search** — The inventory search bar now matches items by category. Typing "biscotti" returns every cookie/biscuit regardless of brand or exact name; the match uses both the direct category key and the translated label.
|
||||
- **Brand map in `guessCategoryFromName`** — A fast-path brand table (Oreo, Ringo, Uno, Barilla, De Cecco, Galbani, Mutti, Lavazza, etc.) provides instant category resolution before any regex evaluation.
|
||||
- **PHP `guess_category` endpoint** — New server-side action that calls Gemini to classify a product name into a local category key, with file-based caching (`data/category_ai_cache.json`). Returns `altro` immediately when no Gemini API key is configured.
|
||||
|
||||
### Fixed
|
||||
- **Duplicate banner alerts** — `loadBannerAlerts()` was occasionally enqueuing the same item multiple times when called concurrently. Fixed with a `_bannerLoading` re-entrancy guard and a `_queuedItemIds` Set that prevents any item from being pushed more than once per refresh cycle.
|
||||
- **`mapToLocalCategory` with `en:dairies` / `en:dairies-and-eggs`** — The dairy regex was not matching OpenFoodFacts tags that use the `dairi` stem; extended to cover the full range of dairy tags.
|
||||
- **`mapToLocalCategory` always returning `altro`** — When the input category was already `altro`, the function exited the direct-match loop before attempting any fallback, losing all name-based guesses. The loop now skips the `altro` key for the early-return and falls back to `guessCategoryFromName(productName)` at the end.
|
||||
- **"Tonno all'olio" → condimenti** — `tonno\b` was matched after `olio\b` (condimenti) due to regex ordering. Moved the conserve block before the condimenti block so tuna products resolve correctly.
|
||||
|
||||
### Security
|
||||
- **AI function guards** — All Gemini-powered functions now check `_geminiAvailable` (JS) or the presence of `GEMINI_API_KEY` (PHP) before executing. Affected functions: `_refineCategoryBadgesAsync`, `fetchAllPrices`, `getShoppingPrice`. The PHP endpoint returns `{"success":false,"error":"no_api_key"}` instead of silently returning empty results, making the missing-key state explicit and diagnosable.
|
||||
|
||||
## [1.7.8] - 2026-05-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
|
||||
## 🌍 Recent Updates
|
||||
|
||||
- **Category badges on inventory items** — Every product now shows its macro-category badge (icon + label) inline. Items that can't be classified locally are automatically refined via Gemini AI after the page loads, with server-side caching so the AI call only happens once per product.
|
||||
- **Category search** — Typing a category name (e.g. "biscotti", "latticini") in the inventory search bar now returns all matching items across brands and exact names.
|
||||
- **AI function guards** — All Gemini-powered features (price estimation, category classification, recipe suggestions, etc.) now explicitly check whether a Gemini API key is configured before making any AI call. The UI gracefully skips or shows a friendly message instead of silently failing.
|
||||
- **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab.
|
||||
- **Kiosk v1.7.0: OTA update system** — "Cerca aggiornamenti" button in Settings triggers a forced GitHub release check; new `installUpdate()` JS bridge calls Android `DownloadManager` directly (lockTask mode blocks external browser links); graceful degradation for older APKs with manual instructions. Automatic OTA check every 6 hours with native update banner.
|
||||
- **Kiosk: consistent APK signing** — Project keystore (`evershelf.jks`) committed to the repo; every build — local or CI — now produces an APK with the same signature, eliminating "APK incompatible / signature conflict" errors on OTA update.
|
||||
|
||||
@@ -17,6 +17,7 @@ define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e4526
|
||||
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
|
||||
define('GH_REPO', 'dadaloop82/EverShelf');
|
||||
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
|
||||
define('CATEGORY_CACHE_PATH', __DIR__ . '/../data/category_ai_cache.json');
|
||||
|
||||
/** Decode the XOR-obfuscated GitHub token at runtime. */
|
||||
function _ghToken(): string {
|
||||
@@ -99,6 +100,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Ping / heartbeat — early response, no DB or rate-limit required ───────────
|
||||
if (($_GET['action'] ?? '') === 'ping') {
|
||||
echo json_encode(['ok' => true, 'ts' => time()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===== RATE LIMITING =====
|
||||
/**
|
||||
* Simple file-based rate limiter.
|
||||
@@ -455,6 +462,10 @@ try {
|
||||
getAllShoppingPrices($db);
|
||||
break;
|
||||
|
||||
case 'guess_category':
|
||||
guessCategoryFromAI();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||
@@ -7393,6 +7404,59 @@ PROMPT;
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* GET /api/?action=guess_category&name=...
|
||||
* Returns the macro-category for a product name, using a file cache + Gemini AI fallback.
|
||||
* Response: { category: string }
|
||||
*/
|
||||
function guessCategoryFromAI(): void {
|
||||
$name = trim($_GET['name'] ?? '');
|
||||
if ($name === '') { echo json_encode(['category' => 'altro']); return; }
|
||||
|
||||
// Load cache
|
||||
$cache = [];
|
||||
if (file_exists(CATEGORY_CACHE_PATH)) {
|
||||
$cache = json_decode(file_get_contents(CATEGORY_CACHE_PATH), true) ?? [];
|
||||
}
|
||||
$key = md5(mb_strtolower($name));
|
||||
if (isset($cache[$key])) { echo json_encode(['category' => $cache[$key]]); return; }
|
||||
|
||||
$apiKey = env('GEMINI_API_KEY', '');
|
||||
if ($apiKey === '') { echo json_encode(['category' => 'altro']); return; }
|
||||
|
||||
$cats = 'latticini, carne, pesce, frutta, verdura, pasta, pane, surgelati, bevande, condimenti, snack, conserve, cereali, igiene, pulizia, altro';
|
||||
$prompt = "Sei un classificatore di prodotti alimentari e domestici italiani.\n"
|
||||
. "Classifica il prodotto \"" . addslashes($name) . "\" in UNA di queste categorie esatte: $cats.\n"
|
||||
. "Rispondi con SOLO la parola chiave della categoria, senza spiegazioni né punteggiatura aggiuntiva.";
|
||||
|
||||
$payload = [
|
||||
'contents' => [['parts' => [['text' => $prompt]]]],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0,
|
||||
'maxOutputTokens' => 20,
|
||||
'thinkingConfig' => ['thinkingBudget' => 0],
|
||||
],
|
||||
];
|
||||
|
||||
$result = callGeminiWithFallback($apiKey, $payload, 10);
|
||||
$raw = strtolower(trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''));
|
||||
$raw = preg_replace('/[^a-z_ ]/', '', $raw);
|
||||
$raw = trim($raw);
|
||||
|
||||
$valid = ['latticini','carne','pesce','frutta','verdura','pasta','pane','surgelati',
|
||||
'bevande','condimenti','snack','conserve','cereali','igiene','pulizia','altro'];
|
||||
$cat = in_array($raw, $valid, true) ? $raw : 'altro';
|
||||
|
||||
// Persist to cache
|
||||
$cache[$key] = $cat;
|
||||
@file_put_contents(CATEGORY_CACHE_PATH, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
echo json_encode(['category' => $cat]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/?action=get_shopping_price
|
||||
* POST body: { name, quantity, unit, default_quantity, package_unit, country, currency, lang, force_refresh }
|
||||
@@ -7417,6 +7481,12 @@ function getShoppingPrice(PDO $db): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: price estimation requires Gemini API key
|
||||
if (empty(env('GEMINI_API_KEY'))) {
|
||||
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
||||
return;
|
||||
}
|
||||
|
||||
$cache = _loadPriceCache();
|
||||
$key = _priceKey($name, $country);
|
||||
$now = time();
|
||||
|
||||
@@ -378,6 +378,51 @@ body {
|
||||
|
||||
/* (scan active is defined above in .header-scan-btn:active) */
|
||||
|
||||
/* ── Offline / server-unreachable banner ──────────────────────────────── */
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
padding: 9px 16px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 950;
|
||||
box-shadow: 0 2px 8px rgba(220,38,38,0.35);
|
||||
}
|
||||
.offline-banner-icon { font-size: 1rem; line-height: 1; }
|
||||
.offline-banner-text { flex: 1; text-align: center; }
|
||||
.offline-banner-retry {
|
||||
background: rgba(255,255,255,0.22);
|
||||
border: 1px solid rgba(255,255,255,0.55);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 3px 11px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
|
||||
|
||||
/* When server is offline, block interactions with the main content */
|
||||
body.server-offline .app-content {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
body.server-offline .bottom-nav {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
/* Spesa mode banner */
|
||||
.spesa-mode-banner {
|
||||
display: flex;
|
||||
@@ -1098,6 +1143,11 @@ body {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.badge-category {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.badge-qty {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
|
||||
+168
-35
@@ -1215,13 +1215,12 @@ const URGENCY_BG = {
|
||||
// Map Open Food Facts categories to local categories
|
||||
function mapToLocalCategory(ofCategory, productName) {
|
||||
if (!ofCategory) {
|
||||
// No category tag — try to guess from product name
|
||||
return guessCategoryFromName(productName || '');
|
||||
}
|
||||
const cat = ofCategory.toLowerCase();
|
||||
// Direct match with our local keys
|
||||
// Direct match with our local keys — but NOT 'altro': fall through to name guess
|
||||
for (const key of Object.keys(CATEGORY_ICONS)) {
|
||||
if (cat === key) return key;
|
||||
if (cat === key && key !== 'altro') return key;
|
||||
}
|
||||
|
||||
// Handle specific Open Food Facts tags FIRST (before generic regex)
|
||||
@@ -1233,9 +1232,15 @@ function mapToLocalCategory(ofCategory, productName) {
|
||||
if (/^en:beverages/.test(cat)) return 'bevande';
|
||||
// sweeteners = condimenti
|
||||
if (/sweetener|dolcific/.test(cat)) return 'condimenti';
|
||||
// food-additives, cooking-helpers, flavourings = condimenti
|
||||
if (/food-additive|cooking-helper|aromi|flavour/.test(cat)) return 'condimenti';
|
||||
// breakfasts = cereali
|
||||
if (/breakfast/.test(cat)) return 'cereali';
|
||||
// dried-products = conserve
|
||||
if (/dried-product/.test(cat)) return 'conserve';
|
||||
|
||||
// Specific tag patterns
|
||||
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte|egg|uova|uovo|poultry-egg/.test(cat)) return 'latticini';
|
||||
if (/dairi|dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte\b|egg\b|uova\b|uovo\b|poultry-egg/.test(cat)) return 'latticini';
|
||||
if (/meat|viande|carne|sausage|salum|prosciutt/.test(cat)) return 'carne';
|
||||
if (/fish|poisson|pesce|seafood|tuna|tonno|salmone/.test(cat)) return 'pesce';
|
||||
if (/fruit|frutta|juice|succo|apple|banana/.test(cat)) return 'frutta';
|
||||
@@ -1245,12 +1250,15 @@ function mapToLocalCategory(ofCategory, productName) {
|
||||
if (/frozen|surgelé|surgel|gelat/.test(cat)) return 'surgelati';
|
||||
if (/sauce|condiment|oil|olio|vinegar|aceto|mayo|ketchup|spice|salt|sugar|zuccher/.test(cat)) return 'condimenti';
|
||||
if (/snack|chip|crisp|chocolate|cioccolat|candy|biscuit|cookie|wafer|merendine|patatine/.test(cat)) return 'snack';
|
||||
if (/conserve|canned|can|pelati|passata|preserve|jam|marmellat|miele|honey/.test(cat)) return 'conserve';
|
||||
if (/preserve|jam|marmellat|miele|honey|canned|pelati|passata/.test(cat)) return 'conserve';
|
||||
if (/cereal|muesli|granola|oat|fiocchi/.test(cat)) return 'cereali';
|
||||
if (/hygiene|soap|shampoo|igien|dentifricio|deodorant/.test(cat)) return 'igiene';
|
||||
if (/clean|detergent|pulizia|detersiv/.test(cat)) return 'pulizia';
|
||||
// Beverage check LAST (to avoid false matches on compound tags)
|
||||
if (/^(?!.*plant-based).*(beverage|drink|boisson|bevand|water|acqua|beer|birra|wine|vino|coffee|caffè|tea\b)/.test(cat)) return 'bevande';
|
||||
// Last resort: try product name before giving up
|
||||
const nameGuess = guessCategoryFromName(productName || '');
|
||||
if (nameGuess !== 'altro') return nameGuess;
|
||||
return 'altro';
|
||||
}
|
||||
|
||||
@@ -1258,36 +1266,47 @@ function mapToLocalCategory(ofCategory, productName) {
|
||||
function guessCategoryFromName(name) {
|
||||
if (!name) return 'altro';
|
||||
const n = name.toLowerCase();
|
||||
// ── Known Italian brand names → direct category (fast-path before regex)
|
||||
// "Uno" only if it starts the name (Bahlsen biscuits, not the Italian word)
|
||||
if (/^uno\b/.test(n)) return 'snack';
|
||||
const _brandRx = [
|
||||
[/\b(baiocchi|macine|tarallucci|tegolini|pavesini|plasmon|loacker|manner|digestive|oreo|hanuta|ringo|abbracci|gocciole|pan di stelle|oro saiwa|kinder|ferrero rocher|raffaello|bounty|twix|snickers|pringles|fonzies|tuc\b|ritz\b|mulino bianco|gran cereale|gocciole|saiwa|togo|principe|oro ciok|kit ?kat)\b/, 'snack'],
|
||||
[/\b(barilla|de cecco|garofalo|la molisana|rummo|voiello|divella|agnesi|buitoni)\b/, 'pasta'],
|
||||
[/\b(galbani|granarolo|yomo|danone|muller|müller|pr[eé]sident|santa lucia|jocca|fiorfiore)\b/, 'latticini'],
|
||||
[/\b(mutti|cirio)\b/, 'conserve'],
|
||||
[/\b(san pellegrino|levissima|ferrarelle|lete|nestea|lipton|nescaf[eé]|lavazza|illy\b|kimbo|segafredo)\b/, 'bevande'],
|
||||
];
|
||||
for (const [rx, cat] of _brandRx) { if (rx.test(n)) return cat; }
|
||||
// Pasta & Rice
|
||||
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette/.test(n)) return 'pasta';
|
||||
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette|sedani|tortiglioni|calamarata|spaghettini|vermicelli/.test(n)) return 'pasta';
|
||||
// Pane & Forno
|
||||
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli|pancarrè|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b/.test(n)) return 'pane';
|
||||
if (/pane\b|bauletto|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini\b|sandwich|taralli|pancarr[eè]|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b|pangrattato|pane grattugiato|pan.*carr[eè]/.test(n)) return 'pane';
|
||||
// Latticini (before bevande to avoid latte→bevande)
|
||||
if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie|camembert|emmental|asiago|feta\b|provola|caciotta|caprino/.test(n)) return 'latticini';
|
||||
// Conserve
|
||||
if (/passata|pelati|pomodoro|pomodori|pomodorini|ciliegino|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive|tonno in scatola|sgombro in scatola|concentrato|brodo|dado|besciamella/.test(n)) return 'conserve';
|
||||
// Condimenti (include spezie, farine, zucchero)
|
||||
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa|paprika|curry|cannella|noce moscata|origano|rosmarino|timo|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido|maizena|semola|pesto|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco/.test(n)) return 'condimenti';
|
||||
if (/latte\b|yogurt|y[o]?gurt|yaourt|yougurt|yoghurt|formaggio|mozzarella|burro\b|panna\b|ricott|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie\b|camembert|emmental|asiago|feta\b|provola|caciotta|caprino|philadelphia|skyr|kefir|labneh/.test(n)) return 'latticini';
|
||||
// Conserve — controllo tonno\b PRIMA di condimenti (che ha olio\b)
|
||||
if (/passata|pelati|pomodoro\b|pomodori|pomodorini|ciliegino|sugo\b|polpa di pomod|marmellata|miele\b|zagara|legumi|ceci\b|fagioli\b|lenticchie|olive\b|tonno\b|sgombro in scatola|concentrato|brodo\b|dado\b|besciamella|datterini|passato di/.test(n)) return 'conserve';
|
||||
// Condimenti (include spezie, farine, zucchero, aromi, lieviti)
|
||||
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina\b|maionese|ketchup|senape|salsa\b|paprika|curry\b|cannella|noce moscata|origano|rosmarino|timo\b|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido\b|maizena|semola|pesto\b|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco|aroma\b|aromi\b|arome\b|estratto.*vaniglia|estratto.*limone|polenta\b|semolino\b|cacao amaro|cacao.*polvere|purea|pure\b|pur[ée]e/.test(n)) return 'condimenti';
|
||||
// Bevande (after latticini to avoid latte conflict)
|
||||
if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino/.test(n)) return 'bevande';
|
||||
if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caff[eè]\b|kaffee|kafè|t[eè]\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino|ciobar|ovomaltine|zuppalatte|cioccolata.*calda|latte.*cioccolato/.test(n)) return 'bevande';
|
||||
// Carne (include salumi)
|
||||
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola/.test(n)) return 'carne';
|
||||
if (/pollo\b|manzo|maiale|vitello|tacchino|prosciutto|salame\b|bresaola|mortadella|wurstel|speck\b|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola|schinken|scamorza affumicat|spianata/.test(n)) return 'carne';
|
||||
// Pesce
|
||||
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi|alici|branzino|orata|sardine|trota|dentice|seppia|polpo/.test(n)) return 'pesce';
|
||||
if (/tonno\b|salmone|merluzzo|pesce\b|sgombro\b|gamberi|acciughe|baccal[aà]|vongole|cozze|calamari|surimi|alici|branzino|orata\b|sardine|trota|dentice|seppia|polpo|filetto.*pesce|pesce.*filetto/.test(n)) return 'pesce';
|
||||
// Frutta
|
||||
if (/mela|mele|banana|arancia|pera|fragola|uva\b|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|fico\b|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta';
|
||||
if (/mela\b|mele\b|banana|arancia|pera\b|fragola|uva\b|kiwi\b|limone|frutta\b|mandarino|clementina|pompelmo|avocado|mango\b|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta';
|
||||
// Verdura
|
||||
if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais|zucca|aglio|cetriolo|rapa|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura';
|
||||
if (/insalata|zucchina|zucchine|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais\b|zucca\b|aglio\b|cetriolo|rapa\b|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura';
|
||||
// Surgelati
|
||||
if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato/.test(n)) return 'surgelati';
|
||||
if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato|potato wedge|potato.*wedge/.test(n)) return 'surgelati';
|
||||
// Snack & Dolci
|
||||
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini|torta|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramisù/.test(n)) return 'snack';
|
||||
if (/biscott|cioccolat|nutella|merendine\b|merendina|patatine|caramelle|wafer|cialda|cialdine|sfornatini|torta\b|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramis[uù]|cantucci|amaretti|savoiardi|pralin|confetti dolci|chicchi.*cacao|cacao.*chicchi|risofrolle|sfogliatine|ossi di morto|canestrelli|snack/.test(n)) return 'snack';
|
||||
// Cereali
|
||||
if (/cereali|muesli|fiocchi|granola|polenta|porridge|avena/.test(n)) return 'cereali';
|
||||
if (/cereali|muesli|fiocchi|granola|porridge|avena|mix energia|misto cereal|farro\b|orzo\b|quinoa/.test(n)) return 'cereali';
|
||||
// Igiene personale
|
||||
if (/sapone|shampoo|dentifricio|deodorante|carta igienica|fazzoletti|cotton fioc|assorbente|rasoio|schiuma da barba|gel doccia|balsamo\b|lozione/.test(n)) return 'igiene';
|
||||
// Pulizia casa
|
||||
if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia';
|
||||
if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti\b|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia';
|
||||
return 'altro';
|
||||
}
|
||||
|
||||
@@ -3492,6 +3511,7 @@ function _dismissNoExpiry(productId) {
|
||||
// === ALERT BANNER SYSTEM (replaces old review table) ===
|
||||
let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
|
||||
let _bannerIndex = 0;
|
||||
let _bannerLoading = false; // guard against concurrent calls
|
||||
let _bannerEditPending = false; // true when editing from banner → dismiss after save
|
||||
let _bannerRefreshTimer = null; // periodic refresh while on dashboard
|
||||
let _shoppingPollTimer = null; // periodic refresh while on shopping page (multi-client sync)
|
||||
@@ -3501,10 +3521,12 @@ let _shoppingPollTimer = null; // periodic refresh while on shopping page (mul
|
||||
* merge into a single banner queue and show the first item.
|
||||
*/
|
||||
async function loadBannerAlerts() {
|
||||
if (_bannerLoading) return;
|
||||
_bannerLoading = true;
|
||||
_bannerQueue = [];
|
||||
_bannerIndex = 0;
|
||||
const banner = document.getElementById('alert-banner');
|
||||
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
|
||||
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
|
||||
|
||||
try {
|
||||
const [invData, predData, anomalyData, finishedData] = await Promise.all([
|
||||
@@ -3515,6 +3537,8 @@ async function loadBannerAlerts() {
|
||||
]);
|
||||
const items = invData.inventory || [];
|
||||
const confirmed = getReviewConfirmed();
|
||||
// Track item IDs already queued to prevent the same item appearing in multiple types
|
||||
const _queuedItemIds = new Set();
|
||||
|
||||
// 1. Expired products (highest priority) - derived from inventory
|
||||
// Also considers opened_at: if item is opened and its opened-shelf-life has passed, it's expired too
|
||||
@@ -3547,6 +3571,7 @@ async function loadBannerAlerts() {
|
||||
// Skip items the freezer bonus still considers safe — no need to alarm the user
|
||||
if (getExpiredSafety(item, daysExpired).level === 'ok') return;
|
||||
_bannerQueue.push({ type: 'expired', data: { ...item, days_expired: daysExpired } });
|
||||
_queuedItemIds.add(item.id);
|
||||
});
|
||||
|
||||
// 2. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner)
|
||||
@@ -3562,6 +3587,7 @@ async function loadBannerAlerts() {
|
||||
});
|
||||
|
||||
items.forEach(item => {
|
||||
if (_queuedItemIds.has(item.id)) return; // already in expired
|
||||
if (confirmed[item.id]) return;
|
||||
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
|
||||
const qty = parseFloat(item.quantity);
|
||||
@@ -3600,6 +3626,7 @@ async function loadBannerAlerts() {
|
||||
else if (isLow) warning = '⬇️ Troppo poco';
|
||||
else warning = '⬆️ Troppo';
|
||||
_bannerQueue.push({ type: 'review', data: { ...item, warning, _isLow: isLow } });
|
||||
_queuedItemIds.add(item.id);
|
||||
});
|
||||
|
||||
// 4. Consumption predictions that don't match actual quantity
|
||||
@@ -3628,6 +3655,7 @@ async function loadBannerAlerts() {
|
||||
const PERISHABLE_CATS = ['latticini','carne','pesce','salumi','fresco','verdura','frutta','surgelati',
|
||||
'dairy','meat','fish','fresh','vegetables','fruit','frozen'];
|
||||
items.forEach(item => {
|
||||
if (_queuedItemIds.has(item.id)) return; // already in expired or review
|
||||
if (item.expiry_date) return; // already has expiry
|
||||
if (parseFloat(item.quantity) <= 0) return; // no stock
|
||||
const pid = String(item.product_id || item.id);
|
||||
@@ -3651,6 +3679,8 @@ async function loadBannerAlerts() {
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Banner] loadBannerAlerts error:', e);
|
||||
} finally {
|
||||
_bannerLoading = false;
|
||||
}
|
||||
|
||||
if (_bannerQueue.length > 0) {
|
||||
@@ -4370,7 +4400,10 @@ async function loadInventory() {
|
||||
}
|
||||
|
||||
function renderInventoryItem(item) {
|
||||
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
|
||||
const catKey = mapToLocalCategory(item.category, item.name);
|
||||
const catIcon = CATEGORY_ICONS[catKey] || '📦';
|
||||
const catLabel = t('categories.' + catKey) || catKey;
|
||||
const catBadge = `<span class="inv-badge badge-category" data-cat="${catKey}" data-itemname="${escapeHtml(item.name)}">${catIcon} ${catLabel}</span>`;
|
||||
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
|
||||
const days = daysUntilExpiry(item.expiry_date);
|
||||
const isExpired = days < 0;
|
||||
@@ -4401,6 +4434,7 @@ function renderInventoryItem(item) {
|
||||
${item.brand ? `<div class="inv-brand">${escapeHtml(item.brand)}</div>` : ''}
|
||||
<div class="inv-meta">
|
||||
<span class="inv-badge badge-location">${locInfo.icon} ${locInfo.label}</span>
|
||||
${catBadge}
|
||||
${expiryBadge}
|
||||
${openedBadge}
|
||||
${vacuumBadge}
|
||||
@@ -4421,6 +4455,28 @@ function renderInventory(items) {
|
||||
return;
|
||||
}
|
||||
container.innerHTML = renderGroupedByCategory(items, false);
|
||||
_refineCategoryBadgesAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* After rendering, find all badges still showing 'altro' and ask the server
|
||||
* (Gemini-backed, cached) for a better category. Updates the DOM in place.
|
||||
*/
|
||||
async function _refineCategoryBadgesAsync() {
|
||||
if (!_geminiAvailable) return; // AI not available — keep 'altro' label
|
||||
const badges = Array.from(document.querySelectorAll('.badge-category[data-cat="altro"]'));
|
||||
for (const badge of badges) {
|
||||
const name = badge.dataset.itemname;
|
||||
if (!name) continue;
|
||||
try {
|
||||
const res = await api('guess_category', { name });
|
||||
const cat = res.category;
|
||||
if (cat && cat !== 'altro') {
|
||||
badge.dataset.cat = cat;
|
||||
badge.textContent = (CATEGORY_ICONS[cat] || '📦') + ' ' + (t('categories.' + cat) || cat);
|
||||
}
|
||||
} catch (_) { /* network error — leave as 'altro' */ }
|
||||
}
|
||||
}
|
||||
|
||||
function filterLocation(loc) {
|
||||
@@ -4440,11 +4496,21 @@ function filterInventory() {
|
||||
return;
|
||||
}
|
||||
if (qas) qas.style.display = 'none';
|
||||
const filtered = currentInventory.filter(i =>
|
||||
i.name.toLowerCase().includes(q) ||
|
||||
(i.brand && i.brand.toLowerCase().includes(q)) ||
|
||||
(i.barcode && i.barcode.includes(q))
|
||||
);
|
||||
// Category inferred from the search term itself (e.g. "biscotti" → "snack")
|
||||
const queryCat = guessCategoryFromName(q);
|
||||
const filtered = currentInventory.filter(i => {
|
||||
if (i.name.toLowerCase().includes(q)) return true;
|
||||
if (i.brand && i.brand.toLowerCase().includes(q)) return true;
|
||||
if (i.barcode && i.barcode.includes(q)) return true;
|
||||
const itemCat = mapToLocalCategory(i.category, i.name);
|
||||
// Match category key directly (e.g. "snack", "latticini")
|
||||
if (itemCat.includes(q)) return true;
|
||||
// Match category label (e.g. "dolci" matches "Snack & Dolci", "riso" matches "Pasta & Riso")
|
||||
if ((CATEGORY_LABELS[itemCat] || '').toLowerCase().includes(q)) return true;
|
||||
// Match by inferred category: "biscotti" → queryCat="snack" → all snack items
|
||||
if (queryCat !== 'altro' && itemCat === queryCat) return true;
|
||||
return false;
|
||||
});
|
||||
renderInventory(filtered);
|
||||
}
|
||||
|
||||
@@ -4572,6 +4638,7 @@ function showItemDetail(inventoryId, productId) {
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-danger flex-1" onclick="quickUse(${item.product_id}, '${item.location}')">📤 Usa</button>
|
||||
<button class="btn btn-primary flex-1" onclick="editInventoryItem(${inventoryId})">✏️ Modifica</button>
|
||||
<button class="btn btn-accent flex-1" onclick="closeModal();generateRecipeForIngredient(${JSON.stringify(item.name)})">🍳 Ricetta</button>
|
||||
<button class="btn btn-secondary" onclick="deleteInventoryItem(${inventoryId})" style="padding:12px">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -7810,12 +7877,12 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
|
||||
// Show vacuum checkbox for any container-type unit or if the item was previously vacuum sealed.
|
||||
// Pre-checked when it was already sealed (semi-automatic: if you sealed it last time, you likely will again).
|
||||
const wasVacuum = !!(openedVacuumSealed ?? product.vacuum_sealed);
|
||||
const isContainer = ['conf','g','kg','ml','l'].includes(unit || product.unit || '') || wasVacuum;
|
||||
const vacuumRow = isContainer ? `
|
||||
// Always offer vacuum sealing: any leftover food can be vacuum sealed regardless of unit type.
|
||||
const vacuumRow = `
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
|
||||
<input type="checkbox" id="move-vacuum-check" ${wasVacuum ? 'checked' : ''}>
|
||||
<span>🔒 Metti <b>sotto vuoto</b> il resto${wasVacuum ? ' (era già sigillato)' : ''}</span>
|
||||
</label>` : '';
|
||||
</label>`;
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>${t('move.title')}</h3>
|
||||
@@ -9048,6 +9115,12 @@ async function fetchAllPrices(forceRefresh = false) {
|
||||
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
|
||||
return;
|
||||
}
|
||||
if (!_geminiAvailable) {
|
||||
// AI not configured — prices cannot be estimated without Gemini
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
|
||||
return;
|
||||
}
|
||||
|
||||
_pricesFetching = true;
|
||||
|
||||
@@ -11422,11 +11495,11 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)
|
||||
const locButtons = otherLocs.map(([k, v]) =>
|
||||
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmRecipeMove(${productId}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
|
||||
).join('');
|
||||
const vacuumRow = wasVacuum ? `
|
||||
const vacuumRow = `
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
|
||||
<input type="checkbox" id="move-vacuum-check" checked>
|
||||
<span>${t('move.vacuum_restore')}</span>
|
||||
</label>` : '';
|
||||
<input type="checkbox" id="move-vacuum-check" ${wasVacuum ? 'checked' : ''}>
|
||||
<span>${wasVacuum ? t('move.vacuum_restore') : '🔒 Metti <b>sotto vuoto</b> il resto'}</span>
|
||||
</label>`;
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>${t('move.title')}</h3>
|
||||
@@ -13598,6 +13671,65 @@ async function _finishSetup() {
|
||||
document.getElementById('setup-wizard').style.display = 'none';
|
||||
}
|
||||
|
||||
// ===== SERVER HEARTBEAT =====
|
||||
// Polls the lightweight ?action=ping endpoint every 20 s (online) / 5 s (offline).
|
||||
// When the server is unreachable: shows the #offline-banner, blocks the UI via
|
||||
// body.server-offline, and retries faster until the server responds again.
|
||||
|
||||
let _serverOffline = false;
|
||||
let _heartbeatTimer = null;
|
||||
const _HB_INTERVAL_ONLINE = 20_000; // ms — normal polling interval
|
||||
const _HB_INTERVAL_OFFLINE = 5_000; // ms — faster retry when unreachable
|
||||
|
||||
async function _runHeartbeat() {
|
||||
const ac = new AbortController();
|
||||
const tid = setTimeout(() => ac.abort(), 7000); // 7 s hard timeout
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=ping`, {
|
||||
cache: 'no-store',
|
||||
signal: ac.signal,
|
||||
});
|
||||
clearTimeout(tid);
|
||||
_setServerOffline(!res.ok);
|
||||
} catch (e) {
|
||||
clearTimeout(tid);
|
||||
_setServerOffline(true);
|
||||
}
|
||||
}
|
||||
|
||||
function _setServerOffline(offline) {
|
||||
if (offline === _serverOffline) {
|
||||
// State unchanged — reschedule at the appropriate interval and return
|
||||
_heartbeatTimer = setTimeout(_runHeartbeat,
|
||||
offline ? _HB_INTERVAL_OFFLINE : _HB_INTERVAL_ONLINE);
|
||||
return;
|
||||
}
|
||||
_serverOffline = offline;
|
||||
document.body.classList.toggle('server-offline', offline);
|
||||
const banner = document.getElementById('offline-banner');
|
||||
if (banner) banner.style.display = offline ? '' : 'none';
|
||||
if (offline) {
|
||||
showToast(t('error.server_offline'), 'error');
|
||||
} else {
|
||||
showToast(t('error.server_restored'), 'success');
|
||||
// Refresh the current page since updates may have been missed
|
||||
refreshCurrentPage();
|
||||
}
|
||||
_heartbeatTimer = setTimeout(_runHeartbeat,
|
||||
offline ? _HB_INTERVAL_OFFLINE : _HB_INTERVAL_ONLINE);
|
||||
}
|
||||
|
||||
/** Called by the banner "Retry" button to trigger an immediate check. */
|
||||
function _heartbeatRetry() {
|
||||
clearTimeout(_heartbeatTimer);
|
||||
_runHeartbeat();
|
||||
}
|
||||
|
||||
/** Start the heartbeat loop (called once from _initApp). */
|
||||
function startHeartbeat() {
|
||||
_runHeartbeat(); // immediate first probe
|
||||
}
|
||||
|
||||
async function _initApp() {
|
||||
// Check for setup wizard resume (after language change)
|
||||
const resumeStep = localStorage.getItem('evershelf_setup_step');
|
||||
@@ -13647,6 +13779,7 @@ async function _initApp() {
|
||||
initSpesaMode();
|
||||
initScreensaverShortcuts();
|
||||
startBgShoppingRefresh();
|
||||
startHeartbeat();
|
||||
_injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView)
|
||||
|
||||
// Hide preloader once the dashboard is rendered
|
||||
|
||||
+10
-3
@@ -11,7 +11,7 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260508c">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260511a">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||
@@ -67,7 +67,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.6</span>
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.9</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -94,6 +94,13 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Offline / server-unreachable banner -->
|
||||
<div id="offline-banner" class="offline-banner" style="display:none" role="alert" aria-live="assertive">
|
||||
<span class="offline-banner-icon" aria-hidden="true">🔌</span>
|
||||
<span class="offline-banner-text" data-i18n="error.server_offline">Connessione al server persa</span>
|
||||
<button class="offline-banner-retry" onclick="_heartbeatRetry()" data-i18n="error.server_retry">Riprova</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="app-content" id="app-content">
|
||||
|
||||
@@ -1462,6 +1469,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260510k"></script>
|
||||
<script src="assets/js/app.js?v=20260511a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.8",
|
||||
"version": "1.7.9",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
@@ -775,7 +775,10 @@
|
||||
"appliance_exists": "Gerät bereits vorhanden",
|
||||
"already_exists": "Bereits vorhanden",
|
||||
"network_retry": "Verbindungsfehler. Erneut versuchen.",
|
||||
"select_items": "Wähle mindestens ein Produkt aus"
|
||||
"select_items": "Wähle mindestens ein Produkt aus",
|
||||
"server_offline": "Serververbindung unterbrochen",
|
||||
"server_restored": "Serververbindung wiederhergestellt",
|
||||
"server_retry": "Erneut versuchen"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
|
||||
|
||||
@@ -775,7 +775,10 @@
|
||||
"appliance_exists": "Appliance already exists",
|
||||
"already_exists": "Already exists",
|
||||
"network_retry": "Connection error. Try again.",
|
||||
"select_items": "Select at least one product"
|
||||
"select_items": "Select at least one product",
|
||||
"server_offline": "Server connection lost",
|
||||
"server_restored": "Server connection restored",
|
||||
"server_retry": "Retry"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Do you really want to remove this product from inventory?",
|
||||
|
||||
@@ -775,7 +775,10 @@
|
||||
"appliance_exists": "Elettrodomestico già presente",
|
||||
"already_exists": "Già presente",
|
||||
"network_retry": "Errore di connessione. Riprova.",
|
||||
"select_items": "Seleziona almeno un prodotto"
|
||||
"select_items": "Seleziona almeno un prodotto",
|
||||
"server_offline": "Connessione al server persa",
|
||||
"server_restored": "Connessione al server ripristinata",
|
||||
"server_retry": "Riprova"
|
||||
},
|
||||
"confirm": {
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
|
||||
Reference in New Issue
Block a user