- Replace all CDN script tags (marked, plotly) with self-hosted static files - Add DOMPurify for sanitizing markdown-rendered HTML - Add escapeHtml() helper to base.html for all innerHTML operations - Sanitize dynamic data in innerHTML across 13 templates - Add security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) - Add SSRF protection to proposal intake URL fetcher (block private/loopback IPs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
191 lines
8.3 KiB
HTML
191 lines
8.3 KiB
HTML
{% extends "base.html" %}
|
|
{% set active_page = "proposals" %}
|
|
|
|
{% block title %}Proposal Intake — IETF Draft Analyzer{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
.intake-spinner {
|
|
display: inline-block;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
border: 2px solid rgba(255,255,255,0.3);
|
|
border-top-color: white;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.result-card {
|
|
animation: fadeIn 0.3s ease-out;
|
|
}
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Breadcrumb -->
|
|
<nav class="mb-6 text-sm">
|
|
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
|
|
<span class="text-slate-600 mx-2">/</span>
|
|
<span class="text-slate-400">Intake</span>
|
|
</nav>
|
|
|
|
<div class="mb-6">
|
|
<h1 class="text-2xl font-bold text-white">Proposal Intake</h1>
|
|
<p class="text-slate-400 text-sm mt-1">Paste article text, URLs, or notes below. Claude will analyze the input against all current gaps and generate structured IETF draft proposals automatically.</p>
|
|
</div>
|
|
|
|
<!-- Input form -->
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
|
<div class="mb-4">
|
|
<label for="inputText" class="block text-sm font-medium text-slate-300 mb-2">Input Material</label>
|
|
<textarea id="inputText" rows="12"
|
|
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-200 font-mono placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 resize-y"
|
|
placeholder="Paste one or more of: • Article text or paper abstract • URLs (https://arxiv.org/..., blog posts, etc.) • Your own notes or ideas • A mix of all the above URLs will be fetched automatically. The system will cross-reference everything with the 12 existing gaps and generate draft proposals."></textarea>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<div class="text-xs text-slate-500">
|
|
<span id="charCount">0</span> chars
|
|
<span id="urlCount" class="ml-3 hidden">
|
|
<span class="text-blue-400"><span id="urlNum">0</span> URL(s)</span> detected — will be fetched
|
|
</span>
|
|
</div>
|
|
<button id="submitBtn" onclick="runIntake()"
|
|
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition">
|
|
<svg id="submitIcon" 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="submitText">Generate Proposals</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div id="statusArea" class="hidden mb-6 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
|
|
<div class="flex items-center gap-3 text-sm text-blue-400">
|
|
<span class="intake-spinner"></span>
|
|
<div>
|
|
<span id="statusText">Analyzing input and generating proposals...</span>
|
|
<p class="text-xs text-blue-400/60 mt-1">This may take 30-60 seconds. Claude is reading the input, cross-referencing gaps, and writing full proposal outlines.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div id="errorArea" class="hidden mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
|
<p class="text-sm text-red-400" id="errorText"></p>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div id="resultsArea" class="hidden">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-semibold text-white">
|
|
Generated <span id="resultCount">0</span> Proposal(s)
|
|
</h2>
|
|
<a href="/proposals" class="text-sm text-blue-400 hover:text-blue-300 transition">View all proposals →</a>
|
|
</div>
|
|
<div id="resultsList" class="space-y-4"></div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
const textarea = document.getElementById('inputText');
|
|
const charCount = document.getElementById('charCount');
|
|
const urlCount = document.getElementById('urlCount');
|
|
const urlNum = document.getElementById('urlNum');
|
|
|
|
textarea.addEventListener('input', () => {
|
|
charCount.textContent = textarea.value.length;
|
|
const urls = textarea.value.match(/https?:\/\/[^\s<>"')\]]+/g) || [];
|
|
if (urls.length > 0) {
|
|
urlCount.classList.remove('hidden');
|
|
urlNum.textContent = urls.length;
|
|
} else {
|
|
urlCount.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
function runIntake() {
|
|
const input = textarea.value.trim();
|
|
if (!input) return;
|
|
|
|
const btn = document.getElementById('submitBtn');
|
|
const icon = document.getElementById('submitIcon');
|
|
const text = document.getElementById('submitText');
|
|
const status = document.getElementById('statusArea');
|
|
const error = document.getElementById('errorArea');
|
|
const results = document.getElementById('resultsArea');
|
|
|
|
btn.disabled = true;
|
|
icon.innerHTML = '';
|
|
icon.classList.add('intake-spinner');
|
|
text.textContent = 'Processing...';
|
|
status.classList.remove('hidden');
|
|
error.classList.add('hidden');
|
|
results.classList.add('hidden');
|
|
|
|
const formData = new FormData();
|
|
formData.append('input_text', input);
|
|
|
|
fetch('/proposals/intake', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
status.classList.add('hidden');
|
|
icon.classList.remove('intake-spinner');
|
|
|
|
if (data.error) {
|
|
error.classList.remove('hidden');
|
|
document.getElementById('errorText').textContent = data.error;
|
|
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
|
text.textContent = 'Retry';
|
|
btn.disabled = false;
|
|
} else {
|
|
document.getElementById('resultCount').textContent = data.count;
|
|
const list = document.getElementById('resultsList');
|
|
list.innerHTML = '';
|
|
|
|
data.proposals.forEach((p, i) => {
|
|
const gapPills = (p.gap_ids || []).map(gid =>
|
|
`<a href="/gaps/${parseInt(gid)}" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 hover:text-blue-400 transition">#${parseInt(gid)}</a>`
|
|
).join(' ');
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'result-card bg-slate-900 rounded-xl border border-green-500/30 p-5';
|
|
card.style.animationDelay = `${i * 0.1}s`;
|
|
card.innerHTML = `
|
|
<div class="flex items-start justify-between gap-3 mb-2">
|
|
<a href="/proposals/${parseInt(p.id)}" class="text-base font-semibold text-white hover:text-blue-400 transition">${escapeHtml(p.title)}</a>
|
|
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30 whitespace-nowrap">IDEA</span>
|
|
</div>
|
|
<p class="text-sm text-slate-400 mb-3">${escapeHtml(p.description)}</p>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span class="text-[10px] text-slate-500">Gaps:</span>
|
|
${gapPills || '<span class="text-[10px] text-slate-600">none linked</span>'}
|
|
</div>
|
|
`;
|
|
list.appendChild(card);
|
|
});
|
|
|
|
results.classList.remove('hidden');
|
|
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>';
|
|
text.textContent = 'Done — Generate More?';
|
|
btn.disabled = false;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
status.classList.add('hidden');
|
|
error.classList.remove('hidden');
|
|
document.getElementById('errorText').textContent = 'Network error: ' + err.message;
|
|
icon.classList.remove('intake-spinner');
|
|
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
|
|
text.textContent = 'Retry';
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %}
|