tractatus/public/js/admin/project-manager.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

419 lines
13 KiB
JavaScript
Raw Permalink 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.

/**
* Project Manager - Multi-Project Governance Dashboard
* Handles CRUD operations, filtering, and variable management for projects
*/
// 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';
});
/**
* API request helper with automatic auth header injection
*/
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();
}
// State management
let projects = [];
let filters = {
status: 'true',
database: '',
sortBy: 'name'
};
/**
* Load and display dashboard statistics
*/
async function loadStatistics() {
try {
const response = await apiRequest('/api/admin/projects');
if (!response.success) {
console.error('Invalid stats response:', response);
return;
}
const allProjects = response.projects || [];
const activeProjects = allProjects.filter(p => p.active);
// Count total variables
const totalVariables = allProjects.reduce((sum, p) => sum + (p.variableCount || 0), 0);
// Count unique databases
const databases = new Set();
allProjects.forEach(p => {
if (p.techStack?.database) {
databases.add(p.techStack.database);
}
});
document.getElementById('stat-total').textContent = allProjects.length;
document.getElementById('stat-active').textContent = activeProjects.length;
document.getElementById('stat-variables').textContent = totalVariables;
document.getElementById('stat-databases').textContent = databases.size;
} catch (error) {
console.error('Failed to load statistics:', error);
showToast('Failed to load statistics', 'error');
}
}
/**
* Load and render projects based on current filters
*/
async function loadProjects() {
const container = document.getElementById('projects-grid');
try {
// Show loading state
container.innerHTML = `
<div class="col-span-full text-center py-12 text-gray-500">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mb-4"></div>
<p>Loading projects...</p>
</div>
`;
// Build query parameters
const params = new URLSearchParams();
if (filters.status) params.append('active', filters.status);
if (filters.database) params.append('database', filters.database);
const response = await apiRequest(`/api/admin/projects?${params.toString()}`);
if (!response.success) {
throw new Error('Failed to load projects');
}
projects = response.projects || [];
// Apply client-side sorting
projects.sort((a, b) => {
switch (filters.sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'id':
return a.id.localeCompare(b.id);
case 'variableCount':
return (b.variableCount || 0) - (a.variableCount || 0);
case 'updatedAt':
return new Date(b.updatedAt) - new Date(a.updatedAt);
default:
return 0;
}
});
// Update results count
document.getElementById('filter-results').textContent =
`Showing ${projects.length} project${projects.length !== 1 ? 's' : ''}`;
// Render projects
if (projects.length === 0) {
container.innerHTML = `
<div class="col-span-full text-center py-12 text-gray-500">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No projects found</h3>
<p class="mt-1 text-sm text-gray-500">Try adjusting your filters or create a new project.</p>
</div>
`;
return;
}
// Render project cards
container.innerHTML = projects.map(project => renderProjectCard(project)).join('');
} catch (error) {
console.error('Failed to load projects:', error);
container.innerHTML = `
<div class="col-span-full text-center py-12 text-red-500">
<p>Failed to load projects. Please try again.</p>
</div>
`;
showToast('Failed to load projects', 'error');
}
}
/**
* Render a single project as an HTML card
*/
function renderProjectCard(project) {
const statusBadge = project.active
? '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>'
: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Inactive</span>';
const techStackBadges = [];
if (project.techStack?.framework) {
techStackBadges.push(`<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">${escapeHtml(project.techStack.framework)}</span>`);
}
if (project.techStack?.database) {
techStackBadges.push(`<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">${escapeHtml(project.techStack.database)}</span>`);
}
if (project.techStack?.frontend && techStackBadges.length < 3) {
techStackBadges.push(`<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800">${escapeHtml(project.techStack.frontend)}</span>`);
}
const variableCount = project.variableCount || 0;
return `
<div class="bg-white rounded-lg shadow hover:shadow-lg transition-shadow border border-gray-200">
<div class="p-6">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-1">${escapeHtml(project.name)}</h3>
<p class="text-sm font-mono text-gray-500">${escapeHtml(project.id)}</p>
</div>
${statusBadge}
</div>
${project.description ? `
<p class="text-sm text-gray-600 mb-4 line-clamp-2">${escapeHtml(project.description)}</p>
` : ''}
${techStackBadges.length > 0 ? `
<div class="flex flex-wrap gap-1 mb-4">
${techStackBadges.join('')}
</div>
` : ''}
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-4 text-sm text-gray-500">
<div class="flex items-center">
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
<span>${variableCount} var${variableCount !== 1 ? 's' : ''}</span>
</div>
${project.repositoryUrl ? `
<div class="flex items-center">
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<span>Repo</span>
</div>
` : ''}
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<button data-action="viewProject" data-arg0="${project.id}" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
View Details
</button>
<button data-action="manageVariables" data-arg0="${project.id}" class="px-4 py-2 border border-indigo-300 rounded-md text-sm font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100">
Variables (${variableCount})
</button>
</div>
<div class="grid grid-cols-2 gap-2 mt-2">
<button data-action="editProject" data-arg0="${project.id}" class="px-4 py-2 border border-blue-300 rounded-md text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100">
Edit
</button>
<button data-action="deleteProject" data-arg0="${project.id}" data-arg1="${escapeHtml(project.name)}" class="px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-red-50 hover:bg-red-100">
Delete
</button>
</div>
</div>
</div>
`;
}
// Filter handlers
function applyFilters() {
loadProjects();
}
document.getElementById('filter-status')?.addEventListener('change', (e) => {
filters.status = e.target.value;
applyFilters();
});
document.getElementById('filter-database')?.addEventListener('change', (e) => {
filters.database = e.target.value;
applyFilters();
});
document.getElementById('sort-by')?.addEventListener('change', (e) => {
filters.sortBy = e.target.value;
applyFilters();
});
// Clear filters
document.getElementById('clear-filters-btn')?.addEventListener('click', () => {
filters = {
status: 'true',
database: '',
sortBy: 'name'
};
document.getElementById('filter-status').value = 'true';
document.getElementById('filter-database').value = '';
document.getElementById('sort-by').value = 'name';
applyFilters();
});
// CRUD operations
async function viewProject(projectId) {
if (window.projectEditor) {
window.projectEditor.openView(projectId);
} else {
showToast('Project editor not loaded', 'error');
}
}
async function editProject(projectId) {
if (window.projectEditor) {
window.projectEditor.openEdit(projectId);
} else {
showToast('Project editor not loaded', 'error');
}
}
async function manageVariables(projectId) {
if (window.projectEditor) {
window.projectEditor.openVariables(projectId);
} else {
showToast('Project editor not loaded', 'error');
}
}
async function deleteProject(projectId, projectName) {
if (!confirm(`Delete project "${projectName}"?\n\nThis will:\n- Deactivate the project (soft delete)\n- Deactivate all associated variables\n\nTo permanently delete, use the API with ?hard=true`)) {
return;
}
try {
const response = await apiRequest(`/api/admin/projects/${projectId}`, {
method: 'DELETE'
});
if (response.success) {
showToast('Project deleted successfully', 'success');
loadProjects();
loadStatistics();
} else {
showToast(response.message || 'Failed to delete project', 'error');
}
} catch (error) {
console.error('Delete error:', error);
showToast('Failed to delete project', 'error');
}
}
// New project button
document.getElementById('new-project-btn')?.addEventListener('click', () => {
if (window.projectEditor) {
window.projectEditor.openCreate();
} else {
showToast('Project editor not loaded', 'error');
}
});
/**
* Show a toast notification message
*/
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const toast = document.createElement('div');
toast.className = `${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-2 transition-all duration-300 ease-in-out`;
toast.style.opacity = '0';
toast.style.transform = 'translateX(100px)';
toast.innerHTML = `
<span>${escapeHtml(message)}</span>
<button data-action="remove-parent" class="ml-4 text-white hover:text-gray-200">
×
</button>
`;
container.appendChild(toast);
// Trigger animation
setTimeout(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateX(0)';
}, 10);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100px)';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
// Utility functions
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 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;
const arg1 = button.dataset.arg1;
switch (action) {
case 'viewProject':
viewProject(arg0);
break;
case 'manageVariables':
manageVariables(arg0);
break;
case 'editProject':
editProject(arg0);
break;
case 'deleteProject':
deleteProject(arg0, arg1);
break;
case 'remove-parent':
button.parentElement.remove();
break;
}
});
// Initialize on page load
loadStatistics();
loadProjects();