diff --git a/public/admin/blog-curation.html b/public/admin/blog-curation.html new file mode 100644 index 00000000..6db02766 --- /dev/null +++ b/public/admin/blog-curation.html @@ -0,0 +1,422 @@ + + + + + + External Communications Manager | Tractatus Admin + + + + + + + + +
+ + + +
+ + +
+
+ +
+
+ Workflow: Generate β†’ AI Approval β†’ Pre-Submission (validate & prep) β†’ Submit to publications +
+
+ + +
+
+
+ +
+
+

Tractatus Framework Enforcement Active

+
+

All AI-generated content is validated against:

+
    +
  • inst_016: No fabricated statistics or unverifiable claims
  • +
  • inst_017: No absolute assurance terms (guarantee, 100%, etc.)
  • +
  • inst_018: No unverified production-ready claims
  • +
+

πŸ€– TRA-OPS-0002: AI suggests, human decides. All content requires human review and approval.

+
+
+
+
+ + + + + +
+

Pre-Submission Review & Validation

+

Articles awaiting final validation and submission package preparation before sending to publications.

+ +
+
+
+ +
+
+

Two-Level Validation

+
+

Articles are validated for both content similarity (substantive differences in arguments) and title similarity (avoiding confusion in the same market). Both checks must pass before submission.

+
+
+
+
+ +
+
+
+

Pending Review Articles

+ +
+
+
+
Loading articles...
+
+
+
+ + + + + + + + + + +
+ + + + + + + + + + + diff --git a/public/js/admin/blog-validation.js b/public/js/admin/blog-validation.js new file mode 100644 index 00000000..f57764c1 --- /dev/null +++ b/public/js/admin/blog-validation.js @@ -0,0 +1,815 @@ +/** + * Blog Article Validation + * Two-level validation: Content Similarity + Title Similarity + */ + +// Helper to safely convert ObjectId to string +function toStringId(id) { + if (!id) { + console.warn('[toStringId] Received empty id'); + return ''; + } + if (typeof id === 'string') return id; + + // Handle Buffer object (MongoDB ObjectId as buffer) + if (typeof id === 'object' && id.buffer) { + console.log('[toStringId] Converting buffer to hex string'); + const bytes = []; + for (let i = 0; i < 12; i++) { + if (id.buffer[i] !== undefined) { + bytes.push(id.buffer[i].toString(16).padStart(2, '0')); + } + } + const hexString = bytes.join(''); + console.log('[toStringId] Hex string:', hexString); + return hexString; + } + + // Check for other common MongoDB ObjectId representations + if (typeof id === 'object') { + if (id.$oid) return id.$oid; + if (id.id) return id.id; + if (id._id) return id._id; + } + + const result = String(id); + console.log('[toStringId] Fallback String():', result); + return result; +} + +// Get auth token +function getAuthToken() { + return localStorage.getItem('admin_token'); +} + +// API call helper +async function apiCall(endpoint, options = {}) { + const token = getAuthToken(); + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }; + + const response = await fetch(endpoint, { ...defaultOptions, ...options }); + + if (response.status === 401) { + localStorage.removeItem('admin_token'); + window.location.href = '/admin/login.html'; + throw new Error('Unauthorized'); + } + + return response; +} + +// Load all pending review articles +async function loadValidationArticles() { + const listDiv = document.getElementById('validation-list'); + if (!listDiv) return; + + listDiv.innerHTML = '
Loading articles...
'; + + try { + // Load articles + const articlesResponse = await apiCall('/api/blog/admin/posts?status=pending_review&limit=100'); + + if (!articlesResponse.ok) { + throw new Error('Failed to load articles'); + } + + const articlesData = await articlesResponse.json(); + const articles = articlesData.posts || []; + + console.log('[Validation] Loaded articles:', articles.length); + if (articles.length > 0) { + console.log('[Validation] First article._id type:', typeof articles[0]._id, articles[0]._id); + } + + if (articles.length === 0) { + listDiv.innerHTML = '
No articles pending review
'; + return; + } + + // Load submission tracking for each article + // Note: Submissions endpoint not yet implemented, gracefully handle 404 + const submissionData = {}; + // TODO: Uncomment when /api/blog/:id/submissions endpoint is implemented + /* + for (const article of articles) { + const articleId = toStringId(article._id); + try { + const subResponse = await apiCall(`/api/blog/${articleId}/submissions`); + if (subResponse.ok) { + const subData = await subResponse.json(); + if (subData.submissions && subData.submissions.length > 0) { + submissionData[articleId] = subData.submissions[0]; // Get first active submission + } + } + } catch (err) { + // Silently ignore - endpoint not implemented yet + } + } + */ + + listDiv.innerHTML = articles.map(article => { + const articleId = toStringId(article._id); + const wordCount = article.content ? article.content.split(/\s+/).length : 0; + const submission = submissionData[articleId]; + + // Calculate checklist completion + let checklistItems = 0; + let checklistCompleted = 0; + if (submission && submission.submissionPackage) { + const pkg = submission.submissionPackage; + if (pkg.coverLetter) { + checklistItems++; + if (pkg.coverLetter.completed) checklistCompleted++; + } + if (pkg.notesToEditor) { + checklistItems++; + if (pkg.notesToEditor.completed) checklistCompleted++; + } + if (pkg.authorBio) { + checklistItems++; + if (pkg.authorBio.completed) checklistCompleted++; + } + if (pkg.pitchEmail) { + checklistItems++; + if (pkg.pitchEmail.completed) checklistCompleted++; + } + } + + const checklistColor = checklistCompleted === checklistItems && checklistItems > 0 + ? 'text-green-600' + : 'text-yellow-600'; + + return ` +
+
+
+
+

${article.title}

+ ${submission ? `${submission.publicationName}` : ''} +
+
+ Slug: ${article.slug} + Words: ${wordCount} + Created: ${new Date(article.created_at).toLocaleDateString()} + ${submission ? `${submission.contentType.toUpperCase()}` : ''} +
+ ${submission ? ` +
+ + πŸ“‹ Checklist: ${checklistCompleted}/${checklistItems} complete + + ${submission.submissionMethod ? `via ${submission.submissionMethod}` : ''} +
+ ` : '
⚠️ No submission tracking - click "Manage Submission"
'} +
+
+ + Not validated yet + +
+
+
+
+ + + +
+
+
+ `; + }).join(''); + + // Add edit handlers + listDiv.querySelectorAll('.edit-article').forEach(btn => { + btn.addEventListener('click', () => { + const articleId = btn.dataset.articleId; + openEditModal(articleId); + }); + }); + + // Add validate handlers + listDiv.querySelectorAll('.validate-article').forEach(btn => { + btn.addEventListener('click', () => { + const articleId = btn.dataset.articleId; + const articleTitle = btn.dataset.articleTitle; + runValidation(articleId, articleTitle); + }); + }); + + } catch (error) { + console.error('Failed to load validation articles:', error); + listDiv.innerHTML = '
Error loading articles
'; + } +} + +// Run validation on an article +async function runValidation(articleId, articleTitle) { + const statusDiv = document.querySelector(`.validation-status-${articleId}`); + const btn = document.querySelector(`.validate-article[data-article-id="${articleId}"]`); + + if (!statusDiv || !btn) return; + + // Update UI + btn.disabled = true; + btn.textContent = 'Validating...'; + statusDiv.innerHTML = 'Validating...'; + + try { + const response = await apiCall('/api/blog/validate-article', { + method: 'POST', + body: JSON.stringify({ articleId }) + }); + + const result = await response.json(); + + if (response.ok && result.validation) { + const v = result.validation; + + // Count passes and fails + const contentFails = v.contentChecks.filter(c => !c.pass).length; + const titleFails = v.titleChecks.filter(c => !c.pass).length; + + // Build status badges + let badges = []; + + // Content badge + if (contentFails === 0) { + badges.push('βœ… Content: Pass'); + } else { + badges.push(`❌ Content: ${contentFails} conflict(s)`); + } + + // Title badge + if (titleFails === 0) { + badges.push('βœ… Title: Pass'); + } else { + badges.push(`🚫 Title: ${titleFails} conflict(s)`); + } + + statusDiv.innerHTML = badges.join(' '); + + // Show detailed modal + showValidationModal(articleTitle, result.validation); + } else { + statusDiv.innerHTML = 'Error'; + alert(`Validation error: ${result.message || 'Unknown error'}`); + } + } catch (error) { + console.error('Validation error:', error); + statusDiv.innerHTML = 'Error'; + alert(`Validation error: ${error.message}`); + } finally { + btn.disabled = false; + btn.textContent = 'Validate'; + } +} + +// Show validation results modal +function showValidationModal(articleTitle, validation) { + const { contentChecks, titleChecks, overallStatus, errors, warnings, summary } = validation; + + const contentChecksHtml = contentChecks.length > 0 ? ` +
+

Content Similarity Checks (${summary.contentPasses} pass, ${summary.contentFails} fail)

+
+ ${contentChecks.slice(0, 5).map(check => { + const bgClass = check.pass ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'; + const iconClass = check.pass ? 'text-green-600' : 'text-red-600'; + const icon = check.pass ? 'βœ…' : '❌'; + + return ` +
+
+ ${icon} +
+
vs. "${check.comparedWith}"
+
+ Similarity: ${Math.round(check.similarity * 100)}% +
+
${check.message}
+
+
+
+ `; + }).join('')} +
+
+ ` : '
No other articles to compare
'; + + const titleChecksHtml = titleChecks.length > 0 ? ` +
+

Title Similarity Checks (${summary.titlePasses} pass, ${summary.titleFails} fail)

+
+ ${titleChecks.slice(0, 5).map(check => { + const bgClass = check.pass ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'; + const iconClass = check.pass ? 'text-green-600' : 'text-red-600'; + const icon = check.pass ? 'βœ…' : '🚫'; + + return ` +
+
+ ${icon} +
+
vs. "${check.comparedWith}"
+
+ Similarity: ${Math.round(check.similarity * 100)}% + ${check.sharedWords && check.sharedWords.length > 0 ? ` | Shared: ${check.sharedWords.join(', ')}` : ''} +
+
${check.message}
+
+
+
+ `; + }).join('')} +
+
+ ` : '
No other articles to compare
'; + + const errorsHtml = errors.length > 0 ? ` +
+
❌ Errors - Cannot Submit
+ +
+ ` : ''; + + const warningsHtml = warnings.length > 0 ? ` +
+
⚠️ Warnings
+ +
+ ` : ''; + + const summaryClass = overallStatus === 'pass' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'; + const summaryText = overallStatus === 'pass' ? 'βœ… PASS - Ready for submission' : '❌ FAIL - Revisions required'; + + const modal = ` +
+
+
+

Validation: "${articleTitle}"

+ +
+ +
+
+
+ ${summaryText} + Checked against ${summary.totalChecks} article(s) +
+
+ + ${errorsHtml} + ${warningsHtml} + ${contentChecksHtml} + ${titleChecksHtml} +
+ +
+ +
+
+
+ `; + + const container = document.getElementById('modal-container'); + if (!container) return; + + container.innerHTML = modal; + + // Close modal handlers + container.querySelectorAll('.close-validation-modal').forEach(btn => { + btn.addEventListener('click', () => { + container.innerHTML = ''; + }); + }); +} + +// Open edit modal +async function openEditModal(articleId) { + const container = document.getElementById('modal-container'); + container.innerHTML = '
Loading article...
'; + + try { + const response = await apiCall(`/api/blog/admin/${articleId}`); + + if (!response.ok) { + throw new Error('Failed to load article'); + } + + const data = await response.json(); + const article = data.post; + + const modal = ` +
+
+
+

Edit Article

+ +
+ +
+
+
+
+ + +

Change this if there's a title conflict

+
+ +
+ + +

URL-friendly version (lowercase, hyphens)

+
+ +
+ + +
+ +
+ + +

Markdown format

+
+ +
+
+ + +
+ +
+ + +

Comma-separated

+
+
+
+ +
+
+
+ +
+ + +
+
+
+ `; + + container.innerHTML = modal; + + // Close handlers + container.querySelectorAll('.close-edit-modal').forEach(btn => { + btn.addEventListener('click', () => { + container.innerHTML = ''; + }); + }); + + // Save handler + container.querySelector('#save-article-btn').addEventListener('click', () => { + saveArticle(articleId); + }); + + } catch (error) { + console.error('Failed to load article:', error); + container.innerHTML = ''; + alert('Failed to load article: ' + error.message); + } +} + +// Save article changes +async function saveArticle(articleId) { + const statusDiv = document.getElementById('edit-status-message'); + const saveBtn = document.getElementById('save-article-btn'); + + const form = document.getElementById('edit-article-form'); + const formData = new FormData(form); + + const updates = { + title: formData.get('title'), + slug: formData.get('slug'), + excerpt: formData.get('excerpt'), + content: formData.get('content'), + status: formData.get('status'), + tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t) + }; + + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + statusDiv.textContent = 'Saving changes...'; + statusDiv.className = 'mt-4 text-sm text-blue-600'; + + try { + const response = await apiCall(`/api/blog/${articleId}`, { + method: 'PUT', + body: JSON.stringify(updates) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to save changes'); + } + + statusDiv.textContent = 'βœ“ Changes saved successfully!'; + statusDiv.className = 'mt-4 text-sm text-green-600'; + + // Close modal and refresh list after short delay + setTimeout(() => { + document.getElementById('modal-container').innerHTML = ''; + loadValidationArticles(); + }, 1000); + + } catch (error) { + console.error('Save error:', error); + statusDiv.textContent = `βœ— Error: ${error.message}`; + statusDiv.className = 'mt-4 text-sm text-red-600'; + saveBtn.disabled = false; + saveBtn.textContent = 'Save Changes'; + } +} + +// Load published posts +async function loadPublishedPosts() { + const listDiv = document.getElementById('published-list'); + if (!listDiv) return; + + listDiv.innerHTML = '
Loading published posts...
'; + + try { + const response = await apiCall('/api/blog/admin/posts?status=published&limit=100'); + + if (!response.ok) { + throw new Error('Failed to load published posts'); + } + + const data = await response.json(); + const posts = data.posts || []; + + if (posts.length === 0) { + listDiv.innerHTML = '
No published posts found.
'; + return; + } + + listDiv.innerHTML = posts.map(post => { + const articleId = toStringId(post._id); + const wordCount = post.content ? post.content.split(/\s+/).length : 0; + const excerpt = post.excerpt || (post.content ? post.content.substring(0, 150) + '...' : 'No excerpt'); + const tags = (post.tags || []).slice(0, 3).map(tag => + `${tag}` + ).join(' '); + + return ` +
+
+
+

${post.title}

+

${excerpt}

+
+ ${wordCount} words + Slug: ${post.slug} + ${post.publishedAt ? `Published: ${new Date(post.publishedAt).toLocaleDateString()}` : ''} +
+ ${tags ? `
${tags}
` : ''} +
+
+ + + View Live + +
+
+
+ `; + }).join(''); + + // Attach read button handlers + document.querySelectorAll('.read-article').forEach(btn => { + btn.addEventListener('click', async (e) => { + const articleId = e.target.dataset.articleId; + const post = posts.find(p => toStringId(p._id) === articleId); + if (post) { + showReadModal(post); + } + }); + }); + + } catch (error) { + console.error('Failed to load published posts:', error); + listDiv.innerHTML = '
Failed to load published posts. Please try again.
'; + } +} + +// Show read modal for a post +function showReadModal(post) { + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50'; + modal.innerHTML = ` +
+
+

${post.title}

+ +
+ +
+
+ Slug: ${post.slug} + ${post.publishedAt ? `Published: ${new Date(post.publishedAt).toLocaleDateString()}` : ''} +
+ ${post.tags && post.tags.length > 0 ? ` +
+ ${post.tags.map(tag => `${tag}`).join(' ')} +
+ ` : ''} +
+ + ${post.excerpt ? ` +
+

${post.excerpt}

+
+ ` : ''} + +
+ ${post.content || '

No content available.

'} +
+ +
+ + View Live + + +
+
+ `; + + document.body.appendChild(modal); + + // Close modal handlers + modal.querySelectorAll('.close-modal').forEach(btn => { + btn.addEventListener('click', () => modal.remove()); + }); + + // Close on background click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.remove(); + } + }); +} + +// Initialize section navigation +function initSectionNavigation() { + const tabs = document.querySelectorAll('.section-tab'); + const sections = { + validation: document.getElementById('validation-section'), + draft: document.getElementById('draft-section'), + queue: document.getElementById('queue-section'), + guidelines: document.getElementById('guidelines-section'), + published: document.getElementById('published-section') + }; + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + const targetSection = tab.dataset.section; + + // Update tab styles + tabs.forEach(t => { + t.classList.remove('border-blue-500', 'text-blue-600'); + t.classList.add('border-transparent', 'text-gray-500'); + }); + tab.classList.remove('border-transparent', 'text-gray-500'); + tab.classList.add('border-blue-500', 'text-blue-600'); + + // Show/hide sections + Object.keys(sections).forEach(key => { + if (key === targetSection) { + sections[key].classList.remove('hidden'); + + // Load data for specific sections + if (key === 'validation') { + loadValidationArticles(); + } else if (key === 'published') { + loadPublishedPosts(); + } + } else { + sections[key].classList.add('hidden'); + } + }); + }); + }); +} + +// Initialize validation section +function initValidation() { + console.log('[Validation] Initializing validation section'); + + const refreshBtn = document.getElementById('refresh-validation-btn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', loadValidationArticles); + } + + const refreshPublishedBtn = document.getElementById('refresh-published-btn'); + if (refreshPublishedBtn) { + refreshPublishedBtn.addEventListener('click', loadPublishedPosts); + } + + // Auto-load validation articles + const validationList = document.getElementById('validation-list'); + if (validationList) { + console.log('[Validation] Loading validation articles...'); + loadValidationArticles(); + } else { + console.warn('[Validation] validation-list element not found'); + } + + // Initialize section navigation + initSectionNavigation(); +} + +// Run on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initValidation); +} else { + initValidation(); +} + +// =========================================== +// MANAGE SUBMISSION MODAL +// =========================================== + +// Global state for submission modal +let currentSubmissionArticleId = null; +let currentSubmissionId = null; +let currentArticleData = null; + +/** + * Initialize Manage Submission button handlers + */ +function initManageSubmissionHandlers() { + // Event delegation for manage submission buttons + document.addEventListener('click', (e) => { + if (e.target.classList.contains('manage-submission')) { + const articleId = e.target.dataset.articleId; + const submissionId = e.target.dataset.submissionId; + openManageSubmissionModal(articleId, submissionId || null); + } + }); +} + +// Call this when page loads +initManageSubmissionHandlers(); diff --git a/public/js/admin/submission-modal.js b/public/js/admin/submission-modal.js new file mode 100644 index 00000000..9d0c9ff4 --- /dev/null +++ b/public/js/admin/submission-modal.js @@ -0,0 +1,379 @@ +/** + * Manage Submission Modal + * Handles submission tracking for blog posts to external publications + */ + +// Create the modal HTML structure dynamically +function createManageSubmissionModal() { + 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 = ` +
+
+

Manage Submission

+ +
+ +
+ +
+

+

+
+ words +
+
+ + +
+ + + +
+ + +
+
+ Submission Package Progress + 0% +
+
+
+
+
+ + +
+

Submission Package

+ + +
+
+
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+ +
+
+
+
+ `; + + return modal; +} + +// Open modal and load data +async function openManageSubmissionModal(articleId, submissionId) { + currentSubmissionArticleId = articleId; + currentSubmissionId = submissionId; + + // Create modal if it doesn't exist + let modal = document.getElementById('manage-submission-modal'); + if (!modal) { + modal = createManageSubmissionModal(); + document.getElementById('modal-container').appendChild(modal); + + // Add close handler + document.getElementById('close-submission-modal').addEventListener('click', closeManageSubmissionModal); + + // Load publication targets + await loadPublicationTargets(); + + // Add publication change handler + document.getElementById('publication-select').addEventListener('change', onPublicationChange); + } + + // Load article and submission data + await loadSubmissionData(articleId); + + // Show modal + modal.classList.remove('hidden'); +} + +// Close modal +function closeManageSubmissionModal() { + const modal = document.getElementById('manage-submission-modal'); + if (modal) { + modal.classList.add('hidden'); + clearSubmissionForm(); + } + currentSubmissionArticleId = null; + currentSubmissionId = null; + currentArticleData = null; +} + +// Load publication targets into dropdown +async function loadPublicationTargets() { + try { + const response = await fetch('/api/publications'); + const data = await response.json(); + + const select = document.getElementById('publication-select'); + if (!select) return; // Modal not created yet + const publications = data.data || []; + + publications.forEach(pub => { + const option = document.createElement('option'); + option.value = pub.id; + option.textContent = `${pub.name} (${pub.type})`; + option.dataset.requirements = JSON.stringify(pub.requirements); + option.dataset.submission = JSON.stringify(pub.submission); + select.appendChild(option); + }); + } catch (error) { + console.error('Failed to load publication targets:', error); + } +} + +// Handle publication selection change +function onPublicationChange(e) { + const select = e.target; + const selectedOption = select.options[select.selectedIndex]; + const requirementsDiv = document.getElementById('publication-requirements'); + const requirementsContent = document.getElementById('requirements-content'); + + if (!selectedOption.value) { + requirementsDiv.classList.add('hidden'); + return; + } + + const requirements = JSON.parse(selectedOption.dataset.requirements || '{}'); + const submission = JSON.parse(selectedOption.dataset.submission || '{}'); + + let html = ''; + requirementsContent.innerHTML = html; + requirementsDiv.classList.remove('hidden'); +} + +// Load submission data +async function loadSubmissionData(articleId) { + try { + // Load article data + const articleResponse = await fetch(`/api/blog/${articleId}`); + const articleData = await articleResponse.json(); + currentArticleData = articleData; + + // Populate article info + document.getElementById('article-title').textContent = articleData.title; + document.getElementById('article-excerpt').textContent = articleData.excerpt || ''; + document.getElementById('article-word-count').textContent = articleData.wordCount || 0; + + // Load existing submission if any + const submissionResponse = await fetch(`/api/blog/${articleId}/submissions`); + if (submissionResponse.ok) { + const submissionData = await submissionResponse.json(); + + if (submissionData.submissions && submissionData.submissions.length > 0) { + const submission = submissionData.submissions[0]; + currentSubmissionId = submission._id; + + // Populate publication select + if (submission.publicationId) { + document.getElementById('publication-select').value = submission.publicationId; + onPublicationChange({ target: document.getElementById('publication-select') }); + } + + // Populate checklist items + const fields = ['coverLetter', 'pitchEmail', 'notesToEditor', 'authorBio']; + fields.forEach(field => { + const packageData = submission.submissionPackage?.[field]; + if (packageData) { + document.getElementById(`check-${field}`).checked = packageData.completed || false; + document.getElementById(`content-${field}`).value = packageData.content || ''; + } + }); + + updateProgress(); + } + } + } catch (error) { + console.error('Failed to load submission data:', error); + } +} + +// Clear submission form +function clearSubmissionForm() { + document.getElementById('publication-select').value = ''; + document.getElementById('publication-requirements').classList.add('hidden'); + + const fields = ['coverLetter', 'pitchEmail', 'notesToEditor', 'authorBio']; + fields.forEach(field => { + document.getElementById(`check-${field}`).checked = false; + document.getElementById(`content-${field}`).value = ''; + }); + + updateProgress(); +} + +// Save checklist item +async function saveChecklistItem(field) { + if (!currentSubmissionArticleId) return; + + const publicationId = document.getElementById('publication-select').value; + if (!publicationId) { + alert('Please select a publication first'); + return; + } + + const completed = document.getElementById(`check-${field}`).checked; + const content = document.getElementById(`content-${field}`).value; + + try { + const response = await fetch(`/api/blog/${currentSubmissionArticleId}/submissions`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + publicationId, + submissionPackage: { + [field]: { + completed, + content, + lastUpdated: new Date().toISOString() + } + } + }) + }); + + if (response.ok) { + const data = await response.json(); + currentSubmissionId = data.submission._id; + + // Show saved indicator + const savedIndicator = document.getElementById(`saved-${field}`); + savedIndicator.classList.remove('hidden'); + setTimeout(() => { + savedIndicator.classList.add('hidden'); + }, 2000); + + updateProgress(); + } else { + console.error('Failed to save checklist item'); + } + } catch (error) { + console.error('Error saving checklist item:', error); + } +} + +// Update progress indicator +function updateProgress() { + const fields = ['coverLetter', 'pitchEmail', 'notesToEditor', 'authorBio']; + let completed = 0; + + fields.forEach(field => { + if (document.getElementById(`check-${field}`).checked) { + completed++; + } + }); + + const percentage = Math.round((completed / fields.length) * 100); + document.getElementById('progress-percentage').textContent = `${percentage}%`; + document.getElementById('progress-bar').style.width = `${percentage}%`; +} diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 00000000..b2bcfa78 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,197 @@ +/** + * Tractatus Service Worker + * - Version management and update notifications + * - Cache management for offline support + * - PWA functionality + */ + +const CACHE_VERSION = '1.8.4'; +const CACHE_NAME = `tractatus-v${CACHE_VERSION}`; +const VERSION_CHECK_INTERVAL = 3600000; // 1 hour in milliseconds + +// Assets to cache immediately on install +const CRITICAL_ASSETS = [ + '/', + '/index.html', + '/css/tailwind.css', + '/js/components/navbar.js', + '/images/tractatus-icon.svg', + '/favicon.svg' +]; + +// Install event - cache critical assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + console.log('[Service Worker] Caching critical assets'); + return cache.addAll(CRITICAL_ASSETS); + }).then(() => { + // Force activation of new service worker + return self.skipWaiting(); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => { + console.log('[Service Worker] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }).then(() => { + // Take control of all clients immediately + return self.clients.claim(); + }) + ); +}); + +// Fetch event - network-first strategy with cache fallback +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip chrome-extension and other non-http requests + if (!url.protocol.startsWith('http')) { + return; + } + + // HTML files: Network-ONLY (never cache, always fetch fresh) + // This ensures users always get the latest content without cache refresh + if (request.destination === 'document' || url.pathname.endsWith('.html')) { + event.respondWith( + fetch(request) + .catch(() => { + // Only for offline fallback: serve cached index.html + if (url.pathname === '/' || url.pathname === '/index.html') { + return caches.match('/index.html'); + } + // All other HTML: network only, fail if offline + throw new Error('Network required for HTML pages'); + }) + ); + return; + } + + // Static assets (CSS, JS, images): Network-first for versioned URLs, cache-first for others + if ( + request.destination === 'style' || + request.destination === 'script' || + request.destination === 'image' || + request.destination === 'font' + ) { + // If URL has version parameter, always fetch fresh (network-first) + const hasVersionParam = url.searchParams.has('v'); + + if (hasVersionParam) { + // Network-first for versioned assets (ensures cache-busting works) + event.respondWith( + fetch(request).then((response) => { + // Cache the response for offline use + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, responseClone); + }); + return response; + }).catch(() => { + // Fallback to cache if offline + return caches.match(request); + }) + ); + } else { + // Cache-first for non-versioned assets + event.respondWith( + caches.match(request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + return fetch(request).then((response) => { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, responseClone); + }); + return response; + }); + }) + ); + } + return; + } + + // API calls and other requests: Network-first + event.respondWith( + fetch(request) + .then((response) => { + return response; + }) + .catch(() => { + return caches.match(request); + }) + ); +}); + +// Message event - handle version checks from clients +self.addEventListener('message', (event) => { + if (event.data.type === 'CHECK_VERSION') { + checkVersion().then((versionInfo) => { + event.ports[0].postMessage({ + type: 'VERSION_INFO', + ...versionInfo + }); + }); + } + + if (event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +// Check for version updates +async function checkVersion() { + try { + const response = await fetch('/version.json', { cache: 'no-store' }); + const serverVersion = await response.json(); + + return { + currentVersion: CACHE_VERSION, + serverVersion: serverVersion.version, + updateAvailable: CACHE_VERSION !== serverVersion.version, + forceUpdate: serverVersion.forceUpdate, + changelog: serverVersion.changelog + }; + } catch (error) { + console.error('[Service Worker] Version check failed:', error); + return { + currentVersion: CACHE_VERSION, + serverVersion: null, + updateAvailable: false, + error: true + }; + } +} + +// Periodic background sync for version checks (if supported) +self.addEventListener('periodicsync', (event) => { + if (event.tag === 'version-check') { + event.waitUntil( + checkVersion().then((versionInfo) => { + if (versionInfo.updateAvailable) { + // Notify all clients about update + self.clients.matchAll().then((clients) => { + clients.forEach((client) => { + client.postMessage({ + type: 'UPDATE_AVAILABLE', + ...versionInfo + }); + }); + }); + } + }) + ); + } +}); diff --git a/public/version.json b/public/version.json index d3f08dc3..1f6c2334 100644 --- a/public/version.json +++ b/public/version.json @@ -1,14 +1,15 @@ { - "version": "1.5.0", - "buildDate": "2025-10-23T00:17:00Z", + "version": "1.8.4", + "buildDate": "2025-10-24T09:45:00Z", "changelog": [ - "Leader page: Full WCAG accessibility, 9 accordions with proper ARIA, keyboard navigation", - "Converted accordion divs to semantic