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:
225
scripts/test_all_routes.py
Normal file
225
scripts/test_all_routes.py
Normal file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test ALL web UI routes and report any returning 500 errors.
|
||||
|
||||
Runs against the Flask test client with dev=True so admin routes are accessible.
|
||||
Queries SQLite DB for real IDs needed by dynamic routes.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import sqlite3
|
||||
import traceback
|
||||
|
||||
# Must run from src/ directory
|
||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
|
||||
sys.path.insert(0, src_dir)
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'data', 'drafts.db')
|
||||
|
||||
|
||||
def get_test_ids():
|
||||
"""Query the database for real IDs to use in dynamic routes."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
ids = {}
|
||||
|
||||
# Get a draft name
|
||||
row = conn.execute("SELECT name FROM drafts LIMIT 1").fetchone()
|
||||
ids["draft_name"] = row["name"] if row else "draft-nonexistent"
|
||||
|
||||
# Get a person_id from authors
|
||||
row = conn.execute("SELECT person_id FROM authors WHERE person_id IS NOT NULL LIMIT 1").fetchone()
|
||||
ids["person_id"] = row["person_id"] if row else 1
|
||||
|
||||
# Get a gap_id
|
||||
row = conn.execute("SELECT id FROM gaps LIMIT 1").fetchone()
|
||||
ids["gap_id"] = row["id"] if row else 1
|
||||
|
||||
# Get an idea_id
|
||||
row = conn.execute("SELECT id FROM ideas LIMIT 1").fetchone()
|
||||
ids["idea_id"] = row["id"] if row else 1
|
||||
|
||||
# Get a proposal_id (may not exist)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM proposals LIMIT 1").fetchone()
|
||||
ids["proposal_id"] = row["id"] if row else None
|
||||
except Exception:
|
||||
ids["proposal_id"] = None
|
||||
|
||||
# Get a blog_draft_id (may not exist)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM blog_drafts LIMIT 1").fetchone()
|
||||
ids["blog_draft_id"] = row["id"] if row else None
|
||||
except Exception:
|
||||
ids["blog_draft_id"] = None
|
||||
|
||||
# Get two draft names for compare
|
||||
rows = conn.execute("SELECT name FROM drafts LIMIT 2").fetchall()
|
||||
ids["two_drafts"] = ",".join(r["name"] for r in rows) if len(rows) >= 2 else ""
|
||||
|
||||
conn.close()
|
||||
return ids
|
||||
|
||||
|
||||
def main():
|
||||
ids = get_test_ids()
|
||||
print(f"Test IDs: draft={ids['draft_name']}, person={ids['person_id']}, "
|
||||
f"gap={ids['gap_id']}, idea={ids['idea_id']}, "
|
||||
f"proposal={ids['proposal_id']}, blog={ids['blog_draft_id']}")
|
||||
print()
|
||||
|
||||
from webui.app import create_app
|
||||
app = create_app(dev=True)
|
||||
client = app.test_client()
|
||||
|
||||
# Define all routes to test: (method, url, description)
|
||||
routes = [
|
||||
# === Pages (public) ===
|
||||
("GET", "/", "overview"),
|
||||
("GET", "/drafts", "drafts list"),
|
||||
("GET", f"/drafts/{ids['draft_name']}", "draft detail"),
|
||||
("GET", "/ideas", "ideas"),
|
||||
("GET", "/ratings", "ratings"),
|
||||
("GET", "/timeline", "timeline"),
|
||||
("GET", "/idea-clusters", "idea clusters"),
|
||||
("GET", "/architecture", "architecture"),
|
||||
("GET", f"/authors/{ids['person_id']}", "author detail"),
|
||||
("GET", "/authors", "authors"),
|
||||
("GET", "/citations", "citations"),
|
||||
("GET", "/about", "about"),
|
||||
("GET", "/impressum", "impressum"),
|
||||
("GET", "/datenschutz", "datenschutz"),
|
||||
("GET", "/search", "search (empty)"),
|
||||
("GET", "/search?q=agent", "search (query)"),
|
||||
("GET", "/ask", "ask (empty)"),
|
||||
|
||||
# === API (public) ===
|
||||
("GET", "/api/drafts", "api drafts"),
|
||||
("GET", "/api/stats", "api stats"),
|
||||
("GET", "/api/authors/network", "api author network"),
|
||||
("GET", "/api/citations", "api citations"),
|
||||
("GET", "/api/search?q=agent", "api search"),
|
||||
("GET", "/api/ideas", "api ideas"),
|
||||
("GET", "/api/ratings", "api ratings"),
|
||||
("GET", "/api/timeline", "api timeline"),
|
||||
("GET", "/api/idea-clusters", "api idea clusters"),
|
||||
("GET", "/api/categories", "api categories"),
|
||||
("GET", f"/api/drafts/{ids['draft_name']}", "api draft detail"),
|
||||
("GET", "/api/architecture", "api architecture"),
|
||||
|
||||
# === Admin pages ===
|
||||
("GET", "/gaps", "gaps"),
|
||||
("GET", "/gaps/demo", "gaps demo"),
|
||||
("GET", f"/gaps/{ids['gap_id']}", "gap detail"),
|
||||
("GET", "/api/gaps", "api gaps"),
|
||||
("GET", f"/api/gaps/{ids['gap_id']}", "api gap detail"),
|
||||
("GET", "/monitor", "monitor"),
|
||||
("GET", "/api/monitor", "api monitor"),
|
||||
("GET", "/admin/analytics", "analytics"),
|
||||
("GET", "/landscape", "landscape"),
|
||||
("GET", "/api/landscape", "api landscape"),
|
||||
("GET", "/similarity", "similarity"),
|
||||
("GET", "/api/similarity", "api similarity"),
|
||||
("GET", "/compare", "compare (empty)"),
|
||||
("GET", f"/compare?drafts={ids['two_drafts']}", "compare (with drafts)"),
|
||||
("GET", "/sources", "sources"),
|
||||
("GET", "/false-positives", "false positives"),
|
||||
("GET", "/api/sources", "api sources"),
|
||||
("GET", "/api/false-positives", "api false positives"),
|
||||
("GET", "/api/citations/influence", "api citation influence"),
|
||||
("GET", "/api/citations/bcp", "api bcp analysis"),
|
||||
("GET", "/idea-analysis", "idea analysis"),
|
||||
("GET", "/api/idea-analysis", "api idea analysis"),
|
||||
("GET", f"/ideas/{ids['idea_id']}", "idea detail"),
|
||||
("GET", "/trends", "trends"),
|
||||
("GET", "/complexity", "complexity"),
|
||||
("GET", "/api/trends", "api trends"),
|
||||
("GET", "/api/complexity", "api complexity"),
|
||||
("GET", "/proposals", "proposals"),
|
||||
("GET", "/proposals/new", "proposal new (GET)"),
|
||||
("GET", "/api/proposals", "api proposals"),
|
||||
("GET", "/proposals/intake", "proposal intake (GET)"),
|
||||
("GET", "/blog", "blog drafts"),
|
||||
("GET", "/blog/generate", "blog generate (GET)"),
|
||||
("GET", "/export/obsidian", "obsidian export"),
|
||||
]
|
||||
|
||||
# Add conditional routes that need existing records
|
||||
if ids["proposal_id"]:
|
||||
routes.extend([
|
||||
("GET", f"/proposals/{ids['proposal_id']}", "proposal detail"),
|
||||
("GET", f"/proposals/{ids['proposal_id']}/edit", "proposal edit (GET)"),
|
||||
("GET", f"/api/proposals/{ids['proposal_id']}", "api proposal detail"),
|
||||
])
|
||||
|
||||
if ids["blog_draft_id"]:
|
||||
routes.extend([
|
||||
("GET", f"/blog/{ids['blog_draft_id']}", "blog detail"),
|
||||
("GET", f"/blog/{ids['blog_draft_id']}/edit", "blog edit (GET)"),
|
||||
("GET", f"/blog/{ids['blog_draft_id']}/export", "blog export"),
|
||||
])
|
||||
|
||||
# Run tests
|
||||
results = {"ok": [], "error_500": [], "error_other": [], "exception": []}
|
||||
|
||||
for method, url, desc in routes:
|
||||
try:
|
||||
if method == "GET":
|
||||
resp = client.get(url)
|
||||
elif method == "POST":
|
||||
resp = client.post(url, json={})
|
||||
else:
|
||||
continue
|
||||
|
||||
status = resp.status_code
|
||||
if status == 500:
|
||||
# Try to extract error from response
|
||||
error_text = resp.data.decode("utf-8", errors="replace")[:500]
|
||||
results["error_500"].append((desc, url, status, error_text))
|
||||
print(f" FAIL 500 {desc:30s} {url}")
|
||||
elif status >= 400:
|
||||
results["error_other"].append((desc, url, status))
|
||||
print(f" WARN {status} {desc:30s} {url}")
|
||||
else:
|
||||
results["ok"].append((desc, url, status))
|
||||
print(f" OK {status} {desc:30s} {url}")
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
results["exception"].append((desc, url, str(e), tb))
|
||||
print(f" EXCP {desc:30s} {url} -> {e}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print(f"SUMMARY: {len(results['ok'])} OK, {len(results['error_500'])} x 500, "
|
||||
f"{len(results['error_other'])} x 4xx, {len(results['exception'])} exceptions")
|
||||
print("=" * 70)
|
||||
|
||||
if results["error_500"]:
|
||||
print("\n--- 500 ERRORS (BROKEN ROUTES) ---")
|
||||
for desc, url, status, error_text in results["error_500"]:
|
||||
print(f"\n Route: {desc}")
|
||||
print(f" URL: {url}")
|
||||
print(f" Error preview:")
|
||||
# Show just enough of the error
|
||||
for line in error_text.split("\n")[:10]:
|
||||
print(f" {line}")
|
||||
|
||||
if results["exception"]:
|
||||
print("\n--- EXCEPTIONS (APP CRASHED) ---")
|
||||
for desc, url, err, tb in results["exception"]:
|
||||
print(f"\n Route: {desc}")
|
||||
print(f" URL: {url}")
|
||||
print(f" Exception: {err}")
|
||||
# Show last few lines of traceback
|
||||
tb_lines = tb.strip().split("\n")
|
||||
for line in tb_lines[-5:]:
|
||||
print(f" {line}")
|
||||
|
||||
if results["error_other"]:
|
||||
print("\n--- 4xx RESPONSES (may be expected) ---")
|
||||
for desc, url, status in results["error_other"]:
|
||||
print(f" {status} {desc:30s} {url}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
319
src/ietf_analyzer/blog_drafts.py
Normal file
319
src/ietf_analyzer/blog_drafts.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Blog Draft Generator.
|
||||
|
||||
Generates Medium-style blog posts from project data: gaps, proposals,
|
||||
drafts, stats, and the existing blog series narrative arc.
|
||||
|
||||
Usage:
|
||||
from ietf_analyzer.blog_drafts import BlogDraftGenerator
|
||||
gen = BlogDraftGenerator(config, db)
|
||||
result = gen.generate(topic="all analysis results", style="deep-dive")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
|
||||
load_dotenv()
|
||||
|
||||
import anthropic
|
||||
from rich.console import Console
|
||||
|
||||
from .config import Config
|
||||
from .db import Database
|
||||
|
||||
console = Console()
|
||||
|
||||
BLOG_SYSTEM_PROMPT = """\
|
||||
You are an expert technical writer producing Medium-style blog posts about IETF \
|
||||
AI agent standardization. You write for a technical but non-specialist audience — \
|
||||
engineers, product managers, and policy people who care about AI agent \
|
||||
interoperability and safety.
|
||||
|
||||
Your writing style:
|
||||
- Clear, opinionated, data-driven
|
||||
- Use specific numbers and concrete examples (from the data provided)
|
||||
- Short paragraphs, subheadings, occasional bold for emphasis
|
||||
- Open with a hook that makes the reader care
|
||||
- Include a "So what?" — why this matters for practitioners
|
||||
- End with a forward-looking conclusion or call to action
|
||||
- Tone: informed insider, not academic. Think Stratechery meets IETF.
|
||||
- Use markdown formatting (## headings, **bold**, bullet lists, > blockquotes)
|
||||
- Target length: 1500-3000 words depending on topic depth
|
||||
- Include a suggested title and subtitle
|
||||
|
||||
Output the blog post in markdown. Start with a YAML front matter block:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "The Title"
|
||||
subtitle: "A compelling subtitle"
|
||||
tags: [ietf, ai-agents, standards, ...]
|
||||
estimated_reading_time: "X min read"
|
||||
---
|
||||
```
|
||||
|
||||
Then the full post body in markdown."""
|
||||
|
||||
|
||||
CONTEXT_TEMPLATE = """\
|
||||
## Project Data Context
|
||||
|
||||
You have access to the following data from our IETF AI Agent Standards analysis \
|
||||
project (data as of March 2026):
|
||||
|
||||
### Corpus Overview
|
||||
{overview}
|
||||
|
||||
### Gap Analysis ({gap_count} gaps identified)
|
||||
{gaps}
|
||||
|
||||
### Draft Proposals ({proposal_count} proposals)
|
||||
{proposals}
|
||||
|
||||
### Blog Series Context
|
||||
The analysis is part of a planned 8-post blog series with the thesis:
|
||||
"The IETF's AI agent standardization effort is the largest, fastest-growing, \
|
||||
and most consequential standards race in a decade — but it is building the \
|
||||
highways before the traffic lights."
|
||||
|
||||
Existing posts in the series:
|
||||
{blog_series}
|
||||
|
||||
---
|
||||
|
||||
## User's Request
|
||||
|
||||
**Topic**: {topic}
|
||||
**Style**: {style}
|
||||
**Additional instructions**: {instructions}
|
||||
|
||||
Write the blog post now. Use the data above to support your arguments with \
|
||||
specific numbers and examples. Do not invent statistics — only use what is \
|
||||
provided in the context."""
|
||||
|
||||
|
||||
STYLES = {
|
||||
"deep-dive": "Long-form deep dive (2500-3000 words). Thorough analysis with multiple sections.",
|
||||
"overview": "High-level overview (1500-2000 words). Accessible intro to a topic.",
|
||||
"opinion": "Opinionated take (1500-2000 words). Strong thesis, backed by data.",
|
||||
"listicle": "Structured list format (1500-2000 words). 'N things you should know about X'.",
|
||||
"comparison": "Comparative analysis (2000-2500 words). Side-by-side evaluation of approaches.",
|
||||
"series-post": "Part of the planned blog series (2000-2500 words). Fits the narrative arc.",
|
||||
}
|
||||
|
||||
|
||||
class BlogDraftGenerator:
|
||||
"""Generate blog post drafts from project data."""
|
||||
|
||||
def __init__(self, config: Config, db: Database):
|
||||
self.config = config
|
||||
self.db = db
|
||||
try:
|
||||
self.client = anthropic.Anthropic()
|
||||
except Exception:
|
||||
console.print(
|
||||
"[red bold]No Anthropic API key found.[/]\n"
|
||||
"Set ANTHROPIC_API_KEY environment variable or add to .env"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
def _gather_overview(self) -> str:
|
||||
"""Gather high-level corpus statistics."""
|
||||
total_drafts = self.db.conn.execute("SELECT count(*) FROM drafts").fetchone()[0]
|
||||
rated = self.db.conn.execute("SELECT count(*) FROM ratings WHERE false_positive = 0").fetchone()[0]
|
||||
authors = self.db.conn.execute("SELECT count(*) FROM authors").fetchone()[0]
|
||||
ideas = self.db.conn.execute("SELECT count(*) FROM ideas").fetchone()[0]
|
||||
gaps = self.db.conn.execute("SELECT count(*) FROM gaps").fetchone()[0]
|
||||
proposals = self.db.conn.execute("SELECT count(*) FROM proposals").fetchone()[0]
|
||||
|
||||
# Category breakdown
|
||||
cat_rows = self.db.conn.execute(
|
||||
"SELECT categories FROM ratings WHERE false_positive = 0"
|
||||
).fetchall()
|
||||
from collections import Counter
|
||||
cat_counts = Counter()
|
||||
for row in cat_rows:
|
||||
try:
|
||||
for cat in json.loads(row[0] or "[]"):
|
||||
cat_counts[cat] += 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
top_cats = cat_counts.most_common(10)
|
||||
cat_str = ", ".join(f"{c} ({n})" for c, n in top_cats)
|
||||
|
||||
# Avg scores
|
||||
avg_row = self.db.conn.execute(
|
||||
"SELECT avg(novelty), avg(maturity), avg(relevance), avg(momentum), avg(overlap) "
|
||||
"FROM ratings WHERE false_positive = 0"
|
||||
).fetchone()
|
||||
|
||||
# Source breakdown
|
||||
src_rows = self.db.conn.execute(
|
||||
"SELECT source, count(*) FROM drafts GROUP BY source ORDER BY count(*) DESC"
|
||||
).fetchall()
|
||||
src_str = ", ".join(f"{r[0]}: {r[1]}" for r in src_rows)
|
||||
|
||||
return (
|
||||
f"- **{total_drafts} drafts** tracked ({rated} rated, rest pending)\n"
|
||||
f"- **{authors} authors**, **{ideas} ideas** extracted, **{gaps} gaps** identified\n"
|
||||
f"- **{proposals} draft proposals** in development\n"
|
||||
f"- Sources: {src_str}\n"
|
||||
f"- Top categories: {cat_str}\n"
|
||||
f"- Avg scores (1-5): novelty {avg_row[0]:.1f}, maturity {avg_row[1]:.1f}, "
|
||||
f"relevance {avg_row[2]:.1f}, momentum {avg_row[3]:.1f}, overlap {avg_row[4]:.1f}"
|
||||
)
|
||||
|
||||
def _gather_gaps(self) -> str:
|
||||
"""Gather gap data as context."""
|
||||
gaps = self.db.all_gaps()
|
||||
lines = []
|
||||
for g in gaps:
|
||||
lines.append(
|
||||
f"- **#{g['id']} [{g['severity'].upper()}]** {g['topic']} ({g['category']}): "
|
||||
f"{g['description'][:200]}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _gather_proposals(self) -> str:
|
||||
"""Gather proposal summaries as context."""
|
||||
proposals = self.db.all_proposals()
|
||||
if not proposals:
|
||||
return "(No proposals yet)"
|
||||
lines = []
|
||||
for p in proposals:
|
||||
gap_refs = ", ".join(f"#{gid}" for gid in p.get("gap_ids", []))
|
||||
lines.append(
|
||||
f"- **{p['title']}** [{p['status']}] (gaps: {gap_refs or 'none'}): "
|
||||
f"{p.get('description', '')[:200]}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _gather_blog_series(self) -> str:
|
||||
"""Gather existing blog series info."""
|
||||
blog_dir = Path(self.config.data_dir) / "reports" / "blog-series"
|
||||
if not blog_dir.exists():
|
||||
return "(No blog series found)"
|
||||
lines = []
|
||||
for f in sorted(blog_dir.glob("*.md")):
|
||||
if f.name == "data" or f.is_dir():
|
||||
continue
|
||||
# Read first few lines for title
|
||||
content = f.read_text(errors="replace")
|
||||
title_line = ""
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("# "):
|
||||
title_line = line[2:].strip()
|
||||
break
|
||||
lines.append(f"- `{f.name}`: {title_line or f.stem}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_prompt(self, topic: str, style: str = "deep-dive",
|
||||
instructions: str = "") -> str:
|
||||
"""Build the full prompt with project data context."""
|
||||
overview = self._gather_overview()
|
||||
gaps_text = self._gather_gaps()
|
||||
proposals_text = self._gather_proposals()
|
||||
blog_series = self._gather_blog_series()
|
||||
|
||||
gap_count = len(self.db.all_gaps())
|
||||
proposal_count = self.db.conn.execute("SELECT count(*) FROM proposals").fetchone()[0]
|
||||
|
||||
style_desc = STYLES.get(style, STYLES["deep-dive"])
|
||||
|
||||
return CONTEXT_TEMPLATE.format(
|
||||
overview=overview,
|
||||
gap_count=gap_count,
|
||||
gaps=gaps_text,
|
||||
proposal_count=proposal_count,
|
||||
proposals=proposals_text,
|
||||
blog_series=blog_series,
|
||||
topic=topic,
|
||||
style=f"{style} — {style_desc}",
|
||||
instructions=instructions or "(none)",
|
||||
)
|
||||
|
||||
def generate(self, topic: str, style: str = "deep-dive",
|
||||
instructions: str = "", cheap: bool = False) -> dict:
|
||||
"""Generate a blog post draft.
|
||||
|
||||
Returns dict with keys: title, subtitle, content, tags, usage.
|
||||
"""
|
||||
console.print("[bold]Blog Draft Generator[/]")
|
||||
console.print(f" Topic: {topic}")
|
||||
console.print(f" Style: {style}")
|
||||
|
||||
prompt = self.build_prompt(topic, style, instructions)
|
||||
model = self.config.claude_model_cheap if cheap else self.config.claude_model
|
||||
console.print(f" Calling Claude ({model})...")
|
||||
|
||||
resp = self.client.messages.create(
|
||||
model=model,
|
||||
max_tokens=16000,
|
||||
system=BLOG_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
content = resp.content[0].text.strip()
|
||||
in_tok = resp.usage.input_tokens
|
||||
out_tok = resp.usage.output_tokens
|
||||
|
||||
if "haiku" in model:
|
||||
cost = (in_tok * 0.8 + out_tok * 4) / 1_000_000
|
||||
else:
|
||||
cost = (in_tok * 3 + out_tok * 15) / 1_000_000
|
||||
|
||||
console.print(
|
||||
f" Tokens: {in_tok:,} in / {out_tok:,} out (~${cost:.3f})"
|
||||
)
|
||||
|
||||
# Parse front matter if present
|
||||
title = topic
|
||||
subtitle = ""
|
||||
tags = []
|
||||
body = content
|
||||
|
||||
if content.startswith("---") or content.startswith("```yaml"):
|
||||
# Try to extract YAML front matter
|
||||
try:
|
||||
if content.startswith("```yaml"):
|
||||
fm_block = content.split("```")[1].replace("yaml\n", "", 1)
|
||||
body = "```".join(content.split("```")[2:]).strip()
|
||||
else:
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm_block = parts[1]
|
||||
body = parts[2].strip()
|
||||
|
||||
import re
|
||||
title_match = re.search(r'title:\s*"(.+?)"', fm_block)
|
||||
sub_match = re.search(r'subtitle:\s*"(.+?)"', fm_block)
|
||||
tag_match = re.search(r'tags:\s*\[(.+?)\]', fm_block)
|
||||
if title_match:
|
||||
title = title_match.group(1)
|
||||
if sub_match:
|
||||
subtitle = sub_match.group(1)
|
||||
if tag_match:
|
||||
tags = [t.strip().strip('"').strip("'")
|
||||
for t in tag_match.group(1).split(",")]
|
||||
except Exception:
|
||||
pass # Fall through with raw content
|
||||
|
||||
usage = {
|
||||
"model": model,
|
||||
"input_tokens": in_tok,
|
||||
"output_tokens": out_tok,
|
||||
"cost_usd": round(cost, 4),
|
||||
}
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"tags": tags,
|
||||
"content": body,
|
||||
"full_markdown": content,
|
||||
"usage": usage,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -218,6 +218,26 @@ CREATE TABLE IF NOT EXISTS proposal_gaps (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_gaps_gap ON proposal_gaps(gap_id);
|
||||
|
||||
-- Blog drafts (generated or manually written)
|
||||
CREATE TABLE IF NOT EXISTS blog_drafts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT DEFAULT '',
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
status TEXT DEFAULT 'draft',
|
||||
style TEXT DEFAULT 'deep-dive',
|
||||
tags TEXT DEFAULT '[]',
|
||||
topic TEXT DEFAULT '',
|
||||
instructions TEXT DEFAULT '',
|
||||
content_md TEXT DEFAULT '',
|
||||
model_used TEXT DEFAULT '',
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
-- Annotations (user notes + tags per draft)
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1169,6 +1189,62 @@ class Database:
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# --- Blog Drafts ---
|
||||
|
||||
def all_blog_drafts(self) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM blog_drafts ORDER BY updated_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_blog_draft(self, draft_id: int) -> dict | None:
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM blog_drafts WHERE id = ?", (draft_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def upsert_blog_draft(self, draft: dict) -> int:
|
||||
"""Insert or update a blog draft. Returns the draft ID."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if draft.get("id"):
|
||||
self.conn.execute(
|
||||
"""UPDATE blog_drafts SET title=?, subtitle=?, slug=?, status=?,
|
||||
style=?, tags=?, topic=?, instructions=?, content_md=?,
|
||||
model_used=?, input_tokens=?, output_tokens=?, cost_usd=?,
|
||||
updated_at=?
|
||||
WHERE id=?""",
|
||||
(draft["title"], draft.get("subtitle", ""), draft["slug"],
|
||||
draft.get("status", "draft"), draft.get("style", "deep-dive"),
|
||||
json.dumps(draft.get("tags", [])), draft.get("topic", ""),
|
||||
draft.get("instructions", ""), draft.get("content_md", ""),
|
||||
draft.get("model_used", ""), draft.get("input_tokens", 0),
|
||||
draft.get("output_tokens", 0), draft.get("cost_usd", 0),
|
||||
now, draft["id"]),
|
||||
)
|
||||
self.conn.commit()
|
||||
return draft["id"]
|
||||
else:
|
||||
cur = self.conn.execute(
|
||||
"""INSERT INTO blog_drafts (title, subtitle, slug, status, style,
|
||||
tags, topic, instructions, content_md, model_used,
|
||||
input_tokens, output_tokens, cost_usd, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(draft["title"], draft.get("subtitle", ""), draft["slug"],
|
||||
draft.get("status", "draft"), draft.get("style", "deep-dive"),
|
||||
json.dumps(draft.get("tags", [])), draft.get("topic", ""),
|
||||
draft.get("instructions", ""), draft.get("content_md", ""),
|
||||
draft.get("model_used", ""), draft.get("input_tokens", 0),
|
||||
draft.get("output_tokens", 0), draft.get("cost_usd", 0),
|
||||
now, now),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def delete_blog_draft(self, draft_id: int) -> bool:
|
||||
cur = self.conn.execute("DELETE FROM blog_drafts WHERE id = ?", (draft_id,))
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
# --- Refs ---
|
||||
|
||||
def insert_refs(self, draft_name: str, refs: list[tuple[str, str]]) -> None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import functools
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify, abort, g, Response, redirect, url_for
|
||||
@@ -37,6 +38,8 @@ from webui.data import (
|
||||
get_all_proposals,
|
||||
get_proposal_detail,
|
||||
get_proposals_for_gap,
|
||||
get_all_blog_drafts,
|
||||
get_blog_draft_detail,
|
||||
)
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
@@ -563,6 +566,145 @@ def proposal_intake():
|
||||
return render_template("proposal_intake.html")
|
||||
|
||||
|
||||
# ── Blog Drafts (dev-only) ──────────────────────────────────────────────
|
||||
|
||||
@admin_bp.route("/blog")
|
||||
@admin_required
|
||||
def blog_drafts():
|
||||
drafts = get_all_blog_drafts(db())
|
||||
return render_template("blog_drafts.html", drafts=drafts)
|
||||
|
||||
|
||||
@admin_bp.route("/blog/generate", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def blog_generate():
|
||||
"""Generate a blog post from project data."""
|
||||
if request.method == "POST":
|
||||
topic = request.form.get("topic", "").strip()
|
||||
style = request.form.get("style", "deep-dive")
|
||||
instructions = request.form.get("instructions", "").strip()
|
||||
cheap = request.form.get("cheap") == "on"
|
||||
|
||||
if not topic:
|
||||
return jsonify({"error": "No topic provided"}), 400
|
||||
|
||||
try:
|
||||
from ietf_analyzer.config import Config
|
||||
from ietf_analyzer.blog_drafts import BlogDraftGenerator
|
||||
|
||||
cfg = Config.load()
|
||||
gen = BlogDraftGenerator(cfg, db())
|
||||
result = gen.generate(topic=topic, style=style,
|
||||
instructions=instructions, cheap=cheap)
|
||||
|
||||
# Auto-save to DB
|
||||
import re
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', result["title"].lower()).strip('-')[:80]
|
||||
# Ensure unique slug
|
||||
existing = db().conn.execute(
|
||||
"SELECT count(*) FROM blog_drafts WHERE slug = ?", (slug,)
|
||||
).fetchone()[0]
|
||||
if existing:
|
||||
slug = f"{slug}-{int(datetime.now(timezone.utc).timestamp()) % 10000}"
|
||||
|
||||
draft_id = db().upsert_blog_draft({
|
||||
"title": result["title"],
|
||||
"subtitle": result.get("subtitle", ""),
|
||||
"slug": slug,
|
||||
"status": "draft",
|
||||
"style": style,
|
||||
"tags": result.get("tags", []),
|
||||
"topic": topic,
|
||||
"instructions": instructions,
|
||||
"content_md": result["full_markdown"],
|
||||
"model_used": result["usage"]["model"],
|
||||
"input_tokens": result["usage"]["input_tokens"],
|
||||
"output_tokens": result["usage"]["output_tokens"],
|
||||
"cost_usd": result["usage"]["cost_usd"],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"id": draft_id,
|
||||
"title": result["title"],
|
||||
"subtitle": result.get("subtitle", ""),
|
||||
"content": result["full_markdown"],
|
||||
"tags": result.get("tags", []),
|
||||
"usage": result["usage"],
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
return render_template("blog_generate.html")
|
||||
|
||||
|
||||
@admin_bp.route("/blog/<int:draft_id>")
|
||||
@admin_required
|
||||
def blog_detail(draft_id):
|
||||
draft = get_blog_draft_detail(db(), draft_id)
|
||||
if not draft:
|
||||
abort(404)
|
||||
return render_template("blog_detail.html", draft=draft)
|
||||
|
||||
|
||||
@admin_bp.route("/blog/<int:draft_id>/edit", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def blog_edit(draft_id):
|
||||
if request.method == "POST":
|
||||
data = request.form
|
||||
import re
|
||||
slug = data.get("slug", "").strip()
|
||||
if not slug:
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', data["title"].lower()).strip('-')[:80]
|
||||
tags = [t.strip() for t in data.get("tags_str", "").split(",") if t.strip()]
|
||||
db().upsert_blog_draft({
|
||||
"id": draft_id,
|
||||
"title": data["title"],
|
||||
"subtitle": data.get("subtitle", ""),
|
||||
"slug": slug,
|
||||
"status": data.get("status", "draft"),
|
||||
"style": data.get("style", "deep-dive"),
|
||||
"tags": tags,
|
||||
"topic": data.get("topic", ""),
|
||||
"instructions": data.get("instructions", ""),
|
||||
"content_md": data.get("content_md", ""),
|
||||
"model_used": data.get("model_used", ""),
|
||||
"input_tokens": int(data.get("input_tokens", 0) or 0),
|
||||
"output_tokens": int(data.get("output_tokens", 0) or 0),
|
||||
"cost_usd": float(data.get("cost_usd", 0) or 0),
|
||||
})
|
||||
return redirect(url_for("admin.blog_detail", draft_id=draft_id))
|
||||
|
||||
draft = get_blog_draft_detail(db(), draft_id)
|
||||
if not draft:
|
||||
abort(404)
|
||||
return render_template("blog_edit.html", draft=draft)
|
||||
|
||||
|
||||
@admin_bp.route("/blog/<int:draft_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def blog_delete(draft_id):
|
||||
db().delete_blog_draft(draft_id)
|
||||
return redirect(url_for("admin.blog_drafts"))
|
||||
|
||||
|
||||
@admin_bp.route("/blog/<int:draft_id>/export")
|
||||
@admin_required
|
||||
def blog_export(draft_id):
|
||||
"""Download a blog draft as markdown file."""
|
||||
draft = get_blog_draft_detail(db(), draft_id)
|
||||
if not draft:
|
||||
abort(404)
|
||||
filename = f"{draft['slug']}.md"
|
||||
return Response(
|
||||
draft["content_md"],
|
||||
mimetype="text/markdown",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
# ── Obsidian Export ──────────────────────────────────────────────────────
|
||||
|
||||
@admin_bp.route("/export/obsidian")
|
||||
|
||||
@@ -98,3 +98,9 @@ from webui.data.proposals import ( # noqa: F401
|
||||
get_proposal_detail,
|
||||
get_proposals_for_gap,
|
||||
)
|
||||
|
||||
# Blog Drafts
|
||||
from webui.data.blog import ( # noqa: F401
|
||||
get_all_blog_drafts,
|
||||
get_blog_draft_detail,
|
||||
)
|
||||
|
||||
@@ -117,7 +117,7 @@ def get_author_detail(db: Database, person_id: int) -> dict | None:
|
||||
"name": d.name,
|
||||
"title": d.title,
|
||||
"date": d.date,
|
||||
"status": d.status,
|
||||
"states": d.states,
|
||||
"categories": r.categories if r else [],
|
||||
"score": round(r.composite_score, 2) if r else None,
|
||||
"novelty": r.novelty if r else None,
|
||||
|
||||
30
src/webui/data/blog.py
Normal file
30
src/webui/data/blog.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Blog draft data access for the web dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from ietf_analyzer.db import Database
|
||||
|
||||
|
||||
def get_all_blog_drafts(db: Database) -> list[dict]:
|
||||
"""Return all blog drafts, newest first."""
|
||||
drafts = db.all_blog_drafts()
|
||||
for d in drafts:
|
||||
try:
|
||||
d["tags"] = json.loads(d.get("tags") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d["tags"] = []
|
||||
return drafts
|
||||
|
||||
|
||||
def get_blog_draft_detail(db: Database, draft_id: int) -> dict | None:
|
||||
"""Return a single blog draft with parsed tags."""
|
||||
d = db.get_blog_draft(draft_id)
|
||||
if not d:
|
||||
return None
|
||||
try:
|
||||
d["tags"] = json.loads(d.get("tags") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d["tags"] = []
|
||||
return d
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from ietf_analyzer.db import Database
|
||||
|
||||
@@ -136,6 +136,10 @@
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Proposals
|
||||
</a>
|
||||
<a href="/blog" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'blog' }}">
|
||||
<svg class="w-4 h-4 opacity-60" 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>
|
||||
Blog Drafts
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/timeline" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'timeline' }}">
|
||||
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
|
||||
170
src/webui/templates/blog_detail.html
Normal file
170
src/webui/templates/blog_detail.html
Normal file
@@ -0,0 +1,170 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "blog" %}
|
||||
|
||||
{% block title %}{{ draft.title }} — Blog Drafts{% 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; }
|
||||
</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">{{ draft.title[:60] }}{% if draft.title | length > 60 %}...{% endif %}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">{{ draft.title }}</h1>
|
||||
{% if draft.subtitle %}
|
||||
<p class="text-base text-slate-400 mt-1">{{ draft.subtitle }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap
|
||||
{% if draft.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
|
||||
{% elif draft.status == 'review' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
|
||||
{% elif draft.status == 'published' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
|
||||
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
|
||||
{{ draft.status | upper }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{% if draft.style %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Style</h3>
|
||||
<p class="text-sm text-slate-300">{{ draft.style }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if draft.topic %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Topic</h3>
|
||||
<p class="text-sm text-slate-300">{{ draft.topic[:80] }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if draft.model_used %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Model</h3>
|
||||
<p class="text-sm text-slate-300">{{ draft.model_used }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Cost</h3>
|
||||
<p class="text-sm text-slate-300">
|
||||
{% if draft.cost_usd %}${{ "%.4f" | format(draft.cost_usd) }}{% else %}N/A{% endif %}
|
||||
{% if draft.input_tokens %}
|
||||
<span class="text-slate-500 text-xs">({{ "{:,}".format(draft.input_tokens) }} in / {{ "{:,}".format(draft.output_tokens) }} out)</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if draft.tags %}
|
||||
<div class="flex flex-wrap gap-1.5 mb-4">
|
||||
{% for tag in draft.tags %}
|
||||
<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400 text-xs">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-xs text-slate-500 mb-4">
|
||||
Created: {{ draft.created_at[:10] if draft.created_at else 'N/A' }}
|
||||
· Updated: {{ draft.updated_at[:10] if draft.updated_at else 'N/A' }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-slate-800/50">
|
||||
<a href="/blog/{{ draft.id }}/edit" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Edit
|
||||
</a>
|
||||
<a href="/blog/{{ draft.id }}/export" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium 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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
Export .md
|
||||
</a>
|
||||
<button onclick="copyMarkdown()" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
<span id="copyBtn">Copy</span>
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<form method="POST" action="/blog/{{ draft.id }}/delete" onsubmit="return confirm('Delete this blog draft? This cannot be undone.');" class="inline">
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm font-medium rounded-lg transition ring-1 ring-red-500/20">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<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">Content</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="toggleView('rendered')" id="viewRendered" class="px-3 py-1.5 bg-slate-800 text-slate-300 text-xs font-medium rounded-lg transition">Rendered</button>
|
||||
<button onclick="toggleView('source')" id="viewSource" class="px-3 py-1.5 bg-slate-950 text-slate-500 text-xs font-medium rounded-lg transition">Source</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="renderedView" class="prose max-w-none"></div>
|
||||
<div id="sourceView" class="hidden">
|
||||
<pre class="text-sm text-slate-300 font-mono leading-relaxed whitespace-pre-wrap bg-slate-950 rounded-lg border border-slate-800 p-4">{{ draft.content_md }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
const rawMd = {{ draft.content_md | tojson }};
|
||||
document.getElementById('renderedView').innerHTML = marked.parse(rawMd);
|
||||
|
||||
function toggleView(view) {
|
||||
const rendered = document.getElementById('renderedView');
|
||||
const source = document.getElementById('sourceView');
|
||||
const btnRendered = document.getElementById('viewRendered');
|
||||
const btnSource = document.getElementById('viewSource');
|
||||
|
||||
if (view === 'source') {
|
||||
rendered.classList.add('hidden');
|
||||
source.classList.remove('hidden');
|
||||
btnSource.className = 'px-3 py-1.5 bg-slate-800 text-slate-300 text-xs font-medium rounded-lg transition';
|
||||
btnRendered.className = 'px-3 py-1.5 bg-slate-950 text-slate-500 text-xs font-medium rounded-lg transition';
|
||||
} else {
|
||||
source.classList.add('hidden');
|
||||
rendered.classList.remove('hidden');
|
||||
btnRendered.className = 'px-3 py-1.5 bg-slate-800 text-slate-300 text-xs font-medium rounded-lg transition';
|
||||
btnSource.className = 'px-3 py-1.5 bg-slate-950 text-slate-500 text-xs font-medium rounded-lg transition';
|
||||
}
|
||||
}
|
||||
|
||||
function copyMarkdown() {
|
||||
navigator.clipboard.writeText(rawMd).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
138
src/webui/templates/blog_drafts.html
Normal file
138
src/webui/templates/blog_drafts.html
Normal file
@@ -0,0 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "blog" %}
|
||||
|
||||
{% block title %}Blog Drafts — IETF Draft Analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Blog Drafts</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">{{ drafts | length }} blog draft{{ 's' if drafts | length != 1 }}. Generate Medium-style posts from project data.</p>
|
||||
</div>
|
||||
<a href="/blog/generate" class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition shrink-0">
|
||||
<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>
|
||||
Generate New
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status filter pills -->
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<button onclick="filterStatus('all')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-slate-800 text-slate-300 ring-1 ring-slate-700" data-status="all">
|
||||
All ({{ drafts | length }})
|
||||
</button>
|
||||
{% set ns = namespace(draft=0, review=0, published=0, archived=0) %}
|
||||
{% for d in drafts %}
|
||||
{% if d.status == 'draft' %}{% set ns.draft = ns.draft + 1 %}
|
||||
{% elif d.status == 'review' %}{% set ns.review = ns.review + 1 %}
|
||||
{% elif d.status == 'published' %}{% set ns.published = ns.published + 1 %}
|
||||
{% elif d.status == 'archived' %}{% set ns.archived = ns.archived + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button onclick="filterStatus('draft')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30" data-status="draft">
|
||||
Draft ({{ ns.draft }})
|
||||
</button>
|
||||
<button onclick="filterStatus('review')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30" data-status="review">
|
||||
Review ({{ ns.review }})
|
||||
</button>
|
||||
<button onclick="filterStatus('published')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30" data-status="published">
|
||||
Published ({{ ns.published }})
|
||||
</button>
|
||||
<button onclick="filterStatus('archived')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30" data-status="archived">
|
||||
Archived ({{ ns.archived }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blog draft cards -->
|
||||
<div class="space-y-4" id="blogList">
|
||||
{% for d in drafts %}
|
||||
<a href="/blog/{{ d.id }}" class="blog-card block bg-slate-900 rounded-xl border border-slate-800 hover:border-slate-600 p-5 transition group"
|
||||
data-status="{{ d.status }}" data-title="{{ d.title | lower }}">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-white group-hover:text-blue-400 transition">{{ d.title }}</h2>
|
||||
{% if d.subtitle %}
|
||||
<p class="text-sm text-slate-400 mt-0.5">{{ d.subtitle }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap
|
||||
{% if d.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
|
||||
{% elif d.status == 'review' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
|
||||
{% elif d.status == 'published' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
|
||||
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
|
||||
{{ d.status | upper }}
|
||||
</span>
|
||||
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-400 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-slate-500 mt-3">
|
||||
{% if d.style %}
|
||||
<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400">{{ d.style }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if d.tags %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for tag in d.tags[:5] %}
|
||||
<span class="px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-500">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% if d.tags | length > 5 %}
|
||||
<span class="text-slate-600">+{{ d.tags | length - 5 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if d.model_used %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>{{ d.model_used }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if d.cost_usd %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>${{ "%.3f" | format(d.cost_usd) }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if d.updated_at %}
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>{{ d.updated_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if not drafts %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-12 text-center">
|
||||
<svg class="w-12 h-12 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-400 mb-4">No blog drafts yet. Generate your first post from project data.</p>
|
||||
<a href="/blog/generate" class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium 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>
|
||||
Generate First Post
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let currentStatus = 'all';
|
||||
|
||||
function filterStatus(status) {
|
||||
currentStatus = status;
|
||||
document.querySelectorAll('.status-pill').forEach(pill => {
|
||||
if (pill.dataset.status === status) {
|
||||
pill.style.outline = '2px solid rgba(96, 165, 250, 0.5)';
|
||||
pill.style.outlineOffset = '1px';
|
||||
} else {
|
||||
pill.style.outline = 'none';
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.blog-card').forEach(card => {
|
||||
const match = currentStatus === 'all' || card.dataset.status === currentStatus;
|
||||
card.style.display = match ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
filterStatus('all');
|
||||
</script>
|
||||
{% endblock %}
|
||||
141
src/webui/templates/blog_edit.html
Normal file
141
src/webui/templates/blog_edit.html
Normal file
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "blog" %}
|
||||
|
||||
{% block title %}Edit: {{ draft.title }} — Blog Drafts{% 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>
|
||||
<a href="/blog/{{ draft.id }}" class="text-blue-400 hover:text-blue-300 transition">{{ draft.title[:40] }}{% if draft.title | length > 40 %}...{% endif %}</a>
|
||||
<span class="text-slate-600 mx-2">/</span>
|
||||
<span class="text-slate-400">Edit</span>
|
||||
</nav>
|
||||
|
||||
<form method="POST" action="/blog/{{ draft.id }}/edit">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h1 class="text-xl font-bold text-white mb-5">Edit Blog Draft</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Title *</label>
|
||||
<input type="text" name="title" required value="{{ draft.title }}"
|
||||
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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Subtitle</label>
|
||||
<input type="text" name="subtitle" value="{{ draft.subtitle or '' }}"
|
||||
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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Content (Markdown)</label>
|
||||
<textarea name="content_md" rows="30"
|
||||
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-slate-300 font-mono leading-relaxed focus:outline-none focus:border-blue-500 transition resize-y">{{ draft.content_md }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-white mb-4">Metadata</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">Status</label>
|
||||
<select name="status" class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
|
||||
<option value="draft" {{ 'selected' if draft.status == 'draft' }}>Draft</option>
|
||||
<option value="review" {{ 'selected' if draft.status == 'review' }}>Review</option>
|
||||
<option value="published" {{ 'selected' if draft.status == 'published' }}>Published</option>
|
||||
<option value="archived" {{ 'selected' if draft.status == 'archived' }}>Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">Style</label>
|
||||
<select name="style" class="w-full px-3 py-2 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" {{ 'selected' if draft.style == 'deep-dive' }}>Deep Dive</option>
|
||||
<option value="overview" {{ 'selected' if draft.style == 'overview' }}>Overview</option>
|
||||
<option value="opinion" {{ 'selected' if draft.style == 'opinion' }}>Opinion</option>
|
||||
<option value="listicle" {{ 'selected' if draft.style == 'listicle' }}>Listicle</option>
|
||||
<option value="comparison" {{ 'selected' if draft.style == 'comparison' }}>Comparison</option>
|
||||
<option value="series-post" {{ 'selected' if draft.style == 'series-post' }}>Series Post</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">Slug</label>
|
||||
<input type="text" name="slug" value="{{ draft.slug }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white font-mono focus:outline-none focus:border-blue-500 transition">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">Tags (comma-separated)</label>
|
||||
<input type="text" name="tags_str" value="{{ draft.tags | join(', ') }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">Topic</label>
|
||||
<input type="text" name="topic" value="{{ draft.topic or '' }}"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">Instructions</label>
|
||||
<textarea name="instructions" rows="2"
|
||||
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-slate-400 focus:outline-none focus:border-blue-500 transition resize-y">{{ draft.instructions or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generation info (read-only) -->
|
||||
{% if draft.model_used %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-white mb-4">Generation Info</h2>
|
||||
<div class="space-y-2 text-xs text-slate-400">
|
||||
<div class="flex justify-between">
|
||||
<span>Model</span>
|
||||
<span class="text-slate-300">{{ draft.model_used }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Input tokens</span>
|
||||
<span class="text-slate-300">{{ "{:,}".format(draft.input_tokens or 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Output tokens</span>
|
||||
<span class="text-slate-300">{{ "{:,}".format(draft.output_tokens or 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Cost</span>
|
||||
<span class="text-slate-300">${{ "%.4f" | format(draft.cost_usd or 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="model_used" value="{{ draft.model_used }}">
|
||||
<input type="hidden" name="input_tokens" value="{{ draft.input_tokens or 0 }}">
|
||||
<input type="hidden" name="output_tokens" value="{{ draft.output_tokens or 0 }}">
|
||||
<input type="hidden" name="cost_usd" value="{{ draft.cost_usd or 0 }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold rounded-lg transition mb-3">
|
||||
<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="M5 13l4 4L19 7"/></svg>
|
||||
Save Changes
|
||||
</button>
|
||||
<a href="/blog/{{ draft.id }}" class="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium rounded-lg transition">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
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