- Fixed sync script disconnecting Mongoose (prevents production errors)
- Created text search index (fixes search in rule-manager)
- Enhanced inst_024 with closedown protocol, added inst_061
- Added sync infrastructure: API routes, dashboard widget, auto-sync
- Fixed MemoryProxy tests MongoDB connection
- Created ADR-001 and integration tests
Result: Production stable, 52 rules synced, search working
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
793 lines
27 KiB
JavaScript
793 lines
27 KiB
JavaScript
// Auth check
|
||
const token = localStorage.getItem('admin_token');
|
||
const user = JSON.parse(localStorage.getItem('admin_user') || '{}');
|
||
|
||
if (!token) {
|
||
window.location.href = '/admin/login.html';
|
||
}
|
||
|
||
// Display admin name
|
||
document.getElementById('admin-name').textContent = user.email || 'Admin';
|
||
|
||
// Logout
|
||
document.getElementById('logout-btn').addEventListener('click', () => {
|
||
localStorage.removeItem('admin_token');
|
||
localStorage.removeItem('admin_user');
|
||
window.location.href = '/admin/login.html';
|
||
});
|
||
|
||
// Navigation
|
||
const navLinks = document.querySelectorAll('.nav-link');
|
||
const sections = {
|
||
'overview': document.getElementById('overview-section'),
|
||
'moderation': document.getElementById('moderation-section'),
|
||
'users': document.getElementById('users-section'),
|
||
'documents': document.getElementById('documents-section')
|
||
};
|
||
|
||
navLinks.forEach(link => {
|
||
link.addEventListener('click', (e) => {
|
||
const href = link.getAttribute('href');
|
||
|
||
// Only handle hash-based navigation (internal sections)
|
||
// Let full URLs navigate normally
|
||
if (!href || !href.startsWith('#')) {
|
||
return; // Allow default navigation
|
||
}
|
||
|
||
e.preventDefault();
|
||
const section = href.substring(1);
|
||
|
||
// Update active link
|
||
navLinks.forEach(l => l.classList.remove('active', 'bg-blue-100', 'text-blue-700'));
|
||
link.classList.add('active', 'bg-blue-100', 'text-blue-700');
|
||
|
||
// Show section
|
||
Object.values(sections).forEach(s => s.classList.add('hidden'));
|
||
if (sections[section]) {
|
||
sections[section].classList.remove('hidden');
|
||
loadSection(section);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
});
|