Download static/app.js from 0001AMA/SMOS: direct link, hf CLI and curl.
- Browser
- Download file 26.5 kB
-
https://huggingface.co/spaces/0001AMA/SMOS/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/0001AMA/SMOS/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/0001AMA/SMOS/resolve/main/static/app.js
26.5 kB
| const state = { | |
| config: null, | |
| orders: [], | |
| localMode: false, | |
| recognition: null, | |
| listening: false, | |
| imageTimer: null, | |
| translationTimer: null, | |
| socket: null, | |
| tableId: null, | |
| lastDetectedLanguage: null, | |
| }; | |
| const $ = (selector, root = document) => root.querySelector(selector); | |
| const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; | |
| const staticLanguages = [ | |
| ["en", "English", "en-GB"], ["tr", "Türkçe · Turkish — optimised", "tr-TR"], | |
| ["ar", "العربية · Arabic", "ar-SA"], | |
| ["bn", "বাংলা · Bengali", "bn-BD"], ["zh-CN", "中文 · Chinese", "zh-CN"], | |
| ["cs", "Čeština · Czech", "cs-CZ"], ["da", "Dansk · Danish", "da-DK"], | |
| ["nl", "Nederlands · Dutch", "nl-NL"], ["fi", "Suomi · Finnish", "fi-FI"], | |
| ["fr", "Français · French", "fr-FR"], ["de", "Deutsch · German", "de-DE"], | |
| ["el", "Ελληνικά · Greek", "el-GR"], ["hi", "हिन्दी · Hindi", "hi-IN"], | |
| ["hu", "Magyar · Hungarian", "hu-HU"], ["id", "Bahasa Indonesia", "id-ID"], | |
| ["it", "Italiano · Italian", "it-IT"], ["ja", "日本語 · Japanese", "ja-JP"], | |
| ["ko", "한국어 · Korean", "ko-KR"], ["ms", "Bahasa Melayu · Malay", "ms-MY"], | |
| ["no", "Norsk · Norwegian", "nb-NO"], ["pl", "Polski · Polish", "pl-PL"], | |
| ["pt", "Português · Portuguese", "pt-PT"], ["ro", "Română · Romanian", "ro-RO"], | |
| ["ru", "Русский · Russian", "ru-RU"], ["es", "Español · Spanish", "es-ES"], | |
| ["sw", "Kiswahili · Swahili", "sw-KE"], ["sv", "Svenska · Swedish", "sv-SE"], | |
| ["ta", "தமிழ் · Tamil", "ta-IN"], ["th", "ไทย · Thai", "th-TH"], | |
| ["uk", "Українська · Ukrainian", "uk-UA"], | |
| ["ur", "اردو · Urdu", "ur-PK"], ["vi", "Tiếng Việt · Vietnamese", "vi-VN"], | |
| ].map(([code, name, locale]) => ({ code, name, locale })); | |
| const turkishSafetyTerms = { | |
| "tr-en": { | |
| "alerjim var": ["I have an allergy", ["allergy", "allergic"]], | |
| "glütensiz": ["gluten-free", ["gluten-free", "without gluten"]], | |
| "laktozsuz": ["lactose-free", ["lactose-free", "without lactose"]], | |
| "süt ürünü olmasın": ["no dairy", ["no dairy", "dairy-free", "without dairy"]], | |
| "fıstık alerjisi": ["peanut allergy", ["peanut allergy", "allergic to peanuts"]], | |
| "fıstık alerjim var": ["peanut allergy", ["peanut allergy", "allergic to peanuts"]], | |
| "fıstığa alerjim var": ["peanut allergy", ["peanut allergy", "allergic to peanuts"]], | |
| "kuruyemiş alerjisi": ["nut allergy", ["nut allergy", "allergic to nuts"]], | |
| "kuruyemişe alerjim var": ["nut allergy", ["nut allergy", "allergic to nuts"]], | |
| "domuz eti olmasın": ["no pork", ["no pork", "without pork"]], | |
| "soğansız": ["no onion", ["no onion", "without onion", "onion-free"]], | |
| "sarımsaksız": ["no garlic", ["no garlic", "without garlic", "garlic-free"]], | |
| "acısız": ["not spicy", ["not spicy", "non-spicy", "without spice"]], | |
| }, | |
| "en-tr": { | |
| "i have an allergy": ["alerjim var", ["alerjim var", "alerjim bulunuyor"]], | |
| "gluten-free": ["glütensiz", ["glütensiz", "glutensiz"]], | |
| "lactose-free": ["laktozsuz", ["laktozsuz"]], | |
| "no dairy": ["süt ürünü olmasın", ["süt ürünü olmasın", "süt ürünsüz"]], | |
| "peanut allergy": [ | |
| "fıstık alerjisi", | |
| ["fıstık alerjisi", "fıstığa alerjim var", "yer fıstığına alerjim var"], | |
| ], | |
| "nut allergy": ["kuruyemiş alerjisi", ["kuruyemiş alerjisi"]], | |
| "no pork": ["domuz eti olmasın", ["domuz eti olmasın", "domuz etsiz"]], | |
| "no onion": ["soğansız", ["soğansız", "soğan olmasın"]], | |
| "no garlic": ["sarımsaksız", ["sarımsaksız", "sarımsak olmasın"]], | |
| "not spicy": ["acısız", ["acısız", "acı olmasın", "baharatlı değil"]], | |
| }, | |
| }; | |
| const elements = { | |
| modeButtons: $$(".nav [data-view]"), | |
| themeToggle: $("[data-theme-toggle]"), | |
| themeMeta: $("#meta-theme-color"), | |
| guestView: $("#guest-view"), | |
| kitchenView: $("#kitchen-view"), | |
| kitchenLanguage: $("#kitchen-language"), | |
| orderForm: $("#order-form"), | |
| detectedTable: $("#detected-table"), | |
| orderText: $("#order-text"), | |
| voiceButton: $("#voice-button"), | |
| voiceLabel: $(".voice-label"), | |
| listeningStatus: $("#listening-status"), | |
| previewButton: $("#preview-button"), | |
| translatedPreview: $("#translated-preview"), | |
| translationCard: $("#translation-card"), | |
| speakTranslation: $("#speak-translation"), | |
| dishImage: $("#dish-image"), | |
| imagePlaceholder: $("#image-placeholder"), | |
| imageLoading: $("#image-loading"), | |
| submitOrder: $("#submit-order"), | |
| formMessage: $("#form-message"), | |
| kitchenLanguage: $("#kitchen-language"), | |
| includeClosed: $("#include-closed"), | |
| ordersGrid: $("#orders-grid"), | |
| emptyOrders: $("#empty-orders"), | |
| orderTemplate: $("#order-template"), | |
| statusSummary: $("#status-summary"), | |
| newCount: $("#new-count"), | |
| connection: $("#connection"), | |
| version: $("#version"), | |
| }; | |
| async function api(path, options = {}) { | |
| if (state.localMode) return localApi(path, options); | |
| const response = await fetch(path, { | |
| headers: { "Content-Type": "application/json", ...options.headers }, | |
| ...options, | |
| }); | |
| if ( | |
| path === "/api/config" && | |
| !response.headers.get("content-type")?.includes("application/json") | |
| ) { | |
| state.localMode = true; | |
| return localApi(path, options); | |
| } | |
| if (!response.ok) { | |
| if (path === "/api/config" && response.status === 404) { | |
| state.localMode = true; | |
| return localApi(path, options); | |
| } | |
| const body = await response.json().catch(() => ({})); | |
| throw new Error(body.detail || `Request failed (${response.status})`); | |
| } | |
| return response.json(); | |
| } | |
| function inferSourceLanguage(text, requested = "auto") { | |
| if (requested !== "auto") return requested; | |
| const lowered = text.normalize("NFC").toLowerCase(); | |
| const turkishMarkers = [ | |
| "ç", | |
| "ğ", | |
| "ı", | |
| "ö", | |
| "ş", | |
| "ü", | |
| " istiyorum", | |
| " olsun", | |
| " olmasın", | |
| " lütfen", | |
| ]; | |
| return turkishMarkers.some((marker) => lowered.includes(marker)) ? "tr" : "auto"; | |
| } | |
| function browserLanguageCode() { | |
| const locale = (navigator.language || "en").toLowerCase(); | |
| const exact = staticLanguages.find((language) => language.locale.toLowerCase() === locale); | |
| if (exact) return exact.code; | |
| const base = locale.split("-")[0]; | |
| return staticLanguages.find((language) => language.code.split("-")[0] === base)?.code || "en"; | |
| } | |
| function resolveTableId() { | |
| const params = new URLSearchParams(window.location.search); | |
| const supplied = params.get("table") || params.get("table_id"); | |
| if (supplied) { | |
| const safe = supplied.normalize("NFC").replace(/[^\p{L}\p{N}_-]/gu, "").slice(0, 30); | |
| if (safe) return safe.toUpperCase(); | |
| } | |
| const storageKey = "smos-generated-table-id"; | |
| try { | |
| const existing = localStorage.getItem(storageKey); | |
| if (existing) return existing; | |
| const generated = `DEMO-${crypto.randomUUID().slice(0, 6).toUpperCase()}`; | |
| localStorage.setItem(storageKey, generated); | |
| return generated; | |
| } catch { | |
| return `DEMO-${Math.random().toString(36).slice(2, 8).toUpperCase()}`; | |
| } | |
| } | |
| function preserveTurkishSafetyTerms(sourceText, translatedText, source, target) { | |
| const terms = turkishSafetyTerms[`${source}-${target}`] || {}; | |
| const sourceLower = sourceText.toLocaleLowerCase(source === "tr" ? "tr-TR" : "en-GB"); | |
| const translatedLower = translatedText.toLocaleLowerCase(target === "tr" ? "tr-TR" : "en-GB"); | |
| const missing = []; | |
| const containsWholePhrase = (value, phrase) => { | |
| const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| return new RegExp(`(^|[^\\p{L}\\p{N}_])${escaped}($|[^\\p{L}\\p{N}_])`, "u").test(value); | |
| }; | |
| Object.entries(terms).forEach(([phrase, [canonical, accepted]]) => { | |
| if ( | |
| containsWholePhrase(sourceLower, phrase) && | |
| !accepted.some((term) => | |
| containsWholePhrase(translatedLower, term.toLocaleLowerCase(target === "tr" ? "tr-TR" : "en-GB")) | |
| ) | |
| ) { | |
| missing.push(canonical); | |
| } | |
| }); | |
| if (!missing.length) return translatedText; | |
| const label = target === "en" ? "Order note" : "Sipariş notu"; | |
| return `${translatedText} — ${label}: ${missing.join(", ")}`; | |
| } | |
| function correctTurkishRestaurantContext(sourceText, translatedText, source, target) { | |
| if (source === "tr" && target === "en" && sourceText.toLocaleLowerCase("tr-TR").includes("acısız")) { | |
| return translatedText.replace( | |
| /\b(?:without\s+pain|no\s+pain|pain[\s-]?free|painless)\b/gi, | |
| "not spicy", | |
| ); | |
| } | |
| return translatedText; | |
| } | |
| async function browserGoogleTranslate(text, source, target) { | |
| const effectiveSource = inferSourceLanguage(text, source); | |
| state.lastDetectedLanguage = effectiveSource; | |
| if (!text.trim() || effectiveSource === target) return text; | |
| const normalised = text.normalize("NFC").replace(/[’`]/g, "'").replace(/\s+/g, " ").trim(); | |
| const url = | |
| "https://translate.googleapis.com/translate_a/single?client=gtx&dt=t" + | |
| `&sl=${encodeURIComponent(effectiveSource)}&tl=${encodeURIComponent(target)}` + | |
| `&q=${encodeURIComponent(normalised)}`; | |
| let translated; | |
| try { | |
| const response = await fetch(url); | |
| if (!response.ok) throw new Error("Google translation failed."); | |
| const data = await response.json(); | |
| const detected = data[2]; | |
| if (typeof detected === "string") { | |
| const alias = detected.toLowerCase() === "zh-cn" ? "zh-CN" : detected; | |
| if (staticLanguages.some((language) => language.code === alias)) { | |
| state.lastDetectedLanguage = alias; | |
| } | |
| } | |
| translated = data[0].map((part) => part[0]).join(""); | |
| } catch { | |
| const memoryUrl = | |
| "https://api.mymemory.translated.net/get" + | |
| `?q=${encodeURIComponent(normalised)}&langpair=${encodeURIComponent( | |
| `${effectiveSource === "auto" ? browserLanguageCode() : effectiveSource}|${target}`, | |
| )}`; | |
| const response = await fetch(memoryUrl); | |
| if (!response.ok) throw new Error("Translation is temporarily unavailable."); | |
| const data = await response.json(); | |
| translated = data.responseData?.translatedText; | |
| if (!translated) throw new Error("Translation is temporarily unavailable."); | |
| } | |
| if ( | |
| new Set([effectiveSource, target]).has("tr") && | |
| new Set([effectiveSource, target]).has("en") | |
| ) { | |
| translated = correctTurkishRestaurantContext(normalised, translated, effectiveSource, target); | |
| return preserveTurkishSafetyTerms(normalised, translated, effectiveSource, target); | |
| } | |
| return translated; | |
| } | |
| function localOrders() { | |
| return JSON.parse(localStorage.getItem("smos-orders") || "[]"); | |
| } | |
| function saveLocalOrders(orders) { | |
| localStorage.setItem("smos-orders", JSON.stringify(orders)); | |
| window.dispatchEvent(new CustomEvent("smos-orders-changed")); | |
| } | |
| async function localApi(path, options = {}) { | |
| if (path === "/api/config") { | |
| return { name: "SMOS", version: "0.1.5", languages: staticLanguages }; | |
| } | |
| if (path === "/api/translate") { | |
| const body = JSON.parse(options.body); | |
| const translatedText = await browserGoogleTranslate(body.text, body.source, body.target); | |
| return { | |
| translated_text: translatedText, | |
| source: state.lastDetectedLanguage || body.source, | |
| target: body.target, | |
| }; | |
| } | |
| if (path.startsWith("/api/image")) { | |
| const params = new URL(path, window.location.origin).searchParams; | |
| const description = params.get("description"); | |
| const language = params.get("language") || "en"; | |
| const english = | |
| language === "en" ? description : await browserGoogleTranslate(description, language, "en"); | |
| const prompt = | |
| `professional appetizing restaurant food photography, plated dish, ${english}, ` + | |
| "warm natural light, realistic, no text, no logo"; | |
| return { | |
| url: `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}` + | |
| "?width=768&height=512&nologo=true&enhance=true", | |
| prompt: english, | |
| }; | |
| } | |
| if (path === "/api/orders" && options.method === "POST") { | |
| const body = JSON.parse(options.body); | |
| const orders = localOrders(); | |
| const inferredLanguage = inferSourceLanguage(body.order_text, body.language); | |
| const english = await browserGoogleTranslate(body.order_text, inferredLanguage, "en"); | |
| const detectedLanguage = state.lastDetectedLanguage || inferredLanguage; | |
| const now = new Date().toISOString(); | |
| const order = { | |
| id: crypto.randomUUID(), | |
| order_number: Math.max(100, ...orders.map((item) => item.order_number)) + 1, | |
| customer_name: body.customer_name || "Guest", | |
| table_number: body.table_number, | |
| original_text: body.order_text, | |
| original_language: detectedLanguage, | |
| kitchen_text: english, | |
| display_text: english, | |
| status: "new", | |
| created_at: now, | |
| updated_at: now, | |
| }; | |
| saveLocalOrders([order, ...orders]); | |
| return order; | |
| } | |
| if (path.startsWith("/api/orders?")) { | |
| const params = new URL(path, window.location.origin).searchParams; | |
| const language = params.get("language") || "en"; | |
| const includeClosed = params.get("include_closed") === "true"; | |
| const orders = localOrders().filter( | |
| (order) => includeClosed || !["served", "cancelled"].includes(order.status), | |
| ); | |
| return Promise.all( | |
| orders.map(async (order) => ({ | |
| ...order, | |
| display_text: | |
| language === "en" | |
| ? order.kitchen_text | |
| : await browserGoogleTranslate(order.kitchen_text, "en", language), | |
| })), | |
| ); | |
| } | |
| if (path.startsWith("/api/orders/") && options.method === "PATCH") { | |
| const id = path.split("/").pop(); | |
| const { status } = JSON.parse(options.body); | |
| const orders = localOrders(); | |
| const order = orders.find((item) => item.id === id); | |
| if (!order) throw new Error("Order not found."); | |
| order.status = status; | |
| order.updated_at = new Date().toISOString(); | |
| saveLocalOrders(orders); | |
| return order; | |
| } | |
| throw new Error("This action is unavailable in static demo mode."); | |
| } | |
| function fillLanguageSelect(select, preferred = "en") { | |
| select.innerHTML = ""; | |
| state.config.languages.forEach(({ code, name, locale }) => { | |
| const option = new Option(name, code, code === preferred, code === preferred); | |
| option.dataset.locale = locale; | |
| select.add(option); | |
| }); | |
| } | |
| function selectedLocale(select) { | |
| return select.selectedOptions[0]?.dataset.locale || "en-GB"; | |
| } | |
| function speak(text, locale) { | |
| if (!("speechSynthesis" in window)) { | |
| showMessage("Text-to-speech is not supported by this browser.", "error"); | |
| return; | |
| } | |
| window.speechSynthesis.cancel(); | |
| const utterance = new SpeechSynthesisUtterance(text); | |
| utterance.lang = locale; | |
| utterance.rate = 0.95; | |
| window.speechSynthesis.speak(utterance); | |
| } | |
| function showMessage(message, type = "success") { | |
| elements.formMessage.textContent = message; | |
| elements.formMessage.className = `form-message ${type}`; | |
| } | |
| function switchView(view) { | |
| elements.modeButtons.forEach((button) => { | |
| button.classList.toggle("is-active", button.dataset.view === view); | |
| }); | |
| elements.guestView.classList.toggle("is-active", view === "guest"); | |
| elements.kitchenView.classList.toggle("is-active", view === "kitchen"); | |
| if (view === "kitchen") loadOrders(); | |
| const url = new URL(window.location.href); | |
| url.hash = view === "kitchen" ? "kitchen" : "order"; | |
| window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); | |
| } | |
| const THEME_KEY = "smos-theme"; | |
| function setTheme(theme) { | |
| const next = theme === "light" ? "light" : "dark"; | |
| document.documentElement.setAttribute("data-theme", next); | |
| try { | |
| localStorage.setItem(THEME_KEY, next); | |
| } catch { | |
| /* Private browsing blocks persistence; the theme still applies for this session. */ | |
| } | |
| elements.themeMeta?.setAttribute("content", next === "light" ? "#f6f3ee" : "#070707"); | |
| const goLight = next === "dark"; | |
| elements.themeToggle?.setAttribute( | |
| "aria-label", | |
| goLight ? "Switch to light theme" : "Switch to dark theme", | |
| ); | |
| elements.themeToggle?.setAttribute("title", goLight ? "Light theme" : "Dark theme"); | |
| } | |
| function setupTheme() { | |
| setTheme(document.documentElement.getAttribute("data-theme")); | |
| elements.themeToggle?.addEventListener("click", () => { | |
| const current = document.documentElement.getAttribute("data-theme"); | |
| setTheme(current === "dark" ? "light" : "dark"); | |
| }); | |
| } | |
| function setupSpeechRecognition() { | |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| if (!SpeechRecognition) { | |
| elements.voiceButton.disabled = true; | |
| elements.voiceLabel.textContent = "Voice unavailable"; | |
| elements.listeningStatus.textContent = "Try Chrome, Edge, or Safari"; | |
| return; | |
| } | |
| state.recognition = new SpeechRecognition(); | |
| state.recognition.continuous = true; | |
| state.recognition.interimResults = true; | |
| state.recognition.onstart = () => { | |
| state.listening = true; | |
| elements.voiceButton.classList.add("listening"); | |
| elements.voiceLabel.textContent = "Stop listening"; | |
| elements.listeningStatus.textContent = "Listening…"; | |
| }; | |
| state.recognition.onresult = (event) => { | |
| let finalText = ""; | |
| let interimText = ""; | |
| for (let index = event.resultIndex; index < event.results.length; index += 1) { | |
| const transcript = event.results[index][0].transcript; | |
| if (event.results[index].isFinal) finalText += transcript; | |
| else interimText += transcript; | |
| } | |
| if (finalText) { | |
| const spacer = elements.orderText.value.trim() ? " " : ""; | |
| elements.orderText.value = `${elements.orderText.value.trim()}${spacer}${finalText.trim()}`; | |
| elements.orderText.dispatchEvent(new Event("input")); | |
| } | |
| elements.listeningStatus.textContent = interimText || (state.listening ? "Listening…" : ""); | |
| }; | |
| state.recognition.onerror = (event) => { | |
| elements.listeningStatus.textContent = | |
| event.error === "not-allowed" ? "Microphone permission was denied" : `Voice error: ${event.error}`; | |
| }; | |
| state.recognition.onend = () => { | |
| state.listening = false; | |
| elements.voiceButton.classList.remove("listening"); | |
| elements.voiceLabel.textContent = "Tap to speak"; | |
| if (elements.listeningStatus.textContent === "Listening…") { | |
| elements.listeningStatus.textContent = ""; | |
| } | |
| }; | |
| } | |
| function toggleListening() { | |
| if (!state.recognition) return; | |
| if (state.listening) { | |
| state.recognition.stop(); | |
| return; | |
| } | |
| state.recognition.lang = navigator.language || "en-GB"; | |
| try { | |
| state.recognition.start(); | |
| } catch { | |
| state.recognition.stop(); | |
| } | |
| } | |
| async function previewTranslation() { | |
| const text = elements.orderText.value.trim(); | |
| const source = "auto"; | |
| if (text.length < 2) { | |
| elements.translationCard.classList.add("hidden"); | |
| return ""; | |
| } | |
| if (source === "en") { | |
| elements.translatedPreview.textContent = text; | |
| elements.translationCard.classList.remove("hidden"); | |
| return text; | |
| } | |
| try { | |
| const result = await api("/api/translate", { | |
| method: "POST", | |
| body: JSON.stringify({ text, source, target: "en" }), | |
| }); | |
| elements.translatedPreview.textContent = result.translated_text; | |
| elements.translationCard.classList.remove("hidden"); | |
| return result.translated_text; | |
| } catch (error) { | |
| showMessage(error.message, "error"); | |
| return ""; | |
| } | |
| } | |
| async function visualiseDish() { | |
| const description = elements.orderText.value.trim(); | |
| if (description.length < 4) return; | |
| elements.imageLoading.classList.remove("hidden"); | |
| elements.previewButton.disabled = true; | |
| try { | |
| const result = await api( | |
| `/api/image?description=${encodeURIComponent(description)}&language=auto`, | |
| ); | |
| elements.dishImage.onload = () => { | |
| elements.imageLoading.classList.add("hidden"); | |
| elements.imagePlaceholder.classList.add("hidden"); | |
| elements.dishImage.classList.add("visible"); | |
| elements.previewButton.disabled = false; | |
| }; | |
| elements.dishImage.onerror = () => { | |
| elements.imageLoading.classList.add("hidden"); | |
| elements.previewButton.disabled = false; | |
| showMessage("The food visual could not be generated. Your order can still be sent.", "error"); | |
| }; | |
| elements.dishImage.src = `${result.url}&seed=${Date.now()}`; | |
| } catch (error) { | |
| elements.imageLoading.classList.add("hidden"); | |
| elements.previewButton.disabled = false; | |
| showMessage(error.message, "error"); | |
| } | |
| } | |
| async function submitOrder(event) { | |
| event.preventDefault(); | |
| const originalLabel = elements.submitOrder.innerHTML; | |
| elements.submitOrder.disabled = true; | |
| elements.submitOrder.innerHTML = "<span>Translating & sending…</span><b>···</b>"; | |
| try { | |
| const order = await api("/api/orders", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| customer_name: "Guest", | |
| table_number: state.tableId, | |
| order_text: elements.orderText.value.trim(), | |
| language: "auto", | |
| }), | |
| }); | |
| showMessage(`Order #${order.order_number} is with the kitchen.`, "success"); | |
| elements.orderText.value = ""; | |
| elements.translationCard.classList.add("hidden"); | |
| } catch (error) { | |
| showMessage(error.message, "error"); | |
| } finally { | |
| elements.submitOrder.disabled = false; | |
| elements.submitOrder.innerHTML = originalLabel; | |
| } | |
| } | |
| const statusLabels = { | |
| new: "New", | |
| accepted: "Accepted", | |
| preparing: "Preparing", | |
| ready: "Ready", | |
| served: "Served", | |
| cancelled: "Cancelled", | |
| }; | |
| function relativeTime(timestamp) { | |
| const minutes = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 60000)); | |
| if (minutes < 1) return "just now"; | |
| if (minutes === 1) return "1 min ago"; | |
| if (minutes < 60) return `${minutes} mins ago`; | |
| return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); | |
| } | |
| function renderOrders() { | |
| elements.ordersGrid.innerHTML = ""; | |
| const counts = {}; | |
| state.orders.forEach((order) => { | |
| counts[order.status] = (counts[order.status] || 0) + 1; | |
| const card = elements.orderTemplate.content.firstElementChild.cloneNode(true); | |
| card.dataset.status = order.status; | |
| $(".order-number", card).textContent = `Order #${order.order_number}`; | |
| $("time", card).textContent = relativeTime(order.created_at); | |
| $("time", card).dateTime = order.created_at; | |
| $(".status-pill", card).textContent = statusLabels[order.status]; | |
| $(".status-pill", card).dataset.status = order.status; | |
| $(".table-value", card).textContent = `Table ${order.table_number}`; | |
| $(".order-copy", card).textContent = order.display_text || order.kitchen_text; | |
| $(".original-copy", card).textContent = order.original_text; | |
| const statusSelect = $(".status-select", card); | |
| statusSelect.value = order.status; | |
| statusSelect.addEventListener("change", () => updateStatus(order.id, statusSelect.value)); | |
| $(".speak-order", card).addEventListener("click", () => { | |
| speak(order.display_text || order.kitchen_text, selectedLocale(elements.kitchenLanguage)); | |
| }); | |
| elements.ordersGrid.append(card); | |
| }); | |
| const newOrders = counts.new || 0; | |
| elements.newCount.textContent = newOrders; | |
| elements.newCount.classList.toggle("hidden", newOrders === 0); | |
| elements.statusSummary.innerHTML = Object.entries(statusLabels) | |
| .filter(([key]) => counts[key]) | |
| .map(([key, label]) => `<span class="summary-chip"><b>${counts[key]}</b>${label}</span>`) | |
| .join(""); | |
| elements.emptyOrders.classList.toggle("hidden", state.orders.length !== 0); | |
| } | |
| async function loadOrders() { | |
| try { | |
| state.orders = await api( | |
| `/api/orders?language=${encodeURIComponent(elements.kitchenLanguage.value)}&include_closed=${ | |
| elements.includeClosed.checked | |
| }`, | |
| ); | |
| renderOrders(); | |
| } catch (error) { | |
| elements.ordersGrid.innerHTML = `<p class="form-message error">${error.message}</p>`; | |
| } | |
| } | |
| async function updateStatus(orderId, status) { | |
| try { | |
| await api(`/api/orders/${orderId}`, { | |
| method: "PATCH", | |
| body: JSON.stringify({ status }), | |
| }); | |
| await loadOrders(); | |
| } catch (error) { | |
| alert(error.message); | |
| await loadOrders(); | |
| } | |
| } | |
| function connectSocket() { | |
| if (state.localMode) { | |
| elements.connection.innerHTML = "<i></i> Live demo"; | |
| window.addEventListener("storage", loadOrders); | |
| window.addEventListener("smos-orders-changed", loadOrders); | |
| return; | |
| } | |
| const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; | |
| state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`); | |
| state.socket.onopen = () => { | |
| elements.connection.classList.remove("offline"); | |
| elements.connection.innerHTML = "<i></i> Live"; | |
| }; | |
| state.socket.onmessage = (event) => { | |
| const message = JSON.parse(event.data); | |
| if (message.type?.startsWith("order.")) loadOrders(); | |
| }; | |
| state.socket.onclose = () => { | |
| elements.connection.classList.add("offline"); | |
| elements.connection.innerHTML = "<i></i> Reconnecting"; | |
| window.setTimeout(connectSocket, 2500); | |
| }; | |
| } | |
| async function initialise() { | |
| state.config = await api("/api/config"); | |
| elements.version.textContent = `v${state.config.version}`; | |
| state.tableId = resolveTableId(); | |
| elements.detectedTable.textContent = state.tableId; | |
| fillLanguageSelect(elements.kitchenLanguage, "en"); | |
| setupTheme(); | |
| setupSpeechRecognition(); | |
| connectSocket(); | |
| await loadOrders(); | |
| elements.modeButtons.forEach((button) => | |
| button.addEventListener("click", () => switchView(button.dataset.view)), | |
| ); | |
| elements.voiceButton.addEventListener("click", toggleListening); | |
| elements.previewButton.addEventListener("click", visualiseDish); | |
| elements.orderForm.addEventListener("submit", submitOrder); | |
| elements.speakTranslation.addEventListener("click", () => | |
| speak(elements.translatedPreview.textContent, "en-GB"), | |
| ); | |
| elements.kitchenLanguage.addEventListener("change", loadOrders); | |
| elements.includeClosed.addEventListener("change", loadOrders); | |
| elements.orderText.addEventListener("input", () => { | |
| clearTimeout(state.translationTimer); | |
| clearTimeout(state.imageTimer); | |
| state.translationTimer = setTimeout(previewTranslation, 700); | |
| if (elements.orderText.value.trim().length >= 12) { | |
| state.imageTimer = setTimeout(visualiseDish, 1800); | |
| } | |
| }); | |
| if (window.location.hash === "#kitchen") switchView("kitchen"); | |
| } | |
| initialise().catch((error) => { | |
| document.body.innerHTML = | |
| `<main class="section"><div class="container"><p class="eyebrow">Startup error</p>` + | |
| `<h1>SMOS could not start</h1><p class="section-lede">${error.message}</p></div></main>`; | |
| }); | |