- 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>
445 lines
18 KiB
JavaScript
445 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 = {
|
|
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');
|
|
|
|
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();
|
|
}
|