// 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();