feat: add blog draft generator and fix broken routes
Blog drafting section (dev-only): - BlogDraftGenerator gathers project data (gaps, proposals, stats) as context and calls Claude to produce Medium-style blog posts - DB schema: blog_drafts table with title, content, tags, cost tracking - Web UI: list, generate (async with live preview), detail (rendered + source toggle), edit, and export routes - 6 writing styles: deep-dive, overview, opinion, listicle, comparison, series-post - Nav link added to sidebar under Proposals Bug fixes found via route testing (scripts/test_all_routes.py): - /authors/<id>: Draft.status → Draft.states (correct attribute name) - /false-positives: add missing `import re` in ratings.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
228
src/webui/templates/blog_generate.html
Normal file
228
src/webui/templates/blog_generate.html
Normal file
@@ -0,0 +1,228 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "blog" %}
|
||||
|
||||
{% block title %}Generate Blog Post — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.prose { color: #cbd5e1; line-height: 1.75; }
|
||||
.prose h1, .prose h2, .prose h3 { color: #f1f5f9; font-weight: 700; margin-top: 1.5em; margin-bottom: 0.5em; }
|
||||
.prose h1 { font-size: 1.5rem; }
|
||||
.prose h2 { font-size: 1.25rem; }
|
||||
.prose h3 { font-size: 1.1rem; }
|
||||
.prose p { margin-bottom: 1em; }
|
||||
.prose strong { color: #e2e8f0; }
|
||||
.prose em { color: #94a3b8; }
|
||||
.prose ul, .prose ol { margin-bottom: 1em; padding-left: 1.5em; }
|
||||
.prose li { margin-bottom: 0.25em; }
|
||||
.prose blockquote { border-left: 3px solid #475569; padding-left: 1em; color: #94a3b8; margin: 1em 0; }
|
||||
.prose code { background: #1e293b; padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }
|
||||
.prose pre { background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 1em; overflow-x: auto; margin: 1em 0; }
|
||||
.prose pre code { background: none; padding: 0; }
|
||||
.prose a { color: #60a5fa; text-decoration: underline; }
|
||||
.spinner { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6 text-sm">
|
||||
<a href="/blog" class="text-blue-400 hover:text-blue-300 transition">Blog Drafts</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">Generate</span>
|
||||
</nav>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Input form -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h1 class="text-xl font-bold text-white mb-4">Generate Blog Post</h1>
|
||||
<p class="text-sm text-slate-400 mb-6">Claude will use your project data (gaps, proposals, drafts, stats) to write a Medium-style blog post.</p>
|
||||
|
||||
<form id="generateForm" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Topic *</label>
|
||||
<input type="text" name="topic" required placeholder="e.g., 'all analysis results', 'capability-based security for agents', 'gap #3 deep dive'"
|
||||
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition">
|
||||
<p class="text-xs text-slate-500 mt-1">What should the post be about? Reference gaps, proposals, or general topics.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Style</label>
|
||||
<select name="style" class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
|
||||
<option value="deep-dive">Deep Dive (2500-3000 words)</option>
|
||||
<option value="overview">Overview (1500-2000 words)</option>
|
||||
<option value="opinion">Opinion (1500-2000 words)</option>
|
||||
<option value="listicle">Listicle (1500-2000 words)</option>
|
||||
<option value="comparison">Comparison (2000-2500 words)</option>
|
||||
<option value="series-post">Series Post (2000-2500 words)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Additional Instructions</label>
|
||||
<textarea name="instructions" rows="3" placeholder="Optional: tone, audience, specific data to highlight, angles to take..."
|
||||
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition resize-y"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cheap" class="rounded border-slate-600 bg-slate-950 text-blue-500 focus:ring-blue-500 focus:ring-offset-0">
|
||||
<span class="text-sm text-slate-400">Use Haiku (cheaper, faster, less detailed)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn"
|
||||
class="w-full inline-flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span id="btnText">Generate Blog Post</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Error display -->
|
||||
<div id="errorBox" class="hidden mt-4 p-4 bg-red-900/30 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400" id="errorText"></p>
|
||||
</div>
|
||||
|
||||
<!-- Usage info -->
|
||||
<div id="usageBox" class="hidden mt-4 p-4 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Generation Info</h3>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-slate-400">
|
||||
<span>Model:</span><span id="usageModel" class="text-slate-300"></span>
|
||||
<span>Input tokens:</span><span id="usageIn" class="text-slate-300"></span>
|
||||
<span>Output tokens:</span><span id="usageOut" class="text-slate-300"></span>
|
||||
<span>Cost:</span><span id="usageCost" class="text-slate-300"></span>
|
||||
</div>
|
||||
<a id="viewLink" href="#" class="inline-flex items-center gap-1 mt-3 text-sm text-blue-400 hover:text-blue-300 transition">
|
||||
View saved draft →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview pane -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Preview</h2>
|
||||
<div id="previewActions" class="hidden flex items-center gap-2">
|
||||
<button onclick="copyContent()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition">Copy MD</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="previewEmpty" class="text-center py-16">
|
||||
<svg class="w-16 h-16 text-slate-700 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/></svg>
|
||||
<p class="text-slate-500 text-sm">Your generated blog post will appear here.</p>
|
||||
</div>
|
||||
|
||||
<div id="previewLoading" class="hidden text-center py-16">
|
||||
<svg class="w-10 h-10 text-purple-400 mx-auto mb-4 spinner" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25"></circle>
|
||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" class="opacity-75"></path>
|
||||
</svg>
|
||||
<p class="text-slate-400 text-sm">Generating blog post with Claude...</p>
|
||||
<p class="text-slate-500 text-xs mt-1">This may take 30-60 seconds.</p>
|
||||
</div>
|
||||
|
||||
<div id="previewContent" class="hidden">
|
||||
<div id="previewTitle" class="mb-4">
|
||||
<h1 class="text-xl font-bold text-white" id="titleText"></h1>
|
||||
<p class="text-sm text-slate-400 mt-1" id="subtitleText"></p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-2" id="tagsList"></div>
|
||||
</div>
|
||||
<div class="prose max-w-none" id="markdownPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
let rawMarkdown = '';
|
||||
|
||||
document.getElementById('generateForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const btnText = document.getElementById('btnText');
|
||||
const errorBox = document.getElementById('errorBox');
|
||||
const usageBox = document.getElementById('usageBox');
|
||||
|
||||
// Reset state
|
||||
errorBox.classList.add('hidden');
|
||||
usageBox.classList.add('hidden');
|
||||
document.getElementById('previewEmpty').classList.add('hidden');
|
||||
document.getElementById('previewContent').classList.add('hidden');
|
||||
document.getElementById('previewLoading').classList.remove('hidden');
|
||||
document.getElementById('previewActions').classList.add('hidden');
|
||||
|
||||
btn.disabled = true;
|
||||
btnText.textContent = 'Generating...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/blog/generate', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok || data.error) {
|
||||
throw new Error(data.error || 'Generation failed');
|
||||
}
|
||||
|
||||
// Show preview
|
||||
rawMarkdown = data.content;
|
||||
document.getElementById('titleText').textContent = data.title;
|
||||
document.getElementById('subtitleText').textContent = data.subtitle || '';
|
||||
|
||||
const tagsList = document.getElementById('tagsList');
|
||||
tagsList.innerHTML = '';
|
||||
(data.tags || []).forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-500 text-xs';
|
||||
span.textContent = '#' + tag;
|
||||
tagsList.appendChild(span);
|
||||
});
|
||||
|
||||
document.getElementById('markdownPreview').innerHTML = marked.parse(data.content);
|
||||
document.getElementById('previewLoading').classList.add('hidden');
|
||||
document.getElementById('previewContent').classList.remove('hidden');
|
||||
document.getElementById('previewActions').classList.remove('hidden');
|
||||
|
||||
// Usage info
|
||||
if (data.usage) {
|
||||
document.getElementById('usageModel').textContent = data.usage.model;
|
||||
document.getElementById('usageIn').textContent = data.usage.input_tokens.toLocaleString();
|
||||
document.getElementById('usageOut').textContent = data.usage.output_tokens.toLocaleString();
|
||||
document.getElementById('usageCost').textContent = '$' + data.usage.cost_usd.toFixed(4);
|
||||
usageBox.classList.remove('hidden');
|
||||
}
|
||||
|
||||
if (data.id) {
|
||||
document.getElementById('viewLink').href = '/blog/' + data.id;
|
||||
document.getElementById('viewLink').classList.remove('hidden');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('previewLoading').classList.add('hidden');
|
||||
document.getElementById('previewEmpty').classList.remove('hidden');
|
||||
document.getElementById('errorText').textContent = err.message;
|
||||
errorBox.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btnText.textContent = 'Generate Blog Post';
|
||||
}
|
||||
});
|
||||
|
||||
function copyContent() {
|
||||
navigator.clipboard.writeText(rawMarkdown).then(() => {
|
||||
const btn = event.target;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = orig, 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user