// ── 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));
// Refresh the roster in case a student was just enrolled from the other tab.
if (tabId === "attendance-tab") refreshAttendanceSummary();
}
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 `
${win.clip_url
? `
`
: `
no clip
`}
`;
}).join("");
const attColor = student.attentive_pct >= 70 ? "#22c55e" : student.attentive_pct >= 40 ? "#f59e0b" : "#ef4444";
const idLabel = student.recognized_name
? `${student.recognized_name} ${student.student_label} `
: `${student.student_label} `;
return `
${idLabel}
${student.dominant_action}
${student.attentive_pct}% attentive
${student.windows_seen} windows
${breakdownBars}
${timelineHtml}
`;
}).join("");
}
// ── Attendance tab: classroom picker ─────────────────────────────────────────
const classroomSelect = document.getElementById("classroom-select");
const rosterClassroomLabel = document.getElementById("roster-classroom-label");
let currentClassroomId = null;
function updateRosterClassroomLabel() {
const label = classroomSelect.options[classroomSelect.selectedIndex]?.textContent || "";
rosterClassroomLabel.textContent = label;
}
async function loadClassrooms() {
try {
const response = await fetch("/api/attendance/classrooms");
const data = await response.json();
if (!data.ok || !data.classrooms.length) throw new Error(data.error || "No classrooms available.");
classroomSelect.innerHTML = data.classrooms.map((c) => `${c.label} `).join("");
const saved = localStorage.getItem("prism_classroom");
currentClassroomId = (saved && data.classrooms.some((c) => c.id === saved)) ? saved : data.classrooms[0].id;
classroomSelect.value = currentClassroomId;
updateRosterClassroomLabel();
await refreshAttendanceSummary();
} catch (err) {
console.error("Failed to load classrooms:", err);
}
}
classroomSelect.addEventListener("change", async () => {
currentClassroomId = classroomSelect.value;
localStorage.setItem("prism_classroom", currentClassroomId);
updateRosterClassroomLabel();
markResult.classList.add("hidden");
await refreshAttendanceSummary();
if (!enrollQrPanel.classList.contains("hidden")) await loadEnrollQr();
});
// ── Attendance tab: enrollment QR (students join via cloudflared tunnel) ────
const enrollQrToggle = document.getElementById("enroll-qr-toggle");
const enrollQrPanel = document.getElementById("enroll-qr-panel");
const enrollQrBody = document.getElementById("enroll-qr-body");
async function loadEnrollQr() {
enrollQrBody.innerHTML = `Loading...
`;
try {
const response = await fetch(`/api/enroll-url?classroom=${encodeURIComponent(currentClassroomId)}`);
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || "No enrollment session running.");
enrollQrBody.innerHTML = `
${data.url}
Students scan this to enroll themselves into ${classroomSelect.options[classroomSelect.selectedIndex]?.textContent || "this classroom"} — no Wi-Fi match needed, works over their own mobile data.
`;
} catch (err) {
enrollQrBody.innerHTML = `${err.message} Start it with python start_enrollment_session.py on this laptop, then reopen this panel.
`;
}
}
enrollQrToggle.addEventListener("click", async () => {
const opening = enrollQrPanel.classList.contains("hidden");
enrollQrPanel.classList.toggle("hidden", !opening);
enrollQrToggle.textContent = opening ? "Hide enrollment QR" : "Show enrollment QR";
if (opening) await loadEnrollQr();
});
// ── Enroll Student tab: its own independent classroom picker ────────────────
const enrollClassroomSelect = document.getElementById("enroll-classroom-select");
let currentEnrollClassroomId = null;
async function loadEnrollClassrooms() {
try {
const response = await fetch("/api/attendance/classrooms");
const data = await response.json();
if (!data.ok || !data.classrooms.length) throw new Error(data.error || "No classrooms available.");
enrollClassroomSelect.innerHTML = data.classrooms.map((c) => `${c.label} `).join("");
const saved = localStorage.getItem("prism_classroom");
currentEnrollClassroomId = (saved && data.classrooms.some((c) => c.id === saved)) ? saved : data.classrooms[0].id;
enrollClassroomSelect.value = currentEnrollClassroomId;
} catch (err) {
console.error("Failed to load classrooms:", err);
}
}
enrollClassroomSelect.addEventListener("change", () => {
currentEnrollClassroomId = enrollClassroomSelect.value;
localStorage.setItem("prism_classroom", currentEnrollClassroomId);
});
// ── Enroll Student 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 markedPhotoGallery = document.getElementById("marked-photo-gallery");
const presentList = document.getElementById("present-list");
const suspiciousList = document.getElementById("suspicious-list");
const absentList = document.getElementById("absent-list");
const rosterList = document.getElementById("roster-list");
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";
let enrollCameraRecorder = null;
function switchEnrollTab(tab) {
const previousTab = enrollTab;
enrollTab = tab;
document.getElementById("enroll-tab-files").style.display = tab === "files" ? "" : "none";
document.getElementById("enroll-tab-folder").style.display = tab === "folder" ? "" : "none";
document.getElementById("enroll-tab-camera").style.display = tab === "camera" ? "" : "none";
document.getElementById("tab-files").classList.toggle("tab-active", tab === "files");
document.getElementById("tab-folder").classList.toggle("tab-active", tab === "folder");
document.getElementById("tab-camera").classList.toggle("tab-active", tab === "camera");
if (tab === "camera" && !enrollCameraRecorder) {
enrollCameraRecorder = CameraRecorder.create(document.getElementById("enroll-camera-recorder"));
}
if (previousTab === "camera" && tab !== "camera" && enrollCameraRecorder) {
enrollCameraRecorder.stopStream();
}
}
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({ classroom: currentEnrollClassroomId, 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 (enrollTab === "camera") {
const blob = enrollCameraRecorder && enrollCameraRecorder.getBlob();
if (!blob) { enrollStatus.textContent = "Record a video first."; enrollStatus.classList.add("error"); btn.disabled = false; return; }
const payload = new FormData();
payload.append("classroom", currentEnrollClassroomId);
payload.append("student_name", studentName);
payload.append("media", blob, "recording.webm");
response = await fetch("/api/attendance/enroll", { method: "POST", body: payload });
data = await response.json();
if (data.ok) { enrollStatus.textContent = `Enrolled ${data.student.name} successfully.`; enrollCameraRecorder.reset(); }
} 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("classroom", currentEnrollClassroomId);
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.");
renderEnrollmentResult(data.student, data.media_samples || []);
if (currentEnrollClassroomId === currentClassroomId) 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 at least one classroom photo first."; markStatus.classList.add("error"); return; }
const btn = markForm.querySelector("button[type='submit']");
const payload = new FormData();
payload.append("classroom", currentClassroomId);
Array.from(classroomPhotoInput.files).forEach((f) => payload.append("photos", f));
markStatus.classList.remove("error");
markStatus.textContent = classroomPhotoInput.files.length > 1
? `Detecting faces across ${classroomPhotoInput.files.length} photos and marking attendance...`
: "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.");
renderMarkedPhotos(data.photos || []);
renderAttendanceBuckets(data.present || [], data.suspicious || [], data.absent || [], data.unknown_faces || 0, data.unknown_faces_detail || []);
renderRoster(data.roster || []);
markStatus.textContent = `${data.present.length} present, ${data.suspicious.length} suspicious, ${data.absent.length} absent.`;
markResult.classList.remove("hidden");
} catch (err) {
markStatus.textContent = err.message;
markStatus.classList.add("error");
} finally { btn.disabled = false; }
});
document.getElementById("demo-preview-btn").addEventListener("click", () => {
markStatus.classList.remove("error");
markStatus.textContent = "Demo classroom photo — original, no annotations.";
markedPhotoGallery.innerHTML = ` `;
markResult.classList.remove("hidden");
presentList.innerHTML = "";
suspiciousList.innerHTML = "";
absentList.innerHTML = "";
hideUnknownFacesUI();
});
document.getElementById("demo-btn").addEventListener("click", async () => {
const btn = document.getElementById("demo-btn");
btn.disabled = true;
markStatus.classList.remove("error");
// Step 1 — show original unannotated image immediately
markResult.classList.remove("hidden");
markedPhotoGallery.innerHTML = ` `;
markStatus.textContent = "Here's the demo classroom photo. Running attendance pipeline...";
// Step 2 — run the pipeline
try {
const response = await fetch(`/api/attendance/demo?classroom=${encodeURIComponent(currentClassroomId)}`, { method: "POST" });
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || "Demo failed.");
renderMarkedPhotos([{ marked_url: data.marked_url, clean_url: data.clean_url }]);
renderAttendanceBuckets(data.present || [], data.suspicious || [], data.absent || [], data.unknown_faces || 0, data.unknown_faces_detail || []);
renderRoster(data.roster || []);
markStatus.textContent = `Demo complete — ${data.present.length} present, ${data.suspicious.length} suspicious, ${data.absent.length} absent.`;
} catch (err) {
markStatus.textContent = err.message;
markStatus.classList.add("error");
} finally { btn.disabled = false; }
});
async function refreshAttendanceSummary() {
if (!currentClassroomId) return;
try {
const response = await fetch(`/api/attendance/roster?classroom=${encodeURIComponent(currentClassroomId)}`);
const data = await response.json();
if (response.ok && data.ok) renderRoster(data.students || []);
} 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 renderMarkedPhotos(photos) {
if (!photos || !photos.length) { markResult.classList.add("hidden"); return; }
markResult.classList.remove("hidden");
markedPhotoGallery.innerHTML = photos
.map((p, i) => ` `)
.join("");
currentCleanPhotoUrls = photos.map((p) => p.clean_url);
cleanPhotoImageCache = {};
}
const unknownFacesToggle = document.getElementById("unknown-faces-toggle");
const unknownFacesGrid = document.getElementById("unknown-faces-grid");
let currentUnknownFaces = [];
let unknownFacesExpanded = false;
let currentPresentFaces = [];
function renderAttendanceBuckets(present, suspicious, absent, unknownFaces, unknownFacesDetail) {
currentPresentFaces = present.map((e) => ({ bbox: e.bbox, photoIndex: e.photo_index ?? 0 }));
presentList.innerHTML = `Present (${present.length})
${present.length
? present.map((e, i) => `
${e.student.name}
Confidence ${formatNumber(e.confidence)}
Show face
`).join("")
: 'No students confidently recognized.
'}`;
suspiciousList.innerHTML = `Suspicious (${suspicious.length})
${suspicious.length
? suspicious.map((e) => `
${e.student.name}
Confidence ${formatNumber(e.confidence)} — please verify
Show face
Yes, it's them
Not them
`).join("")
: 'None.
'}`;
absentList.innerHTML = `Absent (${absent.length})
${absent.length
? absent.map((s) => `${s.name}
`).join("")
: 'Everyone enrolled was seen.
'}`;
currentUnknownFaces = unknownFacesDetail || [];
unknownFacesExpanded = false;
unknownFacesGrid.classList.add("hidden");
unknownFacesGrid.innerHTML = "";
if (currentUnknownFaces.length) {
unknownFacesToggle.classList.remove("hidden");
unknownFacesToggle.textContent = `Show unknown faces (${currentUnknownFaces.length})`;
} else {
unknownFacesToggle.classList.add("hidden");
}
}
function addToAbsentList(name) {
if ([...absentList.querySelectorAll(".absent-item strong")].some((el) => el.textContent === name)) return;
const muted = absentList.querySelector(".muted");
if (muted) muted.remove();
absentList.insertAdjacentHTML("beforeend", `${name}
`);
const h3 = absentList.querySelector("h3");
if (h3) h3.textContent = `Absent (${absentList.querySelectorAll(".absent-item").length})`;
}
function removeFromAbsentList(name) {
const match = [...absentList.querySelectorAll(".absent-item")].find((el) => el.querySelector("strong")?.textContent === name);
if (match) match.remove();
const count = absentList.querySelectorAll(".absent-item").length;
const h3 = absentList.querySelector("h3");
if (h3) h3.textContent = `Absent (${count})`;
if (count === 0 && !absentList.querySelector(".muted")) {
absentList.insertAdjacentHTML("beforeend", 'Everyone enrolled was seen.
');
}
}
function addPresentEntry(student, confidence, bbox, photoIndex) {
const muted = presentList.querySelector(".muted");
if (muted) muted.remove();
const index = currentPresentFaces.length;
currentPresentFaces.push({ bbox, photoIndex: photoIndex ?? 0 });
presentList.insertAdjacentHTML("beforeend", `
${student.name}
Confidence ${formatNumber(confidence)}
Show face
`);
const h3 = presentList.querySelector("h3");
if (h3) h3.textContent = `Present (${presentList.querySelectorAll(".present-item").length})`;
}
function addSuspiciousEntry(newSuspicious) {
const muted = suspiciousList.querySelector(".muted");
if (muted) muted.remove();
suspiciousList.insertAdjacentHTML("beforeend", `
${newSuspicious.student.name}
Confidence ${formatNumber(newSuspicious.confidence)} — please verify
Show face
Yes, it's them
Not them
`);
const h3 = suspiciousList.querySelector("h3");
if (h3) h3.textContent = `Suspicious (${suspiciousList.querySelectorAll(".suspicious-item").length})`;
}
async function addUnknownEntry(newUnknown) {
currentUnknownFaces.push({
bbox: newUnknown.bbox,
similarity: newUnknown.similarity,
photo_index: newUnknown.photo_index,
review_id: newUnknown.review_id,
});
unknownFacesToggle.classList.remove("hidden");
unknownFacesToggle.textContent = unknownFacesExpanded
? `Hide unknown faces (${currentUnknownFaces.length})`
: `Show unknown faces (${currentUnknownFaces.length})`;
if (unknownFacesExpanded) await renderUnknownFacesGrid();
}
// Confirming reinforces the model: the embedding that triggered the suspicious
// match gets added to that student's gallery (same as an automatic high-
// confidence match would). Rejecting doesn't just discard the face — it gets
// re-matched against the roster excluding the rejected student, and lands in
// Present/Suspicious/Unknown depending on what that re-match finds, while the
// wrongly-suggested student drops to Absent (unless seen elsewhere).
suspiciousList.addEventListener("click", async (event) => {
const showBtn = event.target.closest(".show-face-btn");
if (showBtn) {
const item = showBtn.closest(".suspicious-item");
const bbox = JSON.parse(item.dataset.bbox);
const photoIndex = Number(item.dataset.photoIndex || 0);
const slot = item.querySelector(".face-reveal");
if (!slot.querySelector("img")) {
try {
const dataUrl = cropFaceThumbnail(imageAsCanvas(await getCleanPhotoImage(photoIndex)), bbox);
slot.innerHTML = ` `;
} catch (err) {
slot.innerHTML = `${err.message} `;
slot.classList.remove("hidden");
showBtn.textContent = "Hide face";
return;
}
}
const wasVisible = !slot.classList.contains("hidden");
slot.classList.toggle("hidden", wasVisible);
showBtn.textContent = wasVisible ? "Show face" : "Hide face";
return;
}
const confirmBtn = event.target.closest(".suspicious-confirm-btn");
const rejectBtn = event.target.closest(".suspicious-reject-btn");
const btn = confirmBtn || rejectBtn;
if (!btn) return;
const confirmed = !!confirmBtn;
const item = btn.closest(".suspicious-item");
const reviewId = btn.dataset.reviewId;
const name = item.querySelector("strong")?.textContent || "this student";
item.querySelectorAll("button").forEach((b) => (b.disabled = true));
try {
const response = await fetch("/api/attendance/suspicious/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classroom: currentClassroomId, review_id: reviewId, confirmed }),
});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || "Failed to resolve.");
item.classList.remove("suspicious-item");
item.classList.add(confirmed ? "present-item" : "absent-item");
if (confirmed) {
item.innerHTML = `${name} Confirmed — added to their gallery. `;
await refreshAttendanceSummary();
return;
}
item.innerHTML = `${name} Not them — re-checked against the rest of the roster. `;
const stillPresent = [...presentList.querySelectorAll(".present-item strong")].some((el) => el.textContent === name);
if (!stillPresent) addToAbsentList(name);
if (data.outcome === "present" && data.new_match) {
addPresentEntry(data.new_match.student, data.new_match.confidence, data.new_match.bbox, data.new_match.photo_index);
removeFromAbsentList(data.new_match.student.name);
} else if (data.outcome === "suspicious" && data.new_suspicious) {
addSuspiciousEntry(data.new_suspicious);
} else if (data.outcome === "unknown" && data.new_unknown) {
await addUnknownEntry(data.new_unknown);
}
await refreshAttendanceSummary();
} catch (err) {
alert(err.message);
item.querySelectorAll("button").forEach((b) => (b.disabled = false));
}
});
function hideUnknownFacesUI() {
currentUnknownFaces = [];
unknownFacesExpanded = false;
unknownFacesToggle.classList.add("hidden");
unknownFacesGrid.classList.add("hidden");
unknownFacesGrid.innerHTML = "";
}
function cropFaceThumbnail(sourceCanvas, bbox, pad = 26, outSize = 220) {
const [x1, y1, x2, y2] = bbox;
const px1 = Math.max(0, x1 - pad);
const py1 = Math.max(0, y1 - pad);
const px2 = Math.min(sourceCanvas.width, x2 + pad);
const py2 = Math.min(sourceCanvas.height, y2 + pad);
const pw = Math.max(1, px2 - px1);
const ph = Math.max(1, py2 - py1);
const out = document.createElement("canvas");
const scale = Math.max(outSize / pw, outSize / ph);
out.width = Math.round(pw * scale);
out.height = Math.round(ph * scale);
const octx = out.getContext("2d");
octx.imageSmoothingQuality = "high";
octx.drawImage(sourceCanvas, px1, py1, pw, ph, 0, 0, out.width, out.height);
return out.toDataURL("image/jpeg", 0.88);
}
function imageAsCanvas(imgEl) {
const source = document.createElement("canvas");
source.width = imgEl.naturalWidth;
source.height = imgEl.naturalHeight;
source.getContext("2d").drawImage(imgEl, 0, 0);
return source;
}
// Face crops (Present "Show face", unknown-faces grid) are cropped from the
// pre-annotation photo, not the boxed/labelled preview — otherwise the
// revealed face would have a bounding-box border and confidence text drawn
// across it. With multiple photos per mark, each face crop has to come from
// the specific photo it was detected in — indexed by photo_index. Cached
// per index so repeated crops don't re-fetch/re-decode.
let currentCleanPhotoUrls = []; // clean_url per photo, indexed by photo_index
let cleanPhotoImageCache = {}; // { [photoIndex]: { url, img } }
function getCleanPhotoImage(photoIndex = 0) {
return new Promise((resolve, reject) => {
const url = currentCleanPhotoUrls[photoIndex];
if (!url) { reject(new Error("No photo loaded yet.")); return; }
const cached = cleanPhotoImageCache[photoIndex];
if (cached && cached.url === url) {
resolve(cached.img);
return;
}
const img = new Image();
img.onload = () => {
cleanPhotoImageCache[photoIndex] = { url, img };
resolve(img);
};
img.onerror = () => reject(new Error("Could not load photo for cropping."));
img.src = url;
});
}
async function cropUnknownFaceThumbnails(faces, pad = 26, outSize = 220) {
const results = [];
for (const { bbox, similarity, photo_index, review_id } of faces) {
const source = imageAsCanvas(await getCleanPhotoImage(photo_index ?? 0));
results.push({ dataUrl: cropFaceThumbnail(source, bbox, pad, outSize), similarity, reviewId: review_id });
}
return results;
}
// Present faces stay hidden by default (privacy) — a "Show face" button
// crops the face on demand from the already-rendered marked photo, the
// same technique used for the unknown-faces grid.
presentList.addEventListener("click", async (event) => {
const btn = event.target.closest(".show-face-btn");
if (!btn) return;
const index = Number(btn.dataset.faceIndex);
const face = currentPresentFaces[index];
const slot = presentList.querySelector(`.face-reveal[data-face-slot="${index}"]`);
if (!face || !slot) return;
if (!slot.querySelector("img")) {
try {
const dataUrl = cropFaceThumbnail(imageAsCanvas(await getCleanPhotoImage(face.photoIndex)), face.bbox);
slot.innerHTML = ` `;
} catch (err) {
slot.innerHTML = `${err.message} `;
slot.classList.remove("hidden");
btn.textContent = "Hide face";
return;
}
}
const wasVisible = !slot.classList.contains("hidden");
slot.classList.toggle("hidden", wasVisible);
btn.textContent = wasVisible ? "Show face" : "Hide face";
});
async function renderUnknownFacesGrid() {
const thumbs = await cropUnknownFaceThumbnails(currentUnknownFaces);
unknownFacesGrid.innerHTML = thumbs
.map(
(t) => `
Unknown · ${formatNumber(t.similarity)}
${t.reviewId ? `
Assign to...
${currentRoster.map((s) => `${s.name} `).join("")}
` : ""}
`
)
.join("");
}
// A teacher identifying an unrecognized face as a specific enrolled student —
// reinforces that student's gallery with this embedding and marks them
// present, the same idea as confirming a suspicious match but for a face
// that had no name candidate at all.
unknownFacesGrid.addEventListener("change", async (event) => {
const select = event.target.closest(".assign-face-select");
if (!select) return;
const studentId = select.value;
if (!studentId) return;
const card = select.closest(".unknown-face-card");
const reviewId = card.dataset.reviewId;
const studentName = select.options[select.selectedIndex].textContent;
select.disabled = true;
try {
const response = await fetch("/api/attendance/unknown/assign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classroom: currentClassroomId, review_id: reviewId, student_id: studentId }),
});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || "Failed to assign.");
card.querySelector("figcaption").textContent = `Assigned to ${studentName}`;
select.remove();
await refreshAttendanceSummary();
} catch (err) {
alert(err.message);
select.disabled = false;
}
});
unknownFacesToggle.addEventListener("click", async () => {
unknownFacesExpanded = !unknownFacesExpanded;
if (unknownFacesExpanded) {
unknownFacesGrid.classList.remove("hidden");
unknownFacesToggle.textContent = `Hide unknown faces (${currentUnknownFaces.length})`;
try {
await renderUnknownFacesGrid();
} catch (err) {
unknownFacesGrid.innerHTML = `${err.message}
`;
}
} else {
unknownFacesGrid.classList.add("hidden");
unknownFacesToggle.textContent = `Show unknown faces (${currentUnknownFaces.length})`;
}
});
let currentRoster = [];
function renderRoster(students) {
currentRoster = 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 ?? "-"}
Delete
`).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)}?classroom=${encodeURIComponent(currentClassroomId)}`, { method: "DELETE" });
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || "Delete failed.");
renderRoster(data.students || []);
} 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");
markedPhotoGallery.addEventListener("click", (event) => {
const img = event.target.closest(".marked-photo-preview");
if (!img) return;
lightboxImg.src = img.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); }
loadClassrooms();
loadEnrollClassrooms();