tractatus/public/js/admin/blog-curation-enhanced.js
TheFlow f7d0b68d39 fix(admin): force fresh API requests to prevent cached 500 errors
- 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
2025-10-24 11:02:43 +13:00

452 lines
18 KiB
JavaScript

/**
* External Communications Manager
* Enhanced Blog Curation with Multi-Channel Content Generation
*/
// Publication targets (loaded from server)
let PUBLICATIONS = {};
// 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 publication targets
async function loadPublicationTargets() {
// For Phase 1, we define publications client-side
// In Phase 2, this would fetch from /api/publications
PUBLICATIONS = {
letter: [
{ id: 'economist-letter', name: 'The Economist', rank: 1, words: '200-250', email: 'letters@economist.com', response: '2-7 days' },
{ id: 'ft-letter', name: 'Financial Times', rank: 2, words: '200-250', email: 'letters.editor@ft.com', response: '2-5 days' },
{ id: 'guardian-letter', name: 'The Guardian', rank: 4, words: '200-250', email: 'letters@theguardian.com', response: '2-5 days' },
{ id: 'nyt-letter', name: 'New York Times', rank: 6, words: '150-200', email: 'letters@nytimes.com', response: '2-7 days' },
{ id: 'wsj-letter', name: 'Wall Street Journal', rank: 11, words: '200-250', email: 'wsj.ltrs@wsj.com', response: '2-5 days' },
{ id: 'der-spiegel-letter', name: 'Der Spiegel (German)', rank: 16, words: '200-300', email: 'leserbriefe@spiegel.de', response: '5-10 days', language: 'de' },
{ id: 'latimes-letter', name: 'Los Angeles Times', rank: 17, words: '100-150', email: 'letters@latimes.com', response: '2-5 days' },
{ id: 'die-presse-letter', name: 'Die Presse (Austrian)', rank: 20, words: '150-200', email: 'leserbriefe@diepresse.com', response: '3-7 days', language: 'de' }
],
oped: [
{ id: 'mit-tech-review-oped', name: 'MIT Technology Review', rank: 3, words: '800-1500', email: 'editors@technologyreview.com', response: '3-6 weeks', pitch: true },
{ id: 'ieee-spectrum-oped', name: 'IEEE Spectrum', rank: 5, words: '1000-2000', method: 'form', response: '4-8 weeks' },
{ id: 'nyt-oped', name: 'New York Times', rank: 6, words: '800-1200', email: 'oped@nytimes.com', response: '2-4 weeks', pitch: true },
{ id: 'washpost-oped', name: 'Washington Post', rank: 7, words: '750-800', method: 'form', response: '2-4 weeks' },
{ id: 'caixin-global-oped', name: 'Caixin Global (China)', rank: 8, words: '800-1500', email: 'english@caixin.com', response: '2-4 weeks', pitch: true },
{ id: 'hindu-open-page', name: 'The Hindu (India)', rank: 9, words: '800-1200', email: 'openpage@thehindu.co.in', response: '1-3 weeks' },
{ id: 'le-monde-oped', name: 'Le Monde (French)', rank: 10, words: '900-1200', method: 'form', response: '2-4 weeks', language: 'fr' },
{ id: 'wired-oped', name: 'Wired', rank: 12, words: '1000-1500', method: 'form', response: '3-6 weeks', pitch: true },
{ id: 'mail-guardian-oped', name: 'Mail & Guardian (South Africa)', rank: 13, words: '800-1200', method: 'website', response: '1-2 weeks' },
{ id: 'venturebeat-oped', name: 'VentureBeat', rank: 16, words: '800-1500', method: 'form', response: '1-2 weeks' },
{ id: 'folha-oped', name: 'Folha de S.Paulo (Brazil)', rank: 16, words: '600-900', method: 'form', response: '1-2 weeks', note: 'English edition available' }
],
social: [
{ id: 'linkedin', name: 'LinkedIn Articles', rank: 14, words: '1000-2000', method: 'self-publish', response: 'immediate' },
{ id: 'daily-blog-nz', name: 'The Daily Blog (NZ)', rank: 15, words: '800-1200', email: 'thedailyblog@gmail.com', response: '1-3 days' },
{ id: 'substack-essay', name: 'Substack', rank: 18, words: '1500-2500', method: 'self-publish', response: 'immediate' },
{ id: 'medium-essay', name: 'Medium', rank: 19, words: '1200-2500', method: 'self-publish', response: 'immediate' }
]
};
}
// Handle content type selection
function setupContentTypeHandlers() {
const contentTypeRadios = document.querySelectorAll('input[name="contentType"]');
const publicationSection = document.getElementById('publication-section');
const articleRefSection = document.getElementById('article-reference-section');
const topicSection = document.getElementById('topic-section');
const topicStepLabel = document.getElementById('topic-step-label');
const contextStepLabel = document.getElementById('context-step-label');
contentTypeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
const type = e.target.value;
// Update UI based on content type
if (type === 'blog') {
// Blog: no publication selector, no article ref
publicationSection.classList.add('hidden');
articleRefSection.classList.add('hidden');
topicSection.classList.remove('hidden');
topicStepLabel.textContent = 'Step 2';
contextStepLabel.textContent = 'Step 3';
} else if (type === 'letter') {
// Letter: show publication + article ref
publicationSection.classList.remove('hidden');
articleRefSection.classList.remove('hidden');
topicSection.classList.add('hidden');
contextStepLabel.textContent = 'Step 4';
populatePublications('letter');
} else if (type === 'oped') {
// Op-Ed: show publication, no article ref
publicationSection.classList.remove('hidden');
articleRefSection.classList.add('hidden');
topicSection.classList.remove('hidden');
topicStepLabel.textContent = 'Step 3';
contextStepLabel.textContent = 'Step 4';
populatePublications('oped');
} else if (type === 'social') {
// Social: show publication (platforms), no article ref
publicationSection.classList.remove('hidden');
articleRefSection.classList.add('hidden');
topicSection.classList.remove('hidden');
topicStepLabel.textContent = 'Step 3';
contextStepLabel.textContent = 'Step 4';
populatePublications('social');
}
});
});
}
// Populate publication dropdown based on type
function populatePublications(type) {
const dropdown = document.getElementById('publication-target');
dropdown.innerHTML = '<option value="">Select publication...</option>';
const pubs = PUBLICATIONS[type] || [];
pubs.sort((a, b) => a.rank - b.rank).forEach(pub => {
const option = document.createElement('option');
option.value = pub.id;
option.textContent = `#${pub.rank} ${pub.name} (${pub.words} words)`;
option.dataset.pubData = JSON.stringify(pub);
dropdown.appendChild(option);
});
}
// Handle publication selection
function setupPublicationHandler() {
const dropdown = document.getElementById('publication-target');
const infoBox = document.getElementById('publication-info');
const wordCount = document.getElementById('pub-word-count');
const submission = document.getElementById('pub-submission');
const response = document.getElementById('pub-response');
const suggestBtn = document.getElementById('suggest-topics-btn');
const suggestionsDiv = document.getElementById('topic-suggestions');
// Elements might not exist if we're on a different section
if (!dropdown) {
console.log('[External Communications] Publication dropdown not found - not in Generate section');
return;
}
dropdown.addEventListener('change', (e) => {
if (!e.target.value) {
infoBox.classList.add('hidden');
suggestBtn.classList.add('hidden');
suggestionsDiv.classList.add('hidden');
return;
}
const option = e.target.selectedOptions[0];
const pub = JSON.parse(option.dataset.pubData);
wordCount.textContent = `${pub.words} words${pub.pitch ? ' (pitch first)' : ''}`;
if (pub.email) {
submission.textContent = pub.email;
} else if (pub.method === 'form') {
submission.textContent = 'Via online form';
} else if (pub.method === 'website') {
submission.textContent = 'Via website contact';
} else if (pub.method === 'self-publish') {
submission.textContent = 'Self-publish platform';
}
response.textContent = pub.response;
infoBox.classList.remove('hidden');
// Show topic suggestions button for publications with readership profiles
// Currently only economist-letter and nyt-oped have readership profiles
const pubsWithProfiles = ['economist-letter', 'nyt-oped'];
if (pubsWithProfiles.includes(pub.id)) {
suggestBtn.classList.remove('hidden');
} else {
suggestBtn.classList.add('hidden');
}
// Clear previous suggestions
suggestionsDiv.classList.add('hidden');
});
}
// Setup publication-specific topic suggestions
function setupTopicSuggestions() {
const suggestBtn = document.getElementById('suggest-topics-btn');
const suggestText = document.getElementById('suggest-topics-text');
const suggestLoader = document.getElementById('suggest-topics-loader');
const suggestionsDiv = document.getElementById('topic-suggestions');
const dropdown = document.getElementById('publication-target');
suggestBtn.addEventListener('click', async () => {
const publicationId = dropdown.value;
if (!publicationId) return;
// Show loading state
suggestBtn.disabled = true;
suggestText.classList.add('hidden');
suggestLoader.classList.remove('hidden');
suggestionsDiv.classList.add('hidden');
try {
const response = await apiCall('/api/blog/suggest-topics-for-publication', {
method: 'POST',
body: JSON.stringify({ publicationId })
});
if (!response.ok) {
throw new Error('Failed to fetch topic suggestions');
}
const data = await response.json();
displayTopicSuggestions(data, publicationId);
} catch (error) {
console.error('Error fetching topic suggestions:', error);
suggestionsDiv.innerHTML = `
<div class="p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-sm text-red-800">Failed to generate topic suggestions. Please try again.</p>
</div>
`;
suggestionsDiv.classList.remove('hidden');
} finally {
// Reset button state
suggestBtn.disabled = false;
suggestText.classList.remove('hidden');
suggestLoader.classList.add('hidden');
}
});
}
// Display topic suggestions
function displayTopicSuggestions(data, publicationId) {
const suggestionsDiv = document.getElementById('topic-suggestions');
const { publication, autoFill, topics } = data;
let html = `
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-lg p-4">
<h4 class="text-sm font-semibold text-purple-900 mb-3">
✨ Topic Suggestions for ${publication.name}
</h4>
<p class="text-xs text-purple-700 mb-4">
📊 Auto-fill: <span class="font-medium">Audience = ${autoFill.audience}</span>,
<span class="font-medium">Tone = ${autoFill.tone}</span>,
<span class="font-medium">Culture = ${autoFill.culture}</span>
</p>
<div class="space-y-3">
`;
topics.forEach((topic, index) => {
html += `
<div class="bg-white border border-purple-200 rounded-md p-4 hover:border-purple-400 hover:shadow-md transition-all cursor-pointer topic-suggestion"
data-topic='${JSON.stringify(topic)}'
data-autofill='${JSON.stringify(autoFill)}'>
<h5 class="font-semibold text-gray-900 mb-2">${index + 1}. ${topic.title}</h5>
<p class="text-sm text-gray-700 mb-2">${topic.rationale}</p>
<div class="text-sm text-gray-600 mb-2">
<strong>Hook:</strong> ${topic.hook}
</div>
<div class="text-xs text-gray-500">
<strong>Key Points:</strong>
<ul class="list-disc list-inside ml-2 mt-1">
${topic.keyPoints.map(point => `<li>${point}</li>`).join('')}
</ul>
</div>
<button class="mt-3 px-3 py-1 bg-purple-600 text-white text-sm rounded hover:bg-purple-700 use-topic-btn">
Use This Topic
</button>
</div>
`;
});
html += `
</div>
</div>
`;
suggestionsDiv.innerHTML = html;
suggestionsDiv.classList.remove('hidden');
// Add click handlers to "Use This Topic" buttons
suggestionsDiv.querySelectorAll('.use-topic-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const card = e.target.closest('.topic-suggestion');
const topic = JSON.parse(card.dataset.topic);
const autoFill = JSON.parse(card.dataset.autofill);
applyTopicSuggestion(topic, autoFill);
});
});
}
// Apply selected topic suggestion to form
function applyTopicSuggestion(topic, autoFill) {
// Fill topic field
document.getElementById('topic').value = topic.title;
// Fill focus field with key points
document.getElementById('focus').value = topic.keyPoints.join('; ');
// Auto-fill audience, tone, culture
document.getElementById('audience').value = autoFill.audience;
document.getElementById('tone').value = autoFill.tone;
document.getElementById('culture').value = autoFill.culture;
// Scroll to topic section
document.getElementById('topic-section').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// Visual feedback
const topicInput = document.getElementById('topic');
topicInput.classList.add('ring-2', 'ring-purple-500');
setTimeout(() => {
topicInput.classList.remove('ring-2', 'ring-purple-500');
}, 2000);
// Show success message
const suggestionsDiv = document.getElementById('topic-suggestions');
const successMsg = document.createElement('div');
successMsg.className = 'mt-3 p-3 bg-green-50 border border-green-200 rounded-md';
successMsg.innerHTML = '<p class="text-sm text-green-800">✅ Topic applied! Fields auto-filled based on publication readership.</p>';
suggestionsDiv.appendChild(successMsg);
setTimeout(() => {
successMsg.remove();
}, 3000);
}
// Handle form submission
async function setupFormHandler() {
const form = document.getElementById('draft-form');
const submitBtn = document.getElementById('draft-btn');
const status = document.getElementById('draft-status');
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = 'Generating...';
status.textContent = 'Generating draft...';
status.className = 'text-sm text-blue-600';
try {
const formData = new FormData(form);
const contentType = formData.get('contentType');
// Build request payload
const payload = {
contentType,
audience: formData.get('audience'),
tone: formData.get('tone') || 'standard',
culture: formData.get('culture') || 'universal',
language: formData.get('language') || 'en'
};
// Add type-specific data
if (contentType === 'blog') {
payload.topic = formData.get('topic');
payload.focus = formData.get('focus');
} else if (contentType === 'letter') {
payload.publicationTarget = formData.get('publicationTarget');
payload.articleReference = {
title: formData.get('articleTitle'),
date: formData.get('articleDate'),
mainPoint: formData.get('mainPoint')
};
} else if (contentType === 'oped' || contentType === 'social') {
payload.publicationTarget = formData.get('publicationTarget');
payload.topic = formData.get('topic');
payload.focus = formData.get('focus');
}
// Validate required fields
if (!payload.audience) {
throw new Error('Please select a target audience');
}
if (contentType === 'letter' && !payload.publicationTarget) {
throw new Error('Please select a publication for your letter');
}
if (contentType === 'letter' && (!payload.articleReference.title || !payload.articleReference.mainPoint)) {
throw new Error('Please provide article title and your main point for letter to editor');
}
if ((contentType === 'blog' || contentType === 'oped') && !payload.topic) {
throw new Error('Please provide a topic');
}
// Submit to API
const response = await apiCall('/api/blog/suggest-topics', {
method: 'POST',
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to generate content');
}
const result = await response.json();
status.textContent = '✓ Draft generated! Added to moderation queue.';
status.className = 'text-sm text-green-600';
// Reset form after short delay
setTimeout(() => {
form.reset();
document.getElementById('publication-section').classList.add('hidden');
document.getElementById('article-reference-section').classList.add('hidden');
status.textContent = '';
submitBtn.textContent = 'Generate Draft';
submitBtn.disabled = false;
}, 3000);
} catch (error) {
console.error('Error generating content:', error);
status.textContent = `Error: ${error.message}`;
status.className = 'text-sm text-red-600';
submitBtn.textContent = 'Generate Draft';
submitBtn.disabled = false;
}
});
}
// Initialize page
async function init() {
await loadPublicationTargets();
setupContentTypeHandlers();
setupPublicationHandler();
setupTopicSuggestions();
setupFormHandler();
console.log('[External Communications] Initialized with', Object.keys(PUBLICATIONS).length, 'content types');
}
// Run on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}