// Small progressive enhancements. Kept deliberately tiny - HTMX does the work. (function () { "use strict"; // Register the service worker so logging survives a sleeping server. if ("serviceWorker" in navigator) { window.addEventListener("load", function () { navigator.serviceWorker.register("/static/sw.js", { scope: "/" }) .catch(function () { /* offline support is best-effort */ }); }); } // A 303 redirect from an HTMX form would otherwise swap the whole page. document.body.addEventListener("htmx:afterRequest", function (event) { var target = event.detail && event.detail.elt; if (target && target.closest && target.closest("form[hx-swap='none']")) { var day = document.getElementById("day"); if (day && window.htmx) { window.htmx.ajax("GET", "/partials/day", { target: "#day", swap: "innerHTML" }); } } }); })(); // Photo preview. Exposed globally because it is wired up via an inline handler // on the file input, which is the simplest thing that works on iOS Safari. // // There are two file inputs (library and camera) sharing one preview. Picking a // file in one clears the other, so the form never submits two candidate images. function ntPick(input, otherId) { var other = document.getElementById(otherId); if (other) other.value = ""; ntPreview(input); } function ntPreview(input) { var image = document.getElementById("preview"); var button = document.getElementById("analyze-btn"); if (!image) return; var file = input.files && input.files[0]; if (!file) { image.hidden = true; if (button) button.disabled = true; return; } image.src = URL.createObjectURL(file); image.hidden = false; if (button) button.disabled = false; } // -------------------------------------------------------------------------- // Barcode scanning // // Uses MediaDevices + ZXing. Camera access needs a secure context: it works on // the Tailscale https:// address and on http://127.0.0.1 (browsers treat // localhost as secure), but NOT on a plain http:// LAN IP. The photo and // manual-entry paths exist so a user who lands in that case still has a way // through rather than a dead end. // -------------------------------------------------------------------------- var ntReader = null; var ntLastCode = null; function ntSetStatus(message, isError) { var node = document.getElementById("scan-status"); if (!node) return; node.textContent = message || ""; node.style.color = isError ? "var(--bad)" : "var(--muted)"; } function ntScanFormats() { return [ ZXing.BarcodeFormat.EAN_13, ZXing.BarcodeFormat.EAN_8, ZXing.BarcodeFormat.UPC_A, ZXing.BarcodeFormat.UPC_E, ZXing.BarcodeFormat.CODE_128, ZXing.BarcodeFormat.CODE_39, ZXing.BarcodeFormat.ITF ]; } function ntLookupBarcode(code) { var cleaned = (code || "").replace(/\D/g, ""); if (cleaned.length < 6) { ntSetStatus("That does not look like a barcode.", true); return; } ntStopScan(); ntSetStatus("Looking up " + cleaned + "..."); var meal = document.getElementById("meal"); var url = "/scan/lookup?barcode=" + encodeURIComponent(cleaned) + "&meal=" + encodeURIComponent(meal ? meal.value : ""); if (window.htmx) { window.htmx.ajax("GET", url, { target: "#results", swap: "innerHTML" }); } else { window.location = url; } } function ntLookupManual() { var field = document.getElementById("manual"); if (field) ntLookupBarcode(field.value); } function ntStartScan() { var video = document.getElementById("scan-video"); var startButton = document.getElementById("scan-start"); var stopButton = document.getElementById("scan-stop"); if (!window.ZXing) { ntSetStatus("The scanner library did not load. Use a photo or type the number.", true); return; } if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { ntSetStatus( "This browser will not open the camera here. On a plain http:// address " + "the camera is blocked; use https, or use a photo below.", true); return; } video.hidden = false; if (startButton) startButton.hidden = true; if (stopButton) stopButton.hidden = false; ntSetStatus("Point the camera at the barcode."); var hints = new Map(); hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, ntScanFormats()); hints.set(ZXing.DecodeHintType.TRY_HARDER, true); ntReader = new ZXing.BrowserMultiFormatReader(hints, { delayBetweenScanAttempts: 200 }); var constraints = { video: { facingMode: { ideal: "environment" }, width: { ideal: 1280 }, height: { ideal: 720 } } }; ntReader.decodeFromConstraints(constraints, video, function (result) { if (!result) return; var text = result.getText(); if (text && text !== ntLastCode) { ntLastCode = text; ntLookupBarcode(text); } }).catch(function (error) { ntStopScan(); var message = (error && error.name === "NotAllowedError") ? "Camera permission was declined. Use a photo below instead." : "Could not open the camera. Use a photo below instead."; ntSetStatus(message, true); }); } function ntStopScan() { if (ntReader) { try { ntReader.reset(); } catch (error) { /* already stopped */ } ntReader = null; } var video = document.getElementById("scan-video"); if (video && video.srcObject) { video.srcObject.getTracks().forEach(function (track) { track.stop(); }); video.srcObject = null; } var startButton = document.getElementById("scan-start"); var stopButton = document.getElementById("scan-stop"); if (startButton) startButton.hidden = false; if (stopButton) stopButton.hidden = true; } function ntDecodePhoto(input) { var file = input.files && input.files[0]; if (!file || !window.ZXing) return; var image = document.getElementById("scan-image"); image.src = URL.createObjectURL(file); image.hidden = false; image.onload = function () { var reader = new ZXing.BrowserMultiFormatReader(); reader.decodeFromImageElement(image).then(function (result) { ntLookupBarcode(result.getText()); }).catch(function () { ntSetStatus( "No barcode found in that photo. Get closer, fill the frame, and keep it in focus.", true); }); }; } // Release the camera when navigating away, otherwise the indicator stays lit. window.addEventListener("pagehide", function () { ntStopScan(); });