Replace "ensures", "guarantee", "foolproof", "world-class" and similar absolute terms with evidence-based language throughout public pages, JS components, and FAQ content. Changes apply inst_017 (no absolute assurance terms) consistently. Replacements: - "ensures X" → "validates X", "so that X", "supports X", "maintains X" - "guarantee" → removed or rephrased with qualified language - "foolproof" → "infallible" - "architecturally impossible" → "architecture prevents without explicit override flags" Preserved: published research papers (architectural-alignment*.html), EU AI Act quotes, Te Tiriti treaty language, and FAQ meta-commentary that deliberately critiques this language (lines 2842-2896). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
990 lines
35 KiB
JavaScript
990 lines
35 KiB
JavaScript
/**
|
|
* Enhanced Submission Modal for Blog Post Submissions
|
|
* UI 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');
|
|
const articleData = await response.json();
|
|
currentArticle = articleData.post; // Extract post from wrapper
|
|
|
|
// Try to load existing submission
|
|
const submissionResponse = await fetch(`/api/submissions/by-blog-post/${articleId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (submissionResponse.ok) {
|
|
const submissionData = await submissionResponse.json();
|
|
currentSubmission = submissionData.data; // Extract data from wrapper
|
|
} 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();
|
|
}
|