tractatus/public/js/admin/dashboard.js
TheFlow 2298d36bed fix(submissions): restructure Economist package and fix article display
- 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>
2025-10-24 08:47:42 +13:00

779 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Auth check (navbar-admin.js also handles this, but keep for safety)
const token = localStorage.getItem('admin_token');
if (!token) {
window.location.href = '/admin/login.html';
}
// Navigation - handled by navbar-admin.js component now
// Section navigation for dashboard tabs
const sections = {
'overview': document.getElementById('overview-section'),
'moderation': document.getElementById('moderation-section'),
'users': document.getElementById('users-section'),
'documents': document.getElementById('documents-section')
};
// Handle hash navigation within dashboard
function showSection(sectionName) {
Object.values(sections).forEach(s => {
if (s) s.classList.add('hidden');
});
if (sections[sectionName]) {
sections[sectionName].classList.remove('hidden');
loadSection(sectionName);
}
}
window.addEventListener('hashchange', () => {
const hash = window.location.hash.slice(1) || 'overview';
showSection(hash);
});
// Show initial section on load
document.addEventListener('DOMContentLoaded', () => {
const initialSection = window.location.hash.slice(1) || 'overview';
showSection(initialSection);
});
// API helper
async function apiRequest(endpoint, options = {}) {
const response = await fetch(endpoint, {
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers
}
});
if (response.status === 401) {
localStorage.removeItem('admin_token');
window.location.href = '/admin/login.html';
return;
}
return response.json();
}
// Load statistics
async function loadStatistics() {
try {
const response = await apiRequest('/api/admin/stats');
if (!response.success || !response.stats) {
console.error('Invalid stats response:', response);
return;
}
const stats = response.stats;
document.getElementById('stat-documents').textContent = stats.documents?.total || 0;
document.getElementById('stat-pending').textContent = stats.moderation?.total_pending || 0;
document.getElementById('stat-approved').textContent = stats.blog?.published || 0;
document.getElementById('stat-users').textContent = stats.users?.total || 0;
} catch (error) {
console.error('Failed to load statistics:', error);
}
}
// Load sync health status
async function loadSyncHealth() {
const statusEl = document.getElementById('sync-status');
const badgeEl = document.getElementById('sync-badge');
const detailsEl = document.getElementById('sync-details');
const iconContainerEl = document.getElementById('sync-icon-container');
try {
const response = await apiRequest('/api/admin/sync/health');
if (!response.success || !response.health) {
console.error('Invalid sync health response:', response);
statusEl.textContent = 'Error';
badgeEl.textContent = 'Error';
badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800';
detailsEl.textContent = 'Failed to check sync health';
iconContainerEl.className = 'flex-shrink-0 bg-red-100 rounded-md p-3';
return;
}
const health = response.health;
const counts = health.counts;
// Update status text
statusEl.textContent = `File: ${counts.file} | DB: ${counts.database}`;
// Update badge and icon based on severity
if (health.severity === 'success') {
badgeEl.textContent = '✓ Synced';
badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800';
iconContainerEl.className = 'flex-shrink-0 bg-green-100 rounded-md p-3';
iconContainerEl.querySelector('svg').classList.remove('text-gray-600', 'text-yellow-600', 'text-red-600');
iconContainerEl.querySelector('svg').classList.add('text-green-600');
} else if (health.severity === 'warning') {
badgeEl.textContent = '⚠ Desync';
badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800';
iconContainerEl.className = 'flex-shrink-0 bg-yellow-100 rounded-md p-3';
iconContainerEl.querySelector('svg').classList.remove('text-gray-600', 'text-green-600', 'text-red-600');
iconContainerEl.querySelector('svg').classList.add('text-yellow-600');
} else {
badgeEl.textContent = '✗ Critical';
badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800';
iconContainerEl.className = 'flex-shrink-0 bg-red-100 rounded-md p-3';
iconContainerEl.querySelector('svg').classList.remove('text-gray-600', 'text-green-600', 'text-yellow-600');
iconContainerEl.querySelector('svg').classList.add('text-red-600');
}
// Update details
if (counts.difference === 0) {
detailsEl.textContent = health.message;
} else {
const missing = health.details?.missingInDatabase?.length || 0;
const orphaned = health.details?.orphanedInDatabase?.length || 0;
detailsEl.textContent = `${health.message} (Missing: ${missing}, Orphaned: ${orphaned})`;
}
} catch (error) {
console.error('Failed to load sync health:', error);
statusEl.textContent = 'Error';
badgeEl.textContent = 'Error';
badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800';
detailsEl.textContent = 'Failed to check sync health';
iconContainerEl.className = 'flex-shrink-0 bg-red-100 rounded-md p-3';
}
}
// Trigger manual sync
async function triggerSync() {
const button = document.getElementById('sync-trigger-btn');
const originalText = button.textContent;
try {
// Disable button and show loading state
button.disabled = true;
button.textContent = 'Syncing...';
const response = await apiRequest('/api/admin/sync/trigger', {
method: 'POST'
});
if (response.success) {
// Show success message
button.textContent = '✓ Synced';
button.classList.remove('bg-blue-600', 'hover:bg-blue-700');
button.classList.add('bg-green-600');
// Reload health status and stats
await loadSyncHealth();
await loadStatistics();
// Reset button after 2 seconds
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-green-600');
button.classList.add('bg-blue-600', 'hover:bg-blue-700');
button.disabled = false;
}, 2000);
} else {
throw new Error(response.message || 'Sync failed');
}
} catch (error) {
console.error('Manual sync error:', error);
button.textContent = '✗ Failed';
button.classList.remove('bg-blue-600', 'hover:bg-blue-700');
button.classList.add('bg-red-600');
// Reset button after 2 seconds
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('bg-red-600');
button.classList.add('bg-blue-600', 'hover:bg-blue-700');
button.disabled = false;
}, 2000);
}
}
// Load recent activity
async function loadRecentActivity() {
const container = document.getElementById('recent-activity');
try {
const response = await apiRequest('/api/admin/activity');
if (!response.success || !response.activity || response.activity.length === 0) {
container.innerHTML = '<div class="text-center py-8 text-gray-500">No recent activity</div>';
return;
}
container.innerHTML = response.activity.map(item => {
// Generate description from activity data
const action = item.action || 'reviewed';
const itemType = item.item_type || 'item';
const description = `${action.charAt(0).toUpperCase() + action.slice(1)} ${itemType}`;
return `
<div class="py-4 flex items-start">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full ${getActivityColor(action)} flex items-center justify-center">
<span class="text-xs font-medium text-white">${getActivityIcon(action)}</span>
</div>
</div>
<div class="ml-4 flex-1">
<p class="text-sm font-medium text-gray-900">${description}</p>
<p class="text-sm text-gray-500">${formatDate(item.timestamp)}</p>
</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Failed to load activity:', error);
container.innerHTML = '<div class="text-center py-8 text-red-500">Failed to load activity</div>';
}
}
// Load moderation queue
async function loadModerationQueue(filter = 'all') {
const container = document.getElementById('moderation-queue');
try {
const response = await apiRequest(`/api/admin/moderation?type=${filter}`);
if (!response.success || !response.items || response.items.length === 0) {
container.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">No items pending review</div>';
return;
}
container.innerHTML = response.items.map(item => `
<div class="px-6 py-4" data-id="${item._id}">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
${item.type}
</span>
<span class="ml-2 text-sm text-gray-500">${formatDate(item.submitted_at)}</span>
</div>
<h4 class="mt-2 text-sm font-medium text-gray-900">${item.title}</h4>
<p class="mt-1 text-sm text-gray-600">${truncate(item.content || item.description, 150)}</p>
</div>
<div class="ml-4 flex-shrink-0 flex space-x-2">
<button data-action="approveItem" data-arg0="${item._id}" class="bg-green-600 text-white px-3 py-1 rounded text-sm hover:bg-green-700">
Approve
</button>
<button data-action="rejectItem" data-arg0="${item._id}" class="bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700">
Reject
</button>
</div>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load moderation queue:', error);
container.innerHTML = '<div class="px-6 py-8 text-center text-red-500">Failed to load queue</div>';
}
}
// Load users
async function loadUsers() {
const container = document.getElementById('users-list');
try {
const response = await apiRequest('/api/admin/users');
if (!response.success || !response.users || response.users.length === 0) {
container.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">No users found</div>';
return;
}
container.innerHTML = response.users.map(user => `
<div class="px-6 py-4 flex items-center justify-between">
<div class="flex items-center">
<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center">
<span class="text-sm font-medium text-gray-600">${user.email.charAt(0).toUpperCase()}</span>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-900">${user.email}</p>
<p class="text-sm text-gray-500">Role: ${user.role}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'}">
${user.role}
</span>
${user._id !== user._id ? `
<button data-action="deleteUser" data-arg0="${user._id}" class="text-red-600 hover:text-red-900 text-sm">
Delete
</button>
` : ''}
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load users:', error);
container.innerHTML = '<div class="px-6 py-8 text-center text-red-500">Failed to load users</div>';
}
}
// Load documents
async function loadDocuments() {
const container = document.getElementById('documents-list');
try {
const response = await apiRequest('/api/documents');
if (!response.success || !response.documents || response.documents.length === 0) {
container.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">No documents found</div>';
return;
}
container.innerHTML = response.documents.map(doc => {
const visibilityBadge = getVisibilityBadge(doc.visibility || 'internal');
const statusBadge = getStatusBadge(doc.workflow_status || 'draft');
const canPublish = doc.visibility === 'internal' && doc.workflow_status !== 'published';
const canUnpublish = doc.visibility === 'public' && doc.workflow_status === 'published';
return `
<div class="px-6 py-4 flex items-center justify-between border-b border-gray-100">
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900">${doc.title}</h4>
<div class="flex items-center space-x-3 mt-1">
<p class="text-sm text-gray-500">${doc.quadrant || 'No quadrant'}</p>
${statusBadge}
${visibilityBadge}
${doc.category ? `<span class="text-xs text-gray-400">Category: ${doc.category}</span>` : ''}
</div>
</div>
<div class="flex items-center space-x-2">
<a href="/docs-viewer.html#${doc.slug}" target="_blank" class="text-blue-600 hover:text-blue-900 text-sm">
View
</a>
${canPublish ? `
<button data-action="openPublishModal" data-arg0="${doc._id}" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Publish
</button>
` : ''}
${canUnpublish ? `
<button data-action="openUnpublishModal" data-arg0="${doc._id}" class="px-3 py-1 bg-yellow-600 text-white text-sm rounded hover:bg-yellow-700">
Unpublish
</button>
` : ''}
<button data-action="deleteDocument" data-arg0="${doc._id}" class="text-red-600 hover:text-red-900 text-sm">
Delete
</button>
</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Failed to load documents:', error);
container.innerHTML = '<div class="px-6 py-8 text-center text-red-500">Failed to load documents</div>';
}
}
// Load section data
function loadSection(section) {
switch (section) {
case 'overview':
loadStatistics();
loadRecentActivity();
break;
case 'moderation':
loadModerationQueue();
break;
case 'users':
loadUsers();
break;
case 'documents':
loadDocuments();
break;
}
}
// Approve item
async function approveItem(itemId) {
if (!confirm('Approve this item?')) return;
try {
const response = await apiRequest(`/api/admin/moderation/${itemId}/approve`, {
method: 'POST'
});
if (response.success) {
loadModerationQueue();
loadStatistics();
} else {
alert('Failed to approve item');
}
} catch (error) {
console.error('Approval error:', error);
alert('Failed to approve item');
}
}
// Reject item
async function rejectItem(itemId) {
if (!confirm('Reject this item?')) return;
try {
const response = await apiRequest(`/api/admin/moderation/${itemId}/reject`, {
method: 'POST'
});
if (response.success) {
loadModerationQueue();
loadStatistics();
} else {
alert('Failed to reject item');
}
} catch (error) {
console.error('Rejection error:', error);
alert('Failed to reject item');
}
}
// Delete user
async function deleteUser(userId) {
if (!confirm('Delete this user? This action cannot be undone.')) return;
try {
const response = await apiRequest(`/api/admin/users/${userId}`, {
method: 'DELETE'
});
if (response.success) {
loadUsers();
loadStatistics();
} else {
alert(response.message || 'Failed to delete user');
}
} catch (error) {
console.error('Delete error:', error);
alert('Failed to delete user');
}
}
// Delete document
async function deleteDocument(docId) {
if (!confirm('Delete this document? This action cannot be undone.')) return;
try {
const response = await apiRequest(`/api/documents/${docId}`, {
method: 'DELETE'
});
if (response.success) {
loadDocuments();
loadStatistics();
} else {
alert('Failed to delete document');
}
} catch (error) {
console.error('Delete error:', error);
alert('Failed to delete document');
}
}
// Open publish modal
async function openPublishModal(docId) {
try {
const response = await apiRequest(`/api/documents/${docId}`);
if (!response.success || !response.document) {
alert('Failed to load document');
return;
}
const doc = response.document;
const modalHTML = `
<div id="publish-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
<h2 class="text-xl font-bold text-gray-900 mb-4">Publish Document</h2>
<div class="mb-4">
<p class="text-sm text-gray-600 mb-2"><strong>Title:</strong> ${doc.title}</p>
<p class="text-sm text-gray-600"><strong>Current Status:</strong> ${doc.workflow_status || 'draft'}</p>
</div>
<form id="publish-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Category <span class="text-red-600">*</span>
</label>
<select id="publish-category" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<option value="">Select category...</option>
<option value="getting-started">Getting Started</option>
<option value="technical-reference">Technical Reference</option>
<option value="research-theory">Research & Theory</option>
<option value="advanced-topics">Advanced Topics</option>
<option value="case-studies">Case Studies</option>
<option value="business-leadership">Business & Leadership</option>
<option value="archives">Archives</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Display Order (optional)
</label>
<input type="number" id="publish-order" value="${doc.order || 0}" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-500 mt-1">Higher numbers appear first</p>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" data-action="closePublishModal" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700">
Publish Document
</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Store doc ID for later
document.getElementById('publish-form').dataset.docId = docId;
// Handle form submission
document.getElementById('publish-form').addEventListener('submit', async (e) => {
e.preventDefault();
const category = document.getElementById('publish-category').value;
const order = parseInt(document.getElementById('publish-order').value) || 0;
await publishDocument(docId, category, order);
});
} catch (error) {
console.error('Failed to open publish modal:', error);
alert('Failed to open publish modal');
}
}
// Publish document
async function publishDocument(docId, category, order) {
try {
const response = await apiRequest(`/api/documents/${docId}/publish`, {
method: 'POST',
body: JSON.stringify({ category, order })
});
if (response.success) {
closePublishModal();
loadDocuments();
loadStatistics();
alert('Document published successfully');
} else {
alert(response.message || 'Failed to publish document');
}
} catch (error) {
console.error('Publish error:', error);
alert('Failed to publish document');
}
}
// Close publish modal
function closePublishModal() {
document.getElementById('publish-modal')?.remove();
}
// Open unpublish modal
async function openUnpublishModal(docId) {
try {
const response = await apiRequest(`/api/documents/${docId}`);
if (!response.success || !response.document) {
alert('Failed to load document');
return;
}
const doc = response.document;
const modalHTML = `
<div id="unpublish-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
<h2 class="text-xl font-bold text-gray-900 mb-4">Unpublish Document</h2>
<div class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded">
<p class="text-sm text-yellow-800 mb-2"><strong>Title:</strong> ${doc.title}</p>
<p class="text-sm text-yellow-800 mb-2"><strong>Current Visibility:</strong> ${doc.visibility}</p>
<p class="text-sm text-yellow-800"><strong>Category:</strong> ${doc.category || 'None'}</p>
</div>
<form id="unpublish-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Reason for unpublishing <span class="text-red-600">*</span>
</label>
<textarea id="unpublish-reason" required rows="3"
placeholder="Explain why this document is being unpublished..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"></textarea>
<p class="text-xs text-gray-500 mt-1">This will be recorded in the audit trail</p>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" data-action="closeUnpublishModal" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-yellow-600 text-white rounded-md hover:bg-yellow-700">
Unpublish Document
</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Store doc ID for later
document.getElementById('unpublish-form').dataset.docId = docId;
// Handle form submission
document.getElementById('unpublish-form').addEventListener('submit', async (e) => {
e.preventDefault();
const reason = document.getElementById('unpublish-reason').value;
await unpublishDocument(docId, reason);
});
} catch (error) {
console.error('Failed to open unpublish modal:', error);
alert('Failed to open unpublish modal');
}
}
// Unpublish document
async function unpublishDocument(docId, reason) {
try {
const response = await apiRequest(`/api/documents/${docId}/unpublish`, {
method: 'POST',
body: JSON.stringify({ reason })
});
if (response.success) {
closeUnpublishModal();
loadDocuments();
loadStatistics();
alert('Document unpublished successfully');
} else {
alert(response.message || 'Failed to unpublish document');
}
} catch (error) {
console.error('Unpublish error:', error);
alert('Failed to unpublish document');
}
}
// Close unpublish modal
function closeUnpublishModal() {
document.getElementById('unpublish-modal')?.remove();
}
// Utility functions
function getVisibilityBadge(visibility) {
const badges = {
'public': '<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">Public</span>',
'internal': '<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800">Internal</span>',
'confidential': '<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800">Confidential</span>',
'archived': '<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800">Archived</span>'
};
return badges[visibility] || badges['internal'];
}
function getStatusBadge(status) {
const badges = {
'draft': '<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800">Draft</span>',
'review': '<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">Review</span>',
'published': '<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">Published</span>'
};
return badges[status] || badges['draft'];
}
function getActivityColor(type) {
const colors = {
'create': 'bg-green-500',
'update': 'bg-blue-500',
'delete': 'bg-red-500',
'approve': 'bg-purple-500'
};
return colors[type] || 'bg-gray-500';
}
function getActivityIcon(type) {
const icons = {
'create': '+',
'update': '↻',
'delete': '×',
'approve': '✓'
};
return icons[type] || '•';
}
function formatDate(dateString) {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function truncate(str, length) {
if (!str) return '';
return str.length > length ? str.substring(0, length) + '...' : str;
}
// Queue filter
document.getElementById('queue-filter')?.addEventListener('change', (e) => {
loadModerationQueue(e.target.value);
});
// Initialize
loadStatistics();
loadRecentActivity();
loadSyncHealth();
// Auto-refresh sync health every 60 seconds
setInterval(() => {
loadSyncHealth();
}, 60000);
// Event delegation for data-action buttons (CSP compliance)
document.addEventListener('click', (e) => {
const button = e.target.closest('[data-action]');
if (!button) return;
const action = button.dataset.action;
const arg0 = button.dataset.arg0;
switch (action) {
case 'approveItem':
approveItem(arg0);
break;
case 'rejectItem':
rejectItem(arg0);
break;
case 'deleteUser':
deleteUser(arg0);
break;
case 'deleteDocument':
deleteDocument(arg0);
break;
case 'openPublishModal':
openPublishModal(arg0);
break;
case 'openUnpublishModal':
openUnpublishModal(arg0);
break;
case 'closePublishModal':
closePublishModal();
break;
case 'closeUnpublishModal':
closeUnpublishModal();
break;
case 'triggerSync':
triggerSync();
break;
}
});