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:
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