/* ═══════════════════════════════════════════════════════════
MAC — MBM AI Cloud · PWA Frontend v3
Premium Dashboard Edition
═══════════════════════════════════════════════════════════ */
// ── API helper ────────────────────────────────────────────
const API = '/api/v1';
const state = { token: localStorage.getItem('mac_token'), user: null, page: 'login' };
let deferredInstallPrompt = null;
let _notifPollIv = null; // real-time notification polling interval
// ── User-scoped localStorage ──────────────────────────────
function _userKey(key) {
const uid = state.user?.id || '_anon';
return `mac_${uid}_${key}`;
}
function userGet(key, fallback) {
try { const v = localStorage.getItem(_userKey(key)); return v !== null ? JSON.parse(v) : fallback; } catch { return fallback; }
}
function userSet(key, val) { localStorage.setItem(_userKey(key), JSON.stringify(val)); }
// ── PWA install prompt capture ────────────────────────────
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredInstallPrompt = e;
const btn = document.getElementById('pwa-install-btn');
if (btn) btn.style.display = '';
});
window.addEventListener('appinstalled', () => {
deferredInstallPrompt = null;
const btn = document.getElementById('pwa-install-btn');
if (btn) btn.style.display = 'none';
});
async function api(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (state.token) headers['Authorization'] = `Bearer ${state.token}`;
const res = await fetch(`${API}${path}`, { ...opts, headers });
if (res.status === 401) { logout(); throw new Error('Unauthorized'); }
return res;
}
async function apiJson(path, opts) { const r = await api(path, opts); return r.json(); }
// ── Session storage (user-scoped) ─────────────────────────
function getSessions() { return userGet('sessions', []); }
function saveSessions(s) { userSet('sessions', s); }
function getSession(id) { return getSessions().find(s => s.id === id); }
// ── Eye toggle SVGs ───────────────────────────────────────
const EYE_OPEN = ' ';
const EYE_CLOSED = ' ';
// ── MAC Thinking Animation ────────────────────────────────
function macThinkingHTML() {
return `
`;
}
function startMacThinking(el) {
const letters = el.querySelectorAll('.mac-tl');
let active = 0;
const iv = setInterval(() => {
letters.forEach((l, i) => l.classList.toggle('lit', i === active));
active = (active + 1) % letters.length;
}, 400);
el._macThinkIv = iv;
}
function stopMacThinking(el) {
if (el._macThinkIv) { clearInterval(el._macThinkIv); el._macThinkIv = null; }
}
function pwField(id, label, placeholder) {
return ``;
}
function bindEyeToggles(root) {
(root || document).querySelectorAll('.pw-toggle').forEach(btn => {
btn.onclick = () => {
const inp = document.getElementById(btn.dataset.target);
if (!inp) return;
const show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
btn.innerHTML = show ? EYE_OPEN : EYE_CLOSED;
};
});
}
// ── Theme ─────────────────────────────────────────────────
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('mac_theme', theme);
}
// Apply saved theme immediately (default: warm)
(function() {
const saved = localStorage.getItem('mac_theme') || 'warm';
document.documentElement.setAttribute('data-theme', saved);
})();
// ── Router ────────────────────────────────────────────────
function navigate(page) {
if (state.user && state.user.must_change_password && page !== 'set-password' && page !== 'login') {
page = 'set-password';
}
state.page = page;
window.history.pushState({}, '', page === 'login' ? '/' : `#${page}`);
render();
}
window.addEventListener('popstate', () => {
if (state.user && state.user.must_change_password) {
window.history.pushState({}, '', '#set-password');
state.page = 'set-password';
render();
return;
}
const hash = location.hash.slice(1);
state.page = hash || (state.token ? 'dashboard' : 'login');
render();
});
// ── Bootstrap ─────────────────────────────────────────────
async function init() {
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/static/sw.js', {scope: '/'});
if (state.token) {
try {
const u = await apiJson('/auth/me');
state.user = u;
if (u.must_change_password) {
state.token = null; state.user = null;
localStorage.removeItem('mac_token');
state.page = 'login';
} else {
state.page = location.hash.slice(1) || 'dashboard';
// Load user-scoped data
_nbLoadFromStorage();
// Subscribe to push notifications
subscribeToPush();
// Request browser notification permission
requestNotificationPermission();
// Start real-time notification polling
startNotifPolling();
}
} catch { state.token = null; localStorage.removeItem('mac_token'); state.page = 'login'; }
}
render();
}
let _dashRefreshIv = null;
function render() {
// Clear dashboard auto-refresh when navigating away
if (_dashRefreshIv) { clearInterval(_dashRefreshIv); _dashRefreshIv = null; }
const app = document.getElementById('app');
if (!state.token || state.page === 'login') { app.innerHTML = authPage(); bindAuth(); return; }
if (state.user && state.user.must_change_password) {
state.page = 'set-password';
window.history.replaceState({}, '', '#set-password');
app.innerHTML = setPasswordPage(); bindSetPassword(); bindEyeToggles();
return;
}
if (state.page === 'set-password') { app.innerHTML = setPasswordPage(); bindSetPassword(); bindEyeToggles(); return; }
app.innerHTML = shell();
bindShell();
if (state.page === 'dashboard') {
renderDashboard();
_dashRefreshIv = setInterval(() => { if (state.page === 'dashboard') renderDashboard(); }, 30000);
}
else if (state.page === 'chat') renderChat();
else if (state.page === 'notebooks') {
if ((state.user?.role || 'student') !== 'admin') { navigate('dashboard'); return; }
renderNotebooks();
}
else if (state.page === 'admin') renderAdmin();
else if (state.page === 'settings') renderSettings();
else if (state.page === 'doubts') renderDoubts();
else if (state.page === 'attendance') renderAttendance();
else if (state.page === 'copycheck') renderCopyCheck();
else { state.page = 'dashboard'; renderDashboard(); _dashRefreshIv = setInterval(() => { if (state.page === 'dashboard') renderDashboard(); }, 30000); }
}
function logout() {
state.token = null; state.user = null;
localStorage.removeItem('mac_token');
// Stop notification polling
if (_notifPollIv) { clearInterval(_notifPollIv); _notifPollIv = null; }
// Reset notebook state (don't clear storage — user data stays for next login)
_nbState.notebooks = []; _nbState.current = null; _nbState.cells = []; _nbState.outputs = {};
navigate('login');
}
async function installPWA() {
if (!deferredInstallPrompt) return;
deferredInstallPrompt.prompt();
const result = await deferredInstallPrompt.userChoice;
if (result.outcome === 'accepted') deferredInstallPrompt = null;
}
/* ═══════════════════════════════════════════════════════════
AUTH PAGE — Login (username+password) / First-time (roll+DOB)
═══════════════════════════════════════════════════════════ */
let authMode = 'login';
function authPage() {
if (authMode === 'verify') {
return `
`;
}
return `
`;
}
function bindAuth() {
const form = document.getElementById('auth-form');
if (!form) return;
bindEyeToggles();
const switchToVerify = document.getElementById('switch-to-verify');
const switchToLogin = document.getElementById('switch-to-login');
if (switchToVerify) switchToVerify.onclick = (e) => { e.preventDefault(); authMode = 'verify'; render(); };
if (switchToLogin) switchToLogin.onclick = (e) => { e.preventDefault(); authMode = 'login'; render(); };
form.onsubmit = async (e) => {
e.preventDefault();
const err = document.getElementById('auth-error');
err.textContent = '';
const roll = document.getElementById('auth-roll').value.trim();
if (authMode === 'verify') {
const dob = document.getElementById('auth-dob').value.trim();
if (!roll || !dob) { err.textContent = 'Both fields are required'; return; }
if (!/^\d{8}$/.test(dob)) { err.textContent = 'DOB must be 8 digits (DDMMYYYY)'; return; }
try {
const r = await fetch(`${API}/auth/verify`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roll_number: roll, dob }),
});
if (!r.ok) { const d = await r.json(); err.textContent = d.detail?.message || 'Verification failed'; return; }
const data = await r.json();
state.token = data.access_token; state.user = data.user;
localStorage.setItem('mac_token', data.access_token);
_nbLoadFromStorage(); requestNotificationPermission(); startNotifPolling(); subscribeToPush();
if (data.must_change_password) navigate('set-password'); else navigate('dashboard');
} catch (ex) { err.textContent = 'Connection error'; }
} else {
const pw = document.getElementById('auth-pw').value;
if (!roll || !pw) { err.textContent = 'Both fields are required'; return; }
try {
const r = await fetch(`${API}/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roll_number: roll, password: pw }),
});
if (!r.ok) { const d = await r.json(); err.textContent = d.detail?.message || 'Invalid username or password'; return; }
const data = await r.json();
state.token = data.access_token; state.user = data.user;
localStorage.setItem('mac_token', data.access_token);
_nbLoadFromStorage(); requestNotificationPermission(); startNotifPolling(); subscribeToPush();
if (data.must_change_password) navigate('set-password'); else navigate('dashboard');
} catch (ex) { err.textContent = 'Connection error'; }
}
};
}
/* ═══════════════════════════════════════════════════════════
SET PASSWORD (first-time / forced)
═══════════════════════════════════════════════════════════ */
function setPasswordPage() {
const u = state.user || {};
return `
MAC
Set Your Password
Welcome, ${esc(u.name || u.roll_number || '')} !
You must set a secure password before continuing.
`;
}
function bindSetPassword() {
document.getElementById('sp-form').onsubmit = async (e) => {
e.preventDefault();
const err = document.getElementById('sp-error');
err.textContent = '';
const pw = document.getElementById('sp-new').value;
const conf = document.getElementById('sp-confirm').value;
if (pw.length < 8) { err.textContent = 'Password must be at least 8 characters'; return; }
if (pw !== conf) { err.textContent = 'Passwords do not match'; return; }
try {
const r = await api('/auth/set-password', {
method: 'POST',
body: JSON.stringify({ new_password: pw, confirm_password: conf }),
});
if (!r.ok) { const d = await r.json(); err.textContent = d.detail?.message || 'Failed'; return; }
state.user = await apiJson('/auth/me');
_nbLoadFromStorage(); requestNotificationPermission(); startNotifPolling(); subscribeToPush();
navigate('dashboard');
} catch (ex) { err.textContent = ex.message; }
};
}
/* ═══════════════════════════════════════════════════════════
APP SHELL
═══════════════════════════════════════════════════════════ */
function shell() {
const u = state.user || {};
const isAdmin = u.role === 'admin';
const isFacultyOrAdmin = u.role === 'faculty' || u.role === 'admin';
const isStudent = u.role === 'student';
const pages = { dashboard: 'Dashboard', chat: 'Chat', notebooks: 'Notebooks', doubts: 'Doubts', attendance: 'Attendance', copycheck: 'Copy Check', settings: 'Settings', admin: 'Admin' };
const dockSide = localStorage.getItem('mac_dock_side') || 'left';
return `
${pages[state.page] || 'Dashboard'}
`;
}
function bindShell() {
document.querySelectorAll('.sidebar-nav a').forEach(a => {
a.onclick = (e) => { e.preventDefault(); closeSidebar(); navigate(a.dataset.page); };
});
const toggle = document.getElementById('menu-toggle');
const overlay = document.getElementById('sidebar-overlay');
if (toggle) toggle.onclick = () => {
document.getElementById('shell').classList.toggle('sidebar-open');
};
if (overlay) overlay.onclick = closeSidebar;
// ── Resizable sidebar (drag edge) ──────────────────────
const shellEl = document.getElementById('shell');
const sidebar = document.getElementById('sidebar');
const resizeHandle = document.getElementById('sidebar-resize');
if (resizeHandle && sidebar) {
let startPos, startSize;
resizeHandle.onmousedown = (e) => {
e.preventDefault();
const side = getCurrentDockSide();
const rect = sidebar.getBoundingClientRect();
startPos = (side === 'left' || side === 'right') ? e.clientX : e.clientY;
startSize = (side === 'left' || side === 'right') ? rect.width : rect.height;
document.body.style.cursor = (side === 'left' || side === 'right') ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
function onMove(ev) {
const curSide = getCurrentDockSide();
let delta;
if (curSide === 'left') delta = ev.clientX - startPos;
else if (curSide === 'right') delta = startPos - ev.clientX;
else if (curSide === 'top') delta = ev.clientY - startPos;
else delta = startPos - ev.clientY;
let size = startSize + delta;
const isHoriz = curSide === 'left' || curSide === 'right';
const minSize = isHoriz ? 52 : 42;
const maxSize = isHoriz ? 400 : 300;
size = Math.max(minSize, Math.min(maxSize, size));
if (isHoriz) {
sidebar.style.width = size + 'px';
sidebar.style.height = '';
sidebar.classList.toggle('compact', size <= 70);
} else {
sidebar.style.height = size + 'px';
sidebar.style.width = '';
}
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
// Double-click to toggle compact/expanded
resizeHandle.ondblclick = () => {
const side = getCurrentDockSide();
if (side === 'left' || side === 'right') {
const w = sidebar.getBoundingClientRect().width;
if (w > 70) {
sidebar.style.width = '52px';
sidebar.classList.add('compact');
} else {
sidebar.style.width = '230px';
sidebar.classList.remove('compact');
}
} else {
const h = sidebar.getBoundingClientRect().height;
sidebar.style.height = (h > 60 ? '42px' : '120px');
}
};
}
// ── Drag sidebar grip to dock to any edge ──────────────
const grip = document.getElementById('sidebar-grip');
if (grip && sidebar) {
let dragOverlay;
grip.onmousedown = (e) => {
e.preventDefault();
// Create full-screen overlay with edge zones
dragOverlay = document.createElement('div');
dragOverlay.style.cssText = 'position:fixed;inset:0;z-index:9999;cursor:grabbing;';
const indicator = document.createElement('div');
indicator.style.cssText = 'position:fixed;background:rgba(0,0,0,.06);border:2px dashed rgba(0,0,0,.2);transition:all .15s;border-radius:4px;pointer-events:none;z-index:10000;';
dragOverlay.appendChild(indicator);
document.body.appendChild(dragOverlay);
function getZone(cx, cy) {
const w = window.innerWidth, h = window.innerHeight;
const edgeSize = 80;
if (cx < edgeSize) return 'left';
if (cx > w - edgeSize) return 'right';
if (cy < edgeSize) return 'top';
if (cy > h - edgeSize) return 'bottom';
return null;
}
function showIndicator(zone) {
if (!zone) { indicator.style.display = 'none'; return; }
indicator.style.display = 'block';
if (zone === 'left') { indicator.style.cssText += 'top:0;left:0;width:230px;height:100%;'; }
else if (zone === 'right') { indicator.style.cssText += 'top:0;right:0;left:auto;width:230px;height:100%;'; }
else if (zone === 'top') { indicator.style.cssText += 'top:0;left:0;width:100%;height:60px;'; }
else if (zone === 'bottom') { indicator.style.cssText += 'bottom:0;left:0;top:auto;width:100%;height:60px;'; }
}
function onMove(ev) {
const zone = getZone(ev.clientX, ev.clientY);
showIndicator(zone);
}
function onUp(ev) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
dragOverlay.remove();
const zone = getZone(ev.clientX, ev.clientY);
if (zone) setDockSide(zone);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
}
function getCurrentDockSide() {
if (shellEl.classList.contains('dock-right')) return 'right';
if (shellEl.classList.contains('dock-top')) return 'top';
if (shellEl.classList.contains('dock-bottom')) return 'bottom';
return 'left';
}
function setDockSide(side) {
shellEl.classList.remove('dock-left', 'dock-right', 'dock-top', 'dock-bottom');
shellEl.classList.add('dock-' + side);
sidebar.style.width = '';
sidebar.style.height = '';
sidebar.classList.remove('compact');
localStorage.setItem('mac_dock_side', side);
// Reset sizes based on side
if (side === 'left' || side === 'right') {
sidebar.style.width = '230px';
} else {
sidebar.style.height = '52px';
}
}
// Notification bell
const bell = document.getElementById('notif-bell');
const panel = document.getElementById('notif-panel');
if (bell && panel) {
bell.onclick = (e) => { e.stopPropagation(); panel.classList.toggle('open'); if (panel.classList.contains('open')) loadNotifications(); };
document.addEventListener('click', (e) => { if (!panel.contains(e.target) && e.target !== bell) panel.classList.remove('open'); }, { once: false });
}
const markAllBtn = document.getElementById('notif-mark-all');
if (markAllBtn) markAllBtn.onclick = async () => {
try { await api('/notifications/read-all', { method: 'POST' }); loadNotifications(); loadNotifCount(); } catch {}
};
// Load notification count
loadNotifCount();
}
function closeSidebar() {
const shell = document.getElementById('shell');
if (shell) shell.classList.remove('sidebar-open');
}
/* ═══════════════════════════════════════════════════════════
USER DASHBOARD — Premium Analytics
═══════════════════════════════════════════════════════════ */
async function renderDashboard() {
const el = document.getElementById('page-content');
el.innerHTML = '';
try {
const [me, quota, history, keyStats] = await Promise.all([
apiJson('/auth/me'),
apiJson('/usage/me/quota'),
apiJson('/usage/me/history?per_page=50'),
apiJson('/keys/my-key/stats').catch(() => null),
]);
state.user = me;
const q = quota;
const tokensUsed = q.current?.tokens_used_today || 0;
const tokensLimit = q.limits?.daily_tokens || 50000;
const reqsUsed = q.current?.requests_this_hour || 0;
const reqsLimit = q.limits?.requests_per_hour || 100;
const tokenPct = Math.min(100, Math.round((tokensUsed / tokensLimit) * 100));
const reqPct = Math.min(100, Math.round((reqsUsed / reqsLimit) * 100));
const reqs = history.requests || [];
// Build activity heatmap data from history
const heatmapData = buildHeatmapData(reqs);
// Build model distribution
const modelDist = {};
reqs.forEach(r => { modelDist[r.model] = (modelDist[r.model] || 0) + 1; });
// Build hourly distribution
const hourlyDist = new Array(24).fill(0);
reqs.forEach(r => { const h = new Date(r.created_at).getHours(); hourlyDist[h]++; });
el.innerHTML = `
Welcome back, ${esc(me.name.split(' ')[0])}
${esc(me.department)} · ${esc(me.role)} · Joined ${new Date(me.created_at).toLocaleDateString('en-IN', {month:'short',year:'numeric'})}
API Key
${esc(me.api_key ? me.api_key.slice(0,8) + '...' + me.api_key.slice(-4) : 'N/A')}
Tokens Today
${fmtNum(tokensUsed)}
${tokenPct}% of ${fmtNum(tokensLimit)}
Requests / Hour
${reqsUsed}
${reqPct}% of ${reqsLimit}
This Week
${fmtNum(keyStats?.tokens_this_week || 0)}
tokens consumed
Chat Sessions
${getSessions().length}
saved locally
${Object.keys(modelDist).length === 0 ? '
No model usage yet
Start a chat to see distribution ' : ''}
${hourlyDist.every(v => v === 0) ? '
No activity recorded yet
Use the chat — your hourly pattern will appear here ' : ''}
${tokenPct}% Tokens ${fmtNum(tokensUsed)}
${reqPct}% Requests ${reqsUsed}
${reqs.length > 0 ? `
Model Endpoint Tokens Latency Status Time
${reqs.slice(0,15).map(r => `
${esc(shortModel(r.model))}
${esc(r.endpoint)}
${fmtNum(r.tokens_in + r.tokens_out)}
${r.latency_ms}ms
${r.status_code < 400 ? ' OK' : ' ' + r.status_code}
${timeAgo(r.created_at)}
`).join('')}
` : '
No activity yet. Start a chat or make an API call!
'}
`;
// Render heatmap
renderHeatmap('heatmap-container', heatmapData);
// Donut charts
makeDonut('chart-tokens', tokensUsed, tokensLimit);
makeDonut('chart-reqs', reqsUsed, reqsLimit);
// Model distribution chart
const modelLabels = Object.keys(modelDist);
const modelValues = Object.values(modelDist);
const cs0 = getComputedStyle(document.documentElement);
const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
const accentCol = cs0.getPropertyValue('--accent').trim() || '#7c6ff7';
const fgCol = cs0.getPropertyValue('--fg').trim() || '#111';
const mutedCol = cs0.getPropertyValue('--muted').trim() || '#888';
const modelColors = isDarkTheme
? [accentCol, '#9b8fff', '#c4baff', '#6b5ce6', '#d4d0ff']
: ['#111', '#555', '#999', '#bbb', '#ddd'];
if (modelLabels.length > 0) {
new Chart(document.getElementById('chart-models'), {
type: 'doughnut',
data: { labels: modelLabels.map(shortModel), datasets: [{ data: modelValues, backgroundColor: modelColors.slice(0, modelLabels.length), borderWidth: 2, borderColor: cs0.getPropertyValue('--card').trim() || '#fff', cutout: '68%', hoverOffset: 8 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#000', titleColor: '#fff', bodyColor: '#fff', cornerRadius: 8, padding: 10 } } },
});
document.getElementById('model-legend').innerHTML = modelLabels.map((m, i) =>
` ${esc(shortModel(m))}${modelValues[i]}
`
).join('');
}
// Hourly area chart with gradient
const hourlyCtx = document.getElementById('chart-hourly').getContext('2d');
const hourlyGrad = hourlyCtx.createLinearGradient(0, 0, 0, 180);
hourlyGrad.addColorStop(0, isDarkTheme ? 'rgba(124,111,247,0.35)' : 'rgba(0,0,0,0.18)');
hourlyGrad.addColorStop(1, isDarkTheme ? 'rgba(124,111,247,0.03)' : 'rgba(0,0,0,0.01)');
new Chart(hourlyCtx.canvas, {
type: 'line',
data: {
labels: Array.from({length:24}, (_, i) => i + 'h'),
datasets: [{
data: hourlyDist,
fill: true,
backgroundColor: hourlyGrad,
borderColor: accentCol,
borderWidth: 2,
pointBackgroundColor: accentCol,
pointBorderColor: cs0.getPropertyValue('--card').trim() || '#fff',
pointBorderWidth: 2,
pointRadius: hourlyDist.map(v => v > 0 ? 4 : 0),
pointHoverRadius: 6,
tension: 0.4,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#000', titleColor: '#fff', bodyColor: '#fff',
cornerRadius: 8, padding: 10,
callbacks: { label: (ctx) => ctx.raw + ' request' + (ctx.raw !== 1 ? 's' : '') }
}
},
scales: {
y: { display: true, beginAtZero: true, grid: { color: isDarkTheme ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }, ticks: { color: mutedCol, font: { size: 10 }, stepSize: 1, precision: 0 } },
x: { grid: { display: false }, ticks: { color: mutedCol, font: { size: 9 }, maxRotation: 0 } }
},
interaction: { intersect: false, mode: 'index' },
},
});
// Models grid
try {
const m = await apiJson('/models');
const list = m.models || [];
const typeLabel = { chat: 'LLM · Chat', stt: 'Speech → Text', tts: 'Text → Speech', embedding: 'Embeddings', vision: 'Vision' };
document.getElementById('models-grid').innerHTML = list.map(md => `
${esc(md.id || md.name)}
${esc(typeLabel[md.model_type] || md.model_type || 'Model')}
${md.status === 'loaded' ? ' Online' : ' Offline'}
`).join('') || 'No models configured
';
} catch { document.getElementById('models-grid').innerHTML = 'Could not load models
'; }
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
Retry `; }
}
/* ═══════════════════════════════════════════════════════════
HEATMAP — GitHub-style contribution graph
═══════════════════════════════════════════════════════════ */
function buildHeatmapData(requests) {
const map = {};
requests.forEach(r => {
const d = new Date(r.created_at).toISOString().slice(0, 10);
map[d] = (map[d] || 0) + 1;
});
return map;
}
function renderHeatmap(containerId, data) {
const container = document.getElementById(containerId);
if (!container) return;
const hasData = Object.values(data).some(v => v > 0);
const today = new Date();
const weeks = 26;
const totalCols = weeks + 1;
const days = weeks * 7;
const maxVal = Math.max(1, ...Object.values(data));
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - days + 1);
startDate.setDate(startDate.getDate() - startDate.getDay()); // align to Sunday
// --- Month labels: collect which columns each month spans, show year at boundary ---
const monthSpans = [];
let curMonth = -1, curYear = -1, spanStart = 0;
for (let w = 0; w < totalCols; w++) {
const d = new Date(startDate); d.setDate(d.getDate() + w * 7);
const m = d.getMonth(), y = d.getFullYear();
if (m !== curMonth) {
if (curMonth !== -1) {
const sd = new Date(startDate.getTime() + spanStart * 7 * 86400000);
const label = sd.toLocaleString('en', { month: 'short' }) + (sd.getFullYear() !== curYear || spanStart === 0 ? " '" + String(sd.getFullYear()).slice(2) : '');
monthSpans.push({ name: label, start: spanStart, span: w - spanStart });
curYear = sd.getFullYear();
}
curMonth = m; spanStart = w;
}
}
const lastD = new Date(startDate.getTime() + spanStart * 7 * 86400000);
const lastLabel = lastD.toLocaleString('en', { month: 'short' }) + (lastD.getFullYear() !== curYear || monthSpans.length === 0 ? " '" + String(lastD.getFullYear()).slice(2) : '');
monthSpans.push({ name: lastLabel, start: spanStart, span: totalCols - spanStart });
const monthRow = monthSpans.map(m => `${m.name} `).join('');
// --- Day labels (all 7) ---
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// --- Grid cells ---
let cells = '';
for (let w = 0; w < totalCols; w++) {
for (let d = 0; d < 7; d++) {
const cellDate = new Date(startDate);
cellDate.setDate(cellDate.getDate() + w * 7 + d);
const dateStr = cellDate.toISOString().slice(0, 10);
const count = data[dateStr] || 0;
const level = count === 0 ? 0 : Math.min(4, Math.ceil((count / maxVal) * 4));
const isFuture = cellDate > today;
const tip = cellDate.toLocaleDateString('en', { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' }) + ': ' + (isFuture ? 'No data yet' : count + ' request' + (count !== 1 ? 's' : ''));
cells += `
`;
}
}
container.innerHTML = `
${monthRow}
${dayNames.map(n => `${n} `).join('')}
${cells}
${!hasData ? 'No activity yet
Your usage will light up here as you chat ' : ''}
`;
}
/* ═══════════════════════════════════════════════════════════
SETTINGS
═══════════════════════════════════════════════════════════ */
async function renderSettings() {
const el = document.getElementById('page-content');
const u = state.user || {};
el.innerHTML = `
Change Password
${pwField('cp-old', 'Current Password', 'Current password')}
${pwField('cp-new', 'New Password', 'Min 8 characters')}
${pwField('cp-confirm', 'Confirm New Password', 'Repeat password')}
Update Password
Theme
Choose a color theme for the entire interface.
API Key
Use this key in your projects to call MAC APIs from anywhere.
${esc(u.api_key || 'N/A')}
Copy
Regenerate Key
Test Key
`;
bindEyeToggles(el);
// Theme picker
const currentTheme = localStorage.getItem('mac_theme') || 'warm';
document.querySelectorAll('#theme-picker .theme-dot').forEach(dot => {
if (dot.dataset.theme === currentTheme) dot.classList.add('active');
dot.onclick = () => {
const theme = dot.dataset.theme;
applyTheme(theme);
document.querySelectorAll('#theme-picker .theme-dot').forEach(d => d.classList.remove('active'));
dot.classList.add('active');
};
});
document.getElementById('save-profile-btn').onclick = async () => {
const msg = document.getElementById('pf-msg');
try {
const r = await api('/auth/me/profile', {
method: 'PUT',
body: JSON.stringify({ name: document.getElementById('pf-name').value, email: document.getElementById('pf-email').value, department: document.getElementById('pf-dept')?.value }),
});
if (!r.ok) { const d = await r.json(); msg.innerHTML = `${esc(d.detail?.message || 'Failed')} `; return; }
state.user = await apiJson('/auth/me');
msg.innerHTML = 'Profile updated ';
} catch (ex) { msg.innerHTML = `${esc(ex.message)} `; }
};
document.getElementById('change-pw-btn').onclick = async () => {
const msg = document.getElementById('cp-msg');
msg.textContent = '';
const oldPw = document.getElementById('cp-old').value;
const newPw = document.getElementById('cp-new').value;
const confPw = document.getElementById('cp-confirm').value;
if (!oldPw || !newPw) { msg.innerHTML = 'All fields required '; return; }
if (newPw.length < 8) { msg.innerHTML = 'Min 8 characters '; return; }
if (newPw !== confPw) { msg.innerHTML = 'Passwords do not match '; return; }
try {
const r = await api('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
});
if (!r.ok) { const d = await r.json(); msg.innerHTML = `${esc(d.detail?.message || 'Failed')} `; return; }
msg.innerHTML = 'Password changed! ';
document.getElementById('cp-old').value = '';
document.getElementById('cp-new').value = '';
document.getElementById('cp-confirm').value = '';
} catch (ex) { msg.innerHTML = `${esc(ex.message)} `; }
};
const regenBtn = document.getElementById('regen-my-key');
if (regenBtn) regenBtn.onclick = async () => {
if (!confirm('Regenerate your API key? The old key will stop working immediately.')) return;
try {
const r = await apiJson('/keys/generate', { method: 'POST' });
document.getElementById('api-key-display').textContent = r.api_key || r.key || 'Generated';
state.user = await apiJson('/auth/me');
} catch (ex) { alert('Failed: ' + ex.message); }
};
const testKeyBtn = document.getElementById('test-key-btn');
if (testKeyBtn) testKeyBtn.onclick = async () => {
const key = document.getElementById('api-key-display').textContent.trim();
const msg = document.getElementById('key-test-msg');
if (!key || key === 'N/A') { msg.innerHTML = 'No key found '; return; }
msg.textContent = 'Testing...';
try {
const r = await fetch('/api/v1/auth/me', { headers: { 'Authorization': `Bearer ${key}` } });
const d = await r.json();
if (r.ok) {
msg.innerHTML = `✓ Works — authenticated as ${esc(d.name)} (${esc(d.role)}) `;
} else {
msg.innerHTML = `✗ ${esc(d.detail?.message || d.detail || 'Key rejected')} `;
}
} catch (ex) {
msg.innerHTML = `✗ Network error: ${esc(ex.message)} `;
}
};
}
/* ═══════════════════════════════════════════════════════════
CHAT
═══════════════════════════════════════════════════════════ */
let currentSession = null;
let isStreaming = false;
function chatEmptyHtml() {
return ``;
}
function startTypewriter() {
const el = document.getElementById('ctl-typewriter');
if (!el) return;
el.innerHTML = '';
const text = 'Cross the Limits';
let i = 0;
el.classList.add('typing');
function type() {
if (i < text.length) {
el.textContent += text[i];
i++;
setTimeout(type, 60 + Math.random() * 40);
} else {
el.classList.remove('typing');
}
}
setTimeout(type, 400);
}
function bindChatChips() {
startTypewriter();
}
function renderChat() {
const el = document.getElementById('page-content');
el.className = 'page page-chat';
const sessions = getSessions();
el.innerHTML = `
`;
bindChat();
bindChatChips();
if (sessions.length > 0 && !currentSession) loadSession(sessions[0].id);
}
function sessionItem(s) {
const active = currentSession && currentSession.id === s.id;
return `
${esc(s.title || 'New Chat')}
`;
}
function bindChat() {
document.getElementById('new-chat-btn').onclick = newChat;
document.getElementById('send-btn').onclick = sendMessage;
const input = document.getElementById('chat-input');
input.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } };
input.oninput = () => { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 120) + 'px'; };
// STT: upload audio file → transcribe via Whisper
const sttBtn = document.getElementById('stt-btn');
const sttFile = document.getElementById('stt-file');
if (sttBtn && sttFile) {
sttBtn.onclick = () => sttFile.click();
sttFile.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
sttFile.value = '';
const fd = new FormData();
fd.append('audio', file);
const status = document.getElementById('chat-status');
status.textContent = 'Transcribing...';
sttBtn.disabled = true;
try {
const res = await fetch('/api/v1/query/speech-to-text', {
method: 'POST',
headers: { 'Authorization': `Bearer ${state.token}` },
body: fd,
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail?.message || data.detail || 'Transcription failed');
const inp = document.getElementById('chat-input');
inp.value = (inp.value ? inp.value + ' ' : '') + data.text;
inp.style.height = 'auto';
inp.style.height = Math.min(inp.scrollHeight, 120) + 'px';
inp.focus();
status.textContent = '';
} catch (err) {
status.textContent = 'STT: ' + err.message;
setTimeout(() => { const s = document.getElementById('chat-status'); if (s) s.textContent = ''; }, 4000);
}
sttBtn.disabled = false;
};
}
// TTS: speaker button on assistant messages (event delegation)
document.getElementById('chat-messages').addEventListener('click', async (e) => {
const btn = e.target.closest('.tts-btn');
if (!btn) return;
const msgEl = btn.closest('[data-msg-index]');
if (!msgEl || !currentSession) return;
const idx = parseInt(msgEl.dataset.msgIndex);
const text = currentSession.messages[idx]?.content;
if (text) await playTTS(text, btn);
});
document.getElementById('session-list').onclick = (e) => {
const del = e.target.closest('[data-del]');
if (del) { deleteSession(del.dataset.del); return; }
const item = e.target.closest('.session-item');
if (item) loadSession(item.dataset.id);
};
// Resizable session sidebar (VS Code style drag handle)
const handle = document.getElementById('chat-resize-handle');
const sidebar = document.getElementById('chat-sidebar');
if (handle && sidebar) {
let startX, startW;
handle.onmousedown = (e) => {
e.preventDefault();
startX = e.clientX;
startW = sidebar.getBoundingClientRect().width;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
function onMove(ev) {
let w = startW + (ev.clientX - startX);
if (w < 60) w = 0; // snap to collapsed
else if (w < 140) w = 140; // minimum usable
else if (w > 500) w = 500; // max
sidebar.style.width = w + 'px';
sidebar.classList.toggle('collapsed', w === 0);
handle.classList.toggle('collapsed', w === 0);
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
// Double-click to toggle collapse/expand
handle.ondblclick = () => {
const w = sidebar.getBoundingClientRect().width;
if (w < 10) {
sidebar.style.width = '240px';
sidebar.classList.remove('collapsed');
handle.classList.remove('collapsed');
} else {
sidebar.style.width = '0px';
sidebar.classList.add('collapsed');
handle.classList.add('collapsed');
}
};
}
loadModelOptions();
loadActiveModelBadge();
}
async function loadModelOptions() {
const sel = document.getElementById('model-select');
try {
const resp = await fetch(API + '/explore/models?model_type=chat&per_page=50');
if (!resp.ok) return;
const data = await resp.json();
(data.models || []).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name + (m.parameters ? ' (' + m.parameters + ')' : '');
sel.appendChild(opt);
});
} catch (e) { /* API offline — auto option is enough */ }
if (currentSession && currentSession.model) sel.value = currentSession.model;
}
async function loadActiveModelBadge() {
const badge = document.getElementById('active-model-badge');
if (!badge) return;
try {
const res = await fetch('/api/v1/explore/health');
if (!res.ok) { badge.innerHTML = ' Offline'; return; }
const data = await res.json();
const models = (data.nodes || []).flatMap(n => n.models_loaded || []);
if (models.length > 0) {
badge.innerHTML = ' ' + esc(shortModel(models[0]));
badge.title = 'Running: ' + models.join(', ');
} else {
badge.innerHTML = ' No model';
}
} catch { badge.innerHTML = ' Offline'; }
}
function newChat() {
const id = 'chat-' + Date.now();
const session = { id, title: 'New Chat', messages: [], model: 'auto', created: new Date().toISOString() };
const sessions = getSessions();
sessions.unshift(session);
saveSessions(sessions);
currentSession = session;
renderChat();
loadSession(id);
}
function loadSession(id) {
const s = getSession(id);
if (!s) return;
currentSession = s;
document.querySelectorAll('.session-item').forEach(el => el.classList.toggle('active', el.dataset.id === id));
const msgs = document.getElementById('chat-messages');
if (s.messages.length === 0) {
msgs.innerHTML = chatEmptyHtml();
startTypewriter();
} else {
msgs.innerHTML = s.messages.map((m, i) => {
if (m.role === 'assistant') {
return ``;
}
return `${esc(m.content)}
`;
}).join('');
msgs.scrollTop = msgs.scrollHeight;
}
if (s.model) document.getElementById('model-select').value = s.model;
}
function deleteSession(id) {
saveSessions(getSessions().filter(s => s.id !== id));
if (currentSession && currentSession.id === id) currentSession = null;
renderChat();
}
async function sendMessage() {
if (isStreaming) return;
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text) return;
if (!currentSession) newChat();
const model = document.getElementById('model-select').value;
currentSession.model = model;
currentSession.messages.push({ role: 'user', content: text });
if (currentSession.title === 'New Chat') currentSession.title = text.slice(0, 40);
persistSession();
const msgs = document.getElementById('chat-messages');
const emptyEl = msgs.querySelector('.chat-empty');
if (emptyEl) emptyEl.remove();
msgs.innerHTML += `${esc(text)}
`;
input.value = ''; input.style.height = 'auto';
const assistantDiv = document.createElement('div');
assistantDiv.className = 'msg msg-assistant';
assistantDiv.innerHTML = macThinkingHTML();
msgs.appendChild(assistantDiv);
msgs.scrollTop = msgs.scrollHeight;
startMacThinking(assistantDiv);
const status = document.getElementById('chat-status');
status.textContent = 'Generating...';
isStreaming = true;
try {
const apiMessages = currentSession.messages.map(m => ({ role: m.role, content: m.content }));
const res = await api('/query/chat', { method: 'POST', body: JSON.stringify({ messages: apiMessages, model, stream: true }) });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail?.message || 'Request failed'); }
let fullContent = '';
stopMacThinking(assistantDiv);
assistantDiv.textContent = '';
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let streamError = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const chunk = JSON.parse(data);
if (chunk.error) throw new Error(chunk.error.message);
const delta = chunk.choices?.[0]?.delta?.content || '';
if (delta) { fullContent += delta; assistantDiv.innerHTML = formatMd(fullContent); msgs.scrollTop = msgs.scrollHeight; }
} catch (parseErr) { if (parseErr.message.includes('Backend') || parseErr.message.includes('model')) throw parseErr; }
}
}
} catch (streamErr) {
streamError = streamErr;
}
if (fullContent) {
currentSession.messages.push({ role: 'assistant', content: fullContent });
persistSession();
const usedModel = model === 'auto' ? 'Qwen2.5-7B-AWQ' : shortModel(model);
const msgIdx = currentSession.messages.length - 1;
assistantDiv.dataset.msgIndex = msgIdx;
assistantDiv.innerHTML = formatMd(fullContent) + ``;
} else if (streamError) {
throw streamError;
} else {
fullContent = '(No response)';
currentSession.messages.push({ role: 'assistant', content: fullContent });
persistSession();
assistantDiv.innerHTML = formatMd(fullContent);
}
} catch (err) {
stopMacThinking(assistantDiv);
assistantDiv.innerHTML = `Error: ${esc(err.message)} `;
currentSession.messages.push({ role: 'assistant', content: `Error: ${err.message}` });
persistSession();
}
isStreaming = false;
status.textContent = '';
msgs.scrollTop = msgs.scrollHeight;
const titleEl = document.querySelector(`.session-item[data-id="${currentSession.id}"] span:first-child`);
if (titleEl) titleEl.textContent = currentSession.title;
}
function persistSession() {
let sessions = getSessions();
const idx = sessions.findIndex(s => s.id === currentSession.id);
if (idx >= 0) sessions[idx] = currentSession; else sessions.unshift(currentSession);
saveSessions(sessions);
}
/* Text-to-Speech: play an assistant message via piper TTS */
async function playTTS(text, btn) {
if (!btn || btn._ttsPlaying) return;
btn._ttsPlaying = true;
const origHTML = btn.innerHTML;
btn.innerHTML = ' ';
btn.title = 'Generating audio...';
try {
const res = await api('/query/text-to-speech', {
method: 'POST',
body: JSON.stringify({ text: text.slice(0, 4000), voice: 'default', speed: 1.0, response_format: 'mp3' }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail?.message || 'TTS unavailable');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
btn.innerHTML = ' ';
btn.title = 'Playing... (click to stop)';
btn.onclick = (e) => { e.stopPropagation(); audio.pause(); };
audio.onended = () => { btn.innerHTML = origHTML; btn.title = 'Listen to this response'; btn._ttsPlaying = false; URL.revokeObjectURL(url); btn.onclick = null; };
audio.onerror = () => { btn.innerHTML = origHTML; btn.title = 'Listen to this response'; btn._ttsPlaying = false; URL.revokeObjectURL(url); btn.onclick = null; };
await audio.play();
} catch (err) {
btn.innerHTML = origHTML;
btn.title = err.message || 'TTS failed';
btn._ttsPlaying = false;
setTimeout(() => { if (btn) btn.title = 'Listen to this response'; }, 3000);
}
}
/* ═══════════════════════════════════════════════════════════
AGENT MODE — Plan-and-Execute with Streaming Steps
═══════════════════════════════════════════════════════════ */
async function sendAgentMessage(query) {
const input = document.getElementById('chat-input');
if (!currentSession) newChat();
currentSession.messages.push({ role: 'user', content: query });
if (currentSession.title === 'New Chat') currentSession.title = '[Agent] ' + query.slice(0, 35);
persistSession();
const msgs = document.getElementById('chat-messages');
const emptyEl = msgs.querySelector('.chat-empty');
if (emptyEl) emptyEl.remove();
msgs.innerHTML += `${esc(query)}
`;
input.value = ''; input.style.height = 'auto';
const assistantDiv = document.createElement('div');
assistantDiv.className = 'msg msg-assistant';
assistantDiv.innerHTML = macThinkingHTML();
msgs.appendChild(assistantDiv);
msgs.scrollTop = msgs.scrollHeight;
startMacThinking(assistantDiv);
const status = document.getElementById('chat-status');
status.textContent = 'Agent working...';
isStreaming = true;
try {
const res = await api('/agent/run', { method: 'POST', body: JSON.stringify({ query }) });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail?.message || 'Agent failed'); }
let stepsHtml = '';
let finalAnswer = '';
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') continue;
try {
const evt = JSON.parse(raw);
const evtType = evt.event || evt.type;
if (evtType === 'plan') {
stopMacThinking(assistantDiv);
const steps = evt.plan || evt.steps || [];
stepsHtml = 'Plan:
';
steps.forEach((s, i) => {
const title = typeof s === 'string' ? s : (s.title || s.description || `Step ${i+1}`);
stepsHtml += `Step ${i + 1}: ${esc(title)}
`;
});
assistantDiv.innerHTML = stepsHtml;
} else if (evtType === 'step_start') {
const si = (evt.step_index !== undefined ? evt.step_index : (evt.step ? evt.step - 1 : 0));
const stepEl = document.getElementById('agent-step-' + si);
if (stepEl) stepEl.classList.add('running');
status.textContent = 'Step ' + (si + 1) + '...';
} else if (evtType === 'step_complete' || evtType === 'step_result' || evtType === 'tool_result') {
const si = (evt.step_index !== undefined ? evt.step_index : (evt.step ? evt.step - 1 : 0));
const stepEl = document.getElementById('agent-step-' + si);
if (stepEl) {
stepEl.classList.remove('running');
stepEl.classList.add('done');
const output = evt.output || (evt.result && JSON.stringify(evt.result).slice(0, 500));
if (output) stepEl.innerHTML += `${esc(String(output).slice(0, 500))}
`;
}
} else if (evtType === 'complete') {
finalAnswer = evt.response || evt.content || '';
} else if (evtType === 'answer') {
finalAnswer = evt.content || evt.response || '';
} else if (evtType === 'error') {
stopMacThinking(assistantDiv);
assistantDiv.innerHTML += `Error: ${esc(evt.message || 'Unknown error')}
`;
}
} catch {}
}
msgs.scrollTop = msgs.scrollHeight;
}
if (finalAnswer) {
assistantDiv.innerHTML += `${formatMd(finalAnswer)}
`;
currentSession.messages.push({ role: 'assistant', content: finalAnswer });
persistSession();
}
} catch (ex) {
stopMacThinking(assistantDiv);
assistantDiv.innerHTML = `Agent error: ${esc(ex.message)}
`;
}
status.textContent = '';
isStreaming = false;
msgs.scrollTop = msgs.scrollHeight;
}
/* ═══════════════════════════════════════════════════════════
ADMIN PANEL — Full Control Dashboard
═══════════════════════════════════════════════════════════ */
let adminTab = 'overview';
async function renderAdmin() {
const el = document.getElementById('page-content');
if (!state.user || state.user.role !== 'admin') {
el.innerHTML = '';
return;
}
el.innerHTML = `
`;
document.querySelectorAll('#admin-tabs .admin-tab').forEach(t => {
t.onclick = () => { adminTab = t.dataset.tab; renderAdmin(); };
});
if (adminTab === 'overview') await renderAdminOverview();
else if (adminTab === 'users') await renderAdminUsers();
else if (adminTab === 'keys') await renderAdminKeys();
else if (adminTab === 'models') await renderAdminModels();
else if (adminTab === 'registry') await renderAdminRegistry();
else if (adminTab === 'cluster') await renderAdminCluster();
else if (adminTab === 'scoped_keys') await renderAdminScopedKeys();
else if (adminTab === 'audit') await renderAdminAuditLog();
else if (adminTab === 'guardrails') await renderAdminGuardrails();
else if (adminTab === 'activity') await renderAdminActivityStream();
}
async function renderAdminOverview() {
const el = document.getElementById('admin-content');
try {
const [stats, modelStats, exceeded, allUsage] = await Promise.all([
apiJson('/auth/admin/stats'),
apiJson('/usage/admin/models').catch(() => ({ models: [] })),
apiJson('/quota/admin/exceeded').catch(() => ({ users: [] })),
apiJson('/usage/admin/all?per_page=100').catch(() => ({ users: [] })),
]);
const allUsers = allUsage.users || [];
// Department breakdown
const deptMap = {};
allUsers.forEach(u => { deptMap[u.department] = (deptMap[u.department] || 0) + 1; });
// Top users by tokens
const topUsers = [...allUsers].sort((a, b) => (b.tokens_today || 0) - (a.tokens_today || 0)).slice(0, 5);
const models = modelStats.models || [];
const exceededUsers = exceeded.users || [];
el.innerHTML = `
Total Users
${stats.total_users}
${stats.active_users} active · ${stats.admin_count} admins
Requests Today
${fmtNum(stats.requests_today)}
across all users
Tokens Today
${fmtNum(stats.tokens_today)}
total consumed
${models.length > 0 ? `
Model Requests Tokens Avg Latency Users
${models.map(m => `
${esc(shortModel(m.model))}
${fmtNum(m.requests_today)}
${fmtNum(m.tokens_today)}
${m.avg_latency_ms || 0}ms
${m.unique_users_today || 0}
`).join('')}
` : '
'}
${topUsers.length > 0 ? `
${topUsers.map((u, i) => `
#${i + 1}
${esc(u.name)}
${esc(u.roll_number)} · ${esc(u.department)}
${fmtNum(u.tokens_today || 0)}
`).join('')}
` : '
'}
${exceededUsers.length > 0 ? `
User Dept Used Limit Over by
${exceededUsers.map(u => `
${esc(u.name || u.roll_number)}
${esc(u.department)}
${fmtNum(u.tokens_used)}
${fmtNum(u.daily_limit)}
${fmtNum(u.exceeded_by || 0)}
`).join('')}
` : '
No one has exceeded their quota
'}
`;
// Department chart
const deptLabels = Object.keys(deptMap);
const deptValues = Object.values(deptMap);
if (deptLabels.length > 0) {
const deptColors = ['#000', '#333', '#666', '#999', '#bbb', '#ddd'];
new Chart(document.getElementById('admin-dept-chart'), {
type: 'bar',
data: {
labels: deptLabels,
datasets: [{ data: deptValues, backgroundColor: deptColors.slice(0, deptLabels.length), borderRadius: 6, barPercentage: 0.6 }],
},
options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { grid: { display: false } }, y: { grid: { display: false } } } },
});
}
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
async function renderAdminUsers() {
const el = document.getElementById('admin-content');
try {
const data = await apiJson('/auth/admin/users');
const users = data.users || [];
el.innerHTML = `
Roll No Name Dept Role Status Pwd Joined Actions
${users.map(u => `
${esc(u.roll_number)}
${esc(u.name)}
${esc(u.department)}
${u.role}
${u.is_active ? ' Active' : ' Inactive'}
${u.must_change_password ? 'Pending ' : 'Set '}
${new Date(u.created_at).toLocaleDateString()}
Student
Faculty
Admin
${u.is_active ? ' ' : ' '}
`).join('')}
`;
el.querySelectorAll('.role-select').forEach(sel => {
sel.onchange = async () => { try { await api(`/auth/admin/users/${sel.dataset.uid}/role`, { method: 'PUT', body: JSON.stringify({ role: sel.value }) }); renderAdmin(); } catch { alert('Failed'); } };
});
el.querySelectorAll('.edit-user').forEach(btn => {
btn.onclick = () => showEditUserModal(btn.dataset.uid, btn.dataset.name, btn.dataset.email, btn.dataset.dept, btn.dataset.role);
});
el.querySelectorAll('.toggle-status').forEach(btn => {
btn.onclick = async () => { try { await api(`/auth/admin/users/${btn.dataset.uid}/status`, { method: 'PUT', body: JSON.stringify({ is_active: btn.dataset.active !== 'true' }) }); renderAdmin(); } catch { alert('Failed'); } };
});
el.querySelectorAll('.reset-pw').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Reset this user\'s password?')) return;
try { const r = await apiJson(`/auth/admin/users/${btn.dataset.uid}/reset-password`, { method: 'POST' }); alert(`Temp password: ${r.temp_password}\nUser must change on next login.`); renderAdmin(); } catch { alert('Failed'); }
};
});
el.querySelectorAll('.regen-key').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Regenerate API key? Old key will stop working.')) return;
try { const r = await apiJson(`/auth/admin/users/${btn.dataset.uid}/regenerate-key`, { method: 'POST' }); alert(`New key: ${r.api_key}`); renderAdmin(); } catch { alert('Failed'); }
};
});
document.getElementById('add-user-btn').onclick = showAddUserModal;
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
async function renderAdminKeys() {
const el = document.getElementById('admin-content');
try {
const data = await apiJson('/keys/admin/all');
const keys = data.keys || [];
el.innerHTML = `
Roll No Name Key Prefix Status Actions
${keys.map(k => `
${esc(k.roll_number)}
${esc(k.name)}
${esc(k.prefix || k.api_key_prefix || '---')}
${k.active !== false ? ' Active' : ' Revoked'}
Revoke
`).join('')}
`;
el.querySelectorAll('.revoke-key').forEach(btn => {
btn.onclick = async () => {
if (!confirm(`Revoke API key for ${btn.dataset.roll}?`)) return;
try { await api('/keys/admin/revoke', { method: 'POST', body: JSON.stringify({ roll_number: btn.dataset.roll }) }); renderAdmin(); } catch { alert('Failed'); }
};
});
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
async function renderAdminModels() {
const el = document.getElementById('admin-content');
try {
const [modelsData, modelStats] = await Promise.all([
apiJson('/models'),
apiJson('/usage/admin/models').catch(() => ({ models: [] })),
]);
const models = modelsData.models || [];
const stats = modelStats.models || [];
el.innerHTML = `
${models.map(m => {
const s = stats.find(st => st.model === m.id) || {};
return `
Requests ${fmtNum(s.requests_today || 0)}
Tokens ${fmtNum(s.tokens_today || 0)}
Latency ${s.avg_latency_ms || 0}ms
Users ${s.unique_users_today || 0}
`;
}).join('')}
`;
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
function showEditUserModal(uid, name, email, dept, role) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
Edit User
Name
Email
Department
CSE ECE ME CE EE IT Other
Role
Student Faculty Admin
Cancel
Save
`;
document.body.appendChild(overlay);
overlay.querySelector('#eu-cancel').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#eu-submit').onclick = async () => {
const err = overlay.querySelector('#eu-error');
err.textContent = '';
const body = {
name: overlay.querySelector('#eu-name').value.trim(),
email: overlay.querySelector('#eu-email').value.trim() || null,
department: overlay.querySelector('#eu-dept').value,
role: overlay.querySelector('#eu-role').value,
};
if (!body.name) { err.textContent = 'Name is required'; return; }
try {
const r = await api(`/auth/admin/users/${uid}`, { method: 'PUT', body: JSON.stringify(body) });
if (!r.ok) { const d = await r.json(); err.textContent = d.detail?.message || 'Failed'; return; }
overlay.remove(); renderAdmin();
} catch (ex) { err.textContent = ex.message; }
};
}
function showAddUserModal() {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
bindEyeToggles(overlay);
overlay.querySelector('#nu-cancel').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#nu-submit').onclick = async () => {
const err = overlay.querySelector('#nu-error');
err.textContent = '';
const body = {
roll_number: overlay.querySelector('#nu-roll').value.trim(),
name: overlay.querySelector('#nu-name').value.trim(),
password: overlay.querySelector('#nu-pass').value,
email: overlay.querySelector('#nu-email').value.trim() || null,
department: overlay.querySelector('#nu-dept').value,
role: overlay.querySelector('#nu-role').value,
must_change_password: overlay.querySelector('#nu-forcecp').checked,
};
if (!body.roll_number || !body.name || !body.password) { err.textContent = 'Roll number, name, password required'; return; }
if (body.password.length < 8) { err.textContent = 'Password min 8 characters'; return; }
try {
const r = await api('/auth/admin/users', { method: 'POST', body: JSON.stringify(body) });
if (!r.ok) { const d = await r.json(); err.textContent = d.detail?.message || 'Failed'; return; }
overlay.remove(); renderAdmin();
} catch (ex) { err.textContent = ex.message; }
};
}
async function renderAdminRegistry() {
const el = document.getElementById('admin-content');
try {
const data = await apiJson('/auth/admin/registry');
const entries = data.entries || [];
el.innerHTML = `
College database. Students verify against this to create accounts.
Roll No Name Dept DOB Batch
${entries.map(e => `
${esc(e.roll_number)}
${esc(e.name)}
${esc(e.department)}
${esc(e.dob)}
${e.batch_year || '-'}
`).join('')}
`;
document.getElementById('add-reg-btn').onclick = () => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
overlay.querySelector('#rg-cancel').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#rg-submit').onclick = async () => {
const err = overlay.querySelector('#rg-error');
err.textContent = '';
const body = {
roll_number: overlay.querySelector('#rg-roll').value.trim(),
name: overlay.querySelector('#rg-name').value.trim(),
department: overlay.querySelector('#rg-dept').value,
dob: overlay.querySelector('#rg-dob').value.trim(),
batch_year: parseInt(overlay.querySelector('#rg-batch').value) || null,
};
if (!body.roll_number || !body.name || !body.dob) { err.textContent = 'All fields except batch required'; return; }
try {
const r = await api('/auth/admin/registry', { method: 'POST', body: JSON.stringify(body) });
if (!r.ok) { const d = await r.json(); err.textContent = d.detail?.message || 'Failed'; return; }
overlay.remove(); renderAdmin();
} catch (ex) { err.textContent = ex.message; }
};
};
document.getElementById('bulk-reg-btn').onclick = () => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
Bulk Import Students
Paste JSON array. Each: { roll_number, name, department, dob, batch_year }
Cancel
Import
`;
document.body.appendChild(overlay);
overlay.querySelector('#bulk-cancel').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#bulk-submit').onclick = async () => {
const err = overlay.querySelector('#bulk-error');
const res = overlay.querySelector('#bulk-result');
err.textContent = ''; res.textContent = '';
let students;
try { students = JSON.parse(overlay.querySelector('#bulk-json').value); } catch { err.textContent = 'Invalid JSON'; return; }
if (!Array.isArray(students)) { err.textContent = 'Must be array'; return; }
try {
const r = await apiJson('/auth/admin/registry/bulk', { method: 'POST', body: JSON.stringify({ students }) });
res.innerHTML = `${esc(r.message)} ` +
(r.errors?.length ? `Errors: ${r.errors.join(', ')} ` : '');
} catch (ex) { err.textContent = ex.message; }
};
};
document.getElementById('upload-reg-btn').onclick = () => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
Upload Student List (CSV or JSON)
CSV columns: roll_number, name, department, dob, batch_year
JSON: array of objects or {"students": [...]}
Cancel
Upload
`;
document.body.appendChild(overlay);
const fileInput = overlay.querySelector('#reg-file-input');
const dropArea = overlay.querySelector('#reg-file-drop');
let selectedFile = null;
dropArea.onclick = () => fileInput.click();
dropArea.ondragover = (e) => { e.preventDefault(); dropArea.classList.add('dragover'); };
dropArea.ondragleave = () => dropArea.classList.remove('dragover');
dropArea.ondrop = (e) => { e.preventDefault(); dropArea.classList.remove('dragover'); if (e.dataTransfer.files[0]) pickFile(e.dataTransfer.files[0]); };
fileInput.onchange = () => { if (fileInput.files[0]) pickFile(fileInput.files[0]); };
function pickFile(f) {
if (!f.name.match(/\.(csv|json)$/i)) { overlay.querySelector('#reg-upload-error').textContent = 'Only .csv or .json files'; return; }
if (f.size > 5*1024*1024) { overlay.querySelector('#reg-upload-error').textContent = 'File too large (max 5MB)'; return; }
selectedFile = f;
overlay.querySelector('#reg-file-name').textContent = f.name + ' (' + (f.size/1024).toFixed(1) + ' KB)';
overlay.querySelector('#reg-upload-error').textContent = '';
overlay.querySelector('#reg-upload-submit').disabled = false;
}
overlay.querySelector('#reg-upload-cancel').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#reg-upload-submit').onclick = async () => {
if (!selectedFile) return;
const err = overlay.querySelector('#reg-upload-error');
const res = overlay.querySelector('#reg-upload-result');
err.textContent = ''; res.textContent = 'Uploading...';
const submitBtn = overlay.querySelector('#reg-upload-submit');
submitBtn.disabled = true;
try {
const form = new FormData();
form.append('file', selectedFile);
const tok = localStorage.getItem('mac_token');
const r = await fetch(API + '/auth/admin/registry/upload', {
method: 'POST',
headers: tok ? { 'Authorization': 'Bearer ' + tok } : {},
body: form,
});
const data = await r.json();
if (!r.ok) { err.textContent = data.detail || 'Upload failed'; res.textContent = ''; submitBtn.disabled = false; return; }
res.innerHTML = '' + esc(data.message) + ' ' +
(data.errors?.length ? 'Errors: ' + data.errors.join(', ') + ' ' : '');
setTimeout(() => { overlay.remove(); renderAdmin(); }, 2000);
} catch (ex) { err.textContent = ex.message; res.textContent = ''; submitBtn.disabled = false; }
};
};
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
/* ═══════════════════════════════════════════════════════════
ADMIN — Cluster / Nodes Management
═══════════════════════════════════════════════════════════ */
async function renderAdminCluster() {
const el = document.getElementById('admin-content');
try {
const data = await apiJson('/nodes');
const nodes = data.nodes || [];
el.innerHTML = `
${nodes.length === 0 ? '
No worker nodes enrolled yet. Generate an enrollment token to add GPU workers.
' : nodes.map(n => `
${esc(n.ip_address || '')}:${n.port || ''} · ${esc(n.gpu_name || 'Unknown GPU')} · ${n.gpu_vram_mb ? Math.round(n.gpu_vram_mb/1024) + 'GB VRAM' : ''}
${n.gpu_util_pct != null ? n.gpu_util_pct + '%' : '--'} GPU
${n.cpu_util_pct != null ? n.cpu_util_pct + '%' : '--'} CPU
${n.ram_used_mb && n.ram_total_mb ? Math.round(n.ram_used_mb/n.ram_total_mb*100) + '%' : '--'} RAM
${n.gpu_vram_used_mb && n.gpu_vram_mb ? Math.round(n.gpu_vram_used_mb/n.gpu_vram_mb*100) + '%' : '--'} VRAM
${n.status === 'online' ? `Drain ` : ''}
${n.status === 'draining' || n.status === 'offline' ? `Activate ` : ''}
Remove
`).join('')}
`;
document.getElementById('gen-enroll-token').onclick = async () => {
const label = prompt('Label for this token (e.g. "PC3-GPU"):');
if (!label) return;
try {
const r = await apiJson('/nodes/enrollment-token', { method: 'POST', body: JSON.stringify({ label, expires_in_hours: 24 }) });
alert('Enrollment Token (use within 24h):\\n\\n' + r.token + '\\n\\nLabel: ' + r.label);
} catch (ex) { alert('Failed: ' + ex.message); }
};
el.querySelectorAll('.drain-node').forEach(btn => {
btn.onclick = async () => { try { await api('/nodes/' + btn.dataset.id + '/drain', { method: 'POST' }); renderAdmin(); } catch { alert('Failed'); } };
});
el.querySelectorAll('.activate-node').forEach(btn => {
btn.onclick = async () => { try { await api('/nodes/' + btn.dataset.id + '/activate', { method: 'POST' }); renderAdmin(); } catch { alert('Failed'); } };
});
el.querySelectorAll('.remove-node').forEach(btn => {
btn.onclick = async () => { if (!confirm('Remove this node?')) return; try { await api('/nodes/' + btn.dataset.id, { method: 'DELETE' }); renderAdmin(); } catch { alert('Failed'); } };
});
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
/* ═══════════════════════════════════════════════════════════
ADMIN — Scoped API Keys
═══════════════════════════════════════════════════════════ */
async function renderAdminScopedKeys() {
const el = document.getElementById('admin-content');
try {
const data = await apiJson('/scoped-keys/admin/all');
const keys = data.keys || [];
el.innerHTML = `
${keys.length === 0 ? 'No scoped API keys created yet
' : `
Owner Name Models Req/hr Tok/day Usage Expires Actions
${keys.map(k => `
${esc(k.user_roll || k.user_id)}
${esc(k.name)}
${(k.allowed_models || []).map(m => '' + esc(m) + ' ').join(' ') || 'All '}
${k.requests_per_hour || '∞'}
${fmtNum(k.tokens_per_day || 0)}
${fmtNum(k.total_requests || 0)} req / ${fmtNum(k.total_tokens || 0)} tok
${k.expires_at ? new Date(k.expires_at).toLocaleDateString() : 'Never'}
Revoke
`).join('')}
`}`;
el.querySelectorAll('.revoke-scoped').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Revoke this scoped key?')) return;
try { await api('/scoped-keys/admin/' + btn.dataset.id, { method: 'DELETE' }); renderAdmin(); } catch { alert('Failed'); }
};
});
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
/* ═══════════════════════════════════════════════════════════
ADMIN — Audit Log
═══════════════════════════════════════════════════════════ */
async function renderAdminAuditLog() {
const el = document.getElementById('admin-content');
try {
const data = await apiJson('/notifications/audit-logs?per_page=100');
const logs = data.logs || [];
el.innerHTML = `
${logs.length === 0 ? 'No audit events recorded yet
' : `
Time Actor Action Resource Details IP
${logs.map(l => `
${timeAgo(l.created_at)}
${esc(l.actor_roll || l.actor_id || 'system')}
${esc(l.action)}
${esc(l.resource_type || '')}${l.resource_id ? '#' + l.resource_id : ''}
${esc((l.details || '').slice(0, 80))}
${esc(l.ip_address || '-')}
`).join('')}
`}`;
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
/* ═══════════════════════════════════════════════════════════
ADMIN — Guardrails Control Panel
═══════════════════════════════════════════════════════════ */
async function renderAdminGuardrails() {
const el = document.getElementById('admin-content');
el.innerHTML = `
`;
document.getElementById('gr-add-btn').onclick = () => showAddGuardrailModal();
await loadGuardrailRules();
}
async function loadGuardrailRules() {
const el = document.getElementById('gr-rules-list');
if (!el) return;
try {
const data = await apiJson('/guardrails/rules');
const rules = data.rules || [];
const cats = [...new Set(rules.map(r => r.category))].sort();
const actionColors = {block:'#ef4444',flag:'#f97316',redact:'#7c3aed',log:'#3b82f6'};
el.innerHTML = cats.map(cat => `
${rules.filter(r => r.category === cat).map(rule => `
${esc(rule.action)}
${rule.priority}
×
`).join('')}
`).join('');
// Bind toggles
el.querySelectorAll('.gr-toggle-input').forEach(cb => {
cb.onchange = async () => {
const ruleId = cb.dataset.ruleId;
cb.disabled = true;
try {
await api(`/guardrails/rules/${ruleId}/toggle`, { method: 'PATCH' });
const row = el.querySelector(`.gr-rule-row[data-rule-id="${ruleId}"]`);
if (row) row.classList.toggle('disabled', !cb.checked);
showToast(cb.checked ? 'Rule enabled' : 'Rule disabled', 'success');
} catch(ex) { cb.checked = !cb.checked; showToast('Failed: ' + ex.message, 'error'); }
cb.disabled = false;
};
});
// Bind delete buttons
el.querySelectorAll('.gr-delete-btn').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Delete this guardrail rule?')) return;
const ruleId = btn.dataset.ruleId;
try {
await api(`/guardrails/rules/${ruleId}`, { method: 'DELETE' });
showToast('Rule deleted', 'success');
await loadGuardrailRules();
} catch(ex) { showToast('Failed: ' + ex.message, 'error'); }
};
});
} catch(ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
function showAddGuardrailModal() {
showModal({
title: 'Add Guardrail Rule',
body: `
Pattern (regex)
Description
`,
confirmText: 'Add Rule',
onConfirm: async () => {
const pattern = document.getElementById('gr-new-pattern').value.trim();
if (!pattern) { showToast('Pattern is required', 'error'); return false; }
const body = {
category: document.getElementById('gr-new-cat').value.trim() || 'custom',
action: document.getElementById('gr-new-action').value,
pattern,
description: document.getElementById('gr-new-desc').value.trim(),
priority: parseInt(document.getElementById('gr-new-priority').value) || 100,
enabled: document.getElementById('gr-new-enabled').checked,
};
const r = await api('/guardrails/rules', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
if (!r.ok) { const d = await r.json(); showToast(d.detail || 'Failed', 'error'); return false; }
showToast('Rule added', 'success');
closeModal();
await loadGuardrailRules();
},
});
}
/* ═══════════════════════════════════════════════════════════
ADMIN — Live Activity Stream (SSE)
═══════════════════════════════════════════════════════════ */
let _activityEs = null;
let _activityLog = [];
async function renderAdminActivityStream() {
const el = document.getElementById('admin-content');
el.innerHTML = `
Connecting to live stream…
`;
document.getElementById('activity-clear-btn').onclick = () => {
_activityLog = [];
renderActivityFeed();
};
// Disconnect any previous SSE connection
if (_activityEs) { _activityEs.close(); _activityEs = null; }
const token = state.token || '';
const url = `/api/v1/notifications/activity-stream?token=${encodeURIComponent(token)}`;
_activityEs = new EventSource(url);
_activityEs.onopen = () => {
document.getElementById('activity-status-dot')?.classList.replace('offline', 'online');
document.getElementById('activity-status-label').textContent = 'Live';
};
_activityEs.addEventListener('connected', (e) => {
const feed = document.getElementById('activity-feed');
if (feed) feed.innerHTML = 'Waiting for activity…
';
});
_activityEs.addEventListener('activity', (e) => {
try {
const entry = JSON.parse(e.data);
_activityLog.unshift(entry);
if (_activityLog.length > 200) _activityLog.pop();
renderActivityFeed();
} catch {}
});
_activityEs.addEventListener('error', (e) => {
try {
const entry = JSON.parse(e.data);
const feed = document.getElementById('activity-feed');
if (feed) feed.innerHTML = `${esc(entry.detail || 'Stream error')}
`;
} catch {}
});
_activityEs.onerror = () => {
document.getElementById('activity-status-dot')?.classList.replace('online', 'offline');
const labelEl = document.getElementById('activity-status-label');
if (labelEl) labelEl.textContent = 'Reconnecting…';
};
// Cleanup when admin tab changes
const tabObserver = new MutationObserver(() => {
if (!document.getElementById('activity-feed')) {
if (_activityEs) { _activityEs.close(); _activityEs = null; }
tabObserver.disconnect();
}
});
const adminContent = document.getElementById('admin-content');
if (adminContent) tabObserver.observe(adminContent, { childList: true });
}
function renderActivityFeed() {
const feed = document.getElementById('activity-feed');
if (!feed) return;
if (_activityLog.length === 0) {
feed.innerHTML = 'No activity yet.
';
return;
}
const actionColors = {
'copy_check': '#7c3aed', 'auth': '#3b82f6', 'admin': '#ef4444',
'query': '#22c55e', 'system': '#888',
};
feed.innerHTML = _activityLog.map(entry => {
const catKey = (entry.action || '').split('.')[0];
const color = actionColors[catKey] || '#888';
return `
${esc(entry.action || '')}
${esc(entry.actor_role || '')} · ${esc(entry.resource_type || '')}
${entry.details ? `${esc((entry.details || '').slice(0, 120))} ` : ''}
${entry.created_at ? timeAgo(entry.created_at) : ''}
`;
}).join('');
}
let doubtView = 'list';
let doubtDetailId = null;
let doubtFilter = 'all';
async function renderDoubts() {
const el = document.getElementById('page-content');
if (doubtView === 'detail' && doubtDetailId) {
await renderDoubtDetail(el, doubtDetailId);
return;
}
el.innerHTML = '';
try {
const u = state.user || {};
const isFacultyOrAdmin = u.role === 'faculty' || u.role === 'admin';
let endpoint = isFacultyOrAdmin ? '/doubts/all' : '/doubts/my';
if (doubtFilter !== 'all') endpoint += '?status=' + doubtFilter;
const data = await apiJson(endpoint);
const doubts = data.doubts || [];
el.innerHTML = `
All Status
Open
Answered
Closed
${doubts.length === 0 ? 'No doubts found. Ask a question to get started!
' :
doubts.map(d => `
${esc(d.department || '')}${d.subject ? ' · ' + esc(d.subject) : ''}
${d.is_anonymous ? 'Anonymous' : esc(d.student_name || '')}
${timeAgo(d.created_at)}
${d.reply_count ? '' + d.reply_count + ' replies ' : ''}
${esc((d.body || '').slice(0, 200))}
`).join('')}`;
document.getElementById('doubt-filter-status').onchange = (e) => { doubtFilter = e.target.value; renderDoubts(); };
el.querySelectorAll('.doubt-card').forEach(card => {
card.onclick = () => { doubtDetailId = card.dataset.doubtId; doubtView = 'detail'; renderDoubts(); };
});
document.getElementById('new-doubt-btn').onclick = showNewDoubtModal;
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
async function renderDoubtDetail(el, id) {
el.innerHTML = '';
try {
const data = await apiJson('/doubts/' + id);
const d = data.doubt || data;
const replies = data.replies || [];
const u = state.user || {};
const canReply = u.role === 'faculty' || u.role === 'admin' || u.id === d.student_id;
el.innerHTML = `
← Back to list
${esc(d.department || '')}${d.subject ? ' · ' + esc(d.subject) : ''}
${d.is_anonymous ? 'Anonymous' : esc(d.student_name || '')}
${timeAgo(d.created_at)}
${formatMd(d.body || '')}
Replies (${replies.length})
${replies.length === 0 ? '
' :
replies.map(r => `
${esc(r.author_name || 'Unknown')} ${esc(r.author_role || '')}
${formatMd(r.body || '')}
${timeAgo(r.created_at)}
`).join('')}
${canReply ? `
Send Reply
` : ''}
`;
document.getElementById('doubt-back').onclick = () => { doubtView = 'list'; doubtDetailId = null; renderDoubts(); };
const replyBtn = document.getElementById('doubt-reply-btn');
if (replyBtn) {
replyBtn.onclick = async () => {
const text = document.getElementById('doubt-reply-text').value.trim();
if (!text) return;
try {
await api('/doubts/' + id + '/reply', { method: 'POST', body: JSON.stringify({ body: text }) });
renderDoubtDetail(el, id);
} catch (ex) { alert('Failed: ' + ex.message); }
};
}
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
function showNewDoubtModal() {
const u = state.user || {};
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
overlay.querySelector('#dbt-cancel').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#dbt-submit').onclick = async () => {
const err = overlay.querySelector('#dbt-error');
err.textContent = '';
const body = {
title: overlay.querySelector('#dbt-title').value.trim(),
body: overlay.querySelector('#dbt-body').value.trim(),
department: overlay.querySelector('#dbt-dept').value,
subject: overlay.querySelector('#dbt-subject').value.trim() || null,
is_anonymous: overlay.querySelector('#dbt-anon').checked,
};
if (!body.title || !body.body) { err.textContent = 'Title and question are required'; return; }
try {
const r = await api('/doubts', { method: 'POST', body: JSON.stringify(body) });
if (!r.ok) { const d = await r.json(); err.textContent = d.detail?.message || 'Failed'; return; }
overlay.remove();
renderDoubts();
} catch (ex) { err.textContent = ex.message; }
};
}
/* ═══════════════════════════════════════════════════════════
ATTENDANCE PAGE — Student Mark / Faculty+Admin Manage
═══════════════════════════════════════════════════════════ */
let _attdCameraStream = null;
let _attdLivenessState = { blinkDetected: false, eyeCenter: false, frameCount: 0, passedChecks: 0 };
function _stopAttdCamera() {
if (_attdCameraStream) {
_attdCameraStream.getTracks().forEach(t => t.stop());
_attdCameraStream = null;
}
}
async function renderAttendance() {
const el = document.getElementById('page-content');
const u = state.user || {};
_stopAttdCamera();
if (u.role === 'student') {
await renderStudentAttendance(el);
} else {
await renderFacultyAttendance(el);
}
}
/* ── Student Attendance: Face capture + liveness ────────── */
async function renderStudentAttendance(el) {
el.innerHTML = '';
try {
// Fetch face status + today's sessions with already_marked info in one go
const [faceStatus, todayData] = await Promise.all([
apiJson('/attendance/face-status'),
apiJson('/attendance/my-today'),
]);
const sessions = todayData.sessions || [];
const liveSessions = sessions.filter(s => s.is_open);
const windowOpen = todayData.window_open;
const windowStr = todayData.window || '';
el.innerHTML = `
${faceStatus.registered
? '
'
: '
'}
${faceStatus.registered ? 'Face Registered' : 'Face Not Registered'}
${faceStatus.registered
? 'Last updated: ' + (faceStatus.captured_at ? timeAgo(faceStatus.captured_at) : 'N/A')
: 'Register before marking attendance.'}
${faceStatus.registered ? 'Update Face' : 'Register Face'}
Today's Sessions
${liveSessions.length} live
${sessions.length === 0
? '
No sessions today.
Check back during class hours.
'
: `
${sessions.map(s => {
const marked = s.already_marked;
const isLive = s.is_open;
return `
${esc(s.title)}
${esc(s.department || '')}${s.subject ? ' · ' + esc(s.subject) : ''}
${marked
? `
`
: isLive
? `
Mark
`
: `
Closed `}
`;
}).join('')}
`}
`;
// Bind register face
document.getElementById('attd-register-face-btn').onclick = () => showFaceCaptureModal('register');
// Bind mark attendance buttons
el.querySelectorAll('.attd-mark-btn').forEach(btn => {
btn.onclick = () => showFaceCaptureModal('mark', btn.dataset.sessionId, btn.dataset.sessionTitle);
});
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
/* ── Face Capture Modal with Liveness Detection ──────────── */
function showFaceCaptureModal(mode, sessionId, sessionTitle) {
_stopAttdCamera();
_attdLivenessState = { blinkDetected: false, eyeCenter: false, frameCount: 0, passedChecks: 0, capturedImage: null };
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
${mode === 'register' ? 'Register Your Face' : 'Mark Attendance'}
${mode === 'mark' ? `
Session: ${sessionTitle || ''}
` : ''}
⏳ Face detected in frame
⏳ Eyes looking at camera
⏳ Hold still for capture
Photo captured!
Cancel
Retake
${mode === 'register' ? 'Register Face' : 'Submit Attendance'}
`;
document.body.appendChild(overlay);
const video = overlay.querySelector('#face-video');
const canvas = overlay.querySelector('#face-canvas');
const guideText = overlay.querySelector('#face-guide-text');
const previewArea = overlay.querySelector('#face-capture-preview');
const previewImg = overlay.querySelector('#face-preview-img');
const previewMsg = overlay.querySelector('#face-preview-msg');
const submitBtn = overlay.querySelector('#face-submit');
const retakeBtn = overlay.querySelector('#face-retake');
const errorEl = overlay.querySelector('#face-error');
const lcFace = overlay.querySelector('#lc-face');
const lcEyes = overlay.querySelector('#lc-eyes');
const lcStill = overlay.querySelector('#lc-still');
let livenessIv = null;
let capturedDataUrl = null;
let videoReady = false;
let cancelled = false; // guard against cancel during camera init
function setCheck(el, status) {
const icon = el.querySelector('.lc-icon');
if (status === 'pass') { icon.textContent = '✅'; el.classList.add('passed'); el.classList.remove('fail'); }
else if (status === 'fail') { icon.textContent = '❌'; el.classList.add('fail'); el.classList.remove('passed'); }
else { icon.textContent = '⏳'; el.classList.remove('passed', 'fail'); }
}
// Start camera
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, audio: false })
.then(stream => {
if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; }
_attdCameraStream = stream;
video.srcObject = stream;
video.onloadedmetadata = () => {
videoReady = true;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
guideText.textContent = 'Position your face in the oval';
startLivenessDetection();
};
})
.catch(err => {
guideText.textContent = 'Camera access denied';
errorEl.textContent = 'Please allow camera access to continue. Error: ' + err.message;
});
function startLivenessDetection() {
let stableFrames = 0;
let faceDetected = false;
const REQUIRED_STABLE = 25; // ~2.5 seconds at 10fps
livenessIv = setInterval(() => {
if (!videoReady || !_attdCameraStream) return;
_attdLivenessState.frameCount++;
// Draw to canvas for analysis
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Simple face-area brightness analysis (center oval region)
const cx = canvas.width / 2, cy = canvas.height / 2;
const rx = canvas.width * 0.25, ry = canvas.height * 0.35;
let skinPixels = 0, totalPixels = 0, brightnessSum = 0;
const d = imageData.data;
for (let y = Math.floor(cy - ry); y < Math.floor(cy + ry); y += 3) {
for (let x = Math.floor(cx - rx); x < Math.floor(cx + rx); x += 3) {
// Check if inside oval
const dx = (x - cx) / rx, dy = (y - cy) / ry;
if (dx * dx + dy * dy > 1) continue;
totalPixels++;
const i = (y * canvas.width + x) * 4;
const r = d[i], g = d[i + 1], b = d[i + 2];
brightnessSum += (r + g + b) / 3;
// Simple skin-tone detection (works across skin tones)
if (r > 60 && g > 40 && b > 20 && r > b && (r - g) < 80 && (Math.max(r, g, b) - Math.min(r, g, b)) < 130) {
skinPixels++;
}
}
}
const skinRatio = totalPixels > 0 ? skinPixels / totalPixels : 0;
const avgBrightness = totalPixels > 0 ? brightnessSum / totalPixels : 0;
faceDetected = skinRatio > 0.2 && avgBrightness > 40 && avgBrightness < 240;
// Check 1: Face in frame
if (faceDetected) {
setCheck(lcFace, 'pass');
} else {
setCheck(lcFace, 'fail');
stableFrames = 0;
guideText.textContent = 'Position your face in the oval';
return;
}
// Check 2: Eyes looking at camera (center of face region has expected brightness variance)
const eyeRegionY = cy - ry * 0.2;
let eyeVariance = 0, eyePixels = 0;
for (let y = Math.floor(eyeRegionY - 20); y < Math.floor(eyeRegionY + 20); y += 2) {
for (let x = Math.floor(cx - rx * 0.5); x < Math.floor(cx + rx * 0.5); x += 2) {
eyePixels++;
const i = (y * canvas.width + x) * 4;
const bright = (d[i] + d[i + 1] + d[i + 2]) / 3;
eyeVariance += Math.abs(bright - avgBrightness);
}
}
const eyeContrast = eyePixels > 0 ? eyeVariance / eyePixels : 0;
const eyesOk = eyeContrast > 8; // Eyes have noticeable contrast (irises/pupils)
if (eyesOk && faceDetected) {
setCheck(lcEyes, 'pass');
_attdLivenessState.eyeCenter = true;
} else {
setCheck(lcEyes, 'fail');
stableFrames = 0;
guideText.textContent = 'Look directly at the camera';
return;
}
// Check 3: Hold still
stableFrames++;
const progress = Math.min(100, Math.round((stableFrames / REQUIRED_STABLE) * 100));
guideText.textContent = `Hold still... ${progress}%`;
if (stableFrames >= REQUIRED_STABLE) {
setCheck(lcStill, 'pass');
// Auto-capture
clearInterval(livenessIv);
livenessIv = null;
capturePhoto();
} else {
setCheck(lcStill, 'pending');
}
}, 100);
}
function capturePhoto() {
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
capturedDataUrl = canvas.toDataURL('image/jpeg', 0.85);
_attdLivenessState.capturedImage = capturedDataUrl;
// Show preview
video.parentElement.style.display = 'none';
overlay.querySelector('#liveness-checks').style.display = 'none';
previewArea.style.display = 'block';
previewImg.src = capturedDataUrl;
previewMsg.textContent = 'Photo captured! Review and submit.';
submitBtn.style.display = '';
retakeBtn.style.display = '';
guideText.textContent = '';
_stopAttdCamera();
}
retakeBtn.onclick = () => {
capturedDataUrl = null;
previewArea.style.display = 'none';
video.parentElement.style.display = '';
overlay.querySelector('#liveness-checks').style.display = '';
submitBtn.style.display = 'none';
retakeBtn.style.display = 'none';
errorEl.textContent = '';
setCheck(lcFace, 'pending'); setCheck(lcEyes, 'pending'); setCheck(lcStill, 'pending');
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, audio: false })
.then(stream => {
if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; }
_attdCameraStream = stream;
video.srcObject = stream;
videoReady = true;
startLivenessDetection();
});
};
submitBtn.onclick = async () => {
if (!capturedDataUrl) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
errorEl.textContent = '';
try {
if (mode === 'register') {
const r = await api('/attendance/register-face', {
method: 'POST',
body: JSON.stringify({ face_image_base64: capturedDataUrl }),
});
const d = await r.json();
if (!r.ok || !d.success) { errorEl.textContent = d.message || d.detail || 'Registration failed'; submitBtn.disabled = false; submitBtn.textContent = 'Register Face'; return; }
overlay.remove(); _stopAttdCamera();
renderAttendance();
} else {
const r = await api('/attendance/mark', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, face_image_base64: capturedDataUrl }),
});
const d = await r.json();
if (!r.ok || !d.success) { errorEl.textContent = d.message || d.detail || 'Attendance marking failed'; submitBtn.disabled = false; submitBtn.textContent = 'Submit Attendance'; return; }
overlay.remove(); _stopAttdCamera();
// Show success toast
showToast('Attendance marked successfully! Confidence: ' + ((d.confidence || 0.95) * 100).toFixed(0) + '%', 'success');
renderAttendance();
}
} catch (ex) {
errorEl.textContent = ex.message;
submitBtn.disabled = false;
submitBtn.textContent = mode === 'register' ? 'Register Face' : 'Submit Attendance';
}
};
overlay.querySelector('#face-cancel').onclick = () => { cancelled = true; if (livenessIv) clearInterval(livenessIv); _stopAttdCamera(); overlay.remove(); };
overlay.onclick = (e) => { if (e.target === overlay) { cancelled = true; if (livenessIv) clearInterval(livenessIv); _stopAttdCamera(); overlay.remove(); } };
}
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = `toast toast-${type || 'info'}`;
toast.textContent = message;
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:12px 24px;border-radius:10px;color:#fff;font-weight:600;z-index:100000;animation:fadeInUp .3s ease;max-width:90vw;text-align:center;' +
(type === 'success' ? 'background:#16a34a;' : type === 'error' ? 'background:#dc2626;' : 'background:#333;');
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity .3s'; setTimeout(() => toast.remove(), 300); }, 4000);
}
/**
* Generic modal helper.
* @param {Object} opts - { title, body (HTML string), confirmText, onConfirm (async fn, return false to keep open) }
*/
function showModal({ title, body, confirmText = 'Confirm', onConfirm } = {}) {
closeModal();
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'generic-modal-overlay';
overlay.innerHTML = `
${body || ''}
Cancel
${esc(confirmText)}
`;
document.body.appendChild(overlay);
overlay.querySelector('.modal-close-btn').onclick = closeModal;
overlay.querySelector('#gm-cancel-btn').onclick = closeModal;
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
overlay.querySelector('#gm-confirm-btn').onclick = async () => {
const btn = overlay.querySelector('#gm-confirm-btn');
btn.disabled = true;
const result = onConfirm ? await onConfirm() : undefined;
if (result !== false) closeModal();
else btn.disabled = false;
};
}
function closeModal() {
document.getElementById('generic-modal-overlay')?.remove();
}
/* ── Faculty/Admin Attendance Management ─────────────────── */
async function renderFacultyAttendance(el) {
const u = state.user || {};
const isAdmin = u.role === 'admin';
el.innerHTML = '';
try {
const [overview, settings] = await Promise.all([
apiJson('/attendance/admin/overview?per_page=50'),
apiJson('/attendance/settings'),
]);
const sessions = overview.sessions || [];
el.innerHTML = `
${sessions.length === 0
? '
No attendance sessions yet.
'
: sessions.map(s => `
${s.is_open ? 'LIVE' : 'CLOSED'}
${esc(s.title)}
${esc(s.department)}${s.subject ? ' · ' + esc(s.subject) : ''}
${new Date(s.session_date).toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})}
${s.record_count} present
${s.avg_confidence != null ? `
${s.avg_confidence}% avg ` : ''}
⬇ CSV
⬇ PDF
${s.is_open ? `
Close Session ` : ''}
${s.record_count > 0 ? 'View Students ▾' : 'No Records'}
Opened by
${esc(s.opened_by_name)} ${s.opened_by_email ? '
(' + esc(s.opened_by_email) + ') ' : ''} · ${timeAgo(s.opened_at)}
${s.students && s.students.length > 0 ? `
# Roll No Name Dept Face Confidence Time IP
${s.students.map((r, i) => `
${i + 1}
${esc(r.roll_number || '—')}
${esc(r.name || 'Unknown')}
${esc(r.department || '—')}
${r.face_verified
? '✓ Verified '
: '✗ Failed '}
${r.confidence}%
${timeAgo(r.marked_at)}
${esc(r.ip_address || '—')}
`).join('')}
` : '
No students have marked attendance yet.
'}
`).join('')}
`;
// New session button
document.getElementById('new-attd-btn').onclick = () => _showNewSessionModal();
// Edit window button (admin only)
el.querySelector('.attd-edit-window-btn')?.addEventListener('click', () => _showWindowSettingsModal(settings));
// Close session buttons
el.querySelectorAll('.attd-close-btn').forEach(btn => {
btn.onclick = async () => {
btn.disabled = true; btn.textContent = 'Closing...';
try { await api('/attendance/sessions/' + btn.dataset.id + '/close', { method: 'POST' }); renderAttendance(); }
catch (ex) { btn.disabled = false; btn.textContent = 'Close Session'; alert('Failed: ' + ex.message); }
};
});
// Expand/collapse student records
el.querySelectorAll('.attd-expand-btn').forEach(btn => {
btn.onclick = () => {
const panel = document.getElementById('asr-' + btn.dataset.id);
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : 'block';
btn.textContent = open ? 'View Students ▾' : 'Hide Students ▴';
};
});
} catch (ex) { el.innerHTML = `Error: ${esc(ex.message)}
`; }
}
function _showNewSessionModal() {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
New Attendance Session
Title
Department
CSE ECE ME CE EE IT
Subject
AI CSE IT Math Physics Other
Cancel
Create
`;
document.body.appendChild(overlay);
overlay.querySelector('#attd-cancel').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const titleInput = overlay.querySelector('#attd-title');
titleInput.focus();
overlay.querySelector('#attd-submit').onclick = async () => {
const err = overlay.querySelector('#attd-error');
err.textContent = '';
const body = {
title: titleInput.value.trim(),
department: overlay.querySelector('#attd-dept').value,
subject: overlay.querySelector('#attd-subject').value || null,
session_date: new Date().toISOString().slice(0, 10),
};
if (!body.title) { err.textContent = 'Title is required'; return; }
const btn = overlay.querySelector('#attd-submit');
btn.disabled = true; btn.textContent = 'Creating...';
try {
const r = await api('/attendance/sessions', { method: 'POST', body: JSON.stringify(body) });
if (!r.ok) {
const d = await r.json();
err.textContent = (typeof d.detail === 'string' ? d.detail : d.detail?.message) || 'Failed';
btn.disabled = false; btn.textContent = 'Create'; return;
}
overlay.remove();
renderAttendance();
} catch (ex) { err.textContent = ex.message; btn.disabled = false; btn.textContent = 'Create'; }
};
}
function _showWindowSettingsModal(current) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
Attendance Window
Set daily open/close times in IST. Changes apply immediately.
Default: 00:01–12:01 IST (midnight to noon). Students can only mark attendance during this window.
Cancel
Save
`;
document.body.appendChild(overlay);
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#wnd-save').onclick = async () => {
const errEl = overlay.querySelector('#wnd-error');
errEl.textContent = '';
const openVal = overlay.querySelector('#wnd-open').value;
const closeVal = overlay.querySelector('#wnd-close').value;
if (!openVal || !closeVal) { errEl.textContent = 'Both times required'; return; }
const [oh, om] = openVal.split(':').map(Number);
const [ch, cm] = closeVal.split(':').map(Number);
if (oh * 60 + om >= ch * 60 + cm) { errEl.textContent = 'Close time must be after open time'; return; }
const btn = overlay.querySelector('#wnd-save');
btn.disabled = true; btn.textContent = 'Saving...';
try {
await api('/attendance/settings', {
method: 'PUT',
body: JSON.stringify({ open_hour: oh, open_minute: om, close_hour: ch, close_minute: cm }),
});
overlay.remove();
showToast('Attendance window updated!', 'success');
renderAttendance();
} catch (ex) { errEl.textContent = ex.message; btn.disabled = false; btn.textContent = 'Save'; }
};
}
/* ═══════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════
COPY CHECK — Session-based AI vision marking + plagiarism
Faculty & Admin only. Students are redirected.
═══════════════════════════════════════════════════════════ */
let ccView = 'list'; // 'list' | 'detail'
let ccSessionId = null; // active session ID
let ccEvalTimer = null; // polling interval for evaluation progress
async function renderCopyCheck() {
const el = document.getElementById('page-content');
el.className = 'page';
const u = state.user || {};
if (u.role === 'student') {
el.innerHTML = `
Copy Check is for faculty and administrators only.
`;
return;
}
if (ccView === 'detail' && ccSessionId) {
await renderCopyCheckDetail(el);
} else {
await renderCopyCheckList(el);
}
}
async function renderCopyCheckList(el) {
el.innerHTML = `
`;
document.getElementById('cc-new-session-btn').onclick = showNewCCSessionModal;
await loadCCSessions();
}
async function loadCCSessions() {
const listEl = document.getElementById('cc-sessions-list');
if (!listEl) return;
try {
const data = await apiJson('/copy-check/sessions?per_page=50');
const sessions = data.sessions || [];
if (sessions.length === 0) {
listEl.innerHTML = `
No sessions yet. Create one to start marking.
`;
return;
}
listEl.innerHTML = sessions.map(s => {
const statusColor = {active:'var(--accent)',evaluating:'var(--warning,#e6a817)',done:'#22c55e',archived:'var(--muted-text)'}[s.status] || '#888';
const progress = s.sheet_count > 0 ? Math.round((s.evaluated_count / s.sheet_count) * 100) : 0;
return `
${esc(s.subject)}
${esc(s.class_name || '')}
·
${esc(s.department)}
·
Total: ${s.total_marks} marks
·
${timeAgo(s.created_at)}
${s.sheet_count > 0 ? `
${s.evaluated_count}/${s.sheet_count} evaluated
` : ''}
${s.status}
${s.plagiarism_run ? 'plagiarism checked ' : ''}
`;
}).join('');
listEl.querySelectorAll('.cc-session-card').forEach(card => {
card.onclick = () => { ccSessionId = card.dataset.id; ccView = 'detail'; renderCopyCheck(); };
});
} catch(ex) {
listEl.innerHTML = `Error: ${esc(ex.message)}
`;
}
}
function showNewCCSessionModal() {
showModal({
title: 'New Copy Check Session',
body: `
Subject / Exam Name
Class / Batch
Syllabus / Exam Paper Context (optional — helps AI grade accurately)
`,
confirmText: 'Create Session',
onConfirm: async () => {
const subject = document.getElementById('cc-sub').value.trim();
if (!subject) { document.getElementById('cc-modal-err').textContent = 'Subject is required.'; document.getElementById('cc-modal-err').style.display='block'; return false; }
const fd = new FormData();
fd.append('subject', subject);
fd.append('class_name', document.getElementById('cc-class').value.trim());
fd.append('department', document.getElementById('cc-dept').value);
fd.append('total_marks', document.getElementById('cc-marks').value);
fd.append('syllabus_text', document.getElementById('cc-syllabus').value.trim());
const res = await api('/copy-check/sessions', { method: 'POST', body: fd });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
const errMsg = Array.isArray(d.detail)
? d.detail.map(e => e.msg || JSON.stringify(e)).join('; ')
: (typeof d.detail === 'string' ? d.detail : (d.detail?.message || JSON.stringify(d.detail || 'Failed')));
document.getElementById('cc-modal-err').textContent = errMsg || 'Failed to create session.';
document.getElementById('cc-modal-err').style.display = 'block';
return false;
}
const sess = await res.json();
ccSessionId = sess.id;
ccView = 'detail';
closeModal();
renderCopyCheck();
},
});
}
async function renderCopyCheckDetail(el) {
el.innerHTML = `
`;
document.getElementById('cc-back-btn').onclick = () => {
ccView = 'list'; ccSessionId = null;
if (ccEvalTimer) { clearInterval(ccEvalTimer); ccEvalTimer = null; }
renderCopyCheck();
};
await loadCCDetail();
}
async function loadCCDetail() {
const bodyEl = document.getElementById('cc-detail-body');
if (!bodyEl) return;
try {
const [sess, studentsData] = await Promise.all([
apiJson(`/copy-check/sessions/${ccSessionId}`),
apiJson(`/copy-check/sessions/${ccSessionId}/students`),
]);
const titleEl = document.getElementById('cc-detail-title');
if (titleEl) titleEl.textContent = `${sess.subject} — ${sess.class_name || ''} ${sess.department}`;
const sheets = sess.sheets || [];
const sheetMap = {};
sheets.forEach(s => { sheetMap[s.student_roll] = s; });
const students = studentsData.students || [];
const plagiarism = sess.plagiarism || [];
const progress = sheets.length > 0 ? Math.round((sess.evaluated_count / sess.sheet_count) * 100) : 0;
const canEvaluate = sheets.some(s => s.status === 'uploaded' || s.status === 'error');
const canPlagiarism = sheets.filter(s => s.status === 'done').length >= 2;
bodyEl.innerHTML = `
${sess.total_marks} Total Marks
${sess.sheet_count} Uploaded
${sess.evaluated_count} Evaluated
${sess.status} Status
${canEvaluate ? `
Evaluate All Sheets
` : ''}
${canPlagiarism ? `
Check Plagiarism
` : ''}
${sess.evaluated_count > 0 ? `
Download Report PDF
` : ''}
${sess.status === 'evaluating' ? `
Evaluating… ${sess.evaluated_count}/${sess.sheet_count}
` : ''}
${sheets.filter(s => s.ai_marks !== null).length > 0 ? `
Marks
Roll No. Name Marks Out of % Feedback
${sheets.filter(s => s.ai_marks !== null).sort((a,b) => (b.ai_marks||0) - (a.ai_marks||0)).map(s => `
${esc(s.student_roll)}
${esc(s.student_name)}
${s.ai_marks}
${sess.total_marks}
${Math.round((s.ai_marks / sess.total_marks) * 100)}%
${esc((s.ai_feedback || '').slice(0, 120))}${s.ai_feedback && s.ai_feedback.length > 120 ? '…' : ''}
`).join('')}
` : ''}
${plagiarism.length > 0 ? `
Plagiarism Report
${plagiarism.filter(p=>p.verdict==='confirmed').length} confirmed
${plagiarism.filter(p=>p.verdict==='suspected').length} suspected
Student A Student B Similarity Verdict
${plagiarism.filter(p => p.verdict !== 'unlikely').sort((a,b) => b.similarity_score - a.similarity_score).map(p => {
const vc = {confirmed:'#ef4444',suspected:'#f97316',unlikely:'#22c55e'}[p.verdict]||'#888';
return `
${esc(p.roll_a)}
${esc(p.roll_b)}
${p.similarity_pct}%
${p.verdict}
`;
}).join('')}
` : ''}
`;
// Bind evaluate button
const evalBtn = document.getElementById('cc-eval-btn');
if (evalBtn) {
evalBtn.onclick = async () => {
evalBtn.disabled = true;
evalBtn.textContent = 'Starting…';
try {
const r = await api(`/copy-check/sessions/${ccSessionId}/evaluate`, { method: 'POST' });
if (!r.ok) { const d = await r.json(); showToast(d.detail || 'Failed', 'error'); evalBtn.disabled = false; return; }
showToast('Evaluation started!', 'success');
// Poll every 4 seconds until done
if (ccEvalTimer) clearInterval(ccEvalTimer);
ccEvalTimer = setInterval(async () => {
const fresh = await apiJson(`/copy-check/sessions/${ccSessionId}`).catch(() => null);
if (!fresh) return;
const pb = document.getElementById('cc-eval-progress-bar');
if (pb) {
const pct = fresh.sheet_count > 0 ? Math.round((fresh.evaluated_count / fresh.sheet_count) * 100) : 0;
const fill = pb.querySelector('.cc-eval-bar-fill');
const label = pb.querySelector('.cc-eval-bar-label');
if (fill) fill.style.width = pct + '%';
if (label) label.textContent = `Evaluating… ${fresh.evaluated_count}/${fresh.sheet_count}`;
}
if (fresh.status === 'done' || (fresh.evaluated_count >= fresh.sheet_count && fresh.sheet_count > 0)) {
clearInterval(ccEvalTimer); ccEvalTimer = null;
showToast('Evaluation complete!', 'success');
await loadCCDetail();
}
}, 4000);
} catch(ex) { showToast(ex.message, 'error'); evalBtn.disabled = false; }
};
}
// Bind plagiarism button
const plgBtn = document.getElementById('cc-plg-btn');
if (plgBtn) {
plgBtn.onclick = async () => {
plgBtn.disabled = true;
plgBtn.textContent = 'Checking…';
try {
const r = await api(`/copy-check/sessions/${ccSessionId}/plagiarism`, { method: 'POST' });
if (!r.ok) { const d = await r.json(); showToast(d.detail || 'Failed', 'error'); plgBtn.disabled = false; return; }
const d = await r.json();
showToast(`Plagiarism check done. ${d.confirmed} confirmed, ${d.suspected} suspected.`, 'success');
await loadCCDetail();
} catch(ex) { showToast(ex.message, 'error'); plgBtn.disabled = false; }
};
}
// Bind individual sheet upload inputs
bodyEl.querySelectorAll('.cc-sheet-input').forEach(input => {
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const roll = input.dataset.roll;
const label = input.closest('label');
label.textContent = 'Uploading…';
label.style.opacity = '0.6';
const fd = new FormData();
fd.append('student_roll', roll);
fd.append('file', file);
try {
const r = await api(`/copy-check/sessions/${ccSessionId}/sheets`, { method: 'POST', body: fd });
if (!r.ok) { const d = await r.json(); showToast(d.detail || 'Upload failed', 'error'); label.textContent = 'Upload'; label.style.opacity = '1'; return; }
showToast(`Sheet uploaded for ${roll}`, 'success');
await loadCCDetail();
} catch(ex) { showToast(ex.message, 'error'); label.textContent = 'Upload'; label.style.opacity = '1'; }
input.value = '';
};
});
// Bind manual upload
const manualFileInput = document.getElementById('cc-manual-file-input');
const manualFileName = document.getElementById('cc-manual-file-name');
if (manualFileInput) {
manualFileInput.onchange = () => {
manualFileName.textContent = manualFileInput.files[0]?.name || 'No file chosen';
};
}
const manualBtn = document.getElementById('cc-manual-upload-btn');
if (manualBtn) {
manualBtn.onclick = async () => {
const roll = document.getElementById('cc-manual-roll').value.trim();
const file = manualFileInput?.files[0];
if (!roll) { showToast('Enter roll number', 'error'); return; }
if (!file) { showToast('Choose a file', 'error'); return; }
manualBtn.disabled = true;
const fd = new FormData();
fd.append('student_roll', roll);
fd.append('file', file);
try {
const r = await api(`/copy-check/sessions/${ccSessionId}/sheets`, { method: 'POST', body: fd });
if (!r.ok) { const d = await r.json(); showToast(d.detail || 'Upload failed', 'error'); manualBtn.disabled = false; return; }
showToast('Sheet uploaded!', 'success');
await loadCCDetail();
} catch(ex) { showToast(ex.message, 'error'); manualBtn.disabled = false; }
if (manualFileInput) manualFileInput.value = '';
if (manualFileName) manualFileName.textContent = 'No file chosen';
document.getElementById('cc-manual-roll').value = '';
manualBtn.disabled = false;
};
}
// Auto-start polling if session is currently evaluating
if (sess.status === 'evaluating' && !ccEvalTimer) {
ccEvalTimer = setInterval(async () => {
const fresh = await apiJson(`/copy-check/sessions/${ccSessionId}`).catch(() => null);
if (!fresh) return;
const pb = document.getElementById('cc-eval-progress-bar');
if (pb) {
const pct = fresh.sheet_count > 0 ? Math.round((fresh.evaluated_count / fresh.sheet_count) * 100) : 0;
const fill = pb.querySelector('.cc-eval-bar-fill');
const label = pb.querySelector('.cc-eval-bar-label');
if (fill) fill.style.width = pct + '%';
if (label) label.textContent = `Evaluating… ${fresh.evaluated_count}/${fresh.sheet_count}`;
}
if (fresh.status === 'done' || (fresh.evaluated_count >= fresh.sheet_count && fresh.sheet_count > 0)) {
clearInterval(ccEvalTimer); ccEvalTimer = null;
await loadCCDetail();
}
}, 4000);
}
} catch(ex) {
bodyEl.innerHTML = `Error: ${esc(ex.message)}
`;
}
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/* ═══════════════════════════════════════════════════════════
NOTEBOOKS — Full IDE (Colab-style)
═══════════════════════════════════════════════════════════ */
// Notebook state
let _nbState = {
notebooks: [],
current: null,
cells: [],
ws: null,
executingCells: new Set(),
outputs: {},
kernelId: null,
sidebarOpen: true,
};
function _nbLoadFromStorage() {
_nbState.notebooks = userGet('notebooks', []);
}
function _nbSave() {
// Save notebook list
if (_nbState.current) {
const nb = _nbState.notebooks.find(n => n.id === _nbState.current);
if (nb) {
nb.cells = _nbState.cells;
nb.outputs = _nbState.outputs;
nb.updated_at = new Date().toISOString();
}
}
userSet('notebooks', _nbState.notebooks);
}
function _nbNewId() { return crypto.randomUUID ? crypto.randomUUID() : 'nb-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); }
function _cellNewId() { return 'cell-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); }
function _nbCreate(title) {
const nb = {
id: _nbNewId(),
title: title || 'Untitled Notebook',
language: 'python',
cells: [{ id: _cellNewId(), type: 'code', source: '', language: 'python' }],
outputs: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
_nbState.notebooks.unshift(nb);
_nbState.current = nb.id;
_nbState.cells = nb.cells;
_nbState.outputs = nb.outputs || {};
_nbSave();
return nb;
}
function _nbLoad(nbId) {
const nb = _nbState.notebooks.find(n => n.id === nbId);
if (!nb) return false;
_nbState.current = nb.id;
_nbState.cells = nb.cells || [];
_nbState.outputs = nb.outputs || {};
return true;
}
function _nbDelete(nbId) {
_nbState.notebooks = _nbState.notebooks.filter(n => n.id !== nbId);
if (_nbState.current === nbId) {
_nbState.current = null;
_nbState.cells = [];
_nbState.outputs = {};
}
_nbSave();
}
function _nbConnectWs() {
if (_nbState.ws && _nbState.ws.readyState <= 1) return;
if (!_nbState.current) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws/notebook/${_nbState.current}`;
const ws = new WebSocket(url);
ws.onopen = () => { _nbState.ws = ws; };
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
_nbHandleWsMsg(msg);
} catch {}
};
ws.onclose = () => {
_nbState.ws = null;
// Auto-reconnect if still on notebooks page
if (state.page === 'notebooks' && _nbState.current) {
setTimeout(_nbConnectWs, 3000);
}
};
ws.onerror = () => {};
_nbState.ws = ws;
}
function _nbHandleWsMsg(msg) {
const cellId = msg.cell_id;
if (!cellId) return;
if (msg.type === 'status') {
if (msg.execution_state === 'busy') {
_nbState.executingCells.add(cellId);
} else if (msg.execution_state === 'idle') {
_nbState.executingCells.delete(cellId);
}
_nbRenderCellStatus(cellId);
return;
}
// Initialize output array
if (!_nbState.outputs[cellId]) _nbState.outputs[cellId] = [];
if (msg.type === 'stream') {
_nbState.outputs[cellId].push({ type: 'stream', name: msg.name, text: msg.text });
} else if (msg.type === 'error') {
_nbState.outputs[cellId].push({ type: 'error', ename: msg.ename, evalue: msg.evalue, traceback: msg.traceback || [] });
_nbState.executingCells.delete(cellId);
_nbRenderCellStatus(cellId);
} else if (msg.type === 'execute_result' || msg.type === 'display_data') {
_nbState.outputs[cellId].push({ type: msg.type, data: msg.data });
}
_nbRenderCellOutput(cellId);
_nbSave();
}
function _nbExecCell(cellId) {
const cell = _nbState.cells.find(c => c.id === cellId);
if (!cell || cell.type !== 'code') return;
_nbState.outputs[cellId] = []; // Clear previous output
_nbRenderCellOutput(cellId);
if (_nbState.ws && _nbState.ws.readyState === 1) {
_nbState.ws.send(JSON.stringify({
type: 'execute',
cell_id: cellId,
code: cell.source,
language: cell.language || 'python',
kernel_id: _nbState.kernelId,
}));
} else {
// Fallback: REST API execution
_nbExecCellRest(cellId, cell);
}
}
async function _nbExecCellRest(cellId, cell) {
_nbState.executingCells.add(cellId);
_nbState.outputs[cellId] = [];
_nbRenderCellStatus(cellId);
_nbRenderCellOutput(cellId);
try {
// First save cell to backend, then execute
const res = await api('/notebooks/cells/' + cellId + '/run', { method: 'POST' });
if (res.ok) {
const data = await res.json();
const exec = data.execution;
if (exec.stdout) _nbState.outputs[cellId].push({ type: 'stream', name: 'stdout', text: exec.stdout });
if (exec.stderr) _nbState.outputs[cellId].push({ type: 'stream', name: 'stderr', text: exec.stderr });
if (exec.status === 'failed' || exec.status === 'timeout') {
_nbState.outputs[cellId].push({ type: 'error', ename: exec.status, evalue: exec.stderr || 'Execution failed', traceback: [] });
}
} else {
// WebSocket-only execution
_nbState.outputs[cellId].push({ type: 'stream', name: 'stderr', text: 'Connecting to execution engine...\n' });
_nbConnectWs();
await new Promise(r => setTimeout(r, 1000));
if (_nbState.ws && _nbState.ws.readyState === 1) {
_nbState.ws.send(JSON.stringify({
type: 'execute', cell_id: cellId,
code: cell.source, language: cell.language || 'python',
}));
return; // WS handler will manage from here
}
_nbState.outputs[cellId].push({ type: 'error', ename: 'ConnectionError', evalue: 'Could not connect to execution engine', traceback: [] });
}
} catch (e) {
_nbState.outputs[cellId].push({ type: 'error', ename: 'Error', evalue: e.message, traceback: [] });
}
_nbState.executingCells.delete(cellId);
_nbRenderCellStatus(cellId);
_nbRenderCellOutput(cellId);
_nbSave();
}
const NB_LANGUAGES = [
{ id: 'python', name: 'Python', color: '#3776AB' },
{ id: 'javascript', name: 'JavaScript', color: '#F7DF1E' },
{ id: 'typescript', name: 'TypeScript', color: '#3178C6' },
{ id: 'c', name: 'C', color: '#A8B9CC' },
{ id: 'cpp', name: 'C++', color: '#00599C' },
{ id: 'java', name: 'Java', color: '#ED8B00' },
{ id: 'go', name: 'Go', color: '#00ADD8' },
{ id: 'rust', name: 'Rust', color: '#DEA584' },
{ id: 'r', name: 'R', color: '#276DC3' },
{ id: 'julia', name: 'Julia', color: '#9558B2' },
{ id: 'bash', name: 'Bash', color: '#4EAA25' },
{ id: 'sql', name: 'SQL', color: '#003B57' },
{ id: 'csharp', name: 'C#', color: '#239120' },
{ id: 'ruby', name: 'Ruby', color: '#CC342D' },
{ id: 'php', name: 'PHP', color: '#777BB4' },
{ id: 'kotlin', name: 'Kotlin', color: '#7F52FF' },
{ id: 'swift', name: 'Swift', color: '#F05138' },
{ id: 'lua', name: 'Lua', color: '#000080' },
{ id: 'haskell', name: 'Haskell', color: '#5D4F85' },
{ id: 'html', name: 'HTML', color: '#E34F26' },
];
function _nbRenderCellStatus(cellId) {
const el = document.getElementById('nb-cell-' + cellId);
if (!el) return;
const isExec = _nbState.executingCells.has(cellId);
el.classList.toggle('executing', isExec);
const btn = el.querySelector('.nb-run-btn');
if (btn) {
btn.innerHTML = isExec
? ' '
: ' ';
}
}
function _nbRenderCellOutput(cellId) {
const container = document.getElementById('nb-output-' + cellId);
if (!container) return;
const outputs = _nbState.outputs[cellId] || [];
if (outputs.length === 0) { container.innerHTML = ''; container.style.display = 'none'; return; }
container.style.display = 'block';
let html = '';
for (const out of outputs) {
if (out.type === 'stream') {
const cls = out.name === 'stderr' ? 'nb-out-stderr' : 'nb-out-stdout';
html += `${esc(out.text)} `;
} else if (out.type === 'error') {
html += `${esc(out.ename || 'Error')}: ${esc(out.evalue || '')} `;
if (out.traceback && out.traceback.length) {
html += `
${esc(out.traceback.join('\n'))} `;
}
html += `
`;
} else if (out.type === 'execute_result' || out.type === 'display_data') {
const data = out.data || {};
if (data['text/html']) html += `${data['text/html']}
`;
else if (data['image/png']) html += ` `;
else if (data['text/plain']) html += `${esc(data['text/plain'])} `;
}
}
container.innerHTML = html;
}
function renderNotebooks() {
const el = document.getElementById('page-content');
el.className = 'page nb-page-container';
el.style.padding = '0';
el.style.overflow = 'hidden';
el.style.display = 'flex';
const nb = _nbState.current ? _nbState.notebooks.find(n => n.id === _nbState.current) : null;
el.innerHTML = `
${nb ? _nbRenderAllCells() : `
MAC Notebooks
Create a new notebook to start coding — 25+ languages, offline, Colab-style.
Create Notebook
`}
`;
_nbBindAll();
if (_nbState.current) _nbConnectWs();
}
function _nbRenderAllCells() {
return _nbState.cells.map((cell, idx) => _nbRenderCell(cell, idx)).join('') + `
+ Code
+ Markdown
`;
}
function _nbRenderCell(cell, idx) {
const lang = NB_LANGUAGES.find(l => l.id === cell.language) || { id: cell.language || 'python', name: cell.language || 'Python', color: '#666' };
const isExec = _nbState.executingCells.has(cell.id);
const outputs = _nbState.outputs[cell.id] || [];
const hasOutput = outputs.length > 0;
if (cell.type === 'markdown') {
return `
${idx + 1}
${cell.source ? formatMd(cell.source) : 'Click to edit markdown… '}
`;
}
// Code cell
return `
${isExec
? ' '
: ' '}
[${idx + 1}]
`;
}
function _nbRefreshCells() {
const container = document.getElementById('nb-cells');
if (!container) return;
container.innerHTML = _nbRenderAllCells();
_nbBindCells();
// Re-render outputs
for (const cellId of Object.keys(_nbState.outputs)) {
_nbRenderCellOutput(cellId);
}
}
function _nbBindAll() {
// New notebook buttons
document.querySelectorAll('.nb-new-btn, .nb-empty-create').forEach(btn => {
btn.onclick = () => { _nbCreate('Untitled Notebook'); renderNotebooks(); };
});
// Sidebar items
document.querySelectorAll('.nb-sidebar-item').forEach(item => {
item.onclick = (e) => {
if (e.target.closest('.nb-item-del')) return;
_nbSave(); // Save current before switching
_nbLoad(item.dataset.id);
renderNotebooks();
};
});
document.querySelectorAll('.nb-item-del').forEach(btn => {
btn.onclick = (e) => { e.stopPropagation(); if (confirm('Delete this notebook?')) { _nbDelete(btn.dataset.del); renderNotebooks(); } };
});
// Sidebar toggle
document.querySelectorAll('.nb-sidebar-toggle, .nb-sidebar-toggle-main').forEach(btn => {
btn.onclick = () => { _nbState.sidebarOpen = !_nbState.sidebarOpen; renderNotebooks(); };
});
// Title input
const titleInput = document.getElementById('nb-title');
if (titleInput) {
titleInput.onchange = () => {
const nb = _nbState.notebooks.find(n => n.id === _nbState.current);
if (nb) { nb.title = titleInput.value; _nbSave(); }
};
}
// Toolbar actions
const addCode = document.querySelector('.nb-add-code');
if (addCode) addCode.onclick = () => { _nbAddCell('code'); };
const addMd = document.querySelector('.nb-add-md');
if (addMd) addMd.onclick = () => { _nbAddCell('markdown'); };
document.querySelector('.nb-clear-outputs')?.addEventListener('click', () => {
_nbState.outputs = {};
_nbSave();
_nbRefreshCells();
});
document.querySelector('.nb-download')?.addEventListener('click', _nbDownloadIpynb);
document.querySelector('.nb-run-all')?.addEventListener('click', _nbRunAll);
_nbBindCells();
}
function _nbBindCells() {
// Code editors - auto-resize and save
document.querySelectorAll('.nb-code-editor').forEach(ta => {
const cellId = ta.dataset.cell;
ta.oninput = () => {
const cell = _nbState.cells.find(c => c.id === cellId);
if (cell) { cell.source = ta.value; _nbSave(); }
ta.rows = Math.max(3, ta.value.split('\n').length);
};
ta.onkeydown = (e) => {
// Shift+Enter to run
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
_nbExecCell(cellId);
}
// Tab for indentation
if (e.key === 'Tab') {
e.preventDefault();
const start = ta.selectionStart;
const end = ta.selectionEnd;
ta.value = ta.value.substring(0, start) + ' ' + ta.value.substring(end);
ta.selectionStart = ta.selectionEnd = start + 4;
ta.oninput();
}
};
});
// Run buttons
document.querySelectorAll('.nb-run-btn').forEach(btn => {
btn.onclick = () => _nbExecCell(btn.dataset.cell);
});
// Language selectors
document.querySelectorAll('.nb-lang-select').forEach(sel => {
sel.onchange = () => {
const cell = _nbState.cells.find(c => c.id === sel.dataset.cell);
if (cell) { cell.language = sel.value; _nbSave(); }
};
});
// Move up/down
document.querySelectorAll('.nb-move-up').forEach(btn => {
btn.onclick = () => {
const idx = _nbState.cells.findIndex(c => c.id === btn.dataset.cell);
if (idx > 0) { [_nbState.cells[idx - 1], _nbState.cells[idx]] = [_nbState.cells[idx], _nbState.cells[idx - 1]]; _nbSave(); _nbRefreshCells(); }
};
});
document.querySelectorAll('.nb-move-down').forEach(btn => {
btn.onclick = () => {
const idx = _nbState.cells.findIndex(c => c.id === btn.dataset.cell);
if (idx < _nbState.cells.length - 1) { [_nbState.cells[idx], _nbState.cells[idx + 1]] = [_nbState.cells[idx + 1], _nbState.cells[idx]]; _nbSave(); _nbRefreshCells(); }
};
});
// Delete cell
document.querySelectorAll('.nb-del-cell').forEach(btn => {
btn.onclick = () => {
_nbState.cells = _nbState.cells.filter(c => c.id !== btn.dataset.cell);
delete _nbState.outputs[btn.dataset.cell];
_nbSave();
_nbRefreshCells();
};
});
// Markdown preview/edit toggle
document.querySelectorAll('.nb-md-preview').forEach(preview => {
preview.onclick = () => {
const editor = preview.parentElement.querySelector('.nb-md-editor');
preview.style.display = 'none';
editor.style.display = 'block';
editor.focus();
};
});
document.querySelectorAll('.nb-md-editor').forEach(editor => {
editor.oninput = () => {
const cell = _nbState.cells.find(c => c.id === editor.dataset.cell);
if (cell) { cell.source = editor.value; _nbSave(); }
};
editor.onblur = () => {
const preview = editor.parentElement.querySelector('.nb-md-preview');
const cell = _nbState.cells.find(c => c.id === editor.dataset.cell);
preview.innerHTML = cell && cell.source ? formatMd(cell.source) : 'Click to edit markdown… ';
preview.style.display = 'block';
editor.style.display = 'none';
};
});
// Bottom add-cell buttons
document.querySelectorAll('.nb-add-code-bottom').forEach(btn => { btn.onclick = () => _nbAddCell('code'); });
document.querySelectorAll('.nb-add-md-bottom').forEach(btn => { btn.onclick = () => _nbAddCell('markdown'); });
}
function _nbAddCell(type, afterIdx) {
const cell = { id: _cellNewId(), type, source: '', language: type === 'code' ? 'python' : undefined };
if (afterIdx !== undefined) {
_nbState.cells.splice(afterIdx + 1, 0, cell);
} else {
_nbState.cells.push(cell);
}
_nbSave();
_nbRefreshCells();
// Scroll to new cell
setTimeout(() => {
const el = document.getElementById('nb-cell-' + cell.id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
async function _nbRunAll() {
for (const cell of _nbState.cells) {
if (cell.type === 'code') {
_nbExecCell(cell.id);
// Wait a bit between cells for sequential execution
await new Promise(r => setTimeout(r, 500));
}
}
}
function _nbDownloadIpynb() {
const nb = _nbState.notebooks.find(n => n.id === _nbState.current);
if (!nb) return;
const ipynb = {
nbformat: 4,
nbformat_minor: 5,
metadata: {
kernelspec: { display_name: 'Python 3', language: 'python', name: 'python3' },
language_info: { name: nb.language || 'python', version: '3.11' },
},
cells: _nbState.cells.map(cell => {
const outputs = (_nbState.outputs[cell.id] || []).map(out => {
if (out.type === 'stream') return { output_type: 'stream', name: out.name, text: [out.text] };
if (out.type === 'error') return { output_type: 'error', ename: out.ename, evalue: out.evalue, traceback: out.traceback };
return { output_type: 'display_data', data: out.data || {}, metadata: {} };
});
return {
cell_type: cell.type === 'code' ? 'code' : 'markdown',
source: (cell.source || '').split('\n').map((l, i, arr) => i < arr.length - 1 ? l + '\n' : l),
metadata: { language: cell.language },
...(cell.type === 'code' ? { execution_count: null, outputs } : {}),
};
}),
};
const blob = new Blob([JSON.stringify(ipynb, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (nb.title || 'notebook').replace(/[^a-zA-Z0-9_-]/g, '_') + '.ipynb';
a.click();
URL.revokeObjectURL(a.href);
}
/* ═══════════════════════════════════════════════════════════
NOTIFICATIONS — Bell, Panel, Push Subscription
═══════════════════════════════════════════════════════════ */
async function loadNotifCount() {
try {
const data = await apiJson('/notifications?per_page=1');
const count = data.unread_count || 0;
const badge = document.getElementById('notif-count');
if (badge) badge.textContent = count > 0 ? (count > 99 ? '99+' : count) : '';
} catch {}
}
async function loadNotifications() {
const list = document.getElementById('notif-list');
if (!list) return;
list.innerHTML = '';
try {
const data = await apiJson('/notifications?per_page=30');
const notifs = data.notifications || [];
if (notifs.length === 0) {
list.innerHTML = 'No notifications yet
';
return;
}
list.innerHTML = notifs.map(n => `
${esc(n.title)}
${esc(n.body || '')}
${timeAgo(n.created_at)}
`).join('');
list.querySelectorAll('.notif-item').forEach(item => {
item.onclick = async () => {
if (item.classList.contains('unread')) {
try { await api('/notifications/' + item.dataset.id + '/read', { method: 'POST' }); item.classList.remove('unread'); loadNotifCount(); } catch {}
}
const link = item.dataset.link;
if (link) { document.getElementById('notif-panel').classList.remove('open'); if (link.startsWith('#')) navigate(link.slice(1)); }
};
});
loadNotifCount();
} catch { list.innerHTML = 'Failed to load
'; }
}
/* Push notification subscription */
async function subscribeToPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
try {
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
const vapidResp = await apiJson('/notifications/vapid-key').catch(() => null);
if (!vapidResp || !vapidResp.public_key) return;
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidResp.public_key),
});
}
const key = sub.getKey('p256dh');
const auth = sub.getKey('auth');
await api('/notifications/push/subscribe', {
method: 'POST',
body: JSON.stringify({
endpoint: sub.endpoint,
p256dh_key: key ? btoa(String.fromCharCode(...new Uint8Array(key))) : '',
auth_key: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
}),
});
} catch {}
}
/* Request browser notification permission on every login */
async function requestNotificationPermission() {
if (!('Notification' in window)) return;
try { await Notification.requestPermission(); } catch {}
}
/* Real-time notification polling — updates badge every 15s */
function startNotifPolling() {
if (_notifPollIv) clearInterval(_notifPollIv);
loadNotifCount();
_notifPollIv = setInterval(() => loadNotifCount(), 15000);
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
}
/* ═══════════════════════════════════════════════════════════
CHART HELPERS
═══════════════════════════════════════════════════════════ */
function makeDonut(id, used, total, color) {
const canvas = document.getElementById(id);
if (!canvas) return;
const remaining = Math.max(0, total - used);
const cs = getComputedStyle(document.documentElement);
const accentColor = color || cs.getPropertyValue('--accent').trim() || '#7c6ff7';
const trackColor = cs.getPropertyValue('--border').trim() || '#e5e5e5';
new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['Used', 'Remaining'],
datasets: [{ data: [used, remaining], backgroundColor: [accentColor, trackColor], borderWidth: 0, cutout: '75%' }],
},
options: {
responsive: false,
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
backgroundColor: 'rgba(0,0,0,0.82)',
titleColor: '#fff',
bodyColor: '#ddd',
borderColor: 'rgba(255,255,255,0.15)',
borderWidth: 1,
cornerRadius: 8,
padding: 10,
boxPadding: 4,
position: 'nearest',
callbacks: {
label: (ctx) => ' ' + ctx.label + ': ' + fmtNum(ctx.raw),
}
}
},
animation: { animateRotate: true, duration: 800 }
},
});
}
/* ═══════════════════════════════════════════════════════════
UTILITIES
═══════════════════════════════════════════════════════════ */
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function fmtNum(n) { return Math.round(n || 0).toLocaleString('en-IN'); }
function timeAgo(iso) {
const d = new Date(iso);
const s = Math.floor((Date.now() - d) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return d.toLocaleDateString();
}
function shortModel(m) {
if (!m) return '?';
return m.replace(/^(Qwen\/|deepseek-ai\/|openai\/)/, '').replace(/-Instruct$/, '').slice(0, 24);
}
function formatMd(text) {
// Split on fenced code blocks first to protect their content
const parts = text.split(/(```[\s\S]*?```)/g);
let html = '';
parts.forEach(part => {
if (part.startsWith('```')) {
const match = part.match(/^```(\w*)\n?([\s\S]*?)```$/);
const lang = (match && match[1]) ? match[1].toLowerCase() : '';
const code = match ? match[2] : part.slice(3, -3);
if (lang === 'mermaid') {
const id = 'mmd-' + Math.random().toString(36).slice(2);
html += ``;
setTimeout(() => {
const el = document.getElementById(id);
if (!el || !window.mermaid) return;
try { mermaid.render('svg-' + id, code).then(({svg}) => { el.innerHTML = svg; }).catch(() => { el.innerHTML = '' + esc(code) + ' '; }); }
catch(e) { el.innerHTML = '' + esc(code) + ' '; }
}, 50);
} else {
const langLabel = lang || 'code';
const copyId = 'copy-' + Math.random().toString(36).slice(2);
let highlighted = '';
if (lang && window.hljs && hljs.getLanguage(lang)) {
try { highlighted = hljs.highlight(code, { language: lang }).value; } catch { highlighted = esc(code); }
} else if (window.hljs) {
try { highlighted = hljs.highlightAuto(code).value; } catch { highlighted = esc(code); }
} else {
highlighted = esc(code);
}
html += ``;
}
} else {
// Process regular markdown in this non-code segment
let s = part;
// Tables
s = s.replace(/(?:(?:^|\n)\|.+\|.*(?:\n|$))+/g, tableStr => {
const rows = tableStr.trim().split('\n').filter(r => r.trim());
if (rows.length < 2) return tableStr;
const headerCells = rows[0].split('|').filter((_, i, a) => i > 0 && i < a.length - 1).map(c => `${inlineMd(c.trim())} `).join('');
let bodyHtml = '';
for (let i = 2; i < rows.length; i++) {
const cells = rows[i].split('|').filter((_, j, a) => j > 0 && j < a.length - 1).map(c => `${inlineMd(c.trim())} `).join('');
bodyHtml += `${cells} `;
}
return `${headerCells} ${bodyHtml}
`;
});
// Headings
s = s.replace(/^### (.+)$/gm, '$1 ');
s = s.replace(/^## (.+)$/gm, '$1 ');
s = s.replace(/^# (.+)$/gm, '$1 ');
// Blockquotes
s = s.replace(/^> (.+)$/gm, '$1 ');
// Horizontal rule
s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, ' ');
// Unordered lists
s = s.replace(/((?:^[-*+] .+(?:\n|$))+)/gm, listStr => {
const items = listStr.trim().split('\n').map(l => `${inlineMd(l.replace(/^[-*+] /, '').trim())} `).join('');
return ``;
});
// Ordered lists
s = s.replace(/((?:^\d+\. .+(?:\n|$))+)/gm, listStr => {
const items = listStr.trim().split('\n').map(l => `${inlineMd(l.replace(/^\d+\. /, '').trim())} `).join('');
return `${items} `;
});
// Paragraphs (blank-line separated non-block content)
s = s.replace(/^(?!<[huo]| {
if (!line.trim()) return '';
return `${inlineMd(line)}
`;
});
// Collapse multiple blank lines
s = s.replace(/\n{2,}/g, '\n');
html += s;
}
});
return html;
}
function inlineMd(text) {
let s = esc(text);
s = s.replace(/\*\*\*(.+?)\*\*\*/g, '$1 ');
s = s.replace(/\*\*(.+?)\*\*/g, '$1 ');
s = s.replace(/\*(.+?)\*/g, '$1 ');
s = s.replace(/~~(.+?)~~/g, '$1');
s = s.replace(/`([^`]+)`/g, '$1 ');
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ');
return s;
}
window.logout = logout;
/* ═══════════════════════════════════════════════════════════
INTERACTIVE BACKGROUND — Physics-based MAC/MBM particles
Text particles scatter on hover/touch, spring back to origin
═══════════════════════════════════════════════════════════ */
const BG = {
canvas: null, ctx: null, particles: [], mouse: { x: -9999, y: -9999, active: false },
raf: null, dpr: 1, W: 0, H: 0,
REPEL_RADIUS: 120,
REPEL_FORCE: 8,
SPRING: 0.04,
DAMPING: 0.88,
WORDS: ['MAC', 'MBM', 'MAC', 'MBM', 'AI', 'MAC', 'MBM'],
FONT_SIZES: [11, 13, 15],
OPACITY_RANGE: [0.03, 0.07],
};
function initBgCanvas() {
// Create persistent canvas (lives outside #app so it survives re-renders)
let canvas = document.getElementById('bg-canvas');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'bg-canvas';
document.body.insertBefore(canvas, document.body.firstChild);
}
BG.canvas = canvas;
BG.ctx = canvas.getContext('2d');
BG.dpr = Math.min(window.devicePixelRatio || 1, 2);
resizeBg();
spawnParticles();
bindBgEvents();
if (!BG.raf) animateBg();
}
function resizeBg() {
BG.W = window.innerWidth;
BG.H = window.innerHeight;
BG.canvas.width = BG.W * BG.dpr;
BG.canvas.height = BG.H * BG.dpr;
BG.canvas.style.width = BG.W + 'px';
BG.canvas.style.height = BG.H + 'px';
BG.ctx.setTransform(BG.dpr, 0, 0, BG.dpr, 0, 0);
}
function spawnParticles() {
BG.particles = [];
const spacing = 80;
const cols = Math.ceil(BG.W / spacing) + 1;
const rows = Math.ceil(BG.H / spacing) + 1;
let idx = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ox = c * spacing + (r % 2 === 0 ? 0 : spacing * 0.5) + (Math.random() - 0.5) * 20;
const oy = r * spacing + (Math.random() - 0.5) * 16;
const word = BG.WORDS[idx % BG.WORDS.length];
const fontSize = BG.FONT_SIZES[idx % BG.FONT_SIZES.length];
const opMin = BG.OPACITY_RANGE[0], opMax = BG.OPACITY_RANGE[1];
const baseOpacity = opMin + Math.random() * (opMax - opMin);
BG.particles.push({
ox, oy, // origin
x: ox, y: oy, // current
vx: 0, vy: 0, // velocity
word,
fontSize,
baseOpacity,
opacity: baseOpacity,
rotation: (Math.random() - 0.5) * 0.3,
rotOrigin: 0,
rot: 0,
});
BG.particles[BG.particles.length - 1].rotOrigin = BG.particles[BG.particles.length - 1].rotation;
idx++;
}
}
}
function bindBgEvents() {
const onMove = (x, y) => { BG.mouse.x = x; BG.mouse.y = y; BG.mouse.active = true; };
window.addEventListener('mousemove', e => onMove(e.clientX, e.clientY), { passive: true });
window.addEventListener('touchmove', e => {
if (e.touches.length > 0) onMove(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
window.addEventListener('touchstart', e => {
if (e.touches.length > 0) onMove(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
window.addEventListener('mouseleave', () => { BG.mouse.active = false; BG.mouse.x = -9999; BG.mouse.y = -9999; });
window.addEventListener('touchend', () => { BG.mouse.active = false; BG.mouse.x = -9999; BG.mouse.y = -9999; }, { passive: true });
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { resizeBg(); spawnParticles(); }, 200);
});
}
function animateBg() {
const { ctx, particles, mouse, W, H } = BG;
ctx.clearRect(0, 0, W, H);
const rr = BG.REPEL_RADIUS;
const rr2 = rr * rr;
const force = BG.REPEL_FORCE;
const spring = BG.SPRING;
const damp = BG.DAMPING;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
// Repulsion from mouse
const dx = p.x - mouse.x;
const dy = p.y - mouse.y;
const dist2 = dx * dx + dy * dy;
if (dist2 < rr2 && dist2 > 0.1) {
const dist = Math.sqrt(dist2);
const f = (1 - dist / rr) * force;
p.vx += (dx / dist) * f;
p.vy += (dy / dist) * f;
// Spin on repel
p.rot += (dx > 0 ? 0.1 : -0.1) * f * 0.05;
// Boost opacity when disturbed
p.opacity = Math.min(0.18, p.baseOpacity + (1 - dist / rr) * 0.12);
} else {
// Fade back to base
p.opacity += (p.baseOpacity - p.opacity) * 0.05;
}
// Spring back to origin
p.vx += (p.ox - p.x) * spring;
p.vy += (p.oy - p.y) * spring;
// Damping
p.vx *= damp;
p.vy *= damp;
// Rotation spring
p.rot += (p.rotOrigin - p.rot) * 0.03;
// Integrate
p.x += p.vx;
p.y += p.vy;
// Draw
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.font = `900 ${p.fontSize}px 'Courier New', monospace`;
ctx.fillStyle = `rgba(0,0,0,${p.opacity.toFixed(3)})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(p.word, 0, 0);
ctx.restore();
}
BG.raf = requestAnimationFrame(animateBg);
}
// Initialize background on load
document.addEventListener('DOMContentLoaded', initBgCanvas);
// Also re-init if canvas gets removed (SPA navigation nukes #app, not body)
const _origRender = render;
window._bgCheck = () => {
if (!document.getElementById('bg-canvas')) initBgCanvas();
};
init();