// pantry-vision frontend logic (docs/project-plan.md Phase 17). Vanilla JS, no // framework, no build step — same "vendored, dependency-free" choice as the digest/ // admin canvas SDKs' render.js. // // Config comes from URL query params (?api=...&token=...), set by the // kitchen-display kiosk's own launch command, NOT hardcoded here — this file is a // generic static asset with no secret in it, served read-only by pantry-web to // whatever device points a browser at it. "use strict"; const params = new URLSearchParams(location.search); const API = (params.get("api") || "").replace(/\/$/, ""); const TOKEN = params.get("token") || ""; if (!API || !TOKEN) { document.body.innerHTML = '

pantry-vision not configured — missing ' + "?api=&token= in the URL. See hosts/kitchen-display/configs/sway/pantry-kiosk.

"; throw new Error("pantry-vision: missing ?api=/&token= query params"); } function api(path, options) { options = options || {}; options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {}); return fetch(`${API}${path}`, options).then((res) => { if (!res.ok) { return res.json().catch(() => ({})).then((body) => { throw new Error(body.error || `${res.status} ${res.statusText}`); }); } return res.json(); }); } // --- Tabs -------------------------------------------------------------------- const tabs = document.querySelectorAll(".tab"); const panels = document.querySelectorAll(".panel"); function activateTab(name) { tabs.forEach((t) => t.classList.toggle("active", t.dataset.tab === name)); panels.forEach((p) => p.classList.toggle("active", p.id === name)); if (name === "inventory") loadInventory(); if (name === "recipes") loadRecipes(); if (name === "scan") resetScan(); } tabs.forEach((t) => t.addEventListener("click", () => activateTab(t.dataset.tab))); // A "Show " MQTT command from touchpanel-style HA control arrives as a URL // fragment reload (hosts/kitchen-display/agent kills and relaunches Chromium at // index.html#recipes, same pattern as the thin client's digest-browser) — honour it // on load, same as any manual tap. if (location.hash) { const initial = location.hash.slice(1); if (["scan", "inventory", "recipes"].includes(initial)) activateTab(initial); } // --- Scan ---------------------------------------------------------------------- const video = document.getElementById("camera-preview"); const captureBtn = document.getElementById("capture-btn"); const cameraError = document.getElementById("camera-error"); const scanCamera = document.getElementById("scan-camera"); const scanResult = document.getElementById("scan-result"); const capturedFrame = document.getElementById("captured-frame"); const confirmForm = document.getElementById("confirm-form"); const confirmStatus = document.getElementById("confirm-status"); const retakeBtn = document.getElementById("retake-btn"); let stream = null; function startCamera() { if (stream) return; navigator.mediaDevices .getUserMedia({ video: { facingMode: "environment" }, audio: false }) .then((s) => { stream = s; video.srcObject = s; cameraError.hidden = true; }) .catch((err) => { cameraError.textContent = `Camera unavailable: ${err.message}. See hosts/kitchen-display/README.md.`; cameraError.hidden = false; }); } function resetScan() { scanResult.hidden = true; scanCamera.hidden = false; confirmStatus.textContent = ""; startCamera(); } captureBtn.addEventListener("click", () => { if (!stream) return; capturedFrame.width = video.videoWidth; capturedFrame.height = video.videoHeight; capturedFrame.getContext("2d").drawImage(video, 0, 0); scanCamera.hidden = true; scanResult.hidden = false; confirmStatus.textContent = "Identifying…"; document.getElementById("f-confidence").textContent = ""; capturedFrame.toBlob( (blob) => { api("/identify", { method: "POST", body: blob, headers: { "Content-Type": "image/jpeg" } }) .then((proposal) => { document.getElementById("f-name").value = proposal.name || ""; document.getElementById("f-category").value = proposal.category || "other"; const days = Number.isFinite(proposal.estimated_shelf_life_days) ? proposal.estimated_shelf_life_days : 7; const due = new Date(); due.setDate(due.getDate() + days); document.getElementById("f-date").value = due.toISOString().slice(0, 10); document.getElementById("f-confidence").textContent = `Model confidence: ${proposal.confidence || "unknown"}` + (proposal.note ? ` — ${proposal.note}` : "") + ". Review before confirming."; confirmStatus.textContent = ""; }) .catch((err) => { confirmStatus.textContent = `Could not identify: ${err.message}. Fill in manually.`; }); }, "image/jpeg", 0.85 ); }); retakeBtn.addEventListener("click", resetScan); confirmForm.addEventListener("submit", (event) => { event.preventDefault(); confirmStatus.textContent = "Adding…"; api("/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: document.getElementById("f-name").value.trim(), category: document.getElementById("f-category").value, best_before_date: document.getElementById("f-date").value, quantity: Number(document.getElementById("f-quantity").value) || 1, }), }) .then(() => { confirmStatus.textContent = "Added. Put it away!"; setTimeout(resetScan, 1500); }) .catch((err) => { confirmStatus.textContent = `Could not add: ${err.message}`; }); }); // --- Inventory ------------------------------------------------------------------- function urgencyClass(daysLeft) { if (daysLeft === null || daysLeft === undefined) return ""; if (daysLeft < 0) return "urgent-expired"; if (daysLeft <= 2) return "urgent-soon"; if (daysLeft <= 7) return "urgent-week"; return ""; } function loadInventory() { const el = document.getElementById("inventory-list"); api("/inventory") .then((data) => { const items = data.items || []; if (!items.length) { el.innerHTML = '

Nothing in stock yet — scan something!

'; return; } el.innerHTML = items .map((item) => { const days = item.days_left; const label = days === null || days === undefined ? "no date" : days < 0 ? `expired ${-days}d ago` : days === 0 ? "expires today" : `${days}d left`; return `
${escapeHtml(item.name)} ×${escapeHtml(String(item.amount ?? ""))} ${escapeHtml(label)}
`; }) .join(""); }) .catch((err) => { el.innerHTML = `

Could not load inventory: ${escapeHtml(err.message)}

`; }); } // --- Recipes ----------------------------------------------------------------- function loadRecipes() { const el = document.getElementById("recipes-list"); api("/recipes") .then((data) => { const recipes = data.recipes || []; if (!recipes.length) { el.innerHTML = '

No recipes in Grocy yet.

'; return; } el.innerHTML = recipes .map((r) => { const badge = r.fulfilled === true ? "✓ can make now" : r.fulfilled === false ? "missing items" : ""; return `
${escapeHtml(r.name || "")} ${escapeHtml(badge)}
`; }) .join(""); }) .catch((err) => { el.innerHTML = `

Could not load recipes: ${escapeHtml(err.message)}

`; }); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // Start on whatever tab is active (default: scan). resetScan();