228 lines
8.2 KiB
JavaScript
228 lines
8.2 KiB
JavaScript
// 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 =
|
||
'<p class="error" style="padding:24px">pantry-vision not configured — missing ' +
|
||
"?api=&token= in the URL. See hosts/kitchen-display/configs/sway/pantry-kiosk.</p>";
|
||
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 <tab>" 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 = '<p class="hint">Nothing in stock yet — scan something!</p>';
|
||
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 `<div class="card ${urgencyClass(days)}">
|
||
<span class="card-name">${escapeHtml(item.name)}</span>
|
||
<span class="card-amount">×${escapeHtml(String(item.amount ?? ""))}</span>
|
||
<span class="card-due">${escapeHtml(label)}</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
})
|
||
.catch((err) => {
|
||
el.innerHTML = `<p class="error">Could not load inventory: ${escapeHtml(err.message)}</p>`;
|
||
});
|
||
}
|
||
|
||
// --- Recipes -----------------------------------------------------------------
|
||
function loadRecipes() {
|
||
const el = document.getElementById("recipes-list");
|
||
api("/recipes")
|
||
.then((data) => {
|
||
const recipes = data.recipes || [];
|
||
if (!recipes.length) {
|
||
el.innerHTML = '<p class="hint">No recipes in Grocy yet.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = recipes
|
||
.map((r) => {
|
||
const badge = r.fulfilled === true ? "✓ can make now" : r.fulfilled === false ? "missing items" : "";
|
||
return `<div class="card">
|
||
<span class="card-name">${escapeHtml(r.name || "")}</span>
|
||
<span class="card-due">${escapeHtml(badge)}</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
})
|
||
.catch((err) => {
|
||
el.innerHTML = `<p class="error">Could not load recipes: ${escapeHtml(err.message)}</p>`;
|
||
});
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
|
||
// Start on whatever tab is active (default: scan).
|
||
resetScan();
|