Spaces:
Sleeping
Sleeping
Download static/admin.js from Anonymous-EmpathyAI/Empathy-Label: direct link, hf CLI and curl.
- Browser
- Download file 20.9 kB
-
https://huggingface.co/spaces/Anonymous-EmpathyAI/Empathy-Label/resolve/main/static/admin.js
- Command line
-
hf download hf://spaces/Anonymous-EmpathyAI/Empathy-Label/static/admin.js
-
curl -L -o admin.js https://huggingface.co/spaces/Anonymous-EmpathyAI/Empathy-Label/resolve/main/static/admin.js
20.9 kB
| // ===== Admin Dashboard JS ===== | |
| function escapeHtml(value) { | |
| return String(value ?? '').replace(/&/g, '&').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 = '<div class="empty-state"><p>No CSV files found in the data folder.</p></div>'; | |
| return; | |
| } | |
| grid.innerHTML = allFiles.map(f => { | |
| return ` | |
| <div class="file-card" style="cursor:default"> | |
| <div class="file-card-header"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent);flex-shrink:0"> | |
| <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/> | |
| <polyline points="14 2 14 8 20 8"/> | |
| </svg> | |
| <span class="file-card-name">${escapeHtml(f.filename)}</span> | |
| </div> | |
| <div class="file-card-stats"> | |
| <span>${f.total_samples} samples</span> | |
| <span>${f.total_assignees} assignee(s)</span> | |
| </div> | |
| <div class="file-card-stats" style="margin-top:4px"> | |
| <span>${f.total_reviews} total reviews</span> | |
| </div> | |
| <button class="ghost-btn" type="button" onclick="openAuditFor(${Number(f.id)})">View review details</button> | |
| </div>`; | |
| }).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 `<tr> | |
| <td>${u.id}</td> | |
| <td style="font-weight:600;color:var(--text-primary)">${escapeHtml(u.username)}</td> | |
| <td><span class="role-badge ${roleClass}">${escapeHtml(u.role)}</span></td> | |
| <td>${created}</td> | |
| <td>${u.role !== 'admin' ? `<button class="danger-btn" onclick="deleteUser(${u.id})">Delete</button>` : '—'}</td> | |
| </tr>`; | |
| }).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 = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted)">No assignments yet</td></tr>'; | |
| 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 `<tr> | |
| <td style="font-weight:600;color:var(--text-primary)">${escapeHtml(a.username)}</td> | |
| <td>${escapeHtml(a.filename)}</td> | |
| <td> | |
| <div class="progress-cell"> | |
| <div class="progress-bar"><div class="progress-fill" style="width:${pct}%;background:${barColor}"></div></div> | |
| <span class="progress-text" style="color:${barColor}">${a.reviewed_count}/${a.total_samples} (${pct}%)</span> | |
| </div> | |
| </td> | |
| <td>${date}</td> | |
| <td> | |
| <button class="ghost-btn" type="button" onclick="openAuditFor(${Number(a.file_id)},${Number(a.id)})">Inspect</button> | |
| <button class="danger-btn" onclick="deleteAssignment(${a.id})">Remove</button> | |
| </td> | |
| </tr>`; | |
| }).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 = '<option value="">Select User...</option>' + | |
| users.filter(u => u.role === 'annotator').map(u => `<option value="${u.id}">${escapeHtml(u.username)}</option>`).join(''); | |
| fileSel.innerHTML = '<option value="">Select File...</option>' + | |
| files.map(f => `<option value="${f.id}">${escapeHtml(f.filename)} (${f.total_samples} samples)</option>`).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 = '<option value="">No reviewer assigned</option>'; | |
| select.value = ''; | |
| select.disabled = true; | |
| return; | |
| } | |
| const previous = Number(preferredId) || Number(select.value); | |
| select.disabled = false; | |
| select.innerHTML = matches.map(assignment => | |
| `<option value="${assignment.id}">${escapeHtml(assignment.username)} · ${assignment.reviewed_count}/${assignment.total_samples} complete</option>` | |
| ).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 => | |
| `<option value="${file.id}">${escapeHtml(auditPartLabel(file.filename))}</option>`).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]) => | |
| `<div class="audit-metric ${warning && value ? 'is-warning' : ''}"><strong>${value}</strong><span>${label}</span></div>` | |
| ).join(''); | |
| } | |
| function auditChip(label, state) { | |
| return `<span class="audit-chip is-${state}">${escapeHtml(label)}</span>`; | |
| } | |
| 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 `<tr> | |
| <td><strong>#${row.sample_index + 1}</strong><span class="audit-sample-id">${escapeHtml(row.sample_id)}</span><span class="audit-sample-id">Gold: ${escapeHtml(row.emotion_type || '—')}</span></td> | |
| <td>${status}</td> | |
| ${ReviewAudit.SECTIONS.map(section => `<td>${auditSectionChip(audit.sections[section.key])}</td>`).join('')} | |
| <td>${audit.commentCount || '—'}</td> | |
| <td>${missing ? `<span class="audit-check">${missing} needs explanation</span>` : '<span class="audit-check is-clear">—</span>'}</td> | |
| <td><button class="ghost-btn audit-inspect-btn" type="button" data-audit-sample="${row.sample_index}">Inspect</button></td> | |
| </tr>`; | |
| }).join('') : '<tr><td colspan="9" class="audit-empty-row">No samples match this filter.</td></tr>'; | |
| 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 => | |
| `<div class="audit-comment">${note.path.length ? `<strong>${escapeHtml(auditPath(note.path))}</strong><br>` : ''}${escapeHtml(note.text)}</div>` | |
| ).join(''); | |
| const diff = changes.map(change => `<div class="audit-field-change"> | |
| <strong>${escapeHtml(auditPath(change.path) || definition.label)}</strong> | |
| <div class="audit-diff-grid"> | |
| <div><span>Original</span>${escapeHtml(auditValue(change.before))}</div> | |
| <div><span>Reviewed</span>${escapeHtml(auditValue(change.after))}</div> | |
| </div> | |
| </div>`).join(''); | |
| return `<section class="audit-section"> | |
| <div class="audit-section-head"><h3>${definition.label}</h3>${auditSectionChip(section)}</div> | |
| ${section.needsExplanation ? '<p class="audit-warning">Changed without a section-specific reviewer comment.</p>' : ''} | |
| ${comments || '<p class="audit-check is-clear">No reviewer comment for this section.</p>'} | |
| ${diff || '<p class="audit-check is-clear">No annotation changes.</p>'} | |
| <details><summary>View full annotation</summary> | |
| <div class="audit-diff-grid"> | |
| <div><span>Original</span><pre class="audit-json">${escapeHtml(auditValue(audit.original[definition.key]))}</pre></div> | |
| <div><span>Reviewed / current</span><pre class="audit-json">${escapeHtml(auditValue(audit.current[definition.key]))}</pre></div> | |
| </div> | |
| </details> | |
| </section>`; | |
| } | |
| 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 | |
| ? `<p class="audit-warning">${audit.needsExplanationCount} changed section(s) lack a reviewer explanation. Please inspect manually.</p>` | |
| : '<p class="audit-check is-clear">No comment-coverage warning. This is not a correctness score.</p>'; | |
| const legacyComment = audit.review?.comment?.trim() | |
| ? `<div class="audit-comment"><strong>Overall legacy comment</strong><br>${escapeHtml(audit.review.comment)}</div>` : ''; | |
| document.getElementById('auditModalContent').innerHTML = ` | |
| ${caution}${legacyComment} | |
| <div class="audit-conversation"><h3>Conversation</h3><pre>${escapeHtml(audit.row.conversation_cut || '')}</pre></div> | |
| ${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(); | |