Fixed 401 Unauthorized errors in blog validation/submission modal: - Added Authorization Bearer token to /api/blog/admin/:id fetch (line 153) - Added Authorization Bearer token to /api/submissions/by-blog-post/:id fetch (line 162) - Added Authorization Bearer token to /api/submissions/:id/export fetch (line 818) All admin API endpoints require authentication. The submission modal was making unauthenticated requests, causing 401 errors when trying to load article data or export submission packages. The 404 error on by-blog-post is expected when no submission exists for that blog post ID yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
988 lines
34 KiB
JavaScript
988 lines
34 KiB
JavaScript
/**
|
|
* Enhanced Submission Modal for Blog Post Submissions
|
|
* World-class UI/UX with tabs, content preview, validation
|
|
* CSP-compliant: Uses event delegation instead of inline handlers
|
|
*/
|
|
|
|
let currentArticle = null;
|
|
let currentSubmission = null;
|
|
let activeTab = 'overview';
|
|
|
|
/**
|
|
* Create enhanced submission modal
|
|
*/
|
|
function createEnhancedSubmissionModal() {
|
|
const modal = document.createElement('div');
|
|
modal.id = 'manage-submission-modal';
|
|
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden';
|
|
|
|
modal.innerHTML = `
|
|
<div class="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col mx-4">
|
|
<!-- Header -->
|
|
<div class="border-b px-6 py-4 flex items-center justify-between">
|
|
<h2 class="text-2xl font-bold text-gray-900" id="modal-title">Manage Submission</h2>
|
|
<button data-action="close-modal" class="text-gray-400 hover:text-gray-600 text-2xl leading-none">
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tab Navigation -->
|
|
<div class="border-b px-6">
|
|
<nav class="flex space-x-8" aria-label="Tabs">
|
|
<button
|
|
data-tab="overview"
|
|
id="tab-overview"
|
|
class="tab-button whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm border-blue-500 text-blue-600">
|
|
Overview
|
|
</button>
|
|
<button
|
|
data-tab="documents"
|
|
id="tab-documents"
|
|
class="tab-button whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
|
Documents
|
|
</button>
|
|
<button
|
|
data-tab="validation"
|
|
id="tab-validation"
|
|
class="tab-button whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
|
Validation & Export
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Tab Content -->
|
|
<div class="flex-1 overflow-y-auto px-6 py-4" id="modal-content">
|
|
<!-- Content will be dynamically loaded here -->
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="border-t px-6 py-4 flex justify-between items-center bg-gray-50">
|
|
<div id="modal-status" class="text-sm text-gray-600"></div>
|
|
<div class="flex space-x-3">
|
|
<button
|
|
data-action="close-modal"
|
|
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
|
|
Close
|
|
</button>
|
|
<button
|
|
data-action="save-submission"
|
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
setupEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Setup event listeners using event delegation
|
|
*/
|
|
function setupEventListeners() {
|
|
const modal = document.getElementById('manage-submission-modal');
|
|
if (!modal) return;
|
|
|
|
// Event delegation for all modal interactions
|
|
modal.addEventListener('click', (e) => {
|
|
const target = e.target;
|
|
|
|
// Close modal
|
|
if (target.hasAttribute('data-action') && target.getAttribute('data-action') === 'close-modal') {
|
|
closeSubmissionModal();
|
|
return;
|
|
}
|
|
|
|
// Save submission
|
|
if (target.hasAttribute('data-action') && target.getAttribute('data-action') === 'save-submission') {
|
|
saveSubmission();
|
|
return;
|
|
}
|
|
|
|
// Tab switching
|
|
if (target.hasAttribute('data-tab')) {
|
|
switchTab(target.getAttribute('data-tab'));
|
|
return;
|
|
}
|
|
|
|
// Export actions
|
|
if (target.hasAttribute('data-export')) {
|
|
exportPackage(target.getAttribute('data-export'));
|
|
return;
|
|
}
|
|
|
|
// Copy to clipboard
|
|
if (target.hasAttribute('data-action') && target.getAttribute('data-action') === 'copy-clipboard') {
|
|
copyToClipboard();
|
|
return;
|
|
}
|
|
|
|
// Translate document
|
|
if (target.hasAttribute('data-action') && target.getAttribute('data-action') === 'translate-document') {
|
|
const docType = target.getAttribute('data-doc-type');
|
|
const fromLang = target.getAttribute('data-from-lang');
|
|
const toLang = target.getAttribute('data-to-lang');
|
|
translateDocument(docType, fromLang, toLang, target);
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Handle text input changes for word count
|
|
modal.addEventListener('blur', (e) => {
|
|
if (e.target.id && e.target.id.startsWith('doc-')) {
|
|
const docType = e.target.id.replace('doc-', '');
|
|
updateDocumentWordCount(docType);
|
|
}
|
|
}, true);
|
|
}
|
|
|
|
/**
|
|
* Open submission modal for article
|
|
*/
|
|
async function openManageSubmissionModal(articleId, submissionId) {
|
|
const modal = document.getElementById('manage-submission-modal');
|
|
if (!modal) {
|
|
createEnhancedSubmissionModal();
|
|
}
|
|
|
|
// Load article and submission data
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(`/api/blog/admin/${articleId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (!response.ok) throw new Error('Failed to load article');
|
|
currentArticle = await response.json();
|
|
|
|
// Try to load existing submission
|
|
const submissionResponse = await fetch(`/api/submissions/by-blog-post/${articleId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (submissionResponse.ok) {
|
|
currentSubmission = await submissionResponse.json();
|
|
} else {
|
|
currentSubmission = null;
|
|
}
|
|
|
|
// Update modal title
|
|
document.getElementById('modal-title').textContent = `Manage Submission: ${currentArticle.title}`;
|
|
|
|
// Show modal
|
|
document.getElementById('manage-submission-modal').classList.remove('hidden');
|
|
|
|
// Load overview tab
|
|
switchTab('overview');
|
|
|
|
} catch (error) {
|
|
console.error('Error loading submission data:', error);
|
|
alert('Failed to load submission data. Please try again.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close submission modal
|
|
*/
|
|
function closeSubmissionModal() {
|
|
document.getElementById('manage-submission-modal').classList.add('hidden');
|
|
currentArticle = null;
|
|
currentSubmission = null;
|
|
activeTab = 'overview';
|
|
}
|
|
|
|
/**
|
|
* Switch between tabs
|
|
*/
|
|
function switchTab(tabName) {
|
|
activeTab = tabName;
|
|
|
|
// Update tab buttons
|
|
document.querySelectorAll('.tab-button').forEach(btn => {
|
|
btn.classList.remove('border-blue-500', 'text-blue-600');
|
|
btn.classList.add('border-transparent', 'text-gray-500');
|
|
});
|
|
|
|
const activeButton = document.getElementById(`tab-${tabName}`);
|
|
activeButton.classList.remove('border-transparent', 'text-gray-500');
|
|
activeButton.classList.add('border-blue-500', 'text-blue-600');
|
|
|
|
// Load tab content
|
|
const content = document.getElementById('modal-content');
|
|
|
|
switch(tabName) {
|
|
case 'overview':
|
|
content.innerHTML = renderOverviewTab();
|
|
// Set progress bar width after rendering
|
|
requestAnimationFrame(() => {
|
|
const progressBar = content.querySelector('[data-progress-bar]');
|
|
if (progressBar) {
|
|
const width = progressBar.getAttribute('data-progress');
|
|
progressBar.style.width = width + '%';
|
|
}
|
|
});
|
|
break;
|
|
case 'documents':
|
|
content.innerHTML = renderDocumentsTab();
|
|
break;
|
|
case 'validation':
|
|
content.innerHTML = renderValidationTab();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render Overview Tab
|
|
*/
|
|
function renderOverviewTab() {
|
|
const submission = currentSubmission || {};
|
|
const article = currentArticle;
|
|
|
|
// Handle standalone submissions (no article)
|
|
const isStandalone = !article;
|
|
const title = isStandalone ? submission.title : article.title;
|
|
const subtitle = isStandalone ? '' : (article.subtitle || '');
|
|
const wordCount = isStandalone ? (submission.wordCount || 0) : (article.content ? article.content.split(/\s+/).length : 0);
|
|
const publicationName = submission.publicationName || 'Not assigned';
|
|
const status = submission.status || 'draft';
|
|
|
|
// Calculate completion percentage
|
|
let completionScore = 0;
|
|
if (submission.documents?.mainArticle?.versions?.length > 0) completionScore += 25;
|
|
if (submission.documents?.coverLetter?.versions?.length > 0) completionScore += 25;
|
|
if (submission.documents?.authorBio?.versions?.length > 0) completionScore += 25;
|
|
if (submission.publicationId) completionScore += 25;
|
|
|
|
// Get content/excerpt - for standalone, use document content if available
|
|
let excerptText = 'No content available';
|
|
if (isStandalone) {
|
|
const mainArticle = submission.documents?.mainArticle?.versions?.[0];
|
|
if (mainArticle?.content) {
|
|
excerptText = mainArticle.content.substring(0, 300) + '...';
|
|
}
|
|
} else {
|
|
excerptText = article.excerpt || (article.content?.substring(0, 300) + '...') || 'No content available';
|
|
}
|
|
|
|
const publishedDate = isStandalone
|
|
? (submission.submittedAt ? new Date(submission.submittedAt).toLocaleDateString() : 'Not submitted')
|
|
: (article.published_at ? new Date(article.published_at).toLocaleDateString() : 'N/A');
|
|
|
|
return `
|
|
<div class="space-y-6">
|
|
${isStandalone ? `
|
|
<!-- Standalone Submission Notice -->
|
|
<div class="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-purple-600 font-semibold">📦 Standalone Submission Package</span>
|
|
<span class="text-sm text-purple-600">This is a direct submission without an associated blog post</span>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Article/Submission Preview -->
|
|
<div class="bg-gray-50 rounded-lg p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">${isStandalone ? 'Submission' : 'Article'} Preview</h3>
|
|
<div class="prose max-w-none">
|
|
<h4 class="text-xl font-bold mb-2">${escapeHtml(title)}</h4>
|
|
${subtitle ? `<div class="text-sm text-gray-600 mb-4">
|
|
${escapeHtml(subtitle)}
|
|
</div>` : ''}
|
|
<div class="text-gray-700 line-clamp-6">
|
|
${escapeHtml(excerptText)}
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 flex items-center space-x-4 text-sm text-gray-600">
|
|
<span><strong>Word Count:</strong> ${wordCount.toLocaleString()}</span>
|
|
<span><strong>${isStandalone ? 'Submitted' : 'Published'}:</strong> ${publishedDate}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submission Status -->
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="bg-white border rounded-lg p-4">
|
|
<div class="text-sm text-gray-600 mb-1">Target Publication</div>
|
|
<div class="text-lg font-semibold text-gray-900">${escapeHtml(publicationName)}</div>
|
|
</div>
|
|
<div class="bg-white border rounded-lg p-4">
|
|
<div class="text-sm text-gray-600 mb-1">Status</div>
|
|
<div class="text-lg font-semibold ${getStatusColor(status)}">
|
|
${status.charAt(0).toUpperCase() + status.slice(1)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress Indicator -->
|
|
<div class="bg-white border rounded-lg p-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="text-sm font-medium text-gray-700">Completion Progress</div>
|
|
<div class="text-sm font-semibold text-gray-900">${completionScore}%</div>
|
|
</div>
|
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" data-progress-bar data-progress="${completionScore}"></div>
|
|
</div>
|
|
<div class="mt-4 space-y-2 text-sm">
|
|
${renderChecklistItem('Main Article', submission.documents?.mainArticle?.versions?.length > 0)}
|
|
${renderChecklistItem('Cover Letter', submission.documents?.coverLetter?.versions?.length > 0)}
|
|
${renderChecklistItem('Author Bio', submission.documents?.authorBio?.versions?.length > 0)}
|
|
${renderChecklistItem('Publication Target Set', !!submission.publicationId)}
|
|
</div>
|
|
</div>
|
|
|
|
${submission.publicationId ? renderPublicationRequirements(submission.publicationId) : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render Documents Tab
|
|
*/
|
|
function renderDocumentsTab() {
|
|
const submission = currentSubmission || {};
|
|
const article = currentArticle;
|
|
const isStandalone = !article;
|
|
|
|
return `
|
|
<div class="space-y-6">
|
|
<!-- Language Version Notice -->
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<h4 class="text-sm font-semibold text-blue-900 mb-2">🌍 Multilingual Support</h4>
|
|
<p class="text-sm text-blue-800">You can create and edit multiple language versions of each document. Each version is saved independently.</p>
|
|
</div>
|
|
|
|
${renderMultilingualDocument('mainArticle', 'Main Article', submission, article, isStandalone)}
|
|
${renderMultilingualDocument('coverLetter', 'Cover Letter / Pitch', submission, null, false)}
|
|
${renderMultilingualDocument('authorBio', 'Author Bio', submission, null, false)}
|
|
${renderMultilingualDocument('pitchEmail', 'Pitch Email (Optional)', submission, null, false)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render Validation Tab
|
|
*/
|
|
function renderValidationTab() {
|
|
const submission = currentSubmission || {};
|
|
const article = currentArticle;
|
|
|
|
// Get document word counts
|
|
const mainArticle = submission.documents?.mainArticle?.versions?.[0]?.content || article.content || '';
|
|
const coverLetter = submission.documents?.coverLetter?.versions?.[0]?.content || '';
|
|
const authorBio = submission.documents?.authorBio?.versions?.[0]?.content || '';
|
|
|
|
const mainWordCount = mainArticle.split(/\s+/).filter(w => w.length > 0).length;
|
|
const coverWordCount = coverLetter.split(/\s+/).filter(w => w.length > 0).length;
|
|
const bioWordCount = authorBio.split(/\s+/).filter(w => w.length > 0).length;
|
|
|
|
// Validation checks
|
|
const hasPublication = !!submission.publicationId;
|
|
const hasMainArticle = mainWordCount > 0;
|
|
const hasCoverLetter = coverWordCount > 0;
|
|
const hasAuthorBio = bioWordCount > 0;
|
|
|
|
// Word count validation for specific publications
|
|
const wordCountWarnings = [];
|
|
if (submission.publicationId === 'economist-letter' && coverWordCount > 250) {
|
|
wordCountWarnings.push('Cover letter exceeds The Economist letter limit (250 words)');
|
|
}
|
|
if (submission.publicationId === 'lemonde-letter' && (coverWordCount < 150 || coverWordCount > 200)) {
|
|
wordCountWarnings.push('Cover letter should be 150-200 words for Le Monde');
|
|
}
|
|
|
|
// Format validation
|
|
const formatWarnings = [];
|
|
if (submission.publicationId === 'economist-letter' && !coverLetter.startsWith('SIR—')) {
|
|
formatWarnings.push('Economist letters should start with "SIR—"');
|
|
}
|
|
|
|
const allChecksPassed = hasPublication && hasMainArticle && hasCoverLetter &&
|
|
wordCountWarnings.length === 0 && formatWarnings.length === 0;
|
|
|
|
return `
|
|
<div class="space-y-6">
|
|
<!-- Validation Checks -->
|
|
<div class="bg-white border rounded-lg p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Validation Checks</h3>
|
|
<div class="space-y-3">
|
|
${renderValidationCheck('Publication target assigned', hasPublication, true)}
|
|
${renderValidationCheck(`Main article has content (${mainWordCount.toLocaleString()} words)`, hasMainArticle, true)}
|
|
${renderValidationCheck(`Cover letter present (${coverWordCount.toLocaleString()} words)`, hasCoverLetter, false)}
|
|
${renderValidationCheck(`Author bio present (${bioWordCount.toLocaleString()} words)`, hasAuthorBio, false)}
|
|
</div>
|
|
</div>
|
|
|
|
${renderWarnings(wordCountWarnings, formatWarnings)}
|
|
${allChecksPassed ? renderSuccessMessage() : ''}
|
|
|
|
<!-- Export Options -->
|
|
${renderExportOptions(allChecksPassed)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Render checklist item
|
|
*/
|
|
function renderChecklistItem(label, completed) {
|
|
const icon = completed ? '✓' : '○';
|
|
const color = completed ? 'text-green-600' : 'text-gray-400';
|
|
return `
|
|
<div class="flex items-center">
|
|
<span class="${color}">${icon}</span>
|
|
<span class="ml-2 text-gray-700">${escapeHtml(label)}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Render publication requirements
|
|
*/
|
|
function renderPublicationRequirements(publicationId) {
|
|
const requirements = {
|
|
'economist-letter': 'Letters should be concise (max 250 words), start with "SIR—", and make a clear, compelling point. Include your credentials if relevant.',
|
|
'lemonde-letter': 'Lettres de 150-200 mots. Style formel mais accessible. Argument clair et bien structuré.',
|
|
'default': 'Check the publication\'s submission guidelines for specific requirements.'
|
|
};
|
|
|
|
const text = requirements[publicationId] || requirements.default;
|
|
|
|
return `
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<h4 class="text-sm font-semibold text-blue-900 mb-2">Publication Requirements</h4>
|
|
<div class="text-sm text-blue-800">${escapeHtml(text)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Render multilingual document editor
|
|
* Supports multiple language versions with translation
|
|
*/
|
|
function renderMultilingualDocument(docType, title, submission, article, isStandalone) {
|
|
const doc = submission.documents?.[docType] || {};
|
|
const versions = doc.versions || [];
|
|
const primaryLang = doc.primaryLanguage || 'en';
|
|
|
|
// Get English and French versions
|
|
const enVersion = versions.find(v => v.language === 'en');
|
|
const frVersion = versions.find(v => v.language === 'fr');
|
|
|
|
// For main article, use blog content as English version if no submission version exists
|
|
let enContent = enVersion?.content || '';
|
|
if (docType === 'mainArticle' && !enContent && article?.content) {
|
|
enContent = article.content;
|
|
}
|
|
|
|
const frContent = frVersion?.content || '';
|
|
|
|
const enWordCount = enContent ? enContent.split(/\s+/).filter(w => w.length > 0).length : 0;
|
|
const frWordCount = frContent ? frContent.split(/\s+/).filter(w => w.length > 0).length : 0;
|
|
|
|
const readonly = docType === 'mainArticle' && !isStandalone && article;
|
|
|
|
return `
|
|
<div class="border rounded-lg overflow-hidden mb-6">
|
|
<div class="bg-gray-100 px-4 py-3 flex items-center justify-between">
|
|
<h4 class="font-semibold text-gray-900">${escapeHtml(title)}</h4>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
data-action="translate-document"
|
|
data-doc-type="${docType}"
|
|
data-from-lang="en"
|
|
data-to-lang="fr"
|
|
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 ${enContent ? '' : 'opacity-50 cursor-not-allowed'}"
|
|
${enContent ? '' : 'disabled'}
|
|
title="Translate English to French using DeepL">
|
|
🌍 EN → FR
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- English Version -->
|
|
<div class="border-b bg-white">
|
|
<div class="px-4 py-2 bg-gray-50 flex items-center justify-between border-b">
|
|
<span class="text-sm font-medium text-gray-700">🇬🇧 English Version${primaryLang === 'en' ? ' (Primary)' : ''}</span>
|
|
<span class="text-xs text-gray-600">${enWordCount.toLocaleString()} words</span>
|
|
</div>
|
|
<div class="p-4">
|
|
<textarea
|
|
id="doc-${docType}-en"
|
|
data-doc-type="${docType}"
|
|
data-lang="en"
|
|
rows="8"
|
|
class="w-full border rounded-md p-3 text-sm font-mono resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
${readonly ? 'readonly' : ''}
|
|
placeholder="${readonly ? '' : 'Enter ' + title.toLowerCase() + ' in English...'}"
|
|
>${escapeHtml(enContent)}</textarea>
|
|
${readonly ? '<p class="text-xs text-gray-500 mt-2">🔒 Linked from blog post - edit the blog post to change this content</p>' : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- French Version -->
|
|
<div class="bg-white">
|
|
<div class="px-4 py-2 bg-gray-50 flex items-center justify-between border-b">
|
|
<span class="text-sm font-medium text-gray-700">🇫🇷 French Version${primaryLang === 'fr' ? ' (Primary)' : ''}</span>
|
|
<span class="text-xs text-gray-600">${frWordCount.toLocaleString()} words</span>
|
|
</div>
|
|
<div class="p-4">
|
|
<textarea
|
|
id="doc-${docType}-fr"
|
|
data-doc-type="${docType}"
|
|
data-lang="fr"
|
|
rows="8"
|
|
class="w-full border rounded-md p-3 text-sm font-mono resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="Enter ${title.toLowerCase()} in French (or translate from English)..."
|
|
>${escapeHtml(frContent)}</textarea>
|
|
${frVersion?.translatedBy ? `<p class="text-xs text-gray-500 mt-2">Translated by: ${frVersion.translatedBy}${frVersion.approved ? ' ✓ Approved' : ''}</p>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Render document editor (legacy single-language version)
|
|
*/
|
|
function renderDocumentEditor(docType, title, content, wordCount, readonly) {
|
|
const readonlyAttr = readonly ? 'readonly' : '';
|
|
const readonlyNote = readonly ? '<p class="text-xs text-gray-500 mt-2">Linked from blog post - edit the blog post to change this content</p>' : '';
|
|
const placeholder = readonly ? '' : `placeholder="Enter ${title.toLowerCase()} content..."`;
|
|
|
|
return `
|
|
<div class="border rounded-lg overflow-hidden">
|
|
<div class="bg-gray-100 px-4 py-3 flex items-center justify-between">
|
|
<h4 class="font-semibold text-gray-900">${escapeHtml(title)}</h4>
|
|
<span class="text-sm text-gray-600" id="wordcount-${docType}">${wordCount.toLocaleString()} words</span>
|
|
</div>
|
|
<div class="p-4">
|
|
<textarea
|
|
id="doc-${docType}"
|
|
rows="${readonly ? '8' : '6'}"
|
|
class="w-full border rounded-md p-3 text-sm font-mono resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
${readonlyAttr}
|
|
${placeholder}
|
|
>${escapeHtml(content)}</textarea>
|
|
${readonlyNote}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Render validation check
|
|
*/
|
|
function renderValidationCheck(label, passed, required) {
|
|
const icon = passed ? '✓' : (required ? '✗' : '⚠');
|
|
const color = passed ? 'text-green-600' : (required ? 'text-red-600' : 'text-yellow-600');
|
|
|
|
return `
|
|
<div class="flex items-center">
|
|
<span class="${color} text-xl mr-3">${icon}</span>
|
|
<span class="text-gray-700">${escapeHtml(label)}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Render warnings
|
|
*/
|
|
function renderWarnings(wordCountWarnings, formatWarnings) {
|
|
if (wordCountWarnings.length === 0 && formatWarnings.length === 0) return '';
|
|
|
|
const allWarnings = [...wordCountWarnings, ...formatWarnings];
|
|
|
|
return `
|
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<h4 class="text-sm font-semibold text-yellow-900 mb-2">⚠ Warnings</h4>
|
|
<ul class="text-sm text-yellow-800 space-y-1 list-disc list-inside">
|
|
${allWarnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}
|
|
</ul>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Render success message
|
|
*/
|
|
function renderSuccessMessage() {
|
|
return `
|
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<h4 class="text-sm font-semibold text-green-900 mb-2">✓ Ready for Export</h4>
|
|
<p class="text-sm text-green-800">All validation checks passed. Your submission package is ready.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Helper: Render export options
|
|
*/
|
|
function renderExportOptions(enabled) {
|
|
const disabledAttr = enabled ? '' : 'disabled';
|
|
|
|
return `
|
|
<div class="bg-white border rounded-lg p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Export Package</h3>
|
|
<div class="space-y-3">
|
|
<button
|
|
data-export="json"
|
|
class="w-full flex items-center justify-between px-4 py-3 border rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
${disabledAttr}
|
|
>
|
|
<div class="flex items-center">
|
|
<svg class="w-5 h-5 text-gray-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
|
</svg>
|
|
<div class="text-left">
|
|
<div class="font-medium text-gray-900">Export as JSON</div>
|
|
<div class="text-xs text-gray-500">Complete package with all metadata</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
data-export="text"
|
|
class="w-full flex items-center justify-between px-4 py-3 border rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
${disabledAttr}
|
|
>
|
|
<div class="flex items-center">
|
|
<svg class="w-5 h-5 text-gray-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
</svg>
|
|
<div class="text-left">
|
|
<div class="font-medium text-gray-900">Export Individual Documents</div>
|
|
<div class="text-xs text-gray-500">Separate text files for each document</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
data-action="copy-clipboard"
|
|
class="w-full flex items-center justify-between px-4 py-3 border rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div class="flex items-center">
|
|
<svg class="w-5 h-5 text-gray-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
|
|
</svg>
|
|
<div class="text-left">
|
|
<div class="font-medium text-gray-900">Copy Cover Letter to Clipboard</div>
|
|
<div class="text-xs text-gray-500">Quick copy for email submissions</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Update word count for document
|
|
*/
|
|
function updateDocumentWordCount(docType) {
|
|
const textarea = document.getElementById(`doc-${docType}`);
|
|
const wordCountSpan = document.getElementById(`wordcount-${docType}`);
|
|
|
|
if (textarea && wordCountSpan) {
|
|
const content = textarea.value;
|
|
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length;
|
|
wordCountSpan.textContent = `${wordCount.toLocaleString()} words`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save submission changes
|
|
*/
|
|
async function saveSubmission() {
|
|
if (!currentArticle) return;
|
|
|
|
try {
|
|
// Gather document content from textareas
|
|
const coverLetter = document.getElementById('doc-coverLetter')?.value || '';
|
|
const authorBio = document.getElementById('doc-authorBio')?.value || '';
|
|
const technicalBrief = document.getElementById('doc-technicalBrief')?.value || '';
|
|
|
|
const submissionData = {
|
|
blogPostId: currentArticle._id,
|
|
publicationId: currentSubmission?.publicationId,
|
|
publicationName: currentSubmission?.publicationName,
|
|
title: currentArticle.title,
|
|
wordCount: currentArticle.content?.split(/\s+/).length || 0,
|
|
contentType: currentSubmission?.contentType || 'article',
|
|
status: currentSubmission?.status || 'draft',
|
|
documents: {
|
|
mainArticle: {
|
|
primaryLanguage: 'en',
|
|
versions: [{
|
|
language: 'en',
|
|
content: currentArticle.content,
|
|
wordCount: currentArticle.content?.split(/\s+/).length || 0,
|
|
translatedBy: 'manual',
|
|
approved: true
|
|
}]
|
|
}
|
|
}
|
|
};
|
|
|
|
// Add cover letter if present
|
|
if (coverLetter.trim()) {
|
|
submissionData.documents.coverLetter = {
|
|
primaryLanguage: 'en',
|
|
versions: [{
|
|
language: 'en',
|
|
content: coverLetter,
|
|
wordCount: coverLetter.split(/\s+/).length,
|
|
translatedBy: 'manual',
|
|
approved: true
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Add author bio if present
|
|
if (authorBio.trim()) {
|
|
submissionData.documents.authorBio = {
|
|
primaryLanguage: 'en',
|
|
versions: [{
|
|
language: 'en',
|
|
content: authorBio,
|
|
wordCount: authorBio.split(/\s+/).length,
|
|
translatedBy: 'manual',
|
|
approved: true
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Add technical brief if present
|
|
if (technicalBrief.trim()) {
|
|
submissionData.documents.technicalBrief = {
|
|
primaryLanguage: 'en',
|
|
versions: [{
|
|
language: 'en',
|
|
content: technicalBrief,
|
|
wordCount: technicalBrief.split(/\s+/).length,
|
|
translatedBy: 'manual',
|
|
approved: true
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Save to server
|
|
const url = currentSubmission?._id
|
|
? `/api/submissions/${currentSubmission._id}`
|
|
: '/api/submissions';
|
|
|
|
const method = currentSubmission?._id ? 'PUT' : 'POST';
|
|
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(submissionData)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to save submission');
|
|
|
|
const savedSubmission = await response.json();
|
|
currentSubmission = savedSubmission;
|
|
|
|
const statusEl = document.getElementById('modal-status');
|
|
statusEl.textContent = '✓ Saved successfully';
|
|
statusEl.className = 'text-sm text-green-600';
|
|
|
|
setTimeout(() => {
|
|
statusEl.textContent = '';
|
|
}, 3000);
|
|
|
|
} catch (error) {
|
|
console.error('Error saving submission:', error);
|
|
const statusEl = document.getElementById('modal-status');
|
|
statusEl.textContent = '✗ Failed to save';
|
|
statusEl.className = 'text-sm text-red-600';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export package
|
|
*/
|
|
async function exportPackage(format) {
|
|
if (!currentSubmission) {
|
|
alert('No submission package to export');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const response = await fetch(`/api/submissions/${currentSubmission._id}/export?format=${format}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (!response.ok) throw new Error('Export failed');
|
|
|
|
if (format === 'json') {
|
|
const data = await response.json();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${currentSubmission.publicationId}-package.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} else if (format === 'text') {
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${currentSubmission.publicationId}-documents.zip`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error exporting package:', error);
|
|
alert('Failed to export package. Please try again.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copy cover letter to clipboard
|
|
*/
|
|
async function copyToClipboard() {
|
|
const coverLetter = document.getElementById('doc-coverLetter')?.value;
|
|
|
|
if (!coverLetter) {
|
|
alert('No cover letter to copy');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(coverLetter);
|
|
const statusEl = document.getElementById('modal-status');
|
|
statusEl.textContent = '✓ Copied to clipboard';
|
|
statusEl.className = 'text-sm text-green-600';
|
|
|
|
setTimeout(() => {
|
|
statusEl.textContent = '';
|
|
}, 3000);
|
|
} catch (error) {
|
|
console.error('Error copying to clipboard:', error);
|
|
alert('Failed to copy to clipboard');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Translate document using DeepL
|
|
*/
|
|
async function translateDocument(docType, fromLang, toLang, buttonEl) {
|
|
const sourceTextarea = document.getElementById(`doc-${docType}-${fromLang}`);
|
|
const targetTextarea = document.getElementById(`doc-${docType}-${toLang}`);
|
|
|
|
if (!sourceTextarea || !targetTextarea) {
|
|
alert('Translation error: Cannot find document fields');
|
|
return;
|
|
}
|
|
|
|
const sourceText = sourceTextarea.value.trim();
|
|
|
|
if (!sourceText) {
|
|
alert('No text to translate. Please enter content in the source language first.');
|
|
return;
|
|
}
|
|
|
|
// Disable button and show loading state
|
|
const originalText = buttonEl.textContent;
|
|
buttonEl.disabled = true;
|
|
buttonEl.textContent = '⏳ Translating...';
|
|
buttonEl.classList.add('opacity-50', 'cursor-not-allowed');
|
|
|
|
try {
|
|
const token = localStorage.getItem('admin_token');
|
|
const submissionId = currentSubmission._id;
|
|
|
|
const response = await fetch(`/api/submissions/${submissionId}/translate`, {
|
|
method: 'POST',
|
|
cache: 'no-store',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({
|
|
docType,
|
|
fromLang,
|
|
toLang,
|
|
text: sourceText
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Translation failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Update target textarea with translation
|
|
targetTextarea.value = data.translatedText;
|
|
|
|
// Update word count
|
|
const wordCount = data.translatedText.split(/\s+/).filter(w => w.length > 0).length;
|
|
const wordCountEl = targetTextarea.closest('.bg-white').querySelector('.text-xs.text-gray-600');
|
|
if (wordCountEl) {
|
|
wordCountEl.textContent = `${wordCount.toLocaleString()} words`;
|
|
}
|
|
|
|
// Show success message
|
|
const statusEl = document.getElementById('modal-status');
|
|
statusEl.textContent = `✓ Translated to ${toLang.toUpperCase()} using DeepL`;
|
|
statusEl.className = 'text-sm text-green-600';
|
|
|
|
setTimeout(() => {
|
|
statusEl.textContent = '';
|
|
}, 5000);
|
|
|
|
} catch (error) {
|
|
console.error('Translation error:', error);
|
|
alert(`Translation failed: ${error.message}`);
|
|
} finally {
|
|
// Re-enable button
|
|
buttonEl.disabled = false;
|
|
buttonEl.textContent = originalText;
|
|
buttonEl.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Utility: Escape HTML
|
|
*/
|
|
function escapeHtml(unsafe) {
|
|
if (!unsafe) return '';
|
|
return unsafe
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Utility: Get status color class
|
|
*/
|
|
function getStatusColor(status) {
|
|
const colors = {
|
|
'ready': 'text-green-600',
|
|
'submitted': 'text-blue-600',
|
|
'published': 'text-purple-600',
|
|
'draft': 'text-gray-600'
|
|
};
|
|
return colors[status] || 'text-gray-600';
|
|
}
|
|
|
|
// Initialize modal on page load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', createEnhancedSubmissionModal);
|
|
} else {
|
|
createEnhancedSubmissionModal();
|
|
}
|