// ── Tab switching ───────────────────────────────────────────────────────────── const tabButtons = Array.from(document.querySelectorAll(".tab-button")); const tabPanels = Array.from(document.querySelectorAll(".tab-panel")); function activateTab(tabId) { tabButtons.forEach((btn) => { const active = btn.dataset.tabTarget === tabId; btn.classList.toggle("active", active); btn.setAttribute("aria-selected", String(active)); }); tabPanels.forEach((panel) => panel.classList.toggle("hidden", panel.id !== tabId)); } tabButtons.forEach((btn) => btn.addEventListener("click", () => activateTab(btn.dataset.tabTarget))); // ── Classroom tab (ClassroomPipeline → /api/classroom/process) ─────────────── const classroomForm = document.getElementById("classroom-form"); const classroomFileInput = document.getElementById("classroom-video-input"); const classroomFileLabel = document.getElementById("classroom-file-label"); const classroomStatus = document.getElementById("classroom-status"); const classroomResults = document.getElementById("classroom-results"); const classroomMetrics = document.getElementById("classroom-metrics"); const classroomSummaryLink = document.getElementById("classroom-summary-link"); const classroomCsvLink = document.getElementById("classroom-csv-link"); const classroomClassBar = document.getElementById("classroom-class-bar"); const classroomStudents = document.getElementById("classroom-students"); function selectedFileText(files, fallback) { if (!files || !files.length) return fallback; return files.length === 1 ? files[0].name : `${files[0].name} + ${files.length - 1} more`; } classroomFileInput.addEventListener("change", () => { classroomFileLabel.textContent = selectedFileText(classroomFileInput.files, "Choose a classroom video (up to 60 min)"); }); const ACTION_COLORS = { "Attentive": "#22c55e", "Writing": "#3b82f6", "Talking": "#f59e0b", "On Phone": "#ef4444", "Sleeping": "#8b5cf6", "Distracted": "#f97316", }; function actionColor(a) { return ACTION_COLORS[a] || "#94a3b8"; } classroomForm.addEventListener("submit", async (e) => { e.preventDefault(); if (!classroomFileInput.files.length) { classroomStatus.textContent = "Choose a video file first."; classroomStatus.classList.add("error"); return; } const btn = classroomForm.querySelector("button[type='submit']"); const payload = new FormData(); payload.append("video", classroomFileInput.files[0]); classroomResults.classList.add("hidden"); classroomStatus.classList.remove("error"); classroomStatus.textContent = "Analysing classroom — this can take several minutes for long videos..."; btn.disabled = true; try { const resp = await fetch("/api/classroom/process", { method: "POST", body: payload }); const data = await resp.json(); if (!data.ok) throw new Error(data.error || "Unknown error"); const s = data.summary; classroomStatus.textContent = `Done — ${s.student_count} students across ${s.total_windows} windows (${s.duration_seconds}s).`; classroomMetrics.innerHTML = [ ["Students", s.student_count], ["Windows", s.total_windows], ["Attentive", `${s.class_attentive_pct}%`], ["Duration", `${Math.round(s.duration_seconds / 60)}m`], ].map(([l, v]) => `
${l}${v ?? "-"}
`).join(""); classroomSummaryLink.href = data.download_urls.summary_json; classroomCsvLink.href = data.download_urls.csv; const actionTotals = {}; let totalObs = 0; for (const student of (s.students || [])) for (const win of (student.timeline || [])) { actionTotals[win.action] = (actionTotals[win.action] || 0) + 1; totalObs++; } const barSegs = Object.entries(actionTotals).sort((a, b) => b[1] - a[1]) .map(([action, count]) => { const pct = totalObs ? (count / totalObs * 100).toFixed(1) : 0; return `
${action} ${pct}%
`; }).join(""); classroomClassBar.innerHTML = `

Class-wide action distribution

${barSegs}
`; renderClassroomStudents(s.students || []); classroomResults.classList.remove("hidden"); } catch (err) { classroomStatus.textContent = `Error: ${err.message}`; classroomStatus.classList.add("error"); } finally { btn.disabled = false; } }); function renderClassroomStudents(students) { if (!students.length) { classroomStudents.innerHTML = "

No students detected.

"; return; } classroomStudents.innerHTML = students.map((student) => { const breakdownBars = Object.entries(student.action_breakdown || {}).sort((a, b) => b[1] - a[1]) .map(([action, pct]) => `
`).join(""); const timelineHtml = (student.timeline || []).map((win) => { const mm = Math.floor(win.window_start_seconds / 60).toString().padStart(2, "0"); const ss = Math.floor(win.window_start_seconds % 60).toString().padStart(2, "0"); return `
${mm}:${ss} ${win.action} ${win.emotion || ""} · ${(win.concentration_pct || 0).toFixed(0)}% conc
${win.clip_url ? `` : `
no clip
`}
`; }).join(""); const attColor = student.attentive_pct >= 70 ? "#22c55e" : student.attentive_pct >= 40 ? "#f59e0b" : "#ef4444"; return `
${student.student_label} ${student.dominant_action} ${student.attentive_pct}% attentive ${student.windows_seen} windows
${breakdownBars}
${timelineHtml}
`; }).join(""); } // ── Attendance tab ───────────────────────────────────────────────────────────── const enrollForm = document.getElementById("enroll-form"); const studentNameInput = document.getElementById("student-name-input"); const enrollMediaInput = document.getElementById("enroll-media-input"); const enrollMediaLabel = document.getElementById("enroll-media-label"); const enrollStatus = document.getElementById("enroll-status"); const enrollResult = document.getElementById("enroll-result"); const markForm = document.getElementById("mark-form"); const classroomPhotoInput = document.getElementById("classroom-photo-input"); const classroomPhotoLabel = document.getElementById("classroom-photo-label"); const markStatus = document.getElementById("mark-status"); const markResult = document.getElementById("mark-result"); const markedPhotoPreview= document.getElementById("marked-photo-preview"); const recognizedList = document.getElementById("recognized-list"); const attendanceLogList = document.getElementById("attendance-log-list"); const rosterList = document.getElementById("roster-list"); const attendanceLogSummary = document.getElementById("attendance-log-summary"); enrollMediaInput.addEventListener("change", () => { enrollMediaLabel.textContent = selectedFileText(enrollMediaInput.files, "Choose photos or videos for enrollment"); }); classroomPhotoInput.addEventListener("change", () => { classroomPhotoLabel.textContent = selectedFileText(classroomPhotoInput.files, "Choose a classroom photo"); }); // Enrollment tab toggle let enrollTab = "files"; function switchEnrollTab(tab) { enrollTab = tab; document.getElementById("enroll-tab-files").style.display = tab === "files" ? "" : "none"; document.getElementById("enroll-tab-folder").style.display = tab === "folder" ? "" : "none"; document.getElementById("tab-files").classList.toggle("tab-active", tab === "files"); document.getElementById("tab-folder").classList.toggle("tab-active", tab === "folder"); } enrollForm.addEventListener("submit", async (event) => { event.preventDefault(); const studentName = studentNameInput.value.trim(); if (!studentName) { enrollStatus.textContent = "Enter a student name."; enrollStatus.classList.add("error"); return; } const btn = enrollForm.querySelector("button[type='submit']"); enrollStatus.classList.remove("error"); enrollStatus.textContent = "Extracting embeddings and saving the student..."; btn.disabled = true; try { let response, data; if (enrollTab === "folder") { const folderPath = document.getElementById("enroll-folder-input").value.trim(); if (!folderPath) { enrollStatus.textContent = "Enter a folder path."; enrollStatus.classList.add("error"); btn.disabled = false; return; } response = await fetch("/api/attendance/enroll-folder", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ student_name: studentName, folder_path: folderPath }), }); data = await response.json(); if (data.ok) enrollStatus.textContent = `Enrolled ${data.student.name} from ${data.files_used} file(s).`; } else { if (!enrollMediaInput.files.length) { enrollStatus.textContent = "Upload at least one photo or video."; enrollStatus.classList.add("error"); btn.disabled = false; return; } const payload = new FormData(); payload.append("student_name", studentName); Array.from(enrollMediaInput.files).forEach((f) => payload.append("media", f)); response = await fetch("/api/attendance/enroll", { method: "POST", body: payload }); data = await response.json(); if (data.ok) enrollStatus.textContent = `Enrolled ${data.student.name} successfully.`; } if (!response.ok || !data.ok) throw new Error(data.error || "Enrollment failed."); renderRoster(data.students || []); renderEnrollmentResult(data.student, data.media_samples || []); await refreshAttendanceSummary(); } catch (err) { enrollStatus.textContent = err.message; enrollStatus.classList.add("error"); } finally { btn.disabled = false; } }); markForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!classroomPhotoInput.files.length) { markStatus.textContent = "Upload a classroom photo first."; markStatus.classList.add("error"); return; } const btn = markForm.querySelector("button[type='submit']"); const payload = new FormData(); payload.append("photo", classroomPhotoInput.files[0]); markStatus.classList.remove("error"); markStatus.textContent = "Detecting faces and marking attendance..."; btn.disabled = true; try { const response = await fetch("/api/attendance/mark", { method: "POST", body: payload }); const data = await response.json(); if (!response.ok || !data.ok) throw new Error(data.error || "Attendance marking failed."); renderMarkedPhoto(data.marked_url); renderRecognizedFaces(data.recognized || [], data.unknown_faces || 0); renderAttendanceLog(data.attendance_log || []); renderRoster(data.roster || []); markStatus.textContent = `Marked ${data.recognized.length} student${data.recognized.length === 1 ? "" : "s"}.`; markResult.classList.remove("hidden"); await refreshAttendanceSummary(); } catch (err) { markStatus.textContent = err.message; markStatus.classList.add("error"); } finally { btn.disabled = false; } }); async function refreshAttendanceSummary() { try { const response = await fetch("/api/attendance/roster"); const data = await response.json(); if (response.ok && data.ok) { renderRoster(data.students || []); renderAttendanceSummary(data.attendance || []); } } catch (e) { console.error(e); } } function renderEnrollmentResult(student, mediaSamples) { if (!student) { enrollResult.classList.add("hidden"); return; } enrollResult.classList.remove("hidden"); enrollResult.innerHTML = `
${student.name} enrolled — ${student.observations ?? 0} embeddings
${mediaSamples.map((s) => `${s.file_name} (${s.frame_samples} frames)`).join(", ")}
`; } function renderMarkedPhoto(url) { if (!url) { markResult.classList.add("hidden"); return; } markResult.classList.remove("hidden"); markedPhotoPreview.src = url; } function renderRecognizedFaces(recognized, unknownFaces) { recognizedList.innerHTML = `

Recognized faces

${recognized.length ? recognized.map((e) => `
${e.student.name}Confidence ${formatNumber(e.confidence)}
`).join("") : '
No enrolled students recognized.
'}
Unknown faces: ${unknownFaces}
`; } function renderAttendanceLog(log) { attendanceLogList.innerHTML = `

Attendance log

${log.map((e) => `
${e.student_name}${e.recognized_at} · ${formatNumber(e.confidence)}
`).join("")}`; } function renderAttendanceSummary(log) { attendanceLogSummary.innerHTML = `

Recent attendance

${log.map((e) => `
${e.student_name}${e.recognized_at} · ${formatNumber(e.confidence)}
`).join("")}`; } function renderRoster(students) { if (!students.length) { rosterList.innerHTML = '
No students enrolled yet.
'; return; } rosterList.innerHTML = students.map((s) => `
${s.name} ${s.observations ?? 0} embeddings · ${s.updated_at ?? "-"}
`).join(""); } rosterList.addEventListener("click", async (event) => { const btn = event.target.closest(".delete-student-button"); if (!btn) return; const studentId = btn.dataset.studentId; const name = btn.closest(".roster-item")?.querySelector("strong")?.textContent || "this student"; if (!confirm(`Delete ${name}? This removes the student and their attendance records.`)) return; btn.disabled = true; btn.textContent = "Deleting..."; try { const response = await fetch(`/api/attendance/students/${encodeURIComponent(studentId)}`, { method: "DELETE" }); const data = await response.json(); if (!response.ok || !data.ok) throw new Error(data.error || "Delete failed."); renderRoster(data.students || []); renderAttendanceSummary(data.attendance || []); await refreshAttendanceSummary(); } catch (err) { alert(err.message); } finally { btn.disabled = false; btn.textContent = "Delete"; } }); // ── Lightbox ────────────────────────────────────────────────────────────────── const lightbox = document.getElementById("photo-lightbox"); const lightboxImg = document.getElementById("lightbox-img"); const lightboxClose = document.getElementById("lightbox-close"); markedPhotoPreview.addEventListener("click", () => { lightboxImg.src = markedPhotoPreview.src; lightbox.classList.remove("hidden"); }); lightboxClose.addEventListener("click", () => lightbox.classList.add("hidden")); lightbox.addEventListener("click", (e) => { if (e.target === lightbox) lightbox.classList.add("hidden"); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") lightbox.classList.add("hidden"); }); // ── Helpers ─────────────────────────────────────────────────────────────────── function formatWindow(s) { const n = Number(s); return isNaN(n) ? "-" : `${n.toFixed(2)}s`; } function formatNumber(v) { if (v == null || isNaN(Number(v))) return "-"; return Number(v).toFixed(4); } refreshAttendanceSummary();