tractatus/public/js/admin/submission-modal-enhanced.js
TheFlow f8758fd95b fix(blog): add missing auth headers to submission modal API calls
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>
2025-10-24 13:00:11 +13:00

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">
&times;
</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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* 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();
}