feat(blog): add Manage Submission modal for publication tracking
Implements comprehensive submission tracking workflow for blog posts targeting external publications. This feature enables systematic management of submission packages and progress monitoring. Frontend: - Add submission-modal.js with complete modal implementation - Modal includes publication selector (22 ranked publications) - 4-item submission checklist (cover letter, pitch, notes, bio) - Auto-save on blur with success indicators - Progress bar (0-100%) tracking completion - Requirements display per publication - Update blog-validation.js with event handlers - Update cache versions (HTML, service worker, version.json) Backend: - Add GET /api/blog/:id/submissions endpoint - Add PUT /api/blog/:id/submissions endpoint (upsert logic) - Implement getSubmissions and updateSubmission controllers - Fix publications controller to use config helper functions - Integration with SubmissionTracking MongoDB model Version: 1.8.4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f05473e23a
commit
ff44a41930
8 changed files with 3470 additions and 9 deletions
422
public/admin/blog-curation.html
Normal file
422
public/admin/blog-curation.html
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>External Communications Manager | Tractatus Admin</title>
|
||||
<link rel="stylesheet" href="/css/tailwind.css?v=1761223918">
|
||||
<link rel="stylesheet" href="/css/tractatus-theme.min.css?v=1761223918">
|
||||
<script defer src="/js/admin/auth-check.js?v=1761223918"></script>
|
||||
<style>
|
||||
.content-type-card input[type="radio"]:checked + div {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
|
||||
<!-- Navigation -->
|
||||
<div id="admin-navbar" data-page-title="External Communications" data-page-icon="blog"></div>
|
||||
<script src="/js/components/navbar-admin.js?v=1761223918"></script>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Section Navigation Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button data-section="validation" class="section-tab border-blue-500 text-blue-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
📋 Pre-Submission
|
||||
</button>
|
||||
<button data-section="draft" class="section-tab border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
✨ Generate
|
||||
</button>
|
||||
<button data-section="queue" class="section-tab border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
⏳ AI Draft Approval
|
||||
</button>
|
||||
<button data-section="guidelines" class="section-tab border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
📖 Guidelines
|
||||
</button>
|
||||
<button data-section="published" class="section-tab border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||
📰 Published
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-500 px-1">
|
||||
<strong>Workflow:</strong> Generate → AI Approval → Pre-Submission (validate & prep) → Submit to publications
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tractatus Enforcement Notice -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg aria-hidden="true" class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Tractatus Framework Enforcement Active</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>All AI-generated content is validated against:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li><strong>inst_016:</strong> No fabricated statistics or unverifiable claims</li>
|
||||
<li><strong>inst_017:</strong> No absolute assurance terms (guarantee, 100%, etc.)</li>
|
||||
<li><strong>inst_018:</strong> No unverified production-ready claims</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-xs">🤖 <strong>TRA-OPS-0002:</strong> AI suggests, human decides. All content requires human review and approval.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draft Content Section -->
|
||||
<div id="draft-section" class="hidden">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Generate External Content</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Draft Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<form id="draft-form">
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- STEP 1: Content Type -->
|
||||
<div class="pb-4 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Step 1: Content Type</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="content-type-card">
|
||||
<input type="radio" name="contentType" value="blog" class="sr-only" checked>
|
||||
<div class="p-4 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all">
|
||||
<div class="font-medium text-gray-900">Website Blog</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Long-form content for agenticgovernance.digital</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="content-type-card">
|
||||
<input type="radio" name="contentType" value="letter" class="sr-only">
|
||||
<div class="p-4 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all">
|
||||
<div class="font-medium text-gray-900">Letter to Editor</div>
|
||||
<div class="text-xs text-gray-500 mt-1">200-300 words, article reference required</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="content-type-card">
|
||||
<input type="radio" name="contentType" value="oped" class="sr-only">
|
||||
<div class="p-4 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all">
|
||||
<div class="font-medium text-gray-900">Op-Ed / Opinion</div>
|
||||
<div class="text-xs text-gray-500 mt-1">750-1500 words, external publication</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="content-type-card">
|
||||
<input type="radio" name="contentType" value="social" class="sr-only">
|
||||
<div class="p-4 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all">
|
||||
<div class="font-medium text-gray-900">Social Media</div>
|
||||
<div class="text-xs text-gray-500 mt-1">LinkedIn, Substack, Medium</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2: Publication Target (conditional) -->
|
||||
<div id="publication-section" class="pb-4 border-b border-gray-200 hidden">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Step 2: Publication Target</h3>
|
||||
<select id="publication-target" name="publicationTarget"
|
||||
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Select publication...</option>
|
||||
</select>
|
||||
<!-- Publication info display -->
|
||||
<div id="publication-info" class="mt-4 hidden p-4 bg-gray-50 rounded-md">
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div>
|
||||
<dt class="font-medium text-gray-700">Word Count:</dt>
|
||||
<dd class="text-gray-600" id="pub-word-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-gray-700">Submission:</dt>
|
||||
<dd class="text-gray-600" id="pub-submission">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-gray-700">Response Time:</dt>
|
||||
<dd class="text-gray-600" id="pub-response">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Publication-Specific Topic Suggestions -->
|
||||
<button type="button" id="suggest-topics-btn"
|
||||
class="mt-4 px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed hidden">
|
||||
<span id="suggest-topics-text">✨ Get Topic Suggestions for This Publication</span>
|
||||
<span id="suggest-topics-loader" class="hidden">⏳ Generating...</span>
|
||||
</button>
|
||||
|
||||
<!-- Topic Suggestions Display -->
|
||||
<div id="topic-suggestions" class="mt-4 hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3: Article Reference (for letters only) -->
|
||||
<div id="article-reference-section" class="pb-4 border-b border-gray-200 hidden">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Step 3: Article Reference</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="article-title" class="block text-sm font-medium text-gray-700">Article Title *</label>
|
||||
<input type="text" id="article-title" name="articleTitle"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="e.g., AI Regulation in Brussels">
|
||||
</div>
|
||||
<div>
|
||||
<label for="article-date" class="block text-sm font-medium text-gray-700">Publication Date *</label>
|
||||
<input type="date" id="article-date" name="articleDate"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="main-point" class="block text-sm font-medium text-gray-700">Your Main Point *</label>
|
||||
<textarea id="main-point" name="mainPoint" rows="3"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="e.g., EU AI Act needs stronger enforcement mechanisms to be effective"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 4: Topic/Theme (for blog/oped) -->
|
||||
<div id="topic-section" class="pb-4 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">
|
||||
<span id="topic-step-label">Step 2</span>: Topic & Content
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="topic" class="block text-sm font-medium text-gray-700">Topic *</label>
|
||||
<input type="text" id="topic" name="topic" required
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="e.g., Understanding AI Boundary Enforcement in Production Systems">
|
||||
</div>
|
||||
<div>
|
||||
<label for="focus" class="block text-sm font-medium text-gray-700">Specific Focus (Optional)</label>
|
||||
<input type="text" id="focus" name="focus"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="e.g., Real-world implementation challenges, case studies, best practices">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 5: Audience & Context -->
|
||||
<div id="context-section" class="pb-4 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">
|
||||
<span id="context-step-label">Step 3</span>: Audience & Context
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="audience" class="block text-sm font-medium text-gray-700">Target Audience *</label>
|
||||
<select id="audience" name="audience" required
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Select audience...</option>
|
||||
<option value="research">Research (Academic, AI safety specialists)</option>
|
||||
<option value="implementer">Implementer (Engineers, architects)</option>
|
||||
<option value="leader">Leader (Policy makers, executives)</option>
|
||||
<option value="general">General (Mixed backgrounds)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tone" class="block text-sm font-medium text-gray-700">Tone & Approach</label>
|
||||
<select id="tone" name="tone"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="standard">Standard (Professional, balanced)</option>
|
||||
<option value="academic">Academic (Rigorous, citations)</option>
|
||||
<option value="practical">Practical (Action-oriented)</option>
|
||||
<option value="strategic">Strategic (High-level, business)</option>
|
||||
<option value="conversational">Conversational (Accessible, engaging)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="culture" class="block text-sm font-medium text-gray-700">Cultural Context</label>
|
||||
<select id="culture" name="culture"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="universal">Universal (Culture-neutral)</option>
|
||||
<option value="indigenous">Indigenous (Māori, First Nations)</option>
|
||||
<option value="global-south">Global South (Emerging economies)</option>
|
||||
<option value="asia-pacific">Asia-Pacific</option>
|
||||
<option value="european">European</option>
|
||||
<option value="north-american">North American</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium text-gray-700">Language</label>
|
||||
<select id="language" name="language"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="en">English</option>
|
||||
<option value="mi">Te Reo Māori</option>
|
||||
<option value="es">Español (Spanish)</option>
|
||||
<option value="fr">Français (French)</option>
|
||||
<option value="de">Deutsch (German)</option>
|
||||
<option value="zh">中文 (Chinese)</option>
|
||||
<option value="ja">日本語 (Japanese)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<button type="submit" id="draft-btn"
|
||||
class="bg-blue-600 text-white px-6 py-2 rounded-md font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
Generate Draft
|
||||
</button>
|
||||
<span id="draft-status" class="text-sm text-gray-500"></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
|
||||
<div class="space-y-3">
|
||||
<button id="suggest-topics-btn"
|
||||
class="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 rounded-md border border-gray-200">
|
||||
<div class="font-medium text-gray-900">Suggest Topics</div>
|
||||
<div class="text-sm text-gray-500">Get AI topic ideas for editorial calendar</div>
|
||||
</button>
|
||||
<button id="analyze-content-btn"
|
||||
class="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 rounded-md border border-gray-200">
|
||||
<div class="font-medium text-gray-900">Analyze Content</div>
|
||||
<div class="text-sm text-gray-500">Check existing content for compliance</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Statistics</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900" id="stat-pending-drafts">-</div>
|
||||
<div class="text-sm text-gray-500">Pending Drafts</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900" id="stat-published-posts">-</div>
|
||||
<div class="text-sm text-gray-500">Published Posts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Section -->
|
||||
<div id="validation-section">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Pre-Submission Review & Validation</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Articles awaiting final validation and submission package preparation before sending to publications.</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg aria-hidden="true" class="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Two-Level Validation</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Articles are validated for both <strong>content similarity</strong> (substantive differences in arguments) and <strong>title similarity</strong> (avoiding confusion in the same market). Both checks must pass before submission.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Pending Review Articles</h3>
|
||||
<button id="refresh-validation-btn" class="text-sm text-blue-600 hover:text-blue-800">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="validation-list" class="divide-y divide-gray-200">
|
||||
<div class="px-6 py-8 text-center text-gray-500">Loading articles...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Queue Section -->
|
||||
<div id="queue-section" class="hidden">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">AI-Generated Draft Approval Queue</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">AI-generated drafts requiring human review and approval before becoming articles.</p>
|
||||
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Pending Drafts</h3>
|
||||
<button id="refresh-queue-btn" class="text-sm text-blue-600 hover:text-blue-800">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="draft-queue" class="divide-y divide-gray-200">
|
||||
<div class="px-6 py-8 text-center text-gray-500">Loading queue...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guidelines Section -->
|
||||
<div id="guidelines-section" class="hidden">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Editorial Guidelines</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Writing Standards</h3>
|
||||
<dl id="editorial-standards" class="space-y-3">
|
||||
<div class="text-center text-gray-500">Loading guidelines...</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Forbidden Patterns</h3>
|
||||
<ul id="forbidden-patterns" class="space-y-2">
|
||||
<li class="text-center text-gray-500">Loading patterns...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Core Principles</h3>
|
||||
<ul id="core-principles" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<li class="text-center text-gray-500">Loading principles...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Published Posts Section -->
|
||||
<div id="published-section" class="hidden">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Published Website Content</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Articles published on the Tractatus website. These are different from outreach articles submitted to external publications.</p>
|
||||
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Published Articles</h3>
|
||||
<button id="refresh-published-btn" class="text-sm text-blue-600 hover:text-blue-800">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="published-list" class="divide-y divide-gray-200">
|
||||
<div class="px-6 py-8 text-center text-gray-500">Loading published posts...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="modal-container"></div>
|
||||
|
||||
<script src="/js/admin/blog-curation.js?v=1761223918"></script>
|
||||
<script src="/js/admin/blog-curation-enhanced.js?v=1761223918"></script>
|
||||
<script src="/js/admin/blog-validation.js?v=1761223918"></script>
|
||||
<script src="/js/admin/submission-modal.js?v=1761223918"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
815
public/js/admin/blog-validation.js
Normal file
815
public/js/admin/blog-validation.js
Normal file
|
|
@ -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 = '<div class="px-6 py-8 text-center text-gray-500">Loading articles...</div>';
|
||||
|
||||
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 = '<div class="px-6 py-8 text-center text-gray-500">No articles pending review</div>';
|
||||
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 `
|
||||
<div class="px-6 py-4 hover:bg-gray-50 border-l-4 ${submission ? 'border-l-blue-500' : 'border-l-gray-300'}" data-article-id="${articleId}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h4 class="font-medium text-gray-900">${article.title}</h4>
|
||||
${submission ? `<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium">${submission.publicationName}</span>` : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<span>Slug: ${article.slug}</span>
|
||||
<span>Words: ${wordCount}</span>
|
||||
<span>Created: ${new Date(article.created_at).toLocaleDateString()}</span>
|
||||
${submission ? `<span class="font-medium">${submission.contentType.toUpperCase()}</span>` : ''}
|
||||
</div>
|
||||
${submission ? `
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="text-xs ${checklistColor}">
|
||||
📋 Checklist: ${checklistCompleted}/${checklistItems} complete
|
||||
</span>
|
||||
${submission.submissionMethod ? `<span class="text-xs text-gray-500">via ${submission.submissionMethod}</span>` : ''}
|
||||
</div>
|
||||
` : '<div class="mt-2 text-xs text-orange-600">⚠️ No submission tracking - click "Manage Submission"</div>'}
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<div class="validation-status-${articleId} inline-flex items-center gap-2">
|
||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
Not validated yet
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex items-center gap-2 flex-wrap">
|
||||
<button class="manage-submission px-4 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700"
|
||||
data-article-id="${articleId}"
|
||||
data-submission-id="${submission ? submission._id : ''}">
|
||||
Manage Submission
|
||||
</button>
|
||||
<button class="edit-article px-4 py-2 bg-gray-600 text-white rounded-md text-sm hover:bg-gray-700"
|
||||
data-article-id="${articleId}">
|
||||
Edit
|
||||
</button>
|
||||
<button class="validate-article px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700"
|
||||
data-article-id="${articleId}"
|
||||
data-article-title="${article.title.replace(/"/g, '"')}">
|
||||
Validate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = '<div class="px-6 py-8 text-center text-red-500">Error loading articles</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = '<span class="px-2 py-1 bg-blue-100 text-blue-600 text-xs rounded">Validating...</span>';
|
||||
|
||||
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('<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">✅ Content: Pass</span>');
|
||||
} else {
|
||||
badges.push(`<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">❌ Content: ${contentFails} conflict(s)</span>`);
|
||||
}
|
||||
|
||||
// Title badge
|
||||
if (titleFails === 0) {
|
||||
badges.push('<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">✅ Title: Pass</span>');
|
||||
} else {
|
||||
badges.push(`<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">🚫 Title: ${titleFails} conflict(s)</span>`);
|
||||
}
|
||||
|
||||
statusDiv.innerHTML = badges.join(' ');
|
||||
|
||||
// Show detailed modal
|
||||
showValidationModal(articleTitle, result.validation);
|
||||
} else {
|
||||
statusDiv.innerHTML = '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">Error</span>';
|
||||
alert(`Validation error: ${result.message || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
statusDiv.innerHTML = '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded">Error</span>';
|
||||
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 ? `
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Content Similarity Checks (${summary.contentPasses} pass, ${summary.contentFails} fail)</h4>
|
||||
<div class="space-y-2">
|
||||
${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 `
|
||||
<div class="border ${bgClass} rounded-md p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="${iconClass} text-lg">${icon}</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-gray-900">vs. "${check.comparedWith}"</div>
|
||||
<div class="text-xs text-gray-600 mt-1">
|
||||
Similarity: ${Math.round(check.similarity * 100)}%
|
||||
</div>
|
||||
<div class="text-xs text-gray-700 mt-1">${check.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '<div class="text-sm text-gray-500 mb-6">No other articles to compare</div>';
|
||||
|
||||
const titleChecksHtml = titleChecks.length > 0 ? `
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Title Similarity Checks (${summary.titlePasses} pass, ${summary.titleFails} fail)</h4>
|
||||
<div class="space-y-2">
|
||||
${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 `
|
||||
<div class="border ${bgClass} rounded-md p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="${iconClass} text-lg">${icon}</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-gray-900">vs. "${check.comparedWith}"</div>
|
||||
<div class="text-xs text-gray-600 mt-1">
|
||||
Similarity: ${Math.round(check.similarity * 100)}%
|
||||
${check.sharedWords && check.sharedWords.length > 0 ? ` | Shared: ${check.sharedWords.join(', ')}` : ''}
|
||||
</div>
|
||||
<div class="text-xs text-gray-700 mt-1">${check.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : '<div class="text-sm text-gray-500 mb-6">No other articles to compare</div>';
|
||||
|
||||
const errorsHtml = errors.length > 0 ? `
|
||||
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
|
||||
<h5 class="font-medium text-red-900 mb-2">❌ Errors - Cannot Submit</h5>
|
||||
<ul class="text-sm text-red-800 list-disc list-inside space-y-1">
|
||||
${errors.map(e => `<li>${e}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const warningsHtml = warnings.length > 0 ? `
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-4">
|
||||
<h5 class="font-medium text-yellow-900 mb-2">⚠️ Warnings</h5>
|
||||
<ul class="text-sm text-yellow-800 list-disc list-inside space-y-1">
|
||||
${warnings.map(w => `<li>${w}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
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 = `
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Validation: "${articleTitle}"</h3>
|
||||
<button class="close-validation-modal text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between p-4 border-2 rounded-lg ${summaryClass.replace('100', '200')}">
|
||||
<span class="font-semibold">${summaryText}</span>
|
||||
<span class="text-sm">Checked against ${summary.totalChecks} article(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${errorsHtml}
|
||||
${warningsHtml}
|
||||
${contentChecksHtml}
|
||||
${titleChecksHtml}
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||
<button class="close-validation-modal px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4"><div class="text-white">Loading article...</div></div>';
|
||||
|
||||
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 = `
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Edit Article</h3>
|
||||
<button class="close-edit-modal text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<form id="edit-article-form">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="edit-title" class="block text-sm font-medium text-gray-700">Title *</label>
|
||||
<input type="text" id="edit-title" name="title" required
|
||||
value="${article.title.replace(/"/g, '"')}"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">Change this if there's a title conflict</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="edit-slug" class="block text-sm font-medium text-gray-700">Slug *</label>
|
||||
<input type="text" id="edit-slug" name="slug" required
|
||||
value="${article.slug}"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">URL-friendly version (lowercase, hyphens)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="edit-excerpt" class="block text-sm font-medium text-gray-700">Excerpt</label>
|
||||
<textarea id="edit-excerpt" name="excerpt" rows="3"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">${article.excerpt || ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="edit-content" class="block text-sm font-medium text-gray-700">Content *</label>
|
||||
<textarea id="edit-content" name="content" rows="15" required
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">${article.content}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">Markdown format</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="edit-status" class="block text-sm font-medium text-gray-700">Status</label>
|
||||
<select id="edit-status" name="status"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="draft" ${article.status === 'draft' ? 'selected' : ''}>Draft</option>
|
||||
<option value="pending_review" ${article.status === 'pending_review' ? 'selected' : ''}>Pending Review</option>
|
||||
<option value="published" ${article.status === 'published' ? 'selected' : ''}>Published</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="edit-tags" class="block text-sm font-medium text-gray-700">Tags</label>
|
||||
<input type="text" id="edit-tags" name="tags"
|
||||
value="${(article.tags || []).join(', ')}"
|
||||
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">Comma-separated</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-status-message" class="mt-4 text-sm"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex justify-between">
|
||||
<button class="close-edit-modal px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="save-article-btn" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<div class="px-6 py-8 text-center text-gray-500">Loading published posts...</div>';
|
||||
|
||||
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 = '<div class="px-6 py-8 text-center text-gray-500">No published posts found.</div>';
|
||||
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 =>
|
||||
`<span class="inline-block bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded">${tag}</span>`
|
||||
).join(' ');
|
||||
|
||||
return `
|
||||
<div class="px-6 py-4 hover:bg-gray-50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-medium text-gray-900">${post.title}</h4>
|
||||
<p class="mt-1 text-sm text-gray-600">${excerpt}</p>
|
||||
<div class="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>${wordCount} words</span>
|
||||
<span>Slug: ${post.slug}</span>
|
||||
${post.publishedAt ? `<span>Published: ${new Date(post.publishedAt).toLocaleDateString()}</span>` : ''}
|
||||
</div>
|
||||
${tags ? `<div class="mt-2 flex flex-wrap gap-1">${tags}</div>` : ''}
|
||||
</div>
|
||||
<div class="ml-4 flex flex-col gap-2">
|
||||
<button class="read-article text-sm text-blue-600 hover:text-blue-800" data-article-id="${articleId}">
|
||||
Read
|
||||
</button>
|
||||
<a href="https://agenticgovernance.digital/blog/${post.slug}" target="_blank"
|
||||
class="text-sm text-gray-600 hover:text-gray-800">
|
||||
View Live
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = '<div class="px-6 py-8 text-center text-red-500">Failed to load published posts. Please try again.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="text-2xl font-bold text-gray-900">${post.title}</h3>
|
||||
<button class="close-modal text-gray-400 hover:text-gray-600 text-3xl font-bold">×</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>Slug: ${post.slug}</span>
|
||||
${post.publishedAt ? `<span>Published: ${new Date(post.publishedAt).toLocaleDateString()}</span>` : ''}
|
||||
</div>
|
||||
${post.tags && post.tags.length > 0 ? `
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
${post.tags.map(tag => `<span class="bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded">${tag}</span>`).join(' ')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${post.excerpt ? `
|
||||
<div class="mb-4 p-4 bg-gray-50 rounded">
|
||||
<p class="text-sm italic text-gray-700">${post.excerpt}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="prose max-w-none">
|
||||
${post.content || '<p class="text-gray-500">No content available.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<a href="https://agenticgovernance.digital/blog/${post.slug}" target="_blank"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
View Live
|
||||
</a>
|
||||
<button class="close-modal px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
379
public/js/admin/submission-modal.js
Normal file
379
public/js/admin/submission-modal.js
Normal file
|
|
@ -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 = `
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto mx-4">
|
||||
<div class="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Manage Submission</h2>
|
||||
<button id="close-submission-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Article Info -->
|
||||
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-r">
|
||||
<h3 id="article-title" class="font-semibold text-gray-900 mb-1"></h3>
|
||||
<p id="article-excerpt" class="text-sm text-gray-600"></p>
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
<span id="article-word-count"></span> words
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Publication Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Target Publication
|
||||
</label>
|
||||
<select id="publication-select"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">Select a publication...</option>
|
||||
</select>
|
||||
<div id="publication-requirements" class="mt-3 hidden">
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 text-sm">
|
||||
<h4 class="font-semibold text-gray-900 mb-2">Requirements</h4>
|
||||
<div id="requirements-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div>
|
||||
<div class="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Submission Package Progress</span>
|
||||
<span id="progress-percentage">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div id="progress-bar" class="bg-green-600 h-2.5 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checklist Items -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Submission Package</h3>
|
||||
|
||||
<!-- Cover Letter -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="check-coverLetter"
|
||||
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="saveChecklistItem('coverLetter')">
|
||||
<label for="check-coverLetter" class="ml-3 font-medium text-gray-900">
|
||||
Cover Letter
|
||||
</label>
|
||||
</div>
|
||||
<span id="saved-coverLetter" class="text-xs text-green-600 hidden">✓ Saved</span>
|
||||
</div>
|
||||
<textarea id="content-coverLetter"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
rows="4"
|
||||
placeholder="Write your cover letter here..."
|
||||
onblur="saveChecklistItem('coverLetter')"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Pitch Email -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="check-pitchEmail"
|
||||
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="saveChecklistItem('pitchEmail')">
|
||||
<label for="check-pitchEmail" class="ml-3 font-medium text-gray-900">
|
||||
Pitch Email
|
||||
</label>
|
||||
</div>
|
||||
<span id="saved-pitchEmail" class="text-xs text-green-600 hidden">✓ Saved</span>
|
||||
</div>
|
||||
<textarea id="content-pitchEmail"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
rows="4"
|
||||
placeholder="Write your pitch email here..."
|
||||
onblur="saveChecklistItem('pitchEmail')"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Notes to Editor -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="check-notesToEditor"
|
||||
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="saveChecklistItem('notesToEditor')">
|
||||
<label for="check-notesToEditor" class="ml-3 font-medium text-gray-900">
|
||||
Notes to Editor
|
||||
</label>
|
||||
</div>
|
||||
<span id="saved-notesToEditor" class="text-xs text-green-600 hidden">✓ Saved</span>
|
||||
</div>
|
||||
<textarea id="content-notesToEditor"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
rows="4"
|
||||
placeholder="Any additional notes for the editor..."
|
||||
onblur="saveChecklistItem('notesToEditor')"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Author Bio -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="check-authorBio"
|
||||
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="saveChecklistItem('authorBio')">
|
||||
<label for="check-authorBio" class="ml-3 font-medium text-gray-900">
|
||||
Author Bio
|
||||
</label>
|
||||
</div>
|
||||
<span id="saved-authorBio" class="text-xs text-green-600 hidden">✓ Saved</span>
|
||||
</div>
|
||||
<textarea id="content-authorBio"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
rows="3"
|
||||
placeholder="Your author biography..."
|
||||
onblur="saveChecklistItem('authorBio')"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<ul class="space-y-1">';
|
||||
|
||||
if (requirements.wordCount) {
|
||||
html += `<li><strong>Word Count:</strong> ${requirements.wordCount.min}-${requirements.wordCount.max} words</li>`;
|
||||
}
|
||||
if (requirements.exclusivity) {
|
||||
html += '<li><strong>Exclusivity:</strong> Must not be published elsewhere</li>';
|
||||
}
|
||||
if (submission.method) {
|
||||
html += `<li><strong>Method:</strong> ${submission.method}`;
|
||||
if (submission.email) {
|
||||
html += ` (${submission.email})`;
|
||||
}
|
||||
html += '</li>';
|
||||
}
|
||||
if (submission.responseTime) {
|
||||
html += `<li><strong>Response Time:</strong> ${submission.responseTime.min}-${submission.responseTime.max} ${submission.responseTime.unit}</li>`;
|
||||
}
|
||||
|
||||
html += '</ul>';
|
||||
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}%`;
|
||||
}
|
||||
197
public/service-worker.js
Normal file
197
public/service-worker.js
Normal file
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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 <button> elements with aria-expanded, aria-controls",
|
||||
"Added keyboard support: Enter and Space keys toggle accordions",
|
||||
"Mobile optimization: 44px/48px touch targets, touch-action: manipulation",
|
||||
"Accordion content panels: role='region' and aria-labelledby for screen readers",
|
||||
"Previous: Implementer diagrams, Researcher Berlin/Weil, Footer i18n deep merge"
|
||||
"Blog Validation: ✨ NEW! Manage Submission modal for tracking publication submissions",
|
||||
"Blog Validation: Track submission packages (cover letters, pitch emails, author bios)",
|
||||
"Blog Validation: Progress indicators and automated checklist saving",
|
||||
"Blog Validation: Integration with 22 ranked publication targets",
|
||||
"API: New submission tracking endpoints (GET/PUT /api/blog/:id/submissions)",
|
||||
"Backend: SubmissionTracking model for managing publication workflows",
|
||||
"Cache: Updated cache-busting and service worker version"
|
||||
],
|
||||
"forceUpdate": true,
|
||||
"minVersion": "1.1.5"
|
||||
"minVersion": "1.8.0"
|
||||
}
|
||||
|
|
|
|||
1325
src/controllers/blog.controller.js
Normal file
1325
src/controllers/blog.controller.js
Normal file
File diff suppressed because it is too large
Load diff
157
src/controllers/publications.controller.js
Normal file
157
src/controllers/publications.controller.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Publications Controller
|
||||
* API endpoints for publication targets metadata
|
||||
*/
|
||||
|
||||
const publicationTargets = require('../config/publication-targets.config');
|
||||
|
||||
/**
|
||||
* GET /api/publications
|
||||
* Get all publication targets with optional filtering
|
||||
*/
|
||||
async function getPublications(req, res) {
|
||||
try {
|
||||
const {
|
||||
type, // letter, oped, social
|
||||
tier, // premier, specialist, regional, digital
|
||||
culture, // global-north, asia, developing-world
|
||||
minRank,
|
||||
maxRank,
|
||||
language // en, de, fr, pt, zh, etc.
|
||||
} = req.query;
|
||||
|
||||
// Get all publications
|
||||
let publications = publicationTargets.getAllPublications();
|
||||
|
||||
// Apply filters
|
||||
if (type) {
|
||||
publications = publications.filter(p => p.type === type);
|
||||
}
|
||||
|
||||
if (tier) {
|
||||
publications = publications.filter(p => p.tier === tier);
|
||||
}
|
||||
|
||||
if (culture) {
|
||||
publications = publications.filter(p =>
|
||||
p.culture && p.culture.includes(culture)
|
||||
);
|
||||
}
|
||||
|
||||
if (minRank) {
|
||||
publications = publications.filter(p => p.rank >= parseInt(minRank, 10));
|
||||
}
|
||||
|
||||
if (maxRank) {
|
||||
publications = publications.filter(p => p.rank <= parseInt(maxRank, 10));
|
||||
}
|
||||
|
||||
if (language) {
|
||||
publications = publications.filter(p =>
|
||||
p.requirements && p.requirements.language === language
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by rank
|
||||
publications.sort((a, b) => a.rank - b.rank);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: publications.length,
|
||||
data: publications
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Publications] Get publications error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch publications'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/publications/:id
|
||||
* Get specific publication by ID
|
||||
*/
|
||||
async function getPublicationById(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const publication = publicationTargets.getPublicationById(id);
|
||||
|
||||
if (!publication) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Publication not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: publication
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Publications] Get publication by ID error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch publication'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/publications/summary
|
||||
* Get summary statistics about publications
|
||||
*/
|
||||
async function getPublicationsSummary(req, res) {
|
||||
try {
|
||||
const publications = publicationTargets.getAllPublications();
|
||||
|
||||
// Calculate summary statistics
|
||||
const summary = {
|
||||
total: publications.length,
|
||||
byType: {
|
||||
letter: publications.filter(p => p.type === 'letter').length,
|
||||
oped: publications.filter(p => p.type === 'oped').length,
|
||||
social: publications.filter(p => p.type === 'social').length
|
||||
},
|
||||
byTier: {
|
||||
premier: publications.filter(p => p.tier === 'premier').length,
|
||||
specialist: publications.filter(p => p.tier === 'specialist').length,
|
||||
regional: publications.filter(p => p.tier === 'regional').length,
|
||||
digital: publications.filter(p => p.tier === 'digital').length
|
||||
},
|
||||
byCulture: {
|
||||
'global-north': publications.filter(p => p.culture && p.culture.includes('global-north')).length,
|
||||
'asia': publications.filter(p => p.culture && p.culture.includes('asia')).length,
|
||||
'developing-world': publications.filter(p => p.culture && p.culture.includes('developing-world')).length
|
||||
},
|
||||
byLanguage: {
|
||||
en: publications.filter(p => !p.requirements?.language || p.requirements.language === 'en').length,
|
||||
de: publications.filter(p => p.requirements?.language === 'de').length,
|
||||
fr: publications.filter(p => p.requirements?.language === 'fr').length,
|
||||
pt: publications.filter(p => p.requirements?.language === 'pt').length,
|
||||
zh: publications.filter(p => p.requirements?.language === 'zh').length
|
||||
},
|
||||
verified: publications.filter(p => p.verified).length,
|
||||
unverified: publications.filter(p => !p.verified).length
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: summary
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Publications] Get summary error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch publication summary'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPublications,
|
||||
getPublicationById,
|
||||
getPublicationsSummary
|
||||
};
|
||||
165
src/routes/blog.routes.js
Normal file
165
src/routes/blog.routes.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Blog Routes
|
||||
* AI-curated blog endpoints
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const blogController = require('../controllers/blog.controller');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
|
||||
const { validateRequired, validateObjectId, validateSlug } = require('../middleware/validation.middleware');
|
||||
const { asyncHandler } = require('../middleware/error.middleware');
|
||||
|
||||
/**
|
||||
* Public routes
|
||||
*/
|
||||
|
||||
// GET /api/blog/rss - RSS feed (must be before /:slug to avoid conflict)
|
||||
router.get('/rss',
|
||||
asyncHandler(blogController.generateRSSFeed)
|
||||
);
|
||||
|
||||
// GET /api/blog - List published posts
|
||||
router.get('/',
|
||||
asyncHandler(blogController.listPublishedPosts)
|
||||
);
|
||||
|
||||
// GET /api/blog/:slug - Get published post by slug
|
||||
router.get('/:slug',
|
||||
asyncHandler(blogController.getPublishedPost)
|
||||
);
|
||||
|
||||
/**
|
||||
* Admin routes
|
||||
*/
|
||||
|
||||
// POST /api/blog/suggest-topics - AI-powered topic suggestions (TRA-OPS-0002)
|
||||
router.post('/suggest-topics',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateRequired(['audience']),
|
||||
asyncHandler(blogController.suggestTopics)
|
||||
);
|
||||
|
||||
// POST /api/blog/suggest-topics-for-publication - Publication-specific topic suggestions
|
||||
router.post('/suggest-topics-for-publication',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateRequired(['publicationId']),
|
||||
asyncHandler(blogController.suggestTopicsForPublication)
|
||||
);
|
||||
|
||||
// POST /api/blog/draft-post - AI-powered blog post drafting (TRA-OPS-0002)
|
||||
// Enforces inst_016, inst_017, inst_018
|
||||
router.post('/draft-post',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateRequired(['topic', 'audience']),
|
||||
asyncHandler(blogController.draftBlogPost)
|
||||
);
|
||||
|
||||
// POST /api/blog/analyze-content - Analyze content for Tractatus compliance
|
||||
router.post('/analyze-content',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateRequired(['title', 'body']),
|
||||
asyncHandler(blogController.analyzeContent)
|
||||
);
|
||||
|
||||
// POST /api/blog/validate-uniqueness - Check content uniqueness against existing articles
|
||||
router.post('/validate-uniqueness',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateRequired(['content']),
|
||||
asyncHandler(blogController.validateUniqueness)
|
||||
);
|
||||
|
||||
// POST /api/blog/check-submission-conflict - Check for submission conflicts
|
||||
router.post('/check-submission-conflict',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateRequired(['contentId', 'targetPublicationId']),
|
||||
asyncHandler(blogController.checkSubmissionConflict)
|
||||
);
|
||||
|
||||
// POST /api/blog/validate-article - Comprehensive validation (content + title)
|
||||
router.post('/validate-article',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateRequired(['articleId']),
|
||||
asyncHandler(blogController.validateArticle)
|
||||
);
|
||||
|
||||
// GET /api/blog/editorial-guidelines - Get editorial guidelines
|
||||
router.get('/editorial-guidelines',
|
||||
authenticateToken,
|
||||
requireRole('admin', 'moderator'),
|
||||
asyncHandler(blogController.getEditorialGuidelines)
|
||||
);
|
||||
|
||||
// GET /api/blog/admin/posts?status=draft
|
||||
router.get('/admin/posts',
|
||||
authenticateToken,
|
||||
requireRole('admin', 'moderator'),
|
||||
asyncHandler(blogController.listPostsByStatus)
|
||||
);
|
||||
|
||||
// GET /api/blog/admin/:id - Get any post by ID
|
||||
router.get('/admin/:id',
|
||||
authenticateToken,
|
||||
requireRole('admin', 'moderator'),
|
||||
validateObjectId('id'),
|
||||
asyncHandler(blogController.getPostById)
|
||||
);
|
||||
|
||||
// POST /api/blog - Create new post
|
||||
router.post('/',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateRequired(['title', 'slug', 'content']),
|
||||
validateSlug,
|
||||
asyncHandler(blogController.createPost)
|
||||
);
|
||||
|
||||
// PUT /api/blog/:id - Update post
|
||||
router.put('/:id',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateObjectId('id'),
|
||||
asyncHandler(blogController.updatePost)
|
||||
);
|
||||
|
||||
// POST /api/blog/:id/publish - Publish post
|
||||
router.post('/:id/publish',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateObjectId('id'),
|
||||
asyncHandler(blogController.publishPost)
|
||||
);
|
||||
|
||||
// DELETE /api/blog/:id - Delete post
|
||||
router.delete('/:id',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateObjectId('id'),
|
||||
asyncHandler(blogController.deletePost)
|
||||
);
|
||||
|
||||
// GET /api/blog/:id/submissions - Get submission tracking data
|
||||
router.get('/:id/submissions',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateObjectId('id'),
|
||||
asyncHandler(blogController.getSubmissions)
|
||||
);
|
||||
|
||||
// PUT /api/blog/:id/submissions - Create/update submission tracking
|
||||
router.put('/:id/submissions',
|
||||
authenticateToken,
|
||||
requireRole('admin'),
|
||||
validateObjectId('id'),
|
||||
asyncHandler(blogController.updateSubmission)
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
Loading…
Add table
Reference in a new issue