1655 lines
69 KiB
JavaScript
1655 lines
69 KiB
JavaScript
window.__previewEnabled = false;
|
|
window.__dashboardPoll = null;
|
|
window.__cameraSourcesCache = null;
|
|
window.__runtimeMode = "manual";
|
|
window.__peoplePollCounter = 0;
|
|
window.__audioPromptLibrary = [];
|
|
|
|
const RESOLUTION_PRESETS = [
|
|
[1920, 1080, 30],
|
|
[1920, 1080, 15],
|
|
[1280, 720, 30],
|
|
[1280, 720, 15],
|
|
[960, 540, 30],
|
|
[960, 540, 15],
|
|
[848, 480, 60],
|
|
[848, 480, 30],
|
|
[640, 480, 60],
|
|
[640, 480, 30],
|
|
[640, 480, 15],
|
|
[424, 240, 60],
|
|
[424, 240, 30],
|
|
];
|
|
|
|
async function apiGet(path) {
|
|
const response = await fetch(path);
|
|
const raw = await response.text();
|
|
let data = null;
|
|
try {
|
|
data = raw ? JSON.parse(raw) : null;
|
|
} catch (_e) {
|
|
if (!response.ok) {
|
|
throw new Error(raw || `HTTP ${response.status}`);
|
|
}
|
|
return raw;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error((data && (data.error || data.result)) || raw || `HTTP ${response.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function apiPostFormData(path, formData) {
|
|
const response = await fetch(path, { method: "POST", body: formData });
|
|
const raw = await response.text();
|
|
let data = null;
|
|
try {
|
|
data = raw ? JSON.parse(raw) : null;
|
|
} catch (_e) {
|
|
if (!response.ok) {
|
|
throw new Error(raw || `HTTP ${response.status}`);
|
|
}
|
|
return raw;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error((data && (data.error || data.result)) || raw || `HTTP ${response.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function apiPostJson(path, payload) {
|
|
const response = await fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload || {}),
|
|
});
|
|
const raw = await response.text();
|
|
let data = null;
|
|
try {
|
|
data = raw ? JSON.parse(raw) : null;
|
|
} catch (_e) {
|
|
if (!response.ok) {
|
|
throw new Error(raw || `HTTP ${response.status}`);
|
|
}
|
|
return raw;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error((data && (data.error || data.result)) || raw || `HTTP ${response.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function el(tag, cls, html) {
|
|
const node = document.createElement(tag);
|
|
if (cls) node.className = cls;
|
|
if (html !== undefined) node.innerHTML = html;
|
|
return node;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function formatSize(n) {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function fmtNum(v, digits = 2) {
|
|
const n = Number(v);
|
|
if (Number.isNaN(n)) return "-";
|
|
return n.toFixed(digits);
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
const n = Number(ts);
|
|
if (!Number.isFinite(n) || n <= 0) return "-";
|
|
try {
|
|
return new Date(n * 1000).toLocaleString();
|
|
} catch (_e) {
|
|
return "-";
|
|
}
|
|
}
|
|
|
|
function cameraStatusLines(camera) {
|
|
return [
|
|
`Camera OK`,
|
|
`Source: ${camera.source || "-"}`,
|
|
`Backend: ${camera.backend || "-"}`,
|
|
`Active: ${camera.profile || "-"}`,
|
|
`Requested: ${camera.requested_profile || "-"}`,
|
|
`Preferred RS Serial: ${camera.preferred_realsense_serial || "-"}`,
|
|
`Active RS Serial: ${camera.realsense_serial || "-"}`,
|
|
].join("\n");
|
|
}
|
|
|
|
function setPreviewEnabled(enabled) {
|
|
window.__previewEnabled = !!enabled;
|
|
const btn = document.getElementById("toggle_preview");
|
|
const img = document.getElementById("live_preview");
|
|
const placeholder = document.getElementById("preview_placeholder");
|
|
const hint = document.getElementById("preview_hint");
|
|
if (btn) btn.textContent = window.__previewEnabled ? "Hide Live Camera" : "Show Live Camera";
|
|
if (!img) return;
|
|
if (window.__previewEnabled) {
|
|
img.style.display = "block";
|
|
img.src = `/preview.mjpg?t=${Date.now()}`;
|
|
if (placeholder) placeholder.style.display = "none";
|
|
if (hint) hint.textContent = "Live preview is running through the shared direct camera service.";
|
|
} else {
|
|
img.removeAttribute("src");
|
|
img.src = "";
|
|
img.style.display = "none";
|
|
if (placeholder) placeholder.style.display = "grid";
|
|
if (hint) hint.textContent = "Live preview is off. Turn it on only when needed.";
|
|
}
|
|
}
|
|
|
|
function restartPreview() {
|
|
if (!window.__previewEnabled) return;
|
|
const img = document.getElementById("live_preview");
|
|
if (!img) return;
|
|
img.src = `/preview.mjpg?t=${Date.now()}`;
|
|
}
|
|
|
|
function setResolutionInputs(width, height, fps) {
|
|
const widthInput = document.getElementById("width_input");
|
|
const heightInput = document.getElementById("height_input");
|
|
const fpsInput = document.getElementById("fps_input");
|
|
const presetSelect = document.getElementById("resolution_select");
|
|
if (widthInput) widthInput.value = String(width || "");
|
|
if (heightInput) heightInput.value = String(height || "");
|
|
if (fpsInput) fpsInput.value = String(fps || "");
|
|
if (presetSelect) {
|
|
const preset = `${width}x${height}@${fps}`;
|
|
const hasPreset = Array.from(presetSelect.options).some((opt) => opt.value === preset);
|
|
presetSelect.value = hasPreset ? preset : "";
|
|
}
|
|
}
|
|
|
|
function renderResolutionOptions() {
|
|
return [
|
|
'<option value="">Preset</option>',
|
|
...RESOLUTION_PRESETS.map(([w, h, fps]) => `<option value="${w}x${h}@${fps}">${w}x${h} @ ${fps}</option>`),
|
|
].join("");
|
|
}
|
|
|
|
function renderSourceOptions(cameraSources) {
|
|
const options = (cameraSources && cameraSources.options) || [];
|
|
if (!options.length) {
|
|
return '<option value="realsense">Preferred RealSense (default)</option>';
|
|
}
|
|
return options
|
|
.map((item) => `<option value="${escapeHtml(item.value)}">${escapeHtml(item.label || item.value)}</option>`)
|
|
.join("");
|
|
}
|
|
|
|
function renderCameraInventory(cameraSources, camera) {
|
|
const lines = [];
|
|
const preferred = String((cameraSources && cameraSources.preferred_realsense_serial) || (camera && camera.preferred_realsense_serial) || "").trim();
|
|
const active = String((camera && camera.realsense_serial) || "").trim();
|
|
const activeSource = String((camera && camera.source) || "").trim();
|
|
|
|
if (preferred) {
|
|
lines.push(`Preferred RealSense: ${preferred}`);
|
|
}
|
|
if (active) {
|
|
lines.push(`Active RealSense: ${active}`);
|
|
}
|
|
|
|
const rsDevices = (cameraSources && cameraSources.realsense_devices) || [];
|
|
if (rsDevices.length) {
|
|
lines.push("RealSense devices:");
|
|
for (const item of rsDevices) {
|
|
const serial = String(item.serial || "").trim() || "-";
|
|
const name = String(item.name || "Intel RealSense").trim();
|
|
const tags = [];
|
|
if (item.is_preferred) tags.push("default");
|
|
if (item.is_active) tags.push("live");
|
|
lines.push(`- ${name} [${serial}]${tags.length ? ` (${tags.join(", ")})` : ""}`);
|
|
}
|
|
}
|
|
|
|
const videoDevices = (cameraSources && cameraSources.video_devices) || [];
|
|
if (videoDevices.length) {
|
|
lines.push("Video nodes:");
|
|
for (const item of videoDevices) {
|
|
const name = String(item.name || "").trim();
|
|
const label = String(item.value || item.label || "").trim();
|
|
const suffix = label === activeSource ? " (live)" : "";
|
|
lines.push(`- ${label}${name ? ` :: ${name}` : ""}${suffix}`);
|
|
}
|
|
}
|
|
|
|
return lines.join("\n") || "No camera inventory available.";
|
|
}
|
|
|
|
function personImageUrl(personId, kind) {
|
|
return `/api/person_image?id=${encodeURIComponent(personId)}&kind=${encodeURIComponent(kind)}&t=${Date.now()}`;
|
|
}
|
|
|
|
function renderPeopleCards(items) {
|
|
if (!items.length) {
|
|
return '<div class="status-box"><i>No people enrolled yet.</i></div>';
|
|
}
|
|
return items.map((person) => {
|
|
const personId = String(person.person_id || "");
|
|
const title = escapeHtml(person.display_name || person.short_id || personId || "guest");
|
|
const meta = [
|
|
`ID: ${escapeHtml(person.short_id || personId || "-")}`,
|
|
`Created: ${escapeHtml(person.created_date || "-")}`,
|
|
`Seen: ${person.times_seen ?? 0}`,
|
|
`Samples: ${person.sample_count ?? 0}`,
|
|
`Last Seen: ${escapeHtml(formatTime(person.last_seen_at || 0))}`,
|
|
].join("\n");
|
|
return `
|
|
<article class="person-card">
|
|
<div class="person-card-head">
|
|
<img src="${personImageUrl(personId, "face")}" alt="${title} face">
|
|
<img src="${personImageUrl(personId, "scene")}" alt="${title} scene">
|
|
</div>
|
|
<div class="person-card-body">
|
|
<div class="person-title">${title}</div>
|
|
<div class="person-meta">${meta}</div>
|
|
<div class="person-actions">
|
|
<a class="btn ghost" href="/api/download_person?id=${encodeURIComponent(personId)}">Download</a>
|
|
<button class="btn ghost person-add-photo" data-id="${escapeHtml(personId)}">Add Photo</button>
|
|
<button class="btn ghost danger person-delete" data-id="${escapeHtml(personId)}">Delete</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
function selectedCameraSerial(source, cameraSources, camera) {
|
|
const src = String(source || "").trim();
|
|
if (src.startsWith("realsense:")) {
|
|
return src.split(":", 2)[1] || "";
|
|
}
|
|
if (src === "realsense") {
|
|
return String((camera && camera.realsense_serial) || (cameraSources && cameraSources.preferred_realsense_serial) || "").trim();
|
|
}
|
|
const devices = (cameraSources && cameraSources.realsense_devices) || [];
|
|
const exact = devices.find((item) => item.value === src);
|
|
return String((exact && exact.serial) || "").trim();
|
|
}
|
|
|
|
function formatPromptKeyLabel(key) {
|
|
return String(key || "")
|
|
.split("_")
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function promptFilenameForKey(key) {
|
|
const item = (window.__audioPromptLibrary || []).find((entry) => entry.key === key);
|
|
return item ? String(item.filename || `${key}.wav`) : `${key}.wav`;
|
|
}
|
|
|
|
function promptTextForKey(key) {
|
|
const item = (window.__audioPromptLibrary || []).find((entry) => entry.key === key);
|
|
return item ? String(item.text || "") : "";
|
|
}
|
|
|
|
function populateAudioPromptRecorder(prompts) {
|
|
window.__audioPromptLibrary = Array.isArray(prompts) ? prompts.slice() : [];
|
|
const keySelect = document.getElementById("audio_prompt_record_key");
|
|
const filenameInput = document.getElementById("audio_prompt_record_filename");
|
|
const textArea = document.getElementById("audio_prompt_record_text");
|
|
if (!keySelect || !filenameInput || !textArea) return;
|
|
|
|
const previousKey = keySelect.value;
|
|
keySelect.innerHTML = (window.__audioPromptLibrary || [])
|
|
.map((item) => `<option value="${escapeHtml(item.key)}">${escapeHtml(formatPromptKeyLabel(item.key))}</option>`)
|
|
.join("");
|
|
if (previousKey && (window.__audioPromptLibrary || []).some((item) => item.key === previousKey)) {
|
|
keySelect.value = previousKey;
|
|
}
|
|
const activeKey = keySelect.value || ((window.__audioPromptLibrary[0] && window.__audioPromptLibrary[0].key) || "");
|
|
if (activeKey) {
|
|
keySelect.value = activeKey;
|
|
if (!filenameInput.value || filenameInput.dataset.autofill !== "0") {
|
|
filenameInput.value = promptFilenameForKey(activeKey);
|
|
filenameInput.dataset.autofill = "1";
|
|
}
|
|
if (!textArea.value || textArea.dataset.autofill !== "0") {
|
|
textArea.value = promptTextForKey(activeKey);
|
|
textArea.dataset.autofill = "1";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshAudioPromptRecordStatus() {
|
|
const status = document.getElementById("audio_prompt_record_status_box");
|
|
if (!status) return;
|
|
try {
|
|
const res = await apiGet("/api/audio_prompt_record_status");
|
|
const s = res.status || {};
|
|
if (s.running) {
|
|
status.textContent = [
|
|
`Recording prompt: ${s.key || "-"}`,
|
|
`Filename: ${s.filename || "-"}`,
|
|
`Started: ${formatTime(s.started_at || 0)}`,
|
|
"Status: generating Gemini audio and recording speaker output...",
|
|
].join("\n");
|
|
return;
|
|
}
|
|
if (s.finished_at) {
|
|
if (s.ok) {
|
|
const result = s.result || {};
|
|
status.textContent = [
|
|
`Recorded prompt: ${result.key || s.key || "-"}`,
|
|
`Saved speaker file: ${result.filename || s.filename || "-"}`,
|
|
`Saved raw file: ${result.raw_filename || "-"}`,
|
|
`Finished: ${formatTime(s.finished_at || 0)}`,
|
|
].join("\n");
|
|
} else {
|
|
status.textContent = [
|
|
`Prompt recording failed for ${s.key || "-"}`,
|
|
`Finished: ${formatTime(s.finished_at || 0)}`,
|
|
`${s.error || "Unknown error"}`,
|
|
].join("\n");
|
|
}
|
|
return;
|
|
}
|
|
status.textContent = "Prompt recorder idle.";
|
|
} catch (e) {
|
|
status.textContent = `Prompt recorder unavailable\n${e.message}`;
|
|
}
|
|
}
|
|
|
|
function renderAudioPromptCards(items) {
|
|
if (!items.length) {
|
|
return '<div class="status-box"><i>No audio prompt keys available.</i></div>';
|
|
}
|
|
return items.map((item) => {
|
|
const exists = !!item.exists;
|
|
const key = String(item.key || "");
|
|
const filename = String(item.filename || "");
|
|
return `
|
|
<article class="audio-prompt-card ${exists ? "ready" : "missing"}">
|
|
<div class="audio-prompt-head">
|
|
<div>
|
|
<div class="audio-prompt-title">${escapeHtml(formatPromptKeyLabel(key))}</div>
|
|
<div class="audio-prompt-key">${escapeHtml(key)}</div>
|
|
</div>
|
|
<div class="replay-tags">
|
|
<span class="replay-tag ${exists ? "active" : "warn"}">${exists ? "uploaded" : "missing"}</span>
|
|
</div>
|
|
</div>
|
|
<div class="audio-prompt-text">${escapeHtml(item.text || "")}</div>
|
|
<div class="audio-prompt-meta">
|
|
File: ${escapeHtml(filename || "-")}<br>
|
|
Size: ${formatSize(item.size || 0)}<br>
|
|
Raw: ${item.raw_exists ? escapeHtml(item.raw_filename || "-") : "missing"}<br>
|
|
Updated: ${formatTime(item.mtime || 0)}
|
|
</div>
|
|
<div class="audio-prompt-actions">
|
|
<button class="btn ghost audio-prompt-upload" data-key="${escapeHtml(key)}" data-filename="${escapeHtml(filename)}">${exists ? "Replace" : "Upload"}</button>
|
|
<button class="btn ghost audio-prompt-record" data-key="${escapeHtml(key)}">Record</button>
|
|
${exists ? `<a class="btn ghost" href="/api/download_audio_prompt?key=${encodeURIComponent(key)}">Download</a>` : ""}
|
|
<button class="btn ghost danger audio-prompt-delete" data-key="${escapeHtml(key)}" ${exists ? "" : "disabled"}>Delete</button>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
async function refreshAudioPromptPanel() {
|
|
const list = document.getElementById("audio_prompt_list");
|
|
const status = document.getElementById("audio_prompt_status_box");
|
|
const modeToggle = document.getElementById("audio_prompt_mode_toggle");
|
|
const toggle = document.getElementById("audio_prompt_fallback_toggle");
|
|
const fileInput = document.getElementById("audio_prompt_upload_file");
|
|
if (!list || !status || !toggle || !fileInput || !modeToggle) return;
|
|
try {
|
|
const res = await apiGet("/api/audio_prompts");
|
|
const prompts = res.prompts || [];
|
|
const promptMode = String(res.mode || "audio").trim().toLowerCase() === "gemini" ? "gemini" : "audio";
|
|
const fallbackToGemini = !!res.fallback_to_gemini;
|
|
const available = prompts.filter((item) => item.exists).length;
|
|
modeToggle.dataset.mode = promptMode;
|
|
modeToggle.textContent = promptMode === "gemini" ? "Situation Speech: GEMINI" : "Situation Speech: AUDIO";
|
|
toggle.dataset.enabled = fallbackToGemini ? "1" : "0";
|
|
toggle.textContent = fallbackToGemini ? "Gemini Fallback: ON" : "Gemini Fallback: OFF";
|
|
populateAudioPromptRecorder(prompts);
|
|
status.textContent = [
|
|
`Prompt folder: ${res.dir || "AI_Photographer/Data/Audio"}`,
|
|
`Available clips: ${available} / ${prompts.length}`,
|
|
`Fixed AI situation speech: ${promptMode.toUpperCase()}`,
|
|
`Missing-clip Gemini fallback: ${fallbackToGemini ? "ON" : "OFF"}`,
|
|
promptMode === "audio"
|
|
? "Recorded audio is the default for AI situations such as greeting on detection, photo request, countdown, refusal, retake, and thank-you. After those fixed prompts finish, normal Gemini conversation continues."
|
|
: "Gemini is currently speaking the fixed AI situation prompts directly. General Gemini conversation remains available before and after the photo flow."
|
|
].join("\n");
|
|
list.innerHTML = renderAudioPromptCards(prompts);
|
|
|
|
list.querySelectorAll(".audio-prompt-upload").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
fileInput.dataset.key = btn.dataset.key || "";
|
|
fileInput.dataset.filename = btn.dataset.filename || "";
|
|
fileInput.click();
|
|
});
|
|
});
|
|
list.querySelectorAll(".audio-prompt-record").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const key = btn.dataset.key || "";
|
|
const keySelect = document.getElementById("audio_prompt_record_key");
|
|
const filenameInput = document.getElementById("audio_prompt_record_filename");
|
|
const textArea = document.getElementById("audio_prompt_record_text");
|
|
if (keySelect) keySelect.value = key;
|
|
if (filenameInput) {
|
|
filenameInput.value = promptFilenameForKey(key);
|
|
filenameInput.dataset.autofill = "1";
|
|
}
|
|
if (textArea) {
|
|
textArea.value = promptTextForKey(key);
|
|
textArea.dataset.autofill = "1";
|
|
textArea.focus();
|
|
}
|
|
});
|
|
});
|
|
list.querySelectorAll(".audio-prompt-delete").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const key = btn.dataset.key || "";
|
|
if (!key) return;
|
|
if (!confirm(`Delete audio prompt clip for ${key}?`)) return;
|
|
await apiGet(`/api/delete_audio_prompt?key=${encodeURIComponent(key)}`);
|
|
await refreshAudioPromptPanel();
|
|
await refreshAudioPromptRecordStatus();
|
|
});
|
|
});
|
|
} catch (e) {
|
|
status.textContent = `Audio prompt library unavailable\n${e.message}`;
|
|
list.innerHTML = "";
|
|
toggle.textContent = "Gemini Fallback";
|
|
delete toggle.dataset.enabled;
|
|
}
|
|
}
|
|
|
|
function bindTopActions() {
|
|
const topStatus = document.getElementById("top_status");
|
|
const topCapture = document.getElementById("top_capture");
|
|
const topTest = document.getElementById("top_test");
|
|
const topFix = document.getElementById("top_fix");
|
|
|
|
if (topStatus) {
|
|
topStatus.onclick = () => window.open("/api/status", "_blank", "noopener");
|
|
}
|
|
if (topCapture) {
|
|
topCapture.onclick = async () => {
|
|
try {
|
|
const res = await apiGet("/api/capture");
|
|
alert(`Captured:\n${res.result || res.path || "OK"}`);
|
|
await renderPhotos();
|
|
} catch (e) {
|
|
alert(`Capture failed: ${e.message}`);
|
|
}
|
|
};
|
|
}
|
|
if (topTest) {
|
|
topTest.onclick = async () => {
|
|
try {
|
|
const res = await apiGet("/api/test");
|
|
alert(JSON.stringify(res, null, 2));
|
|
} catch (e) {
|
|
alert(`Camera test failed: ${e.message}`);
|
|
}
|
|
};
|
|
}
|
|
if (topFix) {
|
|
topFix.onclick = async () => {
|
|
if (!confirm("Run the camera fix flow?")) return;
|
|
try {
|
|
const res = await apiGet("/api/fix");
|
|
alert(JSON.stringify(res, null, 2));
|
|
} catch (e) {
|
|
alert(`Fix failed: ${e.message}`);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
async function refreshAutonomousPanel() {
|
|
const panel = document.getElementById("auto_state_panel");
|
|
if (!panel) return;
|
|
try {
|
|
const r = await apiGet("/api/autonomous_state");
|
|
if (!r.ok) {
|
|
panel.innerHTML = "<i>Autonomous state unavailable</i>";
|
|
return;
|
|
}
|
|
const s = r.state || {};
|
|
panel.innerHTML = `
|
|
<div class="info-grid">
|
|
<div class="stat-line"><span>State</span><b>${escapeHtml(s.state || "IDLE")}</b></div>
|
|
<div class="stat-line"><span>Session</span><b>${s.session_id ?? 0}</b></div>
|
|
<div class="stat-line"><span>Detector</span><b>${escapeHtml((s.detector_backend || "normal").toUpperCase())}</b></div>
|
|
<div class="stat-line"><span>YOLO Runtime</span><b>${escapeHtml((s.yolo_runtime || "-").toUpperCase())}</b></div>
|
|
<div class="stat-line"><span>AI Blocked</span><b>${s.ai_blocked ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Block Reason</span><b>${escapeHtml(s.ai_block_reason || "-")}</b></div>
|
|
<div class="stat-line"><span>People</span><b>${s.person_count ?? 0}</b></div>
|
|
<div class="stat-line"><span>Faces</span><b>${s.face_count ?? 0}</b></div>
|
|
<div class="stat-line"><span>Group</span><b>${s.group_detected ? `Yes (${s.group_size ?? 0})` : "No"}</b></div>
|
|
<div class="stat-line"><span>Intent</span><b>${s.intent_detected ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Depth</span><b>${s.depth_m == null ? "-" : fmtNum(s.depth_m, 2)}</b></div>
|
|
<div class="stat-line"><span>Approach</span><b>${fmtNum(s.approach_speed_mps || 0, 2)} m/s</b></div>
|
|
<div class="stat-line"><span>Guest</span><b>${escapeHtml(s.recognized_person_label || "-")}</b></div>
|
|
<div class="stat-line"><span>Known Guest</span><b>${s.recognized_person_known ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Match</span><b>${s.recognized_person_match_score ? fmtNum(s.recognized_person_match_score, 2) : "-"}</b></div>
|
|
</div>
|
|
`;
|
|
} catch (_e) {
|
|
panel.innerHTML = "<i>Autonomous state unavailable</i>";
|
|
}
|
|
}
|
|
|
|
async function refreshAiReadinessPanel() {
|
|
const panel = document.getElementById("ai_readiness_panel");
|
|
if (!panel) return;
|
|
try {
|
|
const data = await apiGet("/api/ai_readiness");
|
|
panel.innerHTML = `
|
|
<div class="info-grid">
|
|
<div class="stat-line"><span>Ready</span><b>${data.ok ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Backend</span><b>${escapeHtml((data.backend || "normal").toUpperCase())}</b></div>
|
|
<div class="stat-line"><span>Runtime</span><b>${escapeHtml((data.yolo_runtime || "-").toUpperCase())}</b></div>
|
|
<div class="stat-line"><span>YOLO Loaded</span><b>${data.yolo_loaded ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Person Model</span><b>${data.person_model_ok ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Face Model</span><b>${data.face_model_ok ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Strict Required</span><b>${data.strict_required ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Block Reason</span><b>${escapeHtml(data.block_reason || "-")}</b></div>
|
|
</div>
|
|
`;
|
|
} catch (_e) {
|
|
panel.innerHTML = "<i>AI readiness unavailable</i>";
|
|
}
|
|
}
|
|
|
|
async function refreshRuntimeHealthPanel() {
|
|
const panel = document.getElementById("runtime_health_panel");
|
|
if (!panel) return;
|
|
try {
|
|
const r = await apiGet("/api/runtime_health");
|
|
if (!r.ok) {
|
|
panel.innerHTML = "<i>Runtime health unavailable</i>";
|
|
return;
|
|
}
|
|
const h = r.health || {};
|
|
panel.innerHTML = `
|
|
<div class="info-grid">
|
|
<div class="stat-line"><span>WS</span><b>${h.ws_connected ? "Connected" : "Down"}</b></div>
|
|
<div class="stat-line"><span>WS State</span><b>${escapeHtml(h.ws_state || "-")}</b></div>
|
|
<div class="stat-line"><span>Mic Enabled</span><b>${h.mic_enabled ? "Yes" : "No"}</b></div>
|
|
<div class="stat-line"><span>Mic</span><b>${escapeHtml(h.mic_state || "-")}</b></div>
|
|
<div class="stat-line"><span>Speaker</span><b>${escapeHtml(h.speaker_state || "-")}</b></div>
|
|
<div class="stat-line"><span>Gate</span><b>${h.audio_gate_open ? "Open" : "Closed"}</b></div>
|
|
<div class="stat-line"><span>Mic Restarts</span><b>${h.mic_restarts ?? 0}</b></div>
|
|
<div class="stat-line"><span>Speaker Restarts</span><b>${h.speaker_restarts ?? 0}</b></div>
|
|
<div class="stat-line"><span>WS Restarts</span><b>${h.ws_restarts ?? 0}</b></div>
|
|
<div class="stat-line"><span>Last WS Error</span><b>${escapeHtml(h.ws_last_error || "-")}</b></div>
|
|
</div>
|
|
`;
|
|
} catch (_e) {
|
|
panel.innerHTML = "<i>Runtime health unavailable</i>";
|
|
}
|
|
}
|
|
|
|
async function renderPeople() {
|
|
const list = document.getElementById("people_list");
|
|
const status = document.getElementById("people_status_box");
|
|
if (!list || !status) return;
|
|
try {
|
|
const res = await apiGet("/api/people");
|
|
const people = res.people || [];
|
|
status.textContent = people.length
|
|
? `Registered guests: ${people.length}\nReturning guests are recognized from saved face samples in photos/people/.`
|
|
: "No people enrolled yet. AI mode will create a guest profile the first time a single face is confirmed.";
|
|
list.innerHTML = renderPeopleCards(people);
|
|
list.querySelectorAll(".person-delete").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const personId = btn.dataset.id || "";
|
|
if (!personId) return;
|
|
if (!confirm(`Delete person ${personId}?`)) return;
|
|
await apiGet(`/api/delete_person?id=${encodeURIComponent(personId)}`);
|
|
await renderPeople();
|
|
});
|
|
});
|
|
list.querySelectorAll(".person-add-photo").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const personId = btn.dataset.id || "";
|
|
const input = document.getElementById("people_upload_file");
|
|
if (!input) return;
|
|
input.dataset.personId = personId;
|
|
input.click();
|
|
});
|
|
});
|
|
} catch (e) {
|
|
status.textContent = `People registry unavailable\n${e.message}`;
|
|
list.innerHTML = "";
|
|
}
|
|
}
|
|
|
|
async function refreshCameraPanel() {
|
|
const statusBox = document.getElementById("camera_status_box");
|
|
const infoPanel = document.getElementById("camera_info_panel");
|
|
const chipRow = document.getElementById("camera_chip_row");
|
|
const selector = document.getElementById("camera_source_select");
|
|
const sourceMeta = document.getElementById("camera_source_meta");
|
|
const inventoryBox = document.getElementById("camera_inventory_box");
|
|
if (!statusBox || !infoPanel || !chipRow) return;
|
|
|
|
try {
|
|
const [health, cameraSources] = await Promise.all([
|
|
apiGet("/api/camera_health"),
|
|
selector && !window.__cameraSourcesCache ? apiGet("/api/camera_sources") : Promise.resolve(window.__cameraSourcesCache || null),
|
|
]);
|
|
|
|
if (cameraSources && cameraSources.ok) {
|
|
window.__cameraSourcesCache = cameraSources;
|
|
if (selector) {
|
|
const currentValue = selector.dataset.initialized === "1"
|
|
? (selector.value || cameraSources.selected_source || "realsense")
|
|
: (cameraSources.selected_source || "realsense");
|
|
selector.innerHTML = renderSourceOptions(cameraSources);
|
|
selector.value = currentValue;
|
|
if (!selector.value) selector.value = cameraSources.selected_source || "realsense";
|
|
selector.dataset.initialized = "1";
|
|
}
|
|
if (sourceMeta) {
|
|
const rsCount = (cameraSources.realsense_devices || []).length;
|
|
const vidCount = (cameraSources.video_devices || []).length;
|
|
const activeLabel = (cameraSources.active_device && cameraSources.active_device.label) || cameraSources.active_source || "-";
|
|
sourceMeta.textContent = `Preferred RS: ${cameraSources.preferred_realsense_serial || "-"} | Active: ${activeLabel} | RealSense devices: ${rsCount} | Video nodes: ${vidCount}`;
|
|
}
|
|
}
|
|
|
|
const camera = (health && health.camera) || {};
|
|
statusBox.textContent = camera.ok ? cameraStatusLines(camera) : `Camera not ready\nSource: ${camera.source || "-"}\nRequested: ${camera.requested_profile || "-"}\nError: ${camera.last_error || "-"}`;
|
|
chipRow.innerHTML = `
|
|
<div class="chip"><span>Requested</span><strong>${escapeHtml(camera.requested_source || camera.source || "-")}</strong></div>
|
|
<div class="chip"><span>Backend</span><strong>${escapeHtml(camera.backend || "-")}</strong></div>
|
|
<div class="chip"><span>Active Profile</span><strong>${escapeHtml(camera.profile || "-")}</strong></div>
|
|
<div class="chip"><span>Frame Time</span><strong>${escapeHtml(formatTime(camera.frame_time))}</strong></div>
|
|
`;
|
|
infoPanel.textContent = JSON.stringify(health, null, 2);
|
|
if (inventoryBox) {
|
|
inventoryBox.textContent = renderCameraInventory(cameraSources, camera);
|
|
}
|
|
|
|
const widthInput = document.getElementById("width_input");
|
|
if (widthInput && widthInput.dataset.initialized !== "1") {
|
|
setResolutionInputs(camera.requested_width, camera.requested_height, camera.requested_fps);
|
|
widthInput.dataset.initialized = "1";
|
|
}
|
|
} catch (e) {
|
|
statusBox.textContent = `Camera service unavailable\n${e.message}`;
|
|
infoPanel.textContent = JSON.stringify({ ok: false, error: e.message }, null, 2);
|
|
chipRow.innerHTML = "";
|
|
if (inventoryBox) inventoryBox.textContent = "Camera inventory unavailable.";
|
|
}
|
|
}
|
|
|
|
function renderReplayList(items, opts = {}) {
|
|
const manualMode = !!opts.manualMode;
|
|
const testRunning = !!opts.testRunning;
|
|
if (!items.length) {
|
|
return '<div class="status-box"><i>No replay files found.</i></div>';
|
|
}
|
|
return items.map((item) => {
|
|
const tags = [];
|
|
if (item.active) tags.push('<span class="replay-tag active">active</span>');
|
|
if (item.greeting) tags.push('<span class="replay-tag">greeting</span>');
|
|
if (!item.trigger_ok) tags.push('<span class="replay-tag warn">triggerless</span>');
|
|
return `
|
|
<article class="replay-row ${item.active ? "active" : ""}">
|
|
<div class="replay-main">
|
|
<div class="replay-title">${escapeHtml(item.name)}</div>
|
|
<div class="replay-meta">${formatSize(item.size || 0)} · ${formatTime(item.mtime || 0)}</div>
|
|
</div>
|
|
<div class="replay-tags">${tags.join("")}</div>
|
|
<div class="replay-row-actions">
|
|
<button class="btn ghost replay-test" data-name="${escapeHtml(item.name)}" ${manualMode && !testRunning ? "" : "disabled"}>Test</button>
|
|
<button class="btn ghost replay-use" data-name="${escapeHtml(item.name)}">Use</button>
|
|
<button class="btn ghost replay-rename" data-name="${escapeHtml(item.name)}">Rename</button>
|
|
<a class="btn ghost" href="/api/download_replay?name=${encodeURIComponent(item.name)}">Download</a>
|
|
<button class="btn ghost danger replay-delete" data-name="${escapeHtml(item.name)}" ${(item.active || item.greeting) ? "disabled" : ""}>Delete</button>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
function renderReplayRecordStatus(status) {
|
|
if (!status || !status.started_at) {
|
|
return "Recorder idle.";
|
|
}
|
|
if (status.running) {
|
|
return `Recording ${status.name || "-"} for ${status.seconds || 0}s...\nOutput: ${status.output || "-"}`;
|
|
}
|
|
if (status.ok) {
|
|
return `Recording saved\n${status.output || "-"}\nFinished: ${formatTime(status.finished_at || 0)}`;
|
|
}
|
|
return `Recorder error\n${status.error || "-"}${(status.output ? `\nOutput: ${status.output}` : "")}`;
|
|
}
|
|
|
|
function renderReplayTestStatus(status) {
|
|
if (!status || !status.started_at) {
|
|
return "Replay test idle.";
|
|
}
|
|
if (status.running) {
|
|
return `Testing replay\n${status.name || "-"}`;
|
|
}
|
|
if (status.ok) {
|
|
return `Replay test complete\n${status.result || status.name || "-"}`;
|
|
}
|
|
return `Replay test error\n${status.error || "-"}`;
|
|
}
|
|
|
|
async function refreshReplayPanel() {
|
|
const selector = document.getElementById("replay_select");
|
|
const activeNote = document.getElementById("replay_active_note");
|
|
const list = document.getElementById("replay_manager_list");
|
|
const status = document.getElementById("replay_record_status");
|
|
const testStatus = document.getElementById("replay_test_status");
|
|
const greetingSelect = document.getElementById("opt_greeting_replay_file");
|
|
try {
|
|
const [replayResp, activeResp, recordResp, testResp] = await Promise.all([
|
|
apiGet("/api/replays"),
|
|
apiGet("/api/get_replay"),
|
|
apiGet("/api/replay_record_status").catch(() => ({ ok: false, status: {} })),
|
|
apiGet("/api/replay_test_status").catch(() => ({ ok: false, status: {} })),
|
|
]);
|
|
const items = replayResp.items || [];
|
|
const activeReplay = activeResp.replay || "";
|
|
const replayTest = testResp.status || {};
|
|
const manualMode = window.__runtimeMode === "manual";
|
|
|
|
if (selector) {
|
|
selector.innerHTML = items.map((item) => `<option value="${escapeHtml(item.name)}">${escapeHtml(item.name)}</option>`).join("");
|
|
selector.value = activeReplay || (items[0] && items[0].name) || "";
|
|
}
|
|
if (activeNote) {
|
|
activeNote.textContent = activeReplay
|
|
? `Active replay: ${activeReplay}. Manual R2+X and AI photo capture use this replay when AI capture replay is enabled. Replay recording and replay test are manual-mode only.`
|
|
: "No active replay selected.";
|
|
}
|
|
if (list) {
|
|
list.innerHTML = renderReplayList(items, { manualMode, testRunning: !!replayTest.running });
|
|
list.querySelectorAll(".replay-test").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const name = btn.dataset.name || "";
|
|
await apiGet(`/api/test_replay?name=${encodeURIComponent(name)}`);
|
|
await refreshReplayPanel();
|
|
});
|
|
});
|
|
list.querySelectorAll(".replay-use").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
await apiGet(`/api/set_replay?name=${encodeURIComponent(btn.dataset.name || "")}`);
|
|
await refreshReplayPanel();
|
|
});
|
|
});
|
|
list.querySelectorAll(".replay-rename").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const oldName = btn.dataset.name || "";
|
|
const currentLeaf = oldName.split("/").pop() || oldName;
|
|
const nextName = window.prompt("Rename replay", currentLeaf);
|
|
if (!nextName || !nextName.trim()) return;
|
|
await apiGet(
|
|
`/api/rename_replay?old=${encodeURIComponent(oldName)}&new=${encodeURIComponent(nextName.trim())}`
|
|
);
|
|
await refreshReplayPanel();
|
|
});
|
|
});
|
|
list.querySelectorAll(".replay-delete").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const name = btn.dataset.name || "";
|
|
if (!confirm(`Delete replay ${name}?`)) return;
|
|
await apiGet(`/api/delete_replay?name=${encodeURIComponent(name)}`);
|
|
await refreshReplayPanel();
|
|
});
|
|
});
|
|
}
|
|
if (status) {
|
|
status.textContent = renderReplayRecordStatus(recordResp.status || {});
|
|
}
|
|
if (testStatus) {
|
|
testStatus.textContent = renderReplayTestStatus(replayTest);
|
|
}
|
|
if (greetingSelect) {
|
|
const current = greetingSelect.dataset.current || greetingSelect.value;
|
|
greetingSelect.innerHTML = items.map((item) => `<option value="${escapeHtml(item.name)}">${escapeHtml(item.name)}</option>`).join("");
|
|
if (current) greetingSelect.value = current;
|
|
if (!greetingSelect.value && items.length) greetingSelect.value = items[0].name;
|
|
delete greetingSelect.dataset.current;
|
|
}
|
|
} catch (e) {
|
|
if (activeNote) activeNote.textContent = `Replay inventory unavailable: ${e.message}`;
|
|
if (list) list.innerHTML = "";
|
|
if (status) status.textContent = `Recorder unavailable\n${e.message}`;
|
|
if (testStatus) testStatus.textContent = `Replay test unavailable\n${e.message}`;
|
|
}
|
|
}
|
|
|
|
async function renderControls() {
|
|
const ctrl = document.getElementById("controls");
|
|
const [modeResp, micResp, cameraSources] = await Promise.all([
|
|
apiGet("/api/mode"),
|
|
apiGet("/api/mic"),
|
|
apiGet("/api/camera_sources").catch(() => ({ ok: false, options: [] })),
|
|
]);
|
|
|
|
window.__cameraSourcesCache = cameraSources && cameraSources.ok ? cameraSources : null;
|
|
|
|
const mode = modeResp.mode || "manual";
|
|
window.__runtimeMode = mode;
|
|
const modePolicy = modeResp.policy || {};
|
|
const micEnabled = !!(micResp.options && micResp.options.mic_enabled);
|
|
|
|
let backend = "normal";
|
|
let strictLocked = false;
|
|
let aiOptions = {
|
|
hard_target_lock_enabled: true,
|
|
retake_prompt_enabled: true,
|
|
autonomous_greeting_replay_enabled: true,
|
|
autonomous_greeting_replay_file: "right_hand_up.jsonl",
|
|
autonomous_capture_replay_enabled: true,
|
|
face_recognition_enabled: true,
|
|
face_recognition_threshold: 0.88,
|
|
active_replay: "photo_G3.jsonl",
|
|
};
|
|
let aiReadinessMarkup = "";
|
|
let aiOptionsMarkup = "";
|
|
if (mode === "ai") {
|
|
try {
|
|
const bj = await apiGet("/api/detector_backend");
|
|
backend = bj.backend || "normal";
|
|
strictLocked = !!bj.locked;
|
|
} catch (_e) {}
|
|
|
|
try {
|
|
const oj = await apiGet("/api/ai_options");
|
|
if (oj && oj.ok && oj.options) aiOptions = oj.options;
|
|
} catch (_e) {}
|
|
|
|
aiReadinessMarkup = `
|
|
<section class="panel strong">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">AI Vision</div>
|
|
<h3>Detection Backend</h3>
|
|
</div>
|
|
</div>
|
|
<div class="selector-row">
|
|
<select id="det_backend_select">
|
|
<option value="normal">Normal</option>
|
|
<option value="yolo">YOLO</option>
|
|
</select>
|
|
<button id="det_backend_set" class="btn ghost">Apply Backend</button>
|
|
</div>
|
|
${strictLocked ? '<div class="meta" style="margin-top:10px;color:#8b3120;"><b>Strict AI profile active:</b> backend is locked to YOLO in AI mode.</div>' : ""}
|
|
<div id="ai_readiness_panel" class="info-box"><i>Loading AI readiness...</i></div>
|
|
</section>
|
|
`;
|
|
|
|
aiOptionsMarkup = `
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">AI Session</div>
|
|
<h3>Session Options</h3>
|
|
</div>
|
|
</div>
|
|
<div class="inline-actions">
|
|
<label><input type="checkbox" id="opt_hard_lock" ${aiOptions.hard_target_lock_enabled ? "checked" : ""}> Hard Target Lock</label>
|
|
<label><input type="checkbox" id="opt_retake_prompt" ${aiOptions.retake_prompt_enabled ? "checked" : ""}> Retake Prompt</label>
|
|
<label><input type="checkbox" id="opt_capture_replay" ${aiOptions.autonomous_capture_replay_enabled ? "checked" : ""}> Use Active Replay During AI Photo</label>
|
|
<label><input type="checkbox" id="opt_greeting_replay" ${aiOptions.autonomous_greeting_replay_enabled ? "checked" : ""}> Greeting Replay</label>
|
|
<label><input type="checkbox" id="opt_face_recognition" ${aiOptions.face_recognition_enabled ? "checked" : ""}> Returning Guest Recognition</label>
|
|
</div>
|
|
<div class="selector-row" style="margin-top:12px;">
|
|
<select id="opt_greeting_replay_file"></select>
|
|
<input id="opt_face_threshold" type="number" min="0.50" max="0.995" step="0.01" value="${Number(aiOptions.face_recognition_threshold || 0.88).toFixed(2)}" placeholder="face threshold">
|
|
<button id="opt_ai_save" class="btn ghost">Save AI Options</button>
|
|
</div>
|
|
<div class="status-box">AI welcomes approaching guests, asks if they want a photo, guides them into frame, and captures using the active replay when enabled. Returning-guest recognition stores samples in <code>photos/people/</code>. Active replay: ${escapeHtml(aiOptions.active_replay || "-")}.</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
const recorderMarkup = mode === "manual"
|
|
? `
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Recorder</div>
|
|
<h3>Record New Replay</h3>
|
|
</div>
|
|
</div>
|
|
<div class="selector-row">
|
|
<input id="replay_record_name" type="text" placeholder="photo_wave">
|
|
<input id="replay_record_seconds" type="number" min="1" step="1" value="15" placeholder="seconds">
|
|
<button id="replay_record_start" class="btn primary">Start Recording</button>
|
|
</div>
|
|
<div class="meta" style="margin-top:10px;">Recorded files are saved directly in <code>Data/G1</code> and become available in the active replay selector.</div>
|
|
<div id="replay_record_status" class="status-box">Recorder idle.</div>
|
|
</section>
|
|
`
|
|
: `
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Recorder</div>
|
|
<h3>Manual Mode Only</h3>
|
|
</div>
|
|
</div>
|
|
<div class="status-box">Replay recording and replay test are disabled outside manual mode. Replay selection, rename, upload, download, and delete remain available.</div>
|
|
<div id="replay_record_status" class="status-box">Recorder idle.</div>
|
|
</section>
|
|
`;
|
|
|
|
ctrl.innerHTML = `
|
|
<div class="dashboard-stack">
|
|
<div class="control-grid">
|
|
<section class="panel strong">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Runtime Mode</div>
|
|
<h3>${escapeHtml(mode.toUpperCase())}</h3>
|
|
</div>
|
|
</div>
|
|
<div class="selector-row">
|
|
<select id="mode_select">
|
|
<option value="manual">Manual</option>
|
|
<option value="ai">AI</option>
|
|
</select>
|
|
<button id="mode_set" class="btn primary">Set Mode</button>
|
|
</div>
|
|
<div class="status-box">${escapeHtml(modePolicy.description || "")}</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Gemini Mic</div>
|
|
<h3>${micEnabled ? "Microphone ON" : "Microphone OFF"}</h3>
|
|
</div>
|
|
</div>
|
|
<div class="action-row">
|
|
<button id="mic_toggle" class="btn ${micEnabled ? "ghost danger" : "primary"}">${micEnabled ? "Turn Mic Off" : "Turn Mic On"}</button>
|
|
</div>
|
|
<div class="status-box">Microphone audio can stay on in both modes. Manual mode still disables mapped photo commands and AI detection.</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="panel strong">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Camera Console</div>
|
|
<h2>Live Camera</h2>
|
|
</div>
|
|
</div>
|
|
<div class="camera-grid">
|
|
<div>
|
|
<div class="preview-shell">
|
|
<div id="preview_placeholder" class="preview-placeholder">Live preview is off.<br>Use the toggle below when you need a live camera feed.</div>
|
|
<img id="live_preview" class="camera-preview" alt="live preview">
|
|
</div>
|
|
<div class="preview-toolbar" style="margin-top:12px;">
|
|
<button id="toggle_preview" class="btn ghost">Show Live Camera</button>
|
|
<button id="capture_now_btn" class="btn primary">Capture Now</button>
|
|
<button id="camera_refresh_btn" class="btn ghost">Refresh Camera</button>
|
|
</div>
|
|
<div id="preview_hint" class="meta" style="margin-top:10px;">Live preview is off. Turn it on only when needed.</div>
|
|
<div id="camera_chip_row" class="chip-row"></div>
|
|
<div id="camera_status_box" class="status-box">Loading camera status...</div>
|
|
</div>
|
|
<div>
|
|
<div class="selector-row">
|
|
<select id="camera_source_select">${renderSourceOptions(cameraSources)}</select>
|
|
<button id="camera_apply_btn" class="btn ghost">Switch Camera</button>
|
|
<button id="camera_save_default_btn" class="btn ghost">Save As Default</button>
|
|
</div>
|
|
<div id="camera_source_meta" class="meta" style="margin-top:10px;">Loading available camera sources...</div>
|
|
<pre id="camera_inventory_box" class="status-box" style="margin-top:10px;">Loading camera inventory...</pre>
|
|
<div class="resolution-row" style="margin-top:14px;">
|
|
<select id="resolution_select">${renderResolutionOptions()}</select>
|
|
<input id="width_input" type="number" min="1" step="1" placeholder="width">
|
|
<input id="height_input" type="number" min="1" step="1" placeholder="height">
|
|
<input id="fps_input" type="number" min="1" step="1" placeholder="fps">
|
|
<button id="apply_resolution_btn" class="btn ghost">Apply Resolution</button>
|
|
</div>
|
|
<div class="meta" style="margin-top:10px;">Use a preset or enter width, height, and fps manually. The direct camera service will reopen with the requested settings.</div>
|
|
<pre id="camera_info_panel" class="raw-box">Checking camera...</pre>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="control-grid">
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Replay</div>
|
|
<h3>Capture Motion</h3>
|
|
</div>
|
|
</div>
|
|
<div class="selector-row">
|
|
<select id="replay_select"></select>
|
|
<button id="replay_set" class="btn ghost">Set Active Replay</button>
|
|
</div>
|
|
<div id="replay_active_note" class="status-box">Loading replay status...</div>
|
|
</section>
|
|
|
|
${recorderMarkup}
|
|
</div>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Replay Files</div>
|
|
<h3>Replay Inventory</h3>
|
|
</div>
|
|
</div>
|
|
<div class="action-row">
|
|
<button id="replay_upload_btn" class="btn ghost">Upload Replay</button>
|
|
<input id="replay_upload_file" type="file" accept=".jsonl" style="display:none;">
|
|
</div>
|
|
<div id="replay_test_status" class="status-box">Replay test idle.</div>
|
|
<div id="replay_manager_list" class="replay-list"></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">AI Voice Assets</div>
|
|
<h3>Audio Prompt Library</h3>
|
|
</div>
|
|
<div class="action-row">
|
|
<button id="audio_prompt_mode_toggle" class="btn ghost">Situation Speech</button>
|
|
<button id="audio_prompt_fallback_toggle" class="btn ghost">Gemini Fallback</button>
|
|
</div>
|
|
</div>
|
|
<div class="status-box">Upload prerecorded WAV clips for fixed AI situation prompts. In audio mode, AI detection, confirmation, countdown, retake, and thank-you situations use these clips first. Gemini can be switched back on for those same situations from the dashboard.</div>
|
|
<div id="audio_prompt_status_box" class="status-box">Loading audio prompt library...</div>
|
|
<div class="audio-prompt-recorder">
|
|
<div class="selector-row">
|
|
<select id="audio_prompt_record_key"></select>
|
|
<input id="audio_prompt_record_filename" type="text" placeholder="filename.wav">
|
|
<button id="audio_prompt_record_start" class="btn primary">Record From Text</button>
|
|
</div>
|
|
<textarea id="audio_prompt_record_text" class="prompt-editor" rows="4" placeholder="Prompt text to speak exactly..."></textarea>
|
|
<div id="audio_prompt_record_status_box" class="status-box">Prompt recorder idle.</div>
|
|
</div>
|
|
<input id="audio_prompt_upload_file" type="file" accept=".wav" style="display:none;">
|
|
<div id="audio_prompt_list" class="audio-prompt-grid"></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Actions</div>
|
|
<h3>Operator Actions</h3>
|
|
</div>
|
|
</div>
|
|
<div class="action-row">
|
|
<button id="req_photo" class="btn ghost">Request Photo</button>
|
|
<button id="clear_photos" class="btn ghost danger">Clear Photos</button>
|
|
<button id="download_zip" class="btn ghost">Download All</button>
|
|
<button id="upload_now" class="btn ghost">Upload Now</button>
|
|
<button id="scripts_fix_check" class="btn ghost">Check RealSense</button>
|
|
<button id="scripts_fix_run" class="btn ghost danger">Fix RealSense</button>
|
|
</div>
|
|
</section>
|
|
|
|
${aiReadinessMarkup}
|
|
${aiOptionsMarkup}
|
|
|
|
<div class="status-grid">
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">AI State</div>
|
|
<h3>Autonomous State</h3>
|
|
</div>
|
|
</div>
|
|
<div id="auto_state_panel" class="info-box"><i>Loading...</i></div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<div class="panel-eyebrow">Runtime</div>
|
|
<h3>Runtime Health</h3>
|
|
</div>
|
|
</div>
|
|
<div id="runtime_health_panel" class="info-box"><i>Loading...</i></div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (mode === "ai") {
|
|
setTimeout(() => {
|
|
const bSel = document.getElementById("det_backend_select");
|
|
const bBtn = document.getElementById("det_backend_set");
|
|
if (bSel) {
|
|
bSel.value = backend;
|
|
if (strictLocked) {
|
|
bSel.disabled = true;
|
|
bSel.value = "yolo";
|
|
}
|
|
}
|
|
if (bBtn && strictLocked) {
|
|
bBtn.disabled = true;
|
|
}
|
|
const greetingSel = document.getElementById("opt_greeting_replay_file");
|
|
if (greetingSel) {
|
|
greetingSel.dataset.current = aiOptions.autonomous_greeting_replay_file || "";
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
document.getElementById("mode_select").value = mode;
|
|
|
|
document.getElementById("mode_set").addEventListener("click", async () => {
|
|
const sel = document.getElementById("mode_select").value;
|
|
await apiGet(`/api/set_mode?mode=${encodeURIComponent(sel)}`);
|
|
await renderControls();
|
|
await renderPhotos();
|
|
});
|
|
|
|
document.getElementById("mic_toggle").addEventListener("click", async () => {
|
|
const nextEnabled = micEnabled ? 0 : 1;
|
|
await apiGet(`/api/set_mic?enabled=${nextEnabled}`);
|
|
await renderControls();
|
|
});
|
|
|
|
const replaySet = document.getElementById("replay_set");
|
|
if (replaySet) {
|
|
replaySet.addEventListener("click", async () => {
|
|
const name = encodeURIComponent(document.getElementById("replay_select").value);
|
|
await apiGet(`/api/set_replay?name=${name}`);
|
|
await refreshReplayPanel();
|
|
});
|
|
}
|
|
|
|
const replayRecordStart = document.getElementById("replay_record_start");
|
|
if (replayRecordStart) {
|
|
replayRecordStart.addEventListener("click", async () => {
|
|
const name = (document.getElementById("replay_record_name").value || "").trim();
|
|
const seconds = Number(document.getElementById("replay_record_seconds").value || "15");
|
|
if (!name) {
|
|
alert("Enter a replay file name first.");
|
|
return;
|
|
}
|
|
await apiGet(`/api/replay_record_start?name=${encodeURIComponent(name)}&seconds=${encodeURIComponent(seconds)}`);
|
|
await refreshReplayPanel();
|
|
});
|
|
}
|
|
|
|
const replayUploadBtn = document.getElementById("replay_upload_btn");
|
|
const replayUploadFile = document.getElementById("replay_upload_file");
|
|
if (replayUploadBtn && replayUploadFile) {
|
|
replayUploadBtn.addEventListener("click", () => replayUploadFile.click());
|
|
replayUploadFile.addEventListener("change", async () => {
|
|
const file = replayUploadFile.files && replayUploadFile.files[0];
|
|
if (!file) return;
|
|
const requestedName = window.prompt("Replay name", file.name) || file.name;
|
|
const form = new FormData();
|
|
form.append("file", file, file.name);
|
|
form.append("name", requestedName);
|
|
try {
|
|
await apiPostFormData("/api/upload_replay", form);
|
|
await refreshReplayPanel();
|
|
} catch (e) {
|
|
alert(`Replay upload failed: ${e.message}`);
|
|
} finally {
|
|
replayUploadFile.value = "";
|
|
}
|
|
});
|
|
}
|
|
|
|
const audioPromptUploadFile = document.getElementById("audio_prompt_upload_file");
|
|
const audioPromptModeToggle = document.getElementById("audio_prompt_mode_toggle");
|
|
const audioPromptFallbackToggle = document.getElementById("audio_prompt_fallback_toggle");
|
|
const audioPromptRecordKey = document.getElementById("audio_prompt_record_key");
|
|
const audioPromptRecordFilename = document.getElementById("audio_prompt_record_filename");
|
|
const audioPromptRecordText = document.getElementById("audio_prompt_record_text");
|
|
const audioPromptRecordStart = document.getElementById("audio_prompt_record_start");
|
|
if (audioPromptUploadFile) {
|
|
audioPromptUploadFile.addEventListener("change", async () => {
|
|
const file = audioPromptUploadFile.files && audioPromptUploadFile.files[0];
|
|
const key = audioPromptUploadFile.dataset.key || "";
|
|
if (!file || !key) return;
|
|
const defaultName = audioPromptUploadFile.dataset.filename || `${key}.wav`;
|
|
const requestedName = window.prompt("Audio filename", defaultName) || defaultName;
|
|
const form = new FormData();
|
|
form.append("key", key);
|
|
form.append("file", file, file.name);
|
|
form.append("filename", requestedName);
|
|
try {
|
|
await apiPostFormData("/api/upload_audio_prompt", form);
|
|
await refreshAudioPromptPanel();
|
|
} catch (e) {
|
|
alert(`Audio prompt upload failed: ${e.message}`);
|
|
} finally {
|
|
audioPromptUploadFile.value = "";
|
|
delete audioPromptUploadFile.dataset.key;
|
|
delete audioPromptUploadFile.dataset.filename;
|
|
}
|
|
});
|
|
}
|
|
if (audioPromptModeToggle) {
|
|
audioPromptModeToggle.addEventListener("click", async () => {
|
|
const current = String(audioPromptModeToggle.dataset.mode || "audio").trim().toLowerCase();
|
|
const next = current === "gemini" ? "audio" : "gemini";
|
|
await apiGet(`/api/set_audio_prompt_mode?mode=${encodeURIComponent(next)}`);
|
|
await refreshAudioPromptPanel();
|
|
});
|
|
}
|
|
if (audioPromptFallbackToggle) {
|
|
audioPromptFallbackToggle.addEventListener("click", async () => {
|
|
const current = audioPromptFallbackToggle.dataset.enabled === "1";
|
|
await apiGet(`/api/set_audio_prompt_fallback?enabled=${current ? 0 : 1}`);
|
|
await refreshAudioPromptPanel();
|
|
});
|
|
}
|
|
if (audioPromptRecordKey && audioPromptRecordFilename && audioPromptRecordText) {
|
|
audioPromptRecordKey.addEventListener("change", () => {
|
|
const key = audioPromptRecordKey.value || "";
|
|
audioPromptRecordFilename.value = promptFilenameForKey(key);
|
|
audioPromptRecordFilename.dataset.autofill = "1";
|
|
audioPromptRecordText.value = promptTextForKey(key);
|
|
audioPromptRecordText.dataset.autofill = "1";
|
|
});
|
|
audioPromptRecordFilename.addEventListener("input", () => {
|
|
audioPromptRecordFilename.dataset.autofill = "0";
|
|
});
|
|
audioPromptRecordText.addEventListener("input", () => {
|
|
audioPromptRecordText.dataset.autofill = "0";
|
|
});
|
|
}
|
|
if (audioPromptRecordStart && audioPromptRecordKey && audioPromptRecordFilename && audioPromptRecordText) {
|
|
audioPromptRecordStart.addEventListener("click", async () => {
|
|
const key = (audioPromptRecordKey.value || "").trim();
|
|
const filename = (audioPromptRecordFilename.value || "").trim();
|
|
const text = (audioPromptRecordText.value || "").trim();
|
|
if (!key) {
|
|
alert("Select a prompt key first.");
|
|
return;
|
|
}
|
|
if (!text) {
|
|
alert("Prompt text is required.");
|
|
return;
|
|
}
|
|
try {
|
|
await apiPostJson("/api/audio_prompt_record", { key, filename, text });
|
|
await refreshAudioPromptRecordStatus();
|
|
} catch (e) {
|
|
alert(`Prompt recording failed: ${e.message}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
const peopleUploadBtn = document.getElementById("people_upload_btn");
|
|
const peopleUploadFile = document.getElementById("people_upload_file");
|
|
const peopleResetBtn = document.getElementById("people_reset_btn");
|
|
if (peopleUploadBtn && peopleUploadFile) {
|
|
peopleUploadBtn.addEventListener("click", () => {
|
|
peopleUploadFile.dataset.personId = "";
|
|
peopleUploadFile.click();
|
|
});
|
|
peopleUploadFile.addEventListener("change", async () => {
|
|
const file = peopleUploadFile.files && peopleUploadFile.files[0];
|
|
if (!file) return;
|
|
const form = new FormData();
|
|
form.append("file", file, file.name);
|
|
if (peopleUploadFile.dataset.personId) {
|
|
form.append("person_id", peopleUploadFile.dataset.personId);
|
|
}
|
|
try {
|
|
await apiPostFormData("/api/upload_person", form);
|
|
await renderPeople();
|
|
} catch (e) {
|
|
alert(`People upload failed: ${e.message}`);
|
|
} finally {
|
|
peopleUploadFile.value = "";
|
|
delete peopleUploadFile.dataset.personId;
|
|
}
|
|
});
|
|
}
|
|
if (peopleResetBtn) {
|
|
peopleResetBtn.addEventListener("click", async () => {
|
|
if (!confirm("Delete all saved people profiles?")) return;
|
|
await apiGet("/api/reset_people");
|
|
await renderPeople();
|
|
});
|
|
}
|
|
|
|
document.getElementById("req_photo").addEventListener("click", async () => {
|
|
await apiGet("/api/request_photo");
|
|
});
|
|
document.getElementById("clear_photos").addEventListener("click", async () => {
|
|
if (!confirm("Delete ALL photos?")) return;
|
|
await apiGet("/api/clear_photos");
|
|
await renderPhotos();
|
|
});
|
|
document.getElementById("download_zip").addEventListener("click", async () => {
|
|
const r = await apiGet("/api/download_zip");
|
|
if (r.ok) window.location = `/${r.archive}`;
|
|
});
|
|
document.getElementById("upload_now").addEventListener("click", async () => {
|
|
await apiGet("/api/upload_now");
|
|
});
|
|
document.getElementById("scripts_fix_check").addEventListener("click", async () => {
|
|
const r = await apiGet("/api/run_scripts_fix?mode=--check");
|
|
alert(`RealSense check:\n${(r.stdout || []).join("\n") || "true"}`);
|
|
});
|
|
document.getElementById("scripts_fix_run").addEventListener("click", async () => {
|
|
if (!confirm("Run RealSense fix script?")) return;
|
|
const r = await apiGet("/api/run_scripts_fix?mode=--fix");
|
|
alert(`Fix output:\n${(r.stdout || []).join("\n")}`);
|
|
});
|
|
|
|
document.getElementById("toggle_preview").addEventListener("click", () => {
|
|
setPreviewEnabled(!window.__previewEnabled);
|
|
});
|
|
document.getElementById("camera_refresh_btn").addEventListener("click", async () => {
|
|
window.__cameraSourcesCache = null;
|
|
await refreshCameraPanel();
|
|
restartPreview();
|
|
});
|
|
document.getElementById("capture_now_btn").addEventListener("click", async () => {
|
|
const statusBox = document.getElementById("camera_status_box");
|
|
statusBox.textContent = "Capturing...";
|
|
try {
|
|
const resp = await apiGet("/api/capture");
|
|
statusBox.textContent = `Saved\n${resp.result || "OK"}`;
|
|
await renderPhotos();
|
|
await refreshCameraPanel();
|
|
} catch (e) {
|
|
statusBox.textContent = `Capture failed\n${e.message}`;
|
|
}
|
|
});
|
|
document.getElementById("camera_apply_btn").addEventListener("click", async () => {
|
|
const selector = document.getElementById("camera_source_select");
|
|
const statusBox = document.getElementById("camera_status_box");
|
|
const source = selector.value;
|
|
statusBox.textContent = `Switching camera to ${source}...`;
|
|
try {
|
|
await apiGet(`/api/set_camera_source?source=${encodeURIComponent(source)}`);
|
|
restartPreview();
|
|
await refreshCameraPanel();
|
|
} catch (e) {
|
|
statusBox.textContent = `Camera switch failed\n${e.message}`;
|
|
}
|
|
});
|
|
document.getElementById("camera_save_default_btn").addEventListener("click", async () => {
|
|
const selector = document.getElementById("camera_source_select");
|
|
const statusBox = document.getElementById("camera_status_box");
|
|
const camera = ((await apiGet("/api/camera_health")).camera) || {};
|
|
const cameraSources = window.__cameraSourcesCache || await apiGet("/api/camera_sources");
|
|
const serial = selectedCameraSerial(selector.value, cameraSources, camera);
|
|
if (!serial) {
|
|
statusBox.textContent = "Default camera save failed\nSelect a RealSense source first.";
|
|
return;
|
|
}
|
|
statusBox.textContent = `Saving preferred camera ${serial}...`;
|
|
try {
|
|
await apiGet(`/api/set_preferred_camera?serial=${encodeURIComponent(serial)}`);
|
|
window.__cameraSourcesCache = null;
|
|
await refreshCameraPanel();
|
|
statusBox.textContent = `Preferred camera saved\n${serial}`;
|
|
} catch (e) {
|
|
statusBox.textContent = `Default camera save failed\n${e.message}`;
|
|
}
|
|
});
|
|
document.getElementById("resolution_select").addEventListener("change", (e) => {
|
|
const value = e.target.value;
|
|
const match = value.match(/^(\d+)x(\d+)@(\d+)$/);
|
|
if (!match) return;
|
|
setResolutionInputs(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
});
|
|
document.getElementById("apply_resolution_btn").addEventListener("click", async () => {
|
|
const width = Number(document.getElementById("width_input").value);
|
|
const height = Number(document.getElementById("height_input").value);
|
|
const fps = Number(document.getElementById("fps_input").value);
|
|
const statusBox = document.getElementById("camera_status_box");
|
|
statusBox.textContent = `Applying resolution ${width}x${height}@${fps}...`;
|
|
try {
|
|
const resp = await apiGet(`/api/set_camera_resolution?width=${encodeURIComponent(width)}&height=${encodeURIComponent(height)}&fps=${encodeURIComponent(fps)}`);
|
|
const camera = resp.camera || {};
|
|
setResolutionInputs(camera.requested_width, camera.requested_height, camera.requested_fps);
|
|
restartPreview();
|
|
await refreshCameraPanel();
|
|
} catch (e) {
|
|
statusBox.textContent = `Resolution change failed\n${e.message}`;
|
|
}
|
|
});
|
|
|
|
if (mode === "ai") {
|
|
const backendButton = document.getElementById("det_backend_set");
|
|
if (backendButton) {
|
|
backendButton.addEventListener("click", async () => {
|
|
const v = document.getElementById("det_backend_select").value || "normal";
|
|
try {
|
|
await apiGet(`/api/set_detector_backend?backend=${encodeURIComponent(v)}`);
|
|
await renderControls();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
});
|
|
}
|
|
const aiSave = document.getElementById("opt_ai_save");
|
|
if (aiSave) {
|
|
aiSave.addEventListener("click", async () => {
|
|
const hard = document.getElementById("opt_hard_lock").checked ? 1 : 0;
|
|
const retake = document.getElementById("opt_retake_prompt").checked ? 1 : 0;
|
|
const greetEnabled = document.getElementById("opt_greeting_replay").checked ? 1 : 0;
|
|
const greetFile = document.getElementById("opt_greeting_replay_file").value || "";
|
|
const captureReplay = document.getElementById("opt_capture_replay").checked ? 1 : 0;
|
|
const faceRecognition = document.getElementById("opt_face_recognition").checked ? 1 : 0;
|
|
const faceThreshold = document.getElementById("opt_face_threshold").value || "0.88";
|
|
try {
|
|
await apiGet(
|
|
`/api/set_ai_options?hard_target_lock_enabled=${hard}&retake_prompt_enabled=${retake}` +
|
|
`&autonomous_greeting_replay_enabled=${greetEnabled}` +
|
|
`&autonomous_greeting_replay_file=${encodeURIComponent(greetFile)}` +
|
|
`&autonomous_capture_replay_enabled=${captureReplay}` +
|
|
`&face_recognition_enabled=${faceRecognition}` +
|
|
`&face_recognition_threshold=${encodeURIComponent(faceThreshold)}`
|
|
);
|
|
await renderControls();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
setPreviewEnabled(window.__previewEnabled);
|
|
await refreshCameraPanel();
|
|
await refreshReplayPanel();
|
|
await refreshAudioPromptPanel();
|
|
await refreshAudioPromptRecordStatus();
|
|
await refreshAutonomousPanel();
|
|
await refreshAiReadinessPanel();
|
|
await refreshRuntimeHealthPanel();
|
|
await renderPeople();
|
|
|
|
if (window.__dashboardPoll) clearInterval(window.__dashboardPoll);
|
|
window.__peoplePollCounter = 0;
|
|
window.__dashboardPoll = setInterval(async () => {
|
|
await refreshCameraPanel().catch(() => {});
|
|
await refreshReplayPanel().catch(() => {});
|
|
await refreshAutonomousPanel().catch(() => {});
|
|
await refreshAiReadinessPanel().catch(() => {});
|
|
await refreshRuntimeHealthPanel().catch(() => {});
|
|
window.__peoplePollCounter += 1;
|
|
if (window.__peoplePollCounter % 4 === 0) {
|
|
await renderPeople().catch(() => {});
|
|
}
|
|
if (window.__peoplePollCounter % 8 === 0) {
|
|
await refreshAudioPromptPanel().catch(() => {});
|
|
}
|
|
await refreshAudioPromptRecordStatus().catch(() => {});
|
|
}, 1500);
|
|
}
|
|
|
|
async function renderPhotos() {
|
|
const list = document.getElementById("list");
|
|
if (!list) return;
|
|
list.innerHTML = "";
|
|
const res = await apiGet("/api/photos");
|
|
const photos = res.photos || [];
|
|
if (!photos.length) {
|
|
list.innerHTML = '<div class="panel"><i>No photos yet.</i></div>';
|
|
return;
|
|
}
|
|
for (const photo of photos) {
|
|
const card = el("article", "card");
|
|
card.innerHTML = `
|
|
<img class="thumb" src="/${encodeURIComponent(photo.name)}?t=${Date.now()}" alt="${escapeHtml(photo.name)}">
|
|
<div class="card-body">
|
|
<div class="name">${escapeHtml(photo.name)}</div>
|
|
<div class="photo-meta">${formatSize(photo.size || 0)}<br>${formatTime(photo.mtime || 0)}</div>
|
|
<div class="actions">
|
|
<a href="/${encodeURIComponent(photo.name)}" target="_blank">Open</a>
|
|
<a href="#" data-name="${encodeURIComponent(photo.name)}" class="del">Delete</a>
|
|
<a href="/api/reupload?name=${encodeURIComponent(photo.name)}">Reupload</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
card.querySelector(".thumb").addEventListener("click", () => openPreview(photo.name));
|
|
list.appendChild(card);
|
|
}
|
|
|
|
document.querySelectorAll("a.del").forEach((anchor) => {
|
|
anchor.addEventListener("click", async (ev) => {
|
|
ev.preventDefault();
|
|
const name = anchor.getAttribute("data-name");
|
|
if (!confirm("Delete this photo?")) return;
|
|
await apiGet(`/api/delete?name=${name}`);
|
|
await renderPhotos();
|
|
});
|
|
});
|
|
}
|
|
|
|
function openPreview(name) {
|
|
const modal = document.getElementById("preview_modal");
|
|
const img = document.getElementById("modal_img");
|
|
const actions = document.getElementById("modal_actions");
|
|
img.src = `/${encodeURIComponent(name)}`;
|
|
actions.innerHTML = `
|
|
<a href="/${encodeURIComponent(name)}" target="_blank">Open / Download</a>
|
|
<a href="/api/reupload?name=${encodeURIComponent(name)}">Reupload</a>
|
|
<a href="#" id="modal_delete">Delete</a>
|
|
`;
|
|
document.getElementById("modal_delete").addEventListener("click", async (ev) => {
|
|
ev.preventDefault();
|
|
if (!confirm("Delete this photo?")) return;
|
|
await apiGet(`/api/delete?name=${encodeURIComponent(name)}`);
|
|
closeModal();
|
|
await renderPhotos();
|
|
});
|
|
modal.style.display = "flex";
|
|
}
|
|
|
|
function closeModal() {
|
|
const modal = document.getElementById("preview_modal");
|
|
if (modal) modal.style.display = "none";
|
|
}
|
|
|
|
window.addEventListener("load", () => {
|
|
bindTopActions();
|
|
const modalClose = document.getElementById("modal_close");
|
|
if (modalClose) modalClose.addEventListener("click", closeModal);
|
|
renderControls().catch((e) => {
|
|
const ctrl = document.getElementById("controls");
|
|
if (ctrl) ctrl.innerHTML = `<div class="panel"><div class="status-box">Dashboard load failed\n${escapeHtml(e.message)}</div></div>`;
|
|
});
|
|
renderPhotos().catch(() => {});
|
|
});
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
if (window.__dashboardPoll) clearInterval(window.__dashboardPoll);
|
|
if (window.__previewEnabled) setPreviewEnabled(false);
|
|
});
|
|
|
|
async function openScriptsModal() {
|
|
const modal = document.getElementById("scripts_modal");
|
|
const list = document.getElementById("scripts_list");
|
|
const editor = document.getElementById("script_editor");
|
|
const fname = document.getElementById("script_filename");
|
|
list.innerHTML = "Loading...";
|
|
try {
|
|
const r = await apiGet("/api/scripts");
|
|
if ((r.scripts || []).length === 0) {
|
|
list.innerHTML = "<i>No scripts</i>";
|
|
} else {
|
|
list.innerHTML = "";
|
|
r.scripts.forEach((s) => {
|
|
const row = document.createElement("div");
|
|
row.className = "script-row";
|
|
row.innerHTML = `<b>${escapeHtml(s)}</b> <button class="btn-secondary btn-small" data-name="${escapeHtml(s)}" data-act="set">Set</button> <button class="btn-secondary btn-small" data-name="${escapeHtml(s)}" data-act="edit">Edit</button> <button class="btn-danger btn-small" data-name="${escapeHtml(s)}" data-act="del">Delete</button>`;
|
|
list.appendChild(row);
|
|
});
|
|
list.querySelectorAll("button").forEach((btn) => {
|
|
btn.addEventListener("click", async () => {
|
|
const act = btn.getAttribute("data-act");
|
|
const name = btn.getAttribute("data-name");
|
|
if (act === "set") {
|
|
await fetch("/api/set_sanad_script", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
|
|
alert(`Set as active: ${name}`);
|
|
} else if (act === "del") {
|
|
if (!confirm(`Delete script ${name}?`)) return;
|
|
await fetch("/api/delete_script", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
|
|
await openScriptsModal();
|
|
} else if (act === "edit") {
|
|
const r = await apiGet(`/api/script_content?name=${encodeURIComponent(name)}`);
|
|
if (r.ok) {
|
|
editor.value = r.script || "";
|
|
fname.value = name;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
} catch (_e) {
|
|
list.innerHTML = "<i>Error</i>";
|
|
}
|
|
|
|
try {
|
|
const a = await apiGet("/api/sanad_script");
|
|
if (a.ok) editor.value = a.script || "";
|
|
} catch (_e) {}
|
|
|
|
document.getElementById("scripts_modal_close").onclick = () => {
|
|
modal.style.display = "none";
|
|
};
|
|
document.getElementById("script_upload").onclick = async () => {
|
|
const name = fname.value.trim();
|
|
const script = editor.value || "";
|
|
if (!name) {
|
|
alert("Enter filename");
|
|
return;
|
|
}
|
|
await fetch("/api/upload_script", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: name, script }) });
|
|
await openScriptsModal();
|
|
};
|
|
document.getElementById("script_save").onclick = async () => {
|
|
await fetch("/api/sanad_script", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ script: editor.value || "" }) });
|
|
alert("Saved active SANAD script");
|
|
};
|
|
|
|
modal.style.display = "flex";
|
|
}
|