- Add cache: 'no-store' to all apiCall functions in admin JS files - Prevents browser fetch cache from serving stale error responses - Addresses submissions endpoint 500 errors that weren't appearing in server logs - Killed duplicate server process (PID 1583625) - Added debug logging to submissions controller - Files modified: blog-validation.js, blog-curation.js, blog-curation-enhanced.js
861 lines
33 KiB
JavaScript
861 lines
33 KiB
JavaScript
/**
|
|
* Blog Article Validation
|
|
* Two-level validation: Content Similarity + Title Similarity
|
|
*/
|
|
|
|
// Helper to safely convert ObjectId to string
|
|
function toStringId(id) {
|
|
if (!id) {
|
|
console.warn('[toStringId] Received empty id');
|
|
return '';
|
|
}
|
|
if (typeof id === 'string') return id;
|
|
|
|
// Handle Buffer object (MongoDB ObjectId as buffer)
|
|
if (typeof id === 'object' && id.buffer) {
|
|
console.log('[toStringId] Converting buffer to hex string');
|
|
const bytes = [];
|
|
for (let i = 0; i < 12; i++) {
|
|
if (id.buffer[i] !== undefined) {
|
|
bytes.push(id.buffer[i].toString(16).padStart(2, '0'));
|
|
}
|
|
}
|
|
const hexString = bytes.join('');
|
|
console.log('[toStringId] Hex string:', hexString);
|
|
return hexString;
|
|
}
|
|
|
|
// Check for other common MongoDB ObjectId representations
|
|
if (typeof id === 'object') {
|
|
if (id.$oid) return id.$oid;
|
|
if (id.id) return id.id;
|
|
if (id._id) return id._id;
|
|
}
|
|
|
|
const result = String(id);
|
|
console.log('[toStringId] Fallback String():', result);
|
|
return result;
|
|
}
|
|
|
|
// Get auth token
|
|
function getAuthToken() {
|
|
return localStorage.getItem('admin_token');
|
|
}
|
|
|
|
// API call helper
|
|
async function apiCall(endpoint, options = {}) {
|
|
const token = getAuthToken();
|
|
const defaultOptions = {
|
|
cache: 'no-store', // Force fresh requests - prevent cached 500 errors
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
};
|
|
|
|
const response = await fetch(endpoint, { ...defaultOptions, ...options });
|
|
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('admin_token');
|
|
window.location.href = '/admin/login.html';
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
// Load all pending review articles
|
|
async function loadValidationArticles() {
|
|
const listDiv = document.getElementById('validation-list');
|
|
if (!listDiv) return;
|
|
|
|
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">Loading articles and submission packages...</div>';
|
|
|
|
try {
|
|
// Load blog posts with status=pending_review
|
|
const articlesResponse = await apiCall('/api/blog/admin/posts?status=pending_review&limit=100');
|
|
|
|
if (!articlesResponse.ok) {
|
|
throw new Error('Failed to load articles');
|
|
}
|
|
|
|
const articlesData = await articlesResponse.json();
|
|
const articles = articlesData.posts || [];
|
|
|
|
console.log('[Validation] Loaded articles:', articles.length);
|
|
|
|
// ALSO load standalone submission packages (those without blogPostId)
|
|
const submissionsResponse = await apiCall('/api/submissions?limit=100');
|
|
const standaloneSubmissions = [];
|
|
|
|
console.log('[Validation] Submissions response ok:', submissionsResponse.ok);
|
|
|
|
if (submissionsResponse.ok) {
|
|
const submissionsData = await submissionsResponse.json();
|
|
console.log('[Validation] Submissions data:', submissionsData);
|
|
const allSubmissions = submissionsData.data || [];
|
|
console.log('[Validation] Total submissions found:', allSubmissions.length);
|
|
|
|
// Filter for standalone submissions (no blogPostId)
|
|
allSubmissions.forEach(sub => {
|
|
console.log('[Validation] Checking submission:', sub.title, 'blogPostId:', sub.blogPostId);
|
|
if (!sub.blogPostId) {
|
|
const standaloneSub = {
|
|
_id: typeof sub._id === 'object' ? toStringId(sub._id) : sub._id.toString(),
|
|
title: sub.title,
|
|
status: 'submission_package',
|
|
publicationName: sub.publicationName,
|
|
publicationId: sub.publicationId,
|
|
wordCount: sub.wordCount,
|
|
contentType: sub.contentType,
|
|
submissionStatus: sub.status,
|
|
isStandalone: true,
|
|
submissionData: sub
|
|
};
|
|
console.log('[Validation] Adding standalone submission:', standaloneSub.title);
|
|
standaloneSubmissions.push(standaloneSub);
|
|
}
|
|
});
|
|
console.log('[Validation] Loaded standalone submissions:', standaloneSubmissions.length);
|
|
} else {
|
|
console.error('[Validation] Failed to load submissions');
|
|
}
|
|
|
|
// Combine articles and standalone submissions
|
|
const allItems = [...articles, ...standaloneSubmissions];
|
|
|
|
if (allItems.length === 0) {
|
|
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">No articles or packages pending review</div>';
|
|
return;
|
|
}
|
|
|
|
// Load submission tracking for each blog post article (not standalone submissions)
|
|
const submissionData = {};
|
|
for (const article of articles) {
|
|
const articleId = toStringId(article._id);
|
|
try {
|
|
const subResponse = await apiCall(`/api/submissions/by-blog-post/${articleId}`);
|
|
if (subResponse.ok) {
|
|
const subData = await subResponse.json();
|
|
if (subData.data) {
|
|
submissionData[articleId] = subData.data;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Silently ignore if no submission found
|
|
}
|
|
}
|
|
|
|
// Render all items (blog posts + standalone submissions)
|
|
listDiv.innerHTML = allItems.map(item => {
|
|
const itemId = toStringId(item._id);
|
|
const isStandalone = item.isStandalone;
|
|
|
|
// For standalone submissions, use submissionData directly
|
|
const submission = isStandalone ? item.submissionData : submissionData[itemId];
|
|
|
|
// Calculate word count
|
|
const wordCount = isStandalone
|
|
? (item.wordCount || 0)
|
|
: (item.content ? item.content.split(/\s+/).length : 0);
|
|
|
|
// Calculate checklist completion
|
|
let checklistItems = 0;
|
|
let checklistCompleted = 0;
|
|
if (submission && submission.submissionPackage) {
|
|
const pkg = submission.submissionPackage;
|
|
if (pkg.coverLetter) {
|
|
checklistItems++;
|
|
if (pkg.coverLetter.completed) checklistCompleted++;
|
|
}
|
|
if (pkg.notesToEditor) {
|
|
checklistItems++;
|
|
if (pkg.notesToEditor.completed) checklistCompleted++;
|
|
}
|
|
if (pkg.authorBio) {
|
|
checklistItems++;
|
|
if (pkg.authorBio.completed) checklistCompleted++;
|
|
}
|
|
if (pkg.pitchEmail) {
|
|
checklistItems++;
|
|
if (pkg.pitchEmail.completed) checklistCompleted++;
|
|
}
|
|
}
|
|
|
|
const checklistColor = checklistCompleted === checklistItems && checklistItems > 0
|
|
? 'text-green-600'
|
|
: 'text-yellow-600';
|
|
|
|
return `
|
|
<div class="px-6 py-4 hover:bg-gray-50 border-l-4 ${submission ? 'border-l-blue-500' : 'border-l-gray-300'}" data-article-id="${itemId}">
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-3 mb-2">
|
|
<h4 class="font-medium text-gray-900">${item.title}</h4>
|
|
${isStandalone ? `<span class="px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded font-medium">STANDALONE PACKAGE</span>` : ''}
|
|
${submission ? `<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium">${item.publicationName || submission.publicationName}</span>` : ''}
|
|
</div>
|
|
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
|
${!isStandalone ? `<span>Slug: ${item.slug}</span>` : ''}
|
|
<span>Words: ${wordCount}</span>
|
|
${!isStandalone && item.published_at ? `<span>Created: ${new Date(item.published_at).toLocaleDateString()}</span>` : ''}
|
|
${submission ? `<span class="font-medium">${(item.contentType || submission.contentType || 'article').toUpperCase()}</span>` : ''}
|
|
</div>
|
|
${submission ? `
|
|
<div class="mt-2 flex items-center gap-2">
|
|
<span class="text-xs ${checklistColor}">
|
|
📋 Checklist: ${checklistCompleted}/${checklistItems} complete
|
|
</span>
|
|
${submission.submissionMethod ? `<span class="text-xs text-gray-500">via ${submission.submissionMethod}</span>` : ''}
|
|
</div>
|
|
` : '<div class="mt-2 text-xs text-orange-600">⚠️ No submission tracking - click "Manage Submission"</div>'}
|
|
<div class="mt-3 flex items-center gap-2">
|
|
<div class="validation-status-${itemId} inline-flex items-center gap-2">
|
|
${isStandalone
|
|
? `<span class="px-2 py-1 bg-green-100 text-green-600 text-xs rounded">✓ Submission Package Ready</span>`
|
|
: `<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">Not validated yet</span>`
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="ml-4 flex items-center gap-2 flex-wrap">
|
|
<button class="manage-submission px-4 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700"
|
|
data-article-id="${itemId}"
|
|
data-submission-id="${submission ? submission._id : ''}">
|
|
${isStandalone ? 'View Package' : 'Manage Submission'}
|
|
</button>
|
|
${!isStandalone ? `
|
|
<button class="edit-article px-4 py-2 bg-gray-600 text-white rounded-md text-sm hover:bg-gray-700"
|
|
data-article-id="${itemId}">
|
|
Edit
|
|
</button>` : ''}
|
|
${!isStandalone ? `
|
|
<button class="validate-article px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700"
|
|
data-article-id="${itemId}"
|
|
data-article-title="${item.title.replace(/"/g, '"')}">
|
|
Validate` : ''}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Add edit handlers
|
|
listDiv.querySelectorAll('.edit-article').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const articleId = btn.dataset.articleId;
|
|
openEditModal(articleId);
|
|
});
|
|
});
|
|
|
|
// Add validate handlers
|
|
listDiv.querySelectorAll('.validate-article').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const articleId = btn.dataset.articleId;
|
|
const articleTitle = btn.dataset.articleTitle;
|
|
runValidation(articleId, articleTitle);
|
|
});
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load validation articles:', error);
|
|
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-red-500">Error loading articles</div>';
|
|
}
|
|
}
|
|
|
|
// Run validation on an article
|
|
async function runValidation(articleId, articleTitle) {
|
|
const statusDiv = document.querySelector(`.validation-status-${articleId}`);
|
|
const btn = document.querySelector(`.validate-article[data-article-id="${articleId}"]`);
|
|
|
|
if (!statusDiv || !btn) return;
|
|
|
|
// Update UI
|
|
btn.disabled = true;
|
|
btn.textContent = 'Validating...';
|
|
statusDiv.innerHTML = '<span class="px-2 py-1 bg-blue-100 text-blue-600 text-xs rounded">Validating...</span>';
|
|
|
|
try {
|
|
const response = await apiCall('/api/blog/validate-article', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ articleId })
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.validation) {
|
|
const v = result.validation;
|
|
|
|
// Count passes and fails
|
|
const contentFails = v.contentChecks.filter(c => !c.pass).length;
|
|
const titleFails = v.titleChecks.filter(c => !c.pass).length;
|
|
|
|
// Build status badges
|
|
let badges = [];
|
|
|
|
// Content badge
|
|
if (contentFails === 0) {
|
|
badges.push('<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">✅ Content: Pass</span>');
|
|
} else {
|
|
badges.push(`<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">❌ Content: ${contentFails} conflict(s)</span>`);
|
|
}
|
|
|
|
// Title badge
|
|
if (titleFails === 0) {
|
|
badges.push('<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">✅ Title: Pass</span>');
|
|
} else {
|
|
badges.push(`<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">🚫 Title: ${titleFails} conflict(s)</span>`);
|
|
}
|
|
|
|
statusDiv.innerHTML = badges.join(' ');
|
|
|
|
// Show detailed modal
|
|
showValidationModal(articleTitle, result.validation);
|
|
} else {
|
|
statusDiv.innerHTML = '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">Error</span>';
|
|
alert(`Validation error: ${result.message || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Validation error:', error);
|
|
statusDiv.innerHTML = '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">Error</span>';
|
|
alert(`Validation error: ${error.message}`);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Validate';
|
|
}
|
|
}
|
|
|
|
// Show validation results modal
|
|
function showValidationModal(articleTitle, validation) {
|
|
const { contentChecks, titleChecks, overallStatus, errors, warnings, summary } = validation;
|
|
|
|
const contentChecksHtml = contentChecks.length > 0 ? `
|
|
<div class="mb-6">
|
|
<h4 class="text-sm font-semibold text-gray-900 mb-3">Content Similarity Checks (${summary.contentPasses} pass, ${summary.contentFails} fail)</h4>
|
|
<div class="space-y-2">
|
|
${contentChecks.slice(0, 5).map(check => {
|
|
const bgClass = check.pass ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200';
|
|
const iconClass = check.pass ? 'text-green-600' : 'text-red-600';
|
|
const icon = check.pass ? '✅' : '❌';
|
|
|
|
return `
|
|
<div class="border ${bgClass} rounded-md p-3">
|
|
<div class="flex items-start gap-2">
|
|
<span class="${iconClass} text-lg">${icon}</span>
|
|
<div class="flex-1">
|
|
<div class="text-sm font-medium text-gray-900">vs. "${check.comparedWith}"</div>
|
|
<div class="text-xs text-gray-600 mt-1">
|
|
Similarity: ${Math.round(check.similarity * 100)}%
|
|
</div>
|
|
<div class="text-xs text-gray-700 mt-1">${check.message}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
` : '<div class="text-sm text-gray-500 mb-6">No other articles to compare</div>';
|
|
|
|
const titleChecksHtml = titleChecks.length > 0 ? `
|
|
<div class="mb-6">
|
|
<h4 class="text-sm font-semibold text-gray-900 mb-3">Title Similarity Checks (${summary.titlePasses} pass, ${summary.titleFails} fail)</h4>
|
|
<div class="space-y-2">
|
|
${titleChecks.slice(0, 5).map(check => {
|
|
const bgClass = check.pass ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200';
|
|
const iconClass = check.pass ? 'text-green-600' : 'text-red-600';
|
|
const icon = check.pass ? '✅' : '🚫';
|
|
|
|
return `
|
|
<div class="border ${bgClass} rounded-md p-3">
|
|
<div class="flex items-start gap-2">
|
|
<span class="${iconClass} text-lg">${icon}</span>
|
|
<div class="flex-1">
|
|
<div class="text-sm font-medium text-gray-900">vs. "${check.comparedWith}"</div>
|
|
<div class="text-xs text-gray-600 mt-1">
|
|
Similarity: ${Math.round(check.similarity * 100)}%
|
|
${check.sharedWords && check.sharedWords.length > 0 ? ` | Shared: ${check.sharedWords.join(', ')}` : ''}
|
|
</div>
|
|
<div class="text-xs text-gray-700 mt-1">${check.message}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
` : '<div class="text-sm text-gray-500 mb-6">No other articles to compare</div>';
|
|
|
|
const errorsHtml = errors.length > 0 ? `
|
|
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
|
|
<h5 class="font-medium text-red-900 mb-2">❌ Errors - Cannot Submit</h5>
|
|
<ul class="text-sm text-red-800 list-disc list-inside space-y-1">
|
|
${errors.map(e => `<li>${e}</li>`).join('')}
|
|
</ul>
|
|
</div>
|
|
` : '';
|
|
|
|
const warningsHtml = warnings.length > 0 ? `
|
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-4">
|
|
<h5 class="font-medium text-yellow-900 mb-2">⚠️ Warnings</h5>
|
|
<ul class="text-sm text-yellow-800 list-disc list-inside space-y-1">
|
|
${warnings.map(w => `<li>${w}</li>`).join('')}
|
|
</ul>
|
|
</div>
|
|
` : '';
|
|
|
|
const summaryClass = overallStatus === 'pass' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800';
|
|
const summaryText = overallStatus === 'pass' ? '✅ PASS - Ready for submission' : '❌ FAIL - Revisions required';
|
|
|
|
const modal = `
|
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4">
|
|
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h3 class="text-lg font-medium text-gray-900">Validation: "${articleTitle}"</h3>
|
|
<button class="close-validation-modal text-gray-400 hover:text-gray-600">
|
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto px-6 py-4">
|
|
<div class="mb-6">
|
|
<div class="flex items-center justify-between p-4 border-2 rounded-lg ${summaryClass.replace('100', '200')}">
|
|
<span class="font-semibold">${summaryText}</span>
|
|
<span class="text-sm">Checked against ${summary.totalChecks} article(s)</span>
|
|
</div>
|
|
</div>
|
|
|
|
${errorsHtml}
|
|
${warningsHtml}
|
|
${contentChecksHtml}
|
|
${titleChecksHtml}
|
|
</div>
|
|
|
|
<div class="px-6 py-4 border-t border-gray-200 flex justify-end">
|
|
<button class="close-validation-modal px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const container = document.getElementById('modal-container');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = modal;
|
|
|
|
// Close modal handlers
|
|
container.querySelectorAll('.close-validation-modal').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
container.innerHTML = '';
|
|
});
|
|
});
|
|
}
|
|
|
|
// Open edit modal
|
|
async function openEditModal(articleId) {
|
|
const container = document.getElementById('modal-container');
|
|
container.innerHTML = '<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4"><div class="text-white">Loading article...</div></div>';
|
|
|
|
try {
|
|
const response = await apiCall(`/api/blog/admin/${articleId}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load article');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const article = data.post;
|
|
|
|
const modal = `
|
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4">
|
|
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h3 class="text-lg font-medium text-gray-900">Edit Article</h3>
|
|
<button class="close-edit-modal text-gray-400 hover:text-gray-600">
|
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto px-6 py-4">
|
|
<form id="edit-article-form">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label for="edit-title" class="block text-sm font-medium text-gray-700">Title *</label>
|
|
<input type="text" id="edit-title" name="title" required
|
|
value="${article.title.replace(/"/g, '"')}"
|
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
|
<p class="mt-1 text-xs text-gray-500">Change this if there's a title conflict</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="edit-slug" class="block text-sm font-medium text-gray-700">Slug *</label>
|
|
<input type="text" id="edit-slug" name="slug" required
|
|
value="${article.slug}"
|
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
|
<p class="mt-1 text-xs text-gray-500">URL-friendly version (lowercase, hyphens)</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="edit-excerpt" class="block text-sm font-medium text-gray-700">Excerpt</label>
|
|
<textarea id="edit-excerpt" name="excerpt" rows="3"
|
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">${article.excerpt || ''}</textarea>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="edit-content" class="block text-sm font-medium text-gray-700">Content *</label>
|
|
<textarea id="edit-content" name="content" rows="15" required
|
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">${article.content}</textarea>
|
|
<p class="mt-1 text-xs text-gray-500">Markdown format</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="edit-status" class="block text-sm font-medium text-gray-700">Status</label>
|
|
<select id="edit-status" name="status"
|
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
|
<option value="draft" ${article.status === 'draft' ? 'selected' : ''}>Draft</option>
|
|
<option value="pending_review" ${article.status === 'pending_review' ? 'selected' : ''}>Pending Review</option>
|
|
<option value="published" ${article.status === 'published' ? 'selected' : ''}>Published</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="edit-tags" class="block text-sm font-medium text-gray-700">Tags</label>
|
|
<input type="text" id="edit-tags" name="tags"
|
|
value="${(article.tags || []).join(', ')}"
|
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
|
<p class="mt-1 text-xs text-gray-500">Comma-separated</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="edit-status-message" class="mt-4 text-sm"></div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="px-6 py-4 border-t border-gray-200 flex justify-between">
|
|
<button class="close-edit-modal px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
|
|
Cancel
|
|
</button>
|
|
<button id="save-article-btn" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = modal;
|
|
|
|
// Close handlers
|
|
container.querySelectorAll('.close-edit-modal').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
container.innerHTML = '';
|
|
});
|
|
});
|
|
|
|
// Save handler
|
|
container.querySelector('#save-article-btn').addEventListener('click', () => {
|
|
saveArticle(articleId);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load article:', error);
|
|
container.innerHTML = '';
|
|
alert('Failed to load article: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Save article changes
|
|
async function saveArticle(articleId) {
|
|
const statusDiv = document.getElementById('edit-status-message');
|
|
const saveBtn = document.getElementById('save-article-btn');
|
|
|
|
const form = document.getElementById('edit-article-form');
|
|
const formData = new FormData(form);
|
|
|
|
const updates = {
|
|
title: formData.get('title'),
|
|
slug: formData.get('slug'),
|
|
excerpt: formData.get('excerpt'),
|
|
content: formData.get('content'),
|
|
status: formData.get('status'),
|
|
tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t)
|
|
};
|
|
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = 'Saving...';
|
|
statusDiv.textContent = 'Saving changes...';
|
|
statusDiv.className = 'mt-4 text-sm text-blue-600';
|
|
|
|
try {
|
|
const response = await apiCall(`/api/blog/${articleId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(updates)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.message || 'Failed to save changes');
|
|
}
|
|
|
|
statusDiv.textContent = '✓ Changes saved successfully!';
|
|
statusDiv.className = 'mt-4 text-sm text-green-600';
|
|
|
|
// Close modal and refresh list after short delay
|
|
setTimeout(() => {
|
|
document.getElementById('modal-container').innerHTML = '';
|
|
loadValidationArticles();
|
|
}, 1000);
|
|
|
|
} catch (error) {
|
|
console.error('Save error:', error);
|
|
statusDiv.textContent = `✗ Error: ${error.message}`;
|
|
statusDiv.className = 'mt-4 text-sm text-red-600';
|
|
saveBtn.disabled = false;
|
|
saveBtn.textContent = 'Save Changes';
|
|
}
|
|
}
|
|
|
|
// Load published posts
|
|
async function loadPublishedPosts() {
|
|
const listDiv = document.getElementById('published-list');
|
|
if (!listDiv) return;
|
|
|
|
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">Loading published posts...</div>';
|
|
|
|
try {
|
|
const response = await apiCall('/api/blog/admin/posts?status=published&limit=100');
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load published posts');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const posts = data.posts || [];
|
|
|
|
if (posts.length === 0) {
|
|
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-gray-500">No published posts found.</div>';
|
|
return;
|
|
}
|
|
|
|
listDiv.innerHTML = posts.map(post => {
|
|
const articleId = toStringId(post._id);
|
|
const wordCount = post.content ? post.content.split(/\s+/).length : 0;
|
|
const excerpt = post.excerpt || (post.content ? post.content.substring(0, 150) + '...' : 'No excerpt');
|
|
const tags = (post.tags || []).slice(0, 3).map(tag =>
|
|
`<span class="inline-block bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded">${tag}</span>`
|
|
).join(' ');
|
|
|
|
return `
|
|
<div class="px-6 py-4 hover:bg-gray-50">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<h4 class="font-medium text-gray-900">${post.title}</h4>
|
|
<p class="mt-1 text-sm text-gray-600">${excerpt}</p>
|
|
<div class="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
|
<span>${wordCount} words</span>
|
|
<span>Slug: ${post.slug}</span>
|
|
${post.publishedAt ? `<span>Published: ${new Date(post.publishedAt).toLocaleDateString()}</span>` : ''}
|
|
</div>
|
|
${tags ? `<div class="mt-2 flex flex-wrap gap-1">${tags}</div>` : ''}
|
|
</div>
|
|
<div class="ml-4 flex flex-col gap-2">
|
|
<button class="read-article text-sm text-blue-600 hover:text-blue-800" data-article-id="${articleId}">
|
|
Read
|
|
</button>
|
|
<a href="https://agenticgovernance.digital/blog/${post.slug}" target="_blank"
|
|
class="text-sm text-gray-600 hover:text-gray-800">
|
|
View Live
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Attach read button handlers
|
|
document.querySelectorAll('.read-article').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
const articleId = e.target.dataset.articleId;
|
|
const post = posts.find(p => toStringId(p._id) === articleId);
|
|
if (post) {
|
|
showReadModal(post);
|
|
}
|
|
});
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load published posts:', error);
|
|
listDiv.innerHTML = '<div class="px-6 py-8 text-center text-red-500">Failed to load published posts. Please try again.</div>';
|
|
}
|
|
}
|
|
|
|
// Show read modal for a post
|
|
function showReadModal(post) {
|
|
const modal = document.createElement('div');
|
|
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50';
|
|
modal.innerHTML = `
|
|
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<h3 class="text-2xl font-bold text-gray-900">${post.title}</h3>
|
|
<button class="close-modal text-gray-400 hover:text-gray-600 text-3xl font-bold">×</button>
|
|
</div>
|
|
|
|
<div class="mb-4 text-sm text-gray-600">
|
|
<div class="flex items-center gap-4">
|
|
<span>Slug: ${post.slug}</span>
|
|
${post.publishedAt ? `<span>Published: ${new Date(post.publishedAt).toLocaleDateString()}</span>` : ''}
|
|
</div>
|
|
${post.tags && post.tags.length > 0 ? `
|
|
<div class="mt-2 flex flex-wrap gap-1">
|
|
${post.tags.map(tag => `<span class="bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded">${tag}</span>`).join(' ')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
|
|
${post.excerpt ? `
|
|
<div class="mb-4 p-4 bg-gray-50 rounded">
|
|
<p class="text-sm italic text-gray-700">${post.excerpt}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="prose max-w-none">
|
|
${post.content || '<p class="text-gray-500">No content available.</p>'}
|
|
</div>
|
|
|
|
<div class="mt-6 flex justify-end gap-3">
|
|
<a href="https://agenticgovernance.digital/blog/${post.slug}" target="_blank"
|
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
|
View Live
|
|
</a>
|
|
<button class="close-modal px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
// Close modal handlers
|
|
modal.querySelectorAll('.close-modal').forEach(btn => {
|
|
btn.addEventListener('click', () => modal.remove());
|
|
});
|
|
|
|
// Close on background click
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
modal.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize section navigation
|
|
function initSectionNavigation() {
|
|
const tabs = document.querySelectorAll('.section-tab');
|
|
const sections = {
|
|
validation: document.getElementById('validation-section'),
|
|
draft: document.getElementById('draft-section'),
|
|
queue: document.getElementById('queue-section'),
|
|
guidelines: document.getElementById('guidelines-section'),
|
|
published: document.getElementById('published-section')
|
|
};
|
|
|
|
tabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
const targetSection = tab.dataset.section;
|
|
|
|
// Update tab styles
|
|
tabs.forEach(t => {
|
|
t.classList.remove('border-blue-500', 'text-blue-600');
|
|
t.classList.add('border-transparent', 'text-gray-500');
|
|
});
|
|
tab.classList.remove('border-transparent', 'text-gray-500');
|
|
tab.classList.add('border-blue-500', 'text-blue-600');
|
|
|
|
// Show/hide sections
|
|
Object.keys(sections).forEach(key => {
|
|
if (key === targetSection) {
|
|
sections[key].classList.remove('hidden');
|
|
|
|
// Load data for specific sections
|
|
if (key === 'validation') {
|
|
loadValidationArticles();
|
|
} else if (key === 'published') {
|
|
loadPublishedPosts();
|
|
}
|
|
} else {
|
|
sections[key].classList.add('hidden');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Initialize validation section
|
|
function initValidation() {
|
|
console.log('[Validation] Initializing validation section');
|
|
|
|
const refreshBtn = document.getElementById('refresh-validation-btn');
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', loadValidationArticles);
|
|
}
|
|
|
|
const refreshPublishedBtn = document.getElementById('refresh-published-btn');
|
|
if (refreshPublishedBtn) {
|
|
refreshPublishedBtn.addEventListener('click', loadPublishedPosts);
|
|
}
|
|
|
|
// Auto-load validation articles
|
|
const validationList = document.getElementById('validation-list');
|
|
if (validationList) {
|
|
console.log('[Validation] Loading validation articles...');
|
|
loadValidationArticles();
|
|
} else {
|
|
console.warn('[Validation] validation-list element not found');
|
|
}
|
|
|
|
// Initialize section navigation
|
|
initSectionNavigation();
|
|
}
|
|
|
|
// Run on page load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initValidation);
|
|
} else {
|
|
initValidation();
|
|
}
|
|
|
|
// ===========================================
|
|
// MANAGE SUBMISSION MODAL
|
|
// ===========================================
|
|
|
|
// Global state for submission modal
|
|
let currentSubmissionArticleId = null;
|
|
let currentSubmissionId = null;
|
|
let currentArticleData = null;
|
|
|
|
/**
|
|
* Initialize Manage Submission button handlers
|
|
*/
|
|
function initManageSubmissionHandlers() {
|
|
// Event delegation for manage submission buttons
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('manage-submission')) {
|
|
const articleId = e.target.dataset.articleId;
|
|
const submissionId = e.target.dataset.submissionId;
|
|
openManageSubmissionModal(articleId, submissionId || null);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Call this when page loads
|
|
initManageSubmissionHandlers();
|