- Create Economist SubmissionTracking package correctly: * mainArticle = full blog post content * coverLetter = 216-word SIR— letter * Links to blog post via blogPostId - Archive 'Letter to The Economist' from blog posts (it's the cover letter) - Fix date display on article cards (use published_at) - Target publication already displaying via blue badge Database changes: - Make blogPostId optional in SubmissionTracking model - Economist package ID: 68fa85ae49d4900e7f2ecd83 - Le Monde package ID: 68fa2abd2e6acd5691932150 Next: Enhanced modal with tabs, validation, export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
671 lines
23 KiB
JavaScript
671 lines
23 KiB
JavaScript
/**
|
|
* Case Study Moderation Admin Interface
|
|
* Handles review and moderation of community-submitted case studies
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// State
|
|
let submissions = [];
|
|
let currentFilters = {
|
|
status: 'pending',
|
|
failureMode: '',
|
|
score: '',
|
|
sortBy: 'submitted_at'
|
|
};
|
|
let currentSubmission = null;
|
|
let pendingAction = null;
|
|
|
|
// DOM Elements
|
|
const elements = {
|
|
// Stats
|
|
statTotal: document.getElementById('stat-total'),
|
|
statPending: document.getElementById('stat-pending'),
|
|
statApproved: document.getElementById('stat-approved'),
|
|
statRejected: document.getElementById('stat-rejected'),
|
|
|
|
// Filters
|
|
filterStatus: document.getElementById('filter-status'),
|
|
filterFailureMode: document.getElementById('filter-failure-mode'),
|
|
filterScore: document.getElementById('filter-score'),
|
|
sortBy: document.getElementById('sort-by'),
|
|
clearFiltersBtn: document.getElementById('clear-filters-btn'),
|
|
filterResults: document.getElementById('filter-results'),
|
|
|
|
// Submissions
|
|
submissionsContainer: document.getElementById('submissions-container'),
|
|
|
|
// Details Modal
|
|
detailsModal: document.getElementById('details-modal'),
|
|
closeDetailsModal: document.getElementById('close-details-modal'),
|
|
detailsModalCloseBtn: document.getElementById('details-modal-close-btn'),
|
|
detailsModalContent: document.getElementById('details-modal-content'),
|
|
requestInfoBtn: document.getElementById('request-info-btn'),
|
|
rejectBtn: document.getElementById('reject-btn'),
|
|
approveBtn: document.getElementById('approve-btn'),
|
|
|
|
// Action Modal
|
|
actionModal: document.getElementById('action-modal'),
|
|
closeActionModal: document.getElementById('close-action-modal'),
|
|
actionModalTitle: document.getElementById('action-modal-title'),
|
|
actionModalContent: document.getElementById('action-modal-content'),
|
|
actionNotes: document.getElementById('action-notes'),
|
|
actionNotesLabel: document.getElementById('action-notes-label'),
|
|
actionModalCancelBtn: document.getElementById('action-modal-cancel-btn'),
|
|
actionModalSubmitBtn: document.getElementById('action-modal-submit-btn'),
|
|
|
|
// Auth
|
|
adminName: document.getElementById('admin-name'),
|
|
logoutBtn: document.getElementById('logout-btn')
|
|
};
|
|
|
|
/**
|
|
* Initialize the application
|
|
*/
|
|
async function init() {
|
|
// Check authentication
|
|
const token = localStorage.getItem('admin_token');
|
|
if (!token) {
|
|
window.location.href = '/admin/login.html';
|
|
return;
|
|
}
|
|
|
|
// Set admin name
|
|
const adminEmail = localStorage.getItem('admin_email');
|
|
if (elements.adminName && adminEmail) {
|
|
elements.adminName.textContent = adminEmail;
|
|
}
|
|
|
|
// Attach event listeners
|
|
attachEventListeners();
|
|
|
|
// Load data
|
|
await loadSubmissions();
|
|
await loadStats();
|
|
}
|
|
|
|
/**
|
|
* Attach event listeners
|
|
*/
|
|
function attachEventListeners() {
|
|
// Filters
|
|
if (elements.filterStatus) {
|
|
elements.filterStatus.addEventListener('change', handleFilterChange);
|
|
}
|
|
if (elements.filterFailureMode) {
|
|
elements.filterFailureMode.addEventListener('change', handleFilterChange);
|
|
}
|
|
if (elements.filterScore) {
|
|
elements.filterScore.addEventListener('change', handleFilterChange);
|
|
}
|
|
if (elements.sortBy) {
|
|
elements.sortBy.addEventListener('change', handleFilterChange);
|
|
}
|
|
if (elements.clearFiltersBtn) {
|
|
elements.clearFiltersBtn.addEventListener('click', clearFilters);
|
|
}
|
|
|
|
// Details Modal
|
|
if (elements.closeDetailsModal) {
|
|
elements.closeDetailsModal.addEventListener('click', closeDetailsModal);
|
|
}
|
|
if (elements.detailsModalCloseBtn) {
|
|
elements.detailsModalCloseBtn.addEventListener('click', closeDetailsModal);
|
|
}
|
|
if (elements.requestInfoBtn) {
|
|
elements.requestInfoBtn.addEventListener('click', () => openActionModal('request_info'));
|
|
}
|
|
if (elements.rejectBtn) {
|
|
elements.rejectBtn.addEventListener('click', () => openActionModal('reject'));
|
|
}
|
|
if (elements.approveBtn) {
|
|
elements.approveBtn.addEventListener('click', () => openActionModal('approve'));
|
|
}
|
|
|
|
// Action Modal
|
|
if (elements.closeActionModal) {
|
|
elements.closeActionModal.addEventListener('click', closeActionModal);
|
|
}
|
|
if (elements.actionModalCancelBtn) {
|
|
elements.actionModalCancelBtn.addEventListener('click', closeActionModal);
|
|
}
|
|
if (elements.actionModalSubmitBtn) {
|
|
elements.actionModalSubmitBtn.addEventListener('click', handleActionSubmit);
|
|
}
|
|
|
|
// Logout
|
|
if (elements.logoutBtn) {
|
|
elements.logoutBtn.addEventListener('click', () => {
|
|
localStorage.removeItem('admin_token');
|
|
localStorage.removeItem('admin_email');
|
|
window.location.href = '/admin/login.html';
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load submissions from API
|
|
*/
|
|
async function loadSubmissions() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const params = new URLSearchParams();
|
|
|
|
if (currentFilters.status) params.append('status', currentFilters.status);
|
|
if (currentFilters.failureMode) params.append('failure_mode', currentFilters.failureMode);
|
|
if (currentFilters.score) params.append('score', currentFilters.score);
|
|
if (currentFilters.sortBy) params.append('sort', currentFilters.sortBy);
|
|
|
|
const response = await fetch(`/api/cases/submissions?${params.toString()}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('admin_token');
|
|
window.location.href = '/admin/login.html';
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
submissions = data.submissions || [];
|
|
renderSubmissions();
|
|
} else {
|
|
showToast('Failed to load submissions', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading submissions:', error);
|
|
showToast('Error loading submissions', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load statistics
|
|
*/
|
|
async function loadStats() {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch('/api/cases/submissions/stats', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const stats = data.stats || {};
|
|
if (elements.statTotal) elements.statTotal.textContent = stats.total || 0;
|
|
if (elements.statPending) elements.statPending.textContent = stats.pending || 0;
|
|
if (elements.statApproved) elements.statApproved.textContent = stats.approved || 0;
|
|
if (elements.statRejected) elements.statRejected.textContent = stats.rejected || 0;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render submissions list
|
|
*/
|
|
function renderSubmissions() {
|
|
if (!elements.submissionsContainer) return;
|
|
|
|
// Update filter results count
|
|
if (elements.filterResults) {
|
|
const count = submissions.length;
|
|
elements.filterResults.textContent = `Showing ${count} submission${count !== 1 ? 's' : ''}`;
|
|
}
|
|
|
|
// No submissions
|
|
if (submissions.length === 0) {
|
|
elements.submissionsContainer.innerHTML = `
|
|
<div class="bg-white rounded-lg shadow p-12 text-center text-gray-500">
|
|
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
<p class="text-lg font-medium">No submissions found</p>
|
|
<p class="text-sm mt-2">Try adjusting your filters</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Render submission cards
|
|
const submissionsHTML = submissions.map(submission => {
|
|
const statusColors = {
|
|
'pending': 'bg-yellow-100 text-yellow-800',
|
|
'approved': 'bg-green-100 text-green-800',
|
|
'rejected': 'bg-red-100 text-red-800',
|
|
'needs_info': 'bg-blue-100 text-blue-800'
|
|
};
|
|
|
|
const statusColor = statusColors[submission.moderation?.status] || 'bg-gray-100 text-gray-800';
|
|
|
|
const relevanceScore = submission.ai_review?.relevance_score;
|
|
const scoreColor = relevanceScore >= 0.7 ? 'text-green-600' : relevanceScore >= 0.4 ? 'text-yellow-600' : 'text-red-600';
|
|
|
|
const submittedDate = new Date(submission.submitted_at).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
|
|
return `
|
|
<div class="bg-white rounded-lg shadow hover:shadow-lg transition cursor-pointer mb-4 border border-gray-200 hover:border-blue-500 submission-card"
|
|
data-submission-id="${submission._id}">
|
|
<div class="p-6">
|
|
<!-- Header -->
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div class="flex-1">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">${escapeHtml(submission.case_study?.title || 'Untitled')}</h3>
|
|
<div class="flex flex-wrap gap-2 mb-2">
|
|
<span class="px-2 py-1 ${statusColor} rounded text-xs font-medium">
|
|
${submission.moderation?.status?.toUpperCase() || 'PENDING'}
|
|
</span>
|
|
<span class="px-2 py-1 bg-purple-100 text-purple-800 rounded text-xs font-medium">
|
|
${submission.case_study?.failure_mode?.replace(/_/g, ' ').toUpperCase() || 'OTHER'}
|
|
</span>
|
|
${relevanceScore !== undefined ? `
|
|
<span class="px-2 py-1 bg-gray-100 ${scoreColor} rounded text-xs font-medium">
|
|
AI Score: ${(relevanceScore * 100).toFixed(0)}%
|
|
</span>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submitter -->
|
|
<div class="text-sm text-gray-600 mb-3">
|
|
<span class="font-medium">Submitted by:</span>
|
|
${escapeHtml(submission.submitter?.name || 'Anonymous')}
|
|
${submission.submitter?.organization ? ` (${escapeHtml(submission.submitter.organization)})` : ''}
|
|
</div>
|
|
|
|
<!-- Description Preview -->
|
|
<p class="text-sm text-gray-700 line-clamp-2 mb-3">
|
|
${escapeHtml(submission.case_study?.description || '').substring(0, 200)}...
|
|
</p>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex items-center justify-between text-xs text-gray-500">
|
|
<span>Submitted: ${submittedDate}</span>
|
|
${submission.moderation?.reviewed_at ? `
|
|
<span>Reviewed: ${new Date(submission.moderation.reviewed_at).toLocaleDateString()}</span>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
elements.submissionsContainer.innerHTML = submissionsHTML;
|
|
|
|
// Attach click handlers to submission cards (CSP-compliant)
|
|
document.querySelectorAll('.submission-card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const submissionId = card.getAttribute('data-submission-id');
|
|
if (submissionId) {
|
|
viewSubmission(submissionId);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* View submission details
|
|
*/
|
|
async function viewSubmission(submissionId) {
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(`/api/cases/submissions/${submissionId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
currentSubmission = data.submission;
|
|
renderSubmissionDetails(data.submission);
|
|
openDetailsModal();
|
|
} else {
|
|
showToast('Failed to load submission details', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading submission:', error);
|
|
showToast('Error loading submission details', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render submission details in modal
|
|
*/
|
|
function renderSubmissionDetails(submission) {
|
|
if (!elements.detailsModalContent) return;
|
|
|
|
const html = `
|
|
<!-- Status Banner -->
|
|
<div class="mb-6 p-4 rounded-lg ${getStatusBannerColor(submission.moderation?.status)}">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="font-medium">Status: ${submission.moderation?.status?.toUpperCase() || 'PENDING'}</p>
|
|
${submission.moderation?.reviewed_at ? `
|
|
<p class="text-sm mt-1">Reviewed: ${new Date(submission.moderation.reviewed_at).toLocaleString()}</p>
|
|
` : ''}
|
|
</div>
|
|
${submission.ai_review?.relevance_score !== undefined ? `
|
|
<div class="text-right">
|
|
<p class="font-medium">AI Relevance Score</p>
|
|
<p class="text-2xl font-bold">${(submission.ai_review.relevance_score * 100).toFixed(0)}%</p>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submitter Information -->
|
|
<div class="mb-6">
|
|
<h4 class="text-lg font-semibold text-gray-900 mb-3">Submitter Information</h4>
|
|
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
|
|
<p><span class="font-medium">Name:</span> ${escapeHtml(submission.submitter?.name || 'N/A')}</p>
|
|
<p><span class="font-medium">Email:</span> ${escapeHtml(submission.submitter?.email || 'N/A')}</p>
|
|
${submission.submitter?.organization ? `
|
|
<p><span class="font-medium">Organization:</span> ${escapeHtml(submission.submitter.organization)}</p>
|
|
` : ''}
|
|
<p><span class="font-medium">Public Attribution:</span> ${submission.submitter?.public ? 'Yes' : 'No'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Case Study Details -->
|
|
<div class="mb-6">
|
|
<h4 class="text-lg font-semibold text-gray-900 mb-3">Case Study Details</h4>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-700 mb-1">Title</p>
|
|
<p class="text-gray-900">${escapeHtml(submission.case_study?.title || 'N/A')}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-700 mb-1">Failure Mode</p>
|
|
<p class="text-gray-900">${submission.case_study?.failure_mode?.replace(/_/g, ' ').toUpperCase() || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-700 mb-1">Description</p>
|
|
<div class="text-gray-900 whitespace-pre-wrap bg-gray-50 rounded p-4">${escapeHtml(submission.case_study?.description || 'N/A')}</div>
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-700 mb-1">Tractatus Applicability</p>
|
|
<div class="text-gray-900 whitespace-pre-wrap bg-gray-50 rounded p-4">${escapeHtml(submission.case_study?.tractatus_applicability || 'N/A')}</div>
|
|
</div>
|
|
${submission.case_study?.evidence?.length > 0 ? `
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-700 mb-2">Evidence Links</p>
|
|
<ul class="list-disc list-inside space-y-1">
|
|
${submission.case_study.evidence.map(url => `
|
|
<li><a href="${escapeHtml(url)}" target="_blank" class="text-blue-600 hover:underline">${escapeHtml(url)}</a></li>
|
|
`).join('')}
|
|
</ul>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AI Review (if available) -->
|
|
${submission.ai_review?.claude_analysis ? `
|
|
<div class="mb-6">
|
|
<h4 class="text-lg font-semibold text-gray-900 mb-3">AI Analysis</h4>
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<p class="text-sm text-blue-900 whitespace-pre-wrap">${escapeHtml(submission.ai_review.claude_analysis)}</p>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Review Notes (if any) -->
|
|
${submission.moderation?.review_notes ? `
|
|
<div class="mb-6">
|
|
<h4 class="text-lg font-semibold text-gray-900 mb-3">Review Notes</h4>
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<p class="text-gray-900 whitespace-pre-wrap">${escapeHtml(submission.moderation.review_notes)}</p>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
|
|
elements.detailsModalContent.innerHTML = html;
|
|
|
|
// Show/hide action buttons based on status
|
|
const isPending = submission.moderation?.status === 'pending' || submission.moderation?.status === 'needs_info';
|
|
if (elements.requestInfoBtn) elements.requestInfoBtn.style.display = isPending ? 'inline-block' : 'none';
|
|
if (elements.rejectBtn) elements.rejectBtn.style.display = isPending ? 'inline-block' : 'none';
|
|
if (elements.approveBtn) elements.approveBtn.style.display = isPending ? 'inline-block' : 'none';
|
|
}
|
|
|
|
/**
|
|
* Get status banner color
|
|
*/
|
|
function getStatusBannerColor(status) {
|
|
const colors = {
|
|
'pending': 'bg-yellow-50 border border-yellow-200',
|
|
'approved': 'bg-green-50 border border-green-200',
|
|
'rejected': 'bg-red-50 border border-red-200',
|
|
'needs_info': 'bg-blue-50 border border-blue-200'
|
|
};
|
|
return colors[status] || 'bg-gray-50 border border-gray-200';
|
|
}
|
|
|
|
/**
|
|
* Open action modal
|
|
*/
|
|
function openActionModal(action) {
|
|
if (!currentSubmission) return;
|
|
|
|
pendingAction = action;
|
|
|
|
const titles = {
|
|
'approve': 'Approve Case Study',
|
|
'reject': 'Reject Case Study',
|
|
'request_info': 'Request More Information'
|
|
};
|
|
|
|
const labels = {
|
|
'approve': 'Approval Notes',
|
|
'reject': 'Rejection Reason',
|
|
'request_info': 'Information Needed'
|
|
};
|
|
|
|
const content = {
|
|
'approve': 'This case study will be approved and may be published. Add any notes for the submitter:',
|
|
'reject': 'Please provide a reason for rejecting this submission:',
|
|
'request_info': 'What additional information do you need from the submitter?'
|
|
};
|
|
|
|
if (elements.actionModalTitle) {
|
|
elements.actionModalTitle.textContent = titles[action] || 'Action';
|
|
}
|
|
|
|
if (elements.actionModalContent) {
|
|
elements.actionModalContent.innerHTML = `
|
|
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
|
<p class="text-sm font-medium text-gray-900 mb-2">${escapeHtml(currentSubmission.case_study?.title || 'Untitled')}</p>
|
|
<p class="text-xs text-gray-600">${content[action]}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (elements.actionNotesLabel) {
|
|
elements.actionNotesLabel.textContent = labels[action] || 'Notes';
|
|
}
|
|
|
|
if (elements.actionNotes) {
|
|
elements.actionNotes.value = '';
|
|
}
|
|
|
|
if (elements.actionModal) {
|
|
elements.actionModal.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close action modal
|
|
*/
|
|
function closeActionModal() {
|
|
if (elements.actionModal) {
|
|
elements.actionModal.classList.add('hidden');
|
|
}
|
|
pendingAction = null;
|
|
}
|
|
|
|
/**
|
|
* Handle action submission
|
|
*/
|
|
async function handleActionSubmit() {
|
|
if (!currentSubmission || !pendingAction) return;
|
|
|
|
const notes = elements.actionNotes?.value.trim();
|
|
if (!notes) {
|
|
showToast('Please enter notes', 'error');
|
|
return;
|
|
}
|
|
|
|
const endpoints = {
|
|
'approve': '/api/cases/submissions/' + currentSubmission._id + '/approve',
|
|
'reject': '/api/cases/submissions/' + currentSubmission._id + '/reject',
|
|
'request_info': '/api/cases/submissions/' + currentSubmission._id + '/request-info'
|
|
};
|
|
|
|
// Build request body based on action (different endpoints expect different parameter names)
|
|
const requestBody = {};
|
|
if (pendingAction === 'approve') {
|
|
requestBody.notes = notes;
|
|
} else if (pendingAction === 'reject') {
|
|
requestBody.reason = notes; // Controller expects 'reason' for reject
|
|
} else if (pendingAction === 'request_info') {
|
|
requestBody.requested_info = notes; // Controller expects 'requested_info'
|
|
}
|
|
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(endpoints[pendingAction], {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast(`Successfully ${pendingAction.replace('_', ' ')}d submission`, 'success');
|
|
closeActionModal();
|
|
closeDetailsModal();
|
|
await loadSubmissions();
|
|
await loadStats();
|
|
} else {
|
|
showToast(data.message || 'Action failed', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
showToast('Error processing action', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open details modal
|
|
*/
|
|
function openDetailsModal() {
|
|
if (elements.detailsModal) {
|
|
elements.detailsModal.classList.remove('hidden');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close details modal
|
|
*/
|
|
function closeDetailsModal() {
|
|
if (elements.detailsModal) {
|
|
elements.detailsModal.classList.add('hidden');
|
|
document.body.style.overflow = '';
|
|
}
|
|
currentSubmission = null;
|
|
}
|
|
|
|
/**
|
|
* Handle filter changes
|
|
*/
|
|
function handleFilterChange() {
|
|
currentFilters.status = elements.filterStatus?.value || '';
|
|
currentFilters.failureMode = elements.filterFailureMode?.value || '';
|
|
currentFilters.score = elements.filterScore?.value || '';
|
|
currentFilters.sortBy = elements.sortBy?.value || 'submitted_at';
|
|
|
|
loadSubmissions();
|
|
}
|
|
|
|
/**
|
|
* Clear all filters
|
|
*/
|
|
function clearFilters() {
|
|
if (elements.filterStatus) elements.filterStatus.value = '';
|
|
if (elements.filterFailureMode) elements.filterFailureMode.value = '';
|
|
if (elements.filterScore) elements.filterScore.value = '';
|
|
if (elements.sortBy) elements.sortBy.value = 'submitted_at';
|
|
|
|
currentFilters = {
|
|
status: '',
|
|
failureMode: '',
|
|
score: '',
|
|
sortBy: 'submitted_at'
|
|
};
|
|
|
|
loadSubmissions();
|
|
}
|
|
|
|
/**
|
|
* Show toast notification
|
|
*/
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.createElement('div');
|
|
const colors = {
|
|
'success': 'bg-green-500',
|
|
'error': 'bg-red-500',
|
|
'info': 'bg-blue-500'
|
|
};
|
|
|
|
toast.className = `${colors[type] || colors.info} text-white px-6 py-3 rounded-lg shadow-lg`;
|
|
toast.textContent = message;
|
|
|
|
const container = document.getElementById('toast-container');
|
|
if (container) {
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
*/
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
})();
|