// ===== Admin Dashboard JS =====
function escapeHtml(value) {
return String(value ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
function activateTab(name, loadDetails = true) {
document.querySelectorAll('.tab-btn').forEach(button =>
button.classList.toggle('active', button.dataset.tab === name));
document.querySelectorAll('.tab-content').forEach(content =>
content.classList.toggle('active', content.id === 'tab-' + name));
if (name === 'details' && loadDetails && !auditInitialized) loadAuditOptions();
}
// ── Tab switching ──
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
});
// ── Logout ──
document.getElementById('logoutBtn').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
// ── User info ──
fetch('/api/me').then(r => r.json()).then(u => {
document.getElementById('userBadge').textContent = u.username;
});
// ── State ──
let allUsers = [];
let allFiles = [];
// ── Files ──
async function loadFiles() {
const res = await fetch('/api/files');
allFiles = await res.json();
const grid = document.getElementById('filesList');
if (allFiles.length === 0) {
grid.innerHTML = '
No CSV files found in the data folder.
';
return;
}
grid.innerHTML = allFiles.map(f => {
return `
${f.total_samples} samples
${f.total_assignees} assignee(s)
${f.total_reviews} total reviews
`;
}).join('');
}
// ── Users ──
async function loadUsers() {
const res = await fetch('/api/users');
allUsers = await res.json();
const tbody = document.getElementById('usersBody');
tbody.innerHTML = allUsers.map(u => {
const roleClass = u.role === 'admin' ? 'role-admin' : 'role-annotator';
const created = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
return `
| ${u.id} |
${escapeHtml(u.username)} |
${escapeHtml(u.role)} |
${created} |
${u.role !== 'admin' ? `` : '—'} |
`;
}).join('');
}
// ── Create User ──
document.getElementById('addUserBtn').addEventListener('click', () => {
document.getElementById('createUserForm').style.display = 'block';
document.getElementById('newUsername').focus();
});
document.getElementById('cancelUserBtn').addEventListener('click', () => {
document.getElementById('createUserForm').style.display = 'none';
document.getElementById('userFormError').textContent = '';
});
document.getElementById('submitUserBtn').addEventListener('click', async () => {
const username = document.getElementById('newUsername').value.trim();
const password = document.getElementById('newPassword').value;
const role = document.getElementById('newRole').value;
const errEl = document.getElementById('userFormError');
errEl.textContent = '';
const res = await fetch('/api/users', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role })
});
const data = await res.json();
if (!res.ok) { errEl.textContent = data.error; return; }
document.getElementById('createUserForm').style.display = 'none';
document.getElementById('newUsername').value = '';
document.getElementById('newPassword').value = '';
loadUsers();
loadAssignDropdowns();
});
async function deleteUser(id) {
if (!confirm('Delete this user?')) return;
await fetch(`/api/users/${id}`, { method: 'DELETE' });
loadUsers();
loadAssignments();
}
// ── Assignments ──
async function loadAssignments() {
const res = await fetch('/api/assignments');
const data = await res.json();
const tbody = document.getElementById('assignBody');
if (data.length === 0) {
tbody.innerHTML = '| No assignments yet |
';
return;
}
tbody.innerHTML = data.map(a => {
const pct = a.progress;
const barColor = pct >= 100 ? '#34d399' : pct > 50 ? '#fbbf24' : '#60a5fa';
const date = a.assigned_at ? new Date(a.assigned_at).toLocaleDateString() : '—';
return `
| ${escapeHtml(a.username)} |
${escapeHtml(a.filename)} |
${a.reviewed_count}/${a.total_samples} (${pct}%)
|
${date} |
|
`;
}).join('');
}
async function loadAssignDropdowns() {
// Populate user dropdown
const userSel = document.getElementById('assignUser');
const fileSel = document.getElementById('assignFile');
const usersRes = await fetch('/api/users');
const users = await usersRes.json();
const filesRes = await fetch('/api/files');
const files = await filesRes.json();
userSel.innerHTML = '' +
users.filter(u => u.role === 'annotator').map(u => ``).join('');
fileSel.innerHTML = '' +
files.map(f => ``).join('');
}
document.getElementById('addAssignBtn').addEventListener('click', () => {
document.getElementById('createAssignForm').style.display = 'block';
loadAssignDropdowns();
});
document.getElementById('cancelAssignBtn').addEventListener('click', () => {
document.getElementById('createAssignForm').style.display = 'none';
document.getElementById('assignFormError').textContent = '';
});
document.getElementById('submitAssignBtn').addEventListener('click', async () => {
const user_id = document.getElementById('assignUser').value;
const file_id = document.getElementById('assignFile').value;
const errEl = document.getElementById('assignFormError');
errEl.textContent = '';
if (!user_id || !file_id) { errEl.textContent = 'Select both user and file'; return; }
const res = await fetch('/api/assignments', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: parseInt(user_id), file_id: parseInt(file_id) })
});
const data = await res.json();
if (!res.ok) { errEl.textContent = data.error; return; }
document.getElementById('createAssignForm').style.display = 'none';
auditInitialized = false;
loadAssignments();
loadFiles();
});
async function deleteAssignment(id) {
if (!confirm('Remove this assignment?')) return;
await fetch(`/api/assignments/${id}`, { method: 'DELETE' });
auditInitialized = false;
loadAssignments();
loadFiles();
}
// ── Read-only review audit by CSV part ──
let auditInitialized = false;
let auditFiles = [];
let auditAssignments = [];
let auditRows = [];
let auditCurrentFile = null;
let auditCurrentAssignment = null;
let auditOptionsToken = 0;
let auditDataToken = 0;
let auditModalTrigger = null;
async function auditFetchJson(url) {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`);
return data;
}
function auditPartNumber(filename) {
const match = filename.match(/part-(\d+)-of-(\d+)\.csv$/i);
return match ? Number(match[1]) : Infinity;
}
function auditPartLabel(filename) {
const match = filename.match(/part-(\d+)-of-(\d+)\.csv$/i);
return match ? `Part ${match[1]}/${match[2]} · ${filename}` : filename;
}
function fillAuditAssignmentSelect(preferredId) {
const fileId = Number(document.getElementById('auditFileSelect').value);
const select = document.getElementById('auditAssignmentSelect');
const matches = auditAssignments.filter(assignment => assignment.file_id === fileId);
if (!matches.length) {
select.innerHTML = '';
select.value = '';
select.disabled = true;
return;
}
const previous = Number(preferredId) || Number(select.value);
select.disabled = false;
select.innerHTML = matches.map(assignment =>
``
).join('');
select.value = String(matches.some(assignment => assignment.id === previous) ? previous : matches[0].id);
}
async function loadAuditOptions(preferredFileId, preferredAssignmentId) {
const token = ++auditOptionsToken;
auditDataToken++;
document.getElementById('auditStatus').textContent = 'Loading CSV parts...';
try {
const [files, assignments] = await Promise.all([
auditFetchJson('/api/files'), auditFetchJson('/api/assignments'),
]);
if (token !== auditOptionsToken) return;
auditFiles = files.sort((left, right) =>
auditPartNumber(left.filename) - auditPartNumber(right.filename) ||
left.filename.localeCompare(right.filename));
auditAssignments = assignments;
const select = document.getElementById('auditFileSelect');
const preferred = Number(preferredFileId) || Number(select.value);
const chosen = auditFiles.find(file => file.id === preferred) || auditFiles[0];
select.innerHTML = auditFiles.map(file =>
``).join('');
if (!chosen) {
auditRows = [];
document.getElementById('auditSummary').innerHTML = '';
document.getElementById('auditSamplesBody').innerHTML = '';
document.getElementById('auditStatus').textContent = 'No CSV files available.';
return;
}
select.value = String(chosen.id);
fillAuditAssignmentSelect(preferredAssignmentId);
auditInitialized = true;
await loadAuditPart();
} catch (error) {
if (token === auditOptionsToken)
document.getElementById('auditStatus').textContent = `Could not load review details: ${error.message}`;
}
}
async function loadAuditPart() {
const token = ++auditDataToken;
const fileId = Number(document.getElementById('auditFileSelect').value);
const assignmentId = Number(document.getElementById('auditAssignmentSelect').value) || null;
if (!fileId) return;
document.getElementById('auditStatus').textContent = 'Loading sample reviews...';
document.getElementById('auditSummary').innerHTML = '';
document.getElementById('auditSamplesBody').innerHTML = '';
try {
const [fileData, reviews] = await Promise.all([
auditFetchJson(`/api/files/${fileId}/data`),
assignmentId ? auditFetchJson(`/api/assignments/${assignmentId}/reviews`) : Promise.resolve({}),
]);
if (token !== auditDataToken) return;
auditCurrentFile = auditFiles.find(file => file.id === fileId) || null;
auditCurrentAssignment = auditAssignments.find(assignment => assignment.id === assignmentId) || null;
auditRows = ReviewAudit.buildAuditRows(fileData.samples || [], reviews);
renderAuditSummary();
renderAuditRows();
} catch (error) {
if (token === auditDataToken)
document.getElementById('auditStatus').textContent = `Could not load this part: ${error.message}`;
}
}
function renderAuditSummary() {
const metrics = ReviewAudit.summarize(auditRows);
const tiles = [
[`${metrics.completed}/${metrics.total}`, 'Completed samples'],
[metrics.drafts, 'Drafts in progress'],
[metrics.editedSamples, 'Completed samples with edits'],
[`${metrics.explainedSections}/${metrics.changedSections}`, 'Changed sections with comment'],
[metrics.needsExplanation, 'Changed sections without comment', true],
[metrics.unchangedSamples, 'Completed without edits'],
...ReviewAudit.SECTIONS.map(section => [metrics.bySection[section.key], `${section.label} changed`]),
];
document.getElementById('auditSummary').innerHTML = tiles.map(([value, label, warning]) =>
`${value}${label}
`
).join('');
}
function auditChip(label, state) {
return `${escapeHtml(label)}`;
}
function auditSectionChip(section) {
const labels = { edited: 'Edited', kept: 'Kept', 'draft-edit': 'Draft edit', draft: 'Draft', none: '—' };
const state = section.state.startsWith('draft') ? 'draft' : section.state;
const warning = section.needsExplanation ? ' !' : '';
return auditChip(labels[section.state] + warning, state);
}
function renderAuditRows() {
const filter = document.getElementById('auditFilterSelect').value;
const shown = ReviewAudit.filterRows(auditRows, filter);
const tbody = document.getElementById('auditSamplesBody');
tbody.innerHTML = shown.length ? shown.map(audit => {
const row = audit.row;
const status = audit.status === 'complete' ? auditChip('Complete', 'complete')
: audit.status === 'draft' ? auditChip('Draft', 'draft') : auditChip('Not started', 'none');
const missing = audit.needsExplanationCount;
return `
| #${row.sample_index + 1}${escapeHtml(row.sample_id)}Gold: ${escapeHtml(row.emotion_type || '—')} |
${status} |
${ReviewAudit.SECTIONS.map(section => `${auditSectionChip(audit.sections[section.key])} | `).join('')}
${audit.commentCount || '—'} |
${missing ? `${missing} needs explanation` : '—'} |
|
`;
}).join('') : '| No samples match this filter. |
';
tbody.querySelectorAll('[data-audit-sample]').forEach(button => {
button.addEventListener('click', () => openAuditSample(Number(button.dataset.auditSample), button));
});
const reviewer = auditCurrentAssignment ? auditCurrentAssignment.username : 'No reviewer assigned';
document.getElementById('auditStatus').textContent =
`${auditCurrentFile?.filename || 'Part'} · ${reviewer} · Showing ${shown.length}/${auditRows.length} samples`;
}
function auditPath(path) {
return path.map(part => /^\d+$/.test(part) ? `#${Number(part) + 1}`
: part.replace(/_/g, ' ').replace(/\b\w/g, letter => letter.toUpperCase())).join(' › ');
}
function auditValue(value) {
if (value === undefined || value === null || value === '') return '∅';
return typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
}
function renderAuditSection(audit, definition) {
const section = audit.sections[definition.key];
const changes = ReviewAudit.diffValues(audit.original[definition.key], audit.current[definition.key]);
const comments = section.comments.map(note =>
``
).join('');
const diff = changes.map(change => `
${escapeHtml(auditPath(change.path) || definition.label)}
Original${escapeHtml(auditValue(change.before))}
Reviewed${escapeHtml(auditValue(change.after))}
`).join('');
return `
${definition.label}
${auditSectionChip(section)}
${section.needsExplanation ? 'Changed without a section-specific reviewer comment.
' : ''}
${comments || 'No reviewer comment for this section.
'}
${diff || 'No annotation changes.
'}
View full annotation
Original${escapeHtml(auditValue(audit.original[definition.key]))}
Reviewed / current${escapeHtml(auditValue(audit.current[definition.key]))}
`;
}
function openAuditSample(sampleIndex, trigger) {
const audit = auditRows.find(item => item.row.sample_index === sampleIndex);
if (!audit) return;
const modal = document.getElementById('auditModal');
auditModalTrigger = trigger;
document.getElementById('auditModalTitle').textContent =
`Sample #${sampleIndex + 1} · ${audit.row.sample_id || ''}`;
const reviewedAt = audit.review?.reviewed_at ? new Date(audit.review.reviewed_at).toLocaleString() : null;
document.getElementById('auditModalSubtitle').textContent =
`${auditCurrentFile?.filename || ''} · ${auditCurrentAssignment?.username || 'Unassigned'} · ${audit.status} · Gold emotion: ${audit.row.emotion_type || '—'}`
+ (reviewedAt ? ` · Last completed ${reviewedAt}` : '');
const caution = audit.needsExplanationCount
? `${audit.needsExplanationCount} changed section(s) lack a reviewer explanation. Please inspect manually.
`
: 'No comment-coverage warning. This is not a correctness score.
';
const legacyComment = audit.review?.comment?.trim()
? `` : '';
document.getElementById('auditModalContent').innerHTML = `
${caution}${legacyComment}
Conversation
${escapeHtml(audit.row.conversation_cut || '')}
${ReviewAudit.SECTIONS.map(section => renderAuditSection(audit, section)).join('')}`;
modal.hidden = false;
modal.setAttribute('aria-hidden', 'false');
document.getElementById('auditModalCloseBtn').focus();
}
function closeAuditSample() {
const modal = document.getElementById('auditModal');
if (modal.hidden) return;
modal.hidden = true;
modal.setAttribute('aria-hidden', 'true');
document.getElementById('auditModalContent').innerHTML = '';
auditModalTrigger?.focus();
auditModalTrigger = null;
}
async function openAuditFor(fileId, assignmentId) {
activateTab('details', false);
await loadAuditOptions(fileId, assignmentId);
}
document.getElementById('auditFileSelect').addEventListener('change', () => {
fillAuditAssignmentSelect();
loadAuditPart();
});
document.getElementById('auditAssignmentSelect').addEventListener('change', loadAuditPart);
document.getElementById('auditFilterSelect').addEventListener('change', renderAuditRows);
document.getElementById('auditRefreshBtn').addEventListener('click', () =>
loadAuditOptions(Number(document.getElementById('auditFileSelect').value),
Number(document.getElementById('auditAssignmentSelect').value)));
document.getElementById('auditModalCloseBtn').addEventListener('click', closeAuditSample);
document.getElementById('auditModal').addEventListener('click', event => {
if (event.target === event.currentTarget) closeAuditSample();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !document.getElementById('auditModal').hidden) closeAuditSample();
});
// ── Init ──
loadFiles();
loadUsers();
loadAssignments();
` : ''}${escapeHtml(note.text)}