Spaces:
Running
Running
Download app.js from QuantModelDev/shambasense-ai-2: direct link, hf CLI and curl.
- Browser
- Download file 23.2 kB
-
https://huggingface.co/spaces/QuantModelDev/shambasense-ai-2/resolve/main/app.js
- Command line
-
hf download hf://spaces/QuantModelDev/shambasense-ai-2/app.js
-
curl -L -o app.js https://huggingface.co/spaces/QuantModelDev/shambasense-ai-2/resolve/main/app.js
23.2 kB
| /* Static Space frontend. Inference is deliberately adapter-based: no prediction is shown without a verified model response. */ | |
| (() => { | |
| "use strict"; | |
| // Replace this placeholder after the Render service is deployed. | |
| const API_BASE_URL = "https://shambasense-ai.onrender.com"; | |
| const API_BASE_URL_IS_CONFIGURED = !API_BASE_URL.includes("YOUR-RENDER-SERVICE"); | |
| const CONFIG = Object.assign({ | |
| inferenceEndpoint: API_BASE_URL_IS_CONFIGURED ? `${API_BASE_URL.replace(/\/+$/, "")}/api/v1/predict` : "", | |
| maxFileBytes: 10 * 1024 * 1024, | |
| allowedTypes: ["image/jpeg", "image/png", "image/webp"], | |
| historyKey: "shambasense.fieldHistory.v1" | |
| }, window.SHAMBASENSE_CONFIG || {}); | |
| const copy = { | |
| en: { | |
| privateDevice: "Private on this device", language: "Language", eyebrow: "For Kenyan smallholder farmers", | |
| title: "Check a crop leaf before you decide what to do next.", | |
| intro: "Add a clear photo and a few observations. ShambaSense records your field note and, when a real model is connected, displays its estimate separately from practical safety guidance.", | |
| notDiagnosis: "This is a screening aid, not a confirmed diagnosis. Ask a local agricultural extension officer when symptoms are severe or unclear.", | |
| checkLeaf: "Check a leaf", fieldHistory: "Field history", aboutSafety: "About & safety", stepOne: "Step 1 of 2", | |
| addFieldNote: "Add a field note", requiredFields: "Only the photo and crop are required.", photoHeading: "Add a crop photo", | |
| required: "Required", photoHelp: "Use daylight, one leaf, and a steady close-up. JPG, PNG, or WEBP up to 10 MB.", | |
| choosePhoto: "Choose a photo", dragPhoto: "or drag and drop it here", removePhoto: "Remove photo", cropLabel: "Crop", | |
| maize: "Maize", tomato: "Tomato", potato: "Potato", beans: "Beans", banana: "Banana", other: "Other / not sure", | |
| symptomsLabel: "What are you noticing?", symptomsPlaceholder: "For example: yellow spots on older leaves after rain", | |
| analysePhoto: "Analyse photo", clearForm: "Clear form", resultsHeading: "Review the result", waiting: "Waiting for a photo", | |
| emptyTitle: "Your result will appear here", emptyCopy: "Add a photo and select your crop. We will show the model estimate only if genuine inference is available.", | |
| downloadTxt: "Download TXT", downloadJson: "Download JSON", savedLocally: "Saved locally", yourHistory: "Your field history", | |
| clearHistory: "Clear history", useWithCare: "Use with care", aboutTitle: "What ShambaSense can and cannot do", | |
| worksTitle: "What it does", worksCopy: "It helps you record a crop, photo, and symptoms. If a compatible inference service is configured, it displays that service's returned classification and scores without presenting them as a confirmed diagnosis.", | |
| privacyTitle: "Privacy on this Space", privacyCopy: "Field history stays in this browser's local storage. Photos are previewed locally and are not saved by this page. If an inference endpoint is configured, the selected photo and field note are sent to that endpoint for processing; review its privacy policy before use.", | |
| safetyTitle: "Safer next steps", safetyCopy: "Improve the photo, compare symptoms across the plant, and ask an extension officer before applying pesticide. Do not rely on a photo score alone, especially when the crop is valuable or the plant is severely affected.", | |
| sourcesTitle: "Reference material", footerNote: "A practical screening aid for field decisions, not a diagnosis.", | |
| invalidType: "That file type is not supported. Choose a JPG, PNG, or WEBP image.", tooLarge: "That file is larger than 10 MB. Choose a smaller image.", | |
| imageReady: "Photo ready for review.", noPhoto: "Please add a crop photo first.", loading: "Checking the configured inference service…", | |
| inferenceUnavailable: "AI inference is not configured", inferenceUnavailableCopy: "This Static Space has no browser-compatible model artifact or public inference endpoint configured. Your photo and field note were not sent anywhere.", | |
| endpointError: "The inference service could not return a valid result. No diagnosis was saved.", networkError: "The backend could not be reached. Check the service URL or try again later.", saved: "Field note saved on this device.", | |
| photoRemoved: "Photo removed.", noHistory: "No field notes saved yet. Your completed submissions will appear here.", | |
| clearHistoryConfirm: "Clear all locally saved field notes from this browser?", historyCleared: "Field history cleared.", | |
| estimate: "Model estimate", confidence: "Confidence", possibleClasses: "Other likely classes", nextSteps: "Practical next steps", | |
| uncertainty: "Treat this as an estimate, not a confirmed diagnosis.", symptoms: "Observed symptoms", noSymptoms: "No symptoms entered.", | |
| photo: "Photo", noPhotoName: "No photo name", date: "Recorded", report: "Report", inference: "Inference" | |
| }, | |
| sw: { | |
| privateDevice: "Binafsi kwenye kifaa hiki", language: "Lugha", eyebrow: "Kwa wakulima wadogo wa Kenya", | |
| title: "Kagua jani la mmea kabla ya kuamua hatua inayofuata.", | |
| intro: "Ongeza picha iliyo wazi na maelezo machache. ShambaSense huhifadhi taarifa yako na, ikiwa kuna modeli halisi, huonyesha makadirio yake kando na ushauri wa usalama.", | |
| notDiagnosis: "Huu ni msaada wa uchunguzi, si utambuzi wa mwisho. Muulize afisa wa kilimo wa eneo lako ikiwa dalili ni kali au hazieleweki.", | |
| checkLeaf: "Kagua jani", fieldHistory: "Historia ya shamba", aboutSafety: "Kuhusu na usalama", stepOne: "Hatua ya 1 kati ya 2", | |
| addFieldNote: "Ongeza taarifa ya shamba", requiredFields: "Picha na zao pekee ndizo zinazohitajika.", photoHeading: "Ongeza picha ya zao", | |
| required: "Inahitajika", photoHelp: "Tumia mwanga wa mchana, jani moja, na picha iliyotulia. JPG, PNG au WEBP hadi MB 10.", | |
| choosePhoto: "Chagua picha", dragPhoto: "au iburute uiweke hapa", removePhoto: "Ondoa picha", cropLabel: "Zao", | |
| maize: "Mahindi", tomato: "Nyanya", potato: "Viazi", beans: "Maharagwe", banana: "Ndizi", other: "Nyingine / sijui", | |
| symptomsLabel: "Unaona nini?", symptomsPlaceholder: "Mfano: madoa ya njano kwenye majani ya zamani baada ya mvua", | |
| analysePhoto: "Changanua picha", clearForm: "Futa fomu", resultsHeading: "Kagua matokeo", waiting: "Subiri picha", | |
| emptyTitle: "Matokeo yataonekana hapa", emptyCopy: "Ongeza picha na uchague zao. Tutaonyesha makadirio ya modeli ikiwa uchunguzi halisi unapatikana.", | |
| downloadTxt: "Pakua TXT", downloadJson: "Pakua JSON", savedLocally: "Imehifadhiwa hapa", yourHistory: "Historia ya shamba", | |
| clearHistory: "Futa historia", useWithCare: "Tumia kwa uangalifu", aboutTitle: "ShambaSense inaweza na haiwezi kufanya nini", | |
| worksTitle: "Inachofanya", worksCopy: "Inakusaidia kuhifadhi zao, picha na dalili. Ikiwa huduma ya uchunguzi imewekwa, huonyesha majibu yake bila kuyaita utambuzi wa mwisho.", | |
| privacyTitle: "Faragha kwenye Space hii", privacyCopy: "Historia huhifadhiwa kwenye kivinjari hiki. Picha huonyeshwa hapa tu na hazihifadhiwi na ukurasa huu. Ikiwa huduma imewekwa, picha na taarifa zitatumwa kwa huduma hiyo; soma sera yake ya faragha kwanza.", | |
| safetyTitle: "Hatua salama zinazofuata", safetyCopy: "Boresha picha, linganisha dalili kwenye mmea, na muulize afisa wa kilimo kabla ya kutumia dawa. Usitumie alama ya picha pekee kufanya uamuzi.", | |
| sourcesTitle: "Marejeo", footerNote: "Msaada wa uchunguzi wa shamba, si utambuzi.", | |
| invalidType: "Aina hii ya faili haitumiki. Chagua picha ya JPG, PNG au WEBP.", tooLarge: "Faili hili ni kubwa kuliko MB 10. Chagua picha ndogo.", | |
| imageReady: "Picha iko tayari kukaguliwa.", noPhoto: "Tafadhali ongeza picha ya zao kwanza.", loading: "Inakagua huduma ya uchunguzi…", | |
| inferenceUnavailable: "Uchunguzi wa AI haujawekwa", inferenceUnavailableCopy: "Static Space hii haina modeli ya kivinjari au huduma ya uchunguzi iliyowekwa. Picha na taarifa zako hazikutumwa popote.", | |
| endpointError: "Huduma ya uchunguzi haikutoa jibu halali. Hakuna utambuzi uliohifadhiwa.", networkError: "Backend haikupatikana. Kagua anwani ya huduma au ujaribu tena baadaye.", saved: "Taarifa imehifadhiwa kwenye kifaa hiki.", | |
| photoRemoved: "Picha imeondolewa.", noHistory: "Bado hakuna taarifa. Taarifa utakazotuma zitaonekana hapa.", | |
| clearHistoryConfirm: "Futa taarifa zote zilizohifadhiwa kwenye kivinjari hiki?", historyCleared: "Historia imefutwa.", | |
| estimate: "Makadirio ya modeli", confidence: "Uhakika", possibleClasses: "Madaraja mengine yanayowezekana", nextSteps: "Hatua salama zinazofuata", | |
| uncertainty: "Chukulia hili kama makadirio, si utambuzi wa mwisho.", symptoms: "Dalili ulizoona", noSymptoms: "Hakuna dalili zilizoandikwa.", | |
| photo: "Picha", noPhotoName: "Hakuna jina la picha", date: "Tarehe", report: "Ripoti", inference: "Uchunguzi" | |
| } | |
| }; | |
| const $ = (selector) => document.querySelector(selector); | |
| const state = { lang: "en", file: null, previewUrl: null, inference: null, report: null }; | |
| function t(key) { return copy[state.lang][key] || copy.en[key] || key; } | |
| function setText(node, value) { if (node) node.textContent = value; } | |
| function escapeHtml(value) { | |
| return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); | |
| } | |
| function formatDate(value) { | |
| try { return new Intl.DateTimeFormat(state.lang === "sw" ? "sw-KE" : "en-KE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); } | |
| catch (_) { return value; } | |
| } | |
| function setStatus(message, type = "") { | |
| const status = $("#form-status"); | |
| status.textContent = message; | |
| status.className = `form-status ${type}`; | |
| } | |
| function setFileStatus(message, type = "") { | |
| const status = $("#file-status"); | |
| status.textContent = message; | |
| status.className = `field-status ${type}`; | |
| } | |
| function setState(label, type) { | |
| const badge = $("#result-state"); | |
| badge.textContent = label; | |
| badge.className = `state-badge state-${type}`; | |
| } | |
| function setLanguage(lang) { | |
| state.lang = lang; | |
| document.documentElement.lang = lang === "sw" ? "sw" : "en"; | |
| document.querySelectorAll("[data-i18n]").forEach((node) => setText(node, t(node.dataset.i18n))); | |
| document.querySelectorAll("[data-i18n-placeholder]").forEach((node) => { node.placeholder = t(node.dataset.i18nPlaceholder); }); | |
| renderEmptyIfNeeded(); | |
| renderHistory(); | |
| } | |
| function renderEmptyIfNeeded() { | |
| const empty = $(".empty-state"); | |
| if (empty) { | |
| setText(empty.querySelector("h4"), t("emptyTitle")); | |
| setText(empty.querySelector("p"), t("emptyCopy")); | |
| } | |
| } | |
| function validateFile(file) { | |
| if (!file) return t("noPhoto"); | |
| if (!CONFIG.allowedTypes.includes(file.type)) return t("invalidType"); | |
| if (file.size > CONFIG.maxFileBytes) return t("tooLarge"); | |
| return ""; | |
| } | |
| function showFile(file) { | |
| const error = validateFile(file); | |
| if (error) { clearFile(false); setFileStatus(error, "error"); return false; } | |
| state.file = file; | |
| if (state.previewUrl) URL.revokeObjectURL(state.previewUrl); | |
| state.previewUrl = URL.createObjectURL(file); | |
| $("#upload-prompt").hidden = true; | |
| $("#preview-wrap").hidden = false; | |
| $("#image-preview").src = state.previewUrl; | |
| $("#image-preview").alt = `${file.name} preview`; | |
| $("#file-name").textContent = file.name; | |
| $("#remove-image").hidden = false; | |
| setFileStatus(t("imageReady"), "success"); | |
| setState(t("waiting"), "empty"); | |
| return true; | |
| } | |
| function clearFile(showMessage = true) { | |
| if (state.previewUrl) URL.revokeObjectURL(state.previewUrl); | |
| state.file = null; state.previewUrl = null; | |
| $("#image-input").value = ""; | |
| $("#upload-prompt").hidden = false; | |
| $("#preview-wrap").hidden = true; | |
| $("#remove-image").hidden = true; | |
| if (showMessage) setFileStatus(t("photoRemoved"), "success"); | |
| } | |
| function readHistory() { | |
| try { | |
| const value = JSON.parse(localStorage.getItem(CONFIG.historyKey) || "[]"); | |
| return Array.isArray(value) ? value : []; | |
| } catch (_) { return []; } | |
| } | |
| function saveHistory(entry) { | |
| try { | |
| const history = [entry, ...readHistory()].slice(0, 50); | |
| localStorage.setItem(CONFIG.historyKey, JSON.stringify(history)); | |
| } catch (_) { /* Storage may be disabled or full; the current result still remains visible. */ } | |
| } | |
| function renderHistory() { | |
| const target = $("#history-content"); | |
| const history = readHistory(); | |
| if (!history.length) { | |
| target.innerHTML = `<div class="history-empty">${escapeHtml(t("noHistory"))}</div>`; | |
| return; | |
| } | |
| target.innerHTML = `<div class="history-list">${history.map((item) => ` | |
| <article class="history-item"> | |
| <div><h3>${escapeHtml(item.cropLabel || item.crop)}</h3> | |
| <p>${escapeHtml(item.symptoms || t("noSymptoms"))} · ${escapeHtml(item.inferenceStatus || t("inferenceUnavailable"))}</p></div> | |
| <time datetime="${escapeHtml(item.createdAt)}">${escapeHtml(formatDate(item.createdAt))}</time> | |
| </article>`).join("")}</div>`; | |
| } | |
| function guidanceFor(crop) { | |
| const isSw = state.lang === "sw"; | |
| const generic = isSw | |
| ? ["Piga picha nyingine iliyo wazi katika mwanga wa mchana.", "Angalia majani mengine na sehemu ya chini ya majani kwa dalili zinazofanana.", "Muulize afisa wa kilimo wa eneo lako kabla ya kutumia dawa."] | |
| : ["Take another clear photo in daylight and check both sides of the leaf.", "Look across the plant and nearby plants for the same symptoms.", "Ask a local agricultural extension officer before applying any pesticide."]; | |
| return { crop, steps: generic }; | |
| } | |
| function renderUnavailable() { | |
| $("#results-content").innerHTML = `<div class="inference-not-configured"> | |
| <h4>${escapeHtml(t("inferenceUnavailable"))}</h4> | |
| <p>${escapeHtml(t("inferenceUnavailableCopy"))}</p> | |
| <p>${escapeHtml(t("uncertainty"))}</p> | |
| </div> | |
| <div class="result-section"><h5>${escapeHtml(t("nextSteps"))}</h5><ul>${guidanceFor($("#crop-select").value).steps.map((step) => `<li>${escapeHtml(step)}</li>`).join("")}</ul></div>`; | |
| } | |
| function normalizeInference(payload) { | |
| const predictions = payload?.predictions || payload?.top_predictions; | |
| if (!Array.isArray(predictions) || !predictions.length) return null; | |
| const normalized = predictions.slice(0, 3).map((item) => { | |
| const score = Number(item.score ?? item.confidence); | |
| if (!item.label || !Number.isFinite(score) || score < 0 || score > 1) return null; | |
| return { label: String(item.label), score }; | |
| }); | |
| if (normalized.some((item) => !item)) return null; | |
| return { | |
| diagnosis: String(payload.diagnosis || payload.display_label || normalized[0].label), | |
| confidence: normalized[0].score, | |
| predictions: normalized, | |
| guidance: Array.isArray(payload.guidance) | |
| ? payload.guidance.filter((item) => typeof item === "string").slice(0, 5) | |
| : Array.isArray(payload.guidance?.steps) | |
| ? payload.guidance.steps.filter((item) => typeof item === "string").slice(0, 5) | |
| : guidanceFor($("#crop-select").value).steps | |
| }; | |
| } | |
| async function requestInference() { | |
| if (!CONFIG.inferenceEndpoint) return null; | |
| const body = new FormData(); | |
| body.append("image", state.file); | |
| body.append("crop", $("#crop-select").value); | |
| body.append("symptoms", $("#symptoms").value.trim()); | |
| body.append("language", state.lang); | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 30000); | |
| let response; | |
| try { | |
| response = await fetch(CONFIG.inferenceEndpoint, { method: "POST", body, signal: controller.signal }); | |
| } catch (error) { | |
| if (error.name === "AbortError") throw new Error(t("networkError")); | |
| throw new Error(t("networkError")); | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| if (!response.ok) { | |
| let message = t("endpointError"); | |
| try { | |
| const errorPayload = await response.json(); | |
| message = errorPayload?.detail?.message || message; | |
| } catch (_) { /* Keep the safe generic message for non-JSON responses. */ } | |
| throw new Error(message); | |
| } | |
| let payload; | |
| try { | |
| payload = await response.json(); | |
| } catch (_) { | |
| throw new Error(t("endpointError")); | |
| } | |
| return normalizeInference(payload); | |
| } | |
| function renderResult(inference) { | |
| const predictions = inference.predictions.map((item) => `<li><span>${escapeHtml(item.label)}</span><strong>${(item.score * 100).toFixed(1)}%</strong></li>`).join(""); | |
| $("#results-content").innerHTML = `<div class="result-card"> | |
| <h4>${escapeHtml(inference.diagnosis)}</h4> | |
| <p class="result-meta">${escapeHtml(t("estimate"))} · ${escapeHtml(t("uncertainty"))}</p> | |
| <div class="confidence-row"><span>${escapeHtml(t("confidence"))}</span><strong>${(inference.confidence * 100).toFixed(1)}%</strong></div> | |
| <div class="confidence-track" aria-label="${escapeHtml(t("confidence"))}"><div class="confidence-fill" style="width:${Math.round(inference.confidence * 100)}%"></div></div> | |
| <div class="result-section"><h5>${escapeHtml(t("possibleClasses"))}</h5><ul class="prediction-list">${predictions}</ul></div> | |
| <div class="result-section"><h5>${escapeHtml(t("nextSteps"))}</h5><ul>${inference.guidance.map((step) => `<li>${escapeHtml(step)}</li>`).join("")}</ul></div> | |
| </div>`; | |
| } | |
| function buildReport() { | |
| const crop = $("#crop-select"); | |
| const entry = { | |
| createdAt: new Date().toISOString(), crop: crop.value, cropLabel: crop.options[crop.selectedIndex].text, | |
| symptoms: $("#symptoms").value.trim(), photoName: state.file?.name || null, | |
| inferenceStatus: state.inference ? "valid inference returned" : "inference not configured", | |
| inference: state.inference || null | |
| }; | |
| return entry; | |
| } | |
| function downloadFile(filename, content, type) { | |
| const url = URL.createObjectURL(new Blob([content], { type })); | |
| const anchor = document.createElement("a"); | |
| anchor.href = url; anchor.download = filename; anchor.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } | |
| function reportText(report) { | |
| const lines = [`ShambaSense AI field report`, `Recorded: ${formatDate(report.createdAt)}`, `Crop: ${report.cropLabel}`, `Photo: ${report.photoName || t("noPhotoName")}`, `Observed symptoms: ${report.symptoms || t("noSymptoms")}`, `Inference: ${report.inferenceStatus}`]; | |
| if (report.inference) { | |
| lines.push(`Model estimate: ${report.inference.diagnosis}`, `Confidence: ${(report.inference.confidence * 100).toFixed(1)}%`, "", "Top predictions:"); | |
| report.inference.predictions.forEach((item, index) => lines.push(`${index + 1}. ${item.label} — ${(item.score * 100).toFixed(1)}%`)); | |
| } else { | |
| lines.push("", "No diagnosis or confidence was generated because AI inference is not configured for this Static Space."); | |
| } | |
| lines.push("", "Safety: This is a screening aid, not a confirmed diagnosis. Consult a qualified agricultural extension officer before treatment."); | |
| return lines.join("\n"); | |
| } | |
| function completeSubmission() { | |
| const report = buildReport(); | |
| saveHistory(report); | |
| state.report = report; | |
| $("#report-actions").hidden = false; | |
| setStatus(t("saved"), "success"); | |
| } | |
| async function submit(event) { | |
| event.preventDefault(); | |
| if (!state.file) { setStatus(t("noPhoto"), "error"); setFileStatus(t("noPhoto"), "error"); return; } | |
| setState(t("loading"), "loading"); setStatus(t("loading"), ""); | |
| $("#analyse-button").disabled = true; | |
| try { | |
| state.inference = await requestInference(); | |
| if (CONFIG.inferenceEndpoint && !state.inference) throw new Error("Invalid inference response"); | |
| if (state.inference) { renderResult(state.inference); setState(t("estimate"), "ready"); } | |
| else { renderUnavailable(); setState(t("inferenceUnavailable"), "error"); } | |
| completeSubmission(); | |
| } catch (error) { | |
| state.inference = null; renderUnavailable(); setState(t("endpointError"), "error"); setStatus(error.message || t("endpointError"), "error"); | |
| } finally { $("#analyse-button").disabled = false; } | |
| } | |
| function reset() { | |
| clearFile(false); $("#symptoms").value = ""; $("#character-count").textContent = "0"; $("#report-actions").hidden = true; | |
| state.inference = null; state.report = null; setStatus(""); setFileStatus(""); setState(t("waiting"), "empty"); | |
| $("#results-content").innerHTML = `<div class="empty-state"><span class="empty-icon" aria-hidden="true">○</span><h4>${escapeHtml(t("emptyTitle"))}</h4><p>${escapeHtml(t("emptyCopy"))}</p></div>`; | |
| } | |
| function bind() { | |
| $("#language-select").addEventListener("change", (event) => setLanguage(event.target.value)); | |
| $("#image-input").addEventListener("change", (event) => showFile(event.target.files[0])); | |
| $("#remove-image").addEventListener("click", () => clearFile()); | |
| ["dragenter", "dragover"].forEach((eventName) => $("#drop-zone").addEventListener(eventName, (event) => { event.preventDefault(); $("#drop-zone").classList.add("is-dragging"); })); | |
| ["dragleave", "drop"].forEach((eventName) => $("#drop-zone").addEventListener(eventName, (event) => { event.preventDefault(); $("#drop-zone").classList.remove("is-dragging"); })); | |
| $("#drop-zone").addEventListener("drop", (event) => showFile(event.dataTransfer.files[0])); | |
| $("#drop-zone").addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); $("#image-input").click(); } }); | |
| $("#symptoms").addEventListener("input", (event) => { $("#character-count").textContent = event.target.value.length; }); | |
| $("#assessment-form").addEventListener("submit", submit); | |
| $("#reset-button").addEventListener("click", reset); | |
| $("#clear-history").addEventListener("click", () => { | |
| if (window.confirm(t("clearHistoryConfirm"))) { | |
| try { localStorage.removeItem(CONFIG.historyKey); } catch (_) { /* Storage may be disabled. */ } | |
| renderHistory(); | |
| setStatus(t("historyCleared"), "success"); | |
| } | |
| }); | |
| $("#download-txt").addEventListener("click", () => { if (state.report) downloadFile("shambasense-field-report.txt", reportText(state.report), "text/plain;charset=utf-8"); }); | |
| $("#download-json").addEventListener("click", () => { if (state.report) downloadFile("shambasense-field-report.json", JSON.stringify(state.report, null, 2), "application/json"); }); | |
| document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => { | |
| document.querySelectorAll(".tab").forEach((item) => { item.classList.toggle("is-active", item === tab); item.setAttribute("aria-selected", item === tab ? "true" : "false"); }); | |
| document.querySelectorAll(".view").forEach((view) => { const visible = view.id === `view-${tab.dataset.view}`; view.hidden = !visible; view.classList.toggle("is-visible", visible); }); | |
| if (tab.dataset.view === "history") renderHistory(); | |
| })); | |
| } | |
| bind(); | |
| setLanguage("en"); | |
| })(); | |