ai-code-battle/web/src/pages/blog.ts
jedarden c07eb1f2eb Add blog infrastructure for weekly meta reports (Phase 10)
- Add blog generation to Go index builder (cmd/acb-index-builder/blog.go):
  - Weekly meta report generation with competitive analysis
  - Story arc chronicles: rise stories, upsets, rivalries
  - Blog index and individual post JSON generation

- Add blog page to web SPA (web/src/pages/blog.ts):
  - Blog listing with type filters (all/meta-report/chronicle)
  - Individual post view with markdown rendering
  - Tag cloud and post metadata display
  - Added /blog and /blog/:slug routes

- Add Blog link to navigation menu

- Add placeholder blog data files for initial content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 04:39:12 -04:00

673 lines
16 KiB
TypeScript

// Blog page - displays meta reports and chronicles
import { router } from '../router';
interface BlogEntry {
slug: string;
title: string;
date: string;
type: 'meta-report' | 'chronicle';
summary: string;
tags: string[];
}
interface BlogPost extends BlogEntry {
content_md: string;
}
interface BlogIndex {
updated_at: string;
posts: BlogEntry[];
}
let cachedIndex: BlogIndex | null = null;
export async function renderBlogPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="blog-page">
<h1 class="page-title">Blog</h1>
<p class="page-subtitle">Meta reports, chronicles, and stories from the arena</p>
<div class="blog-layout">
<div class="blog-main">
<div class="blog-filters">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="meta-report">Meta Reports</button>
<button class="filter-btn" data-filter="chronicle">Chronicles</button>
</div>
<div id="blog-list" class="blog-list">
<div class="loading">Loading posts...</div>
</div>
</div>
<aside class="blog-sidebar">
<div class="panel">
<h2>Recent Tags</h2>
<div id="tag-cloud" class="tag-cloud">
<span class="loading-text">Loading...</span>
</div>
</div>
<div class="panel">
<h2>Subscribe</h2>
<p class="sidebar-text">New posts are published weekly. Check back for the latest meta analysis and stories.</p>
</div>
</aside>
</div>
</div>
<style>
.blog-page .page-title {
margin-bottom: 8px;
}
.blog-page .page-subtitle {
color: var(--text-muted);
margin-bottom: 24px;
}
.blog-layout {
display: flex;
gap: 24px;
}
.blog-main {
flex: 1;
min-width: 0;
}
.blog-filters {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.filter-btn {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.filter-btn.active {
background-color: var(--accent);
color: white;
border-color: var(--accent);
}
.blog-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.blog-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.blog-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.blog-card-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.blog-card-type {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 4px 8px;
border-radius: 4px;
font-weight: 600;
}
.blog-card-type.meta-report {
background-color: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.blog-card-type.chronicle {
background-color: rgba(16, 185, 129, 0.2);
color: #34d399;
}
.blog-card-date {
color: var(--text-muted);
font-size: 0.875rem;
}
.blog-card-title {
font-size: 1.25rem;
color: var(--text-primary);
margin-bottom: 8px;
}
.blog-card-summary {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
.blog-card-tags {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.blog-tag {
font-size: 0.75rem;
color: var(--text-muted);
background-color: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 4px;
}
.blog-sidebar {
width: 280px;
flex-shrink: 0;
}
.blog-sidebar .panel {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.blog-sidebar .panel h2 {
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 12px;
}
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-item {
font-size: 0.75rem;
color: var(--text-secondary);
background-color: var(--bg-tertiary);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.tag-item:hover {
background-color: var(--accent);
color: white;
}
.sidebar-text {
color: var(--text-muted);
font-size: 0.875rem;
line-height: 1.5;
}
.loading, .loading-text {
color: var(--text-muted);
text-align: center;
padding: 40px 20px;
}
.empty-state {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
}
@media (max-width: 900px) {
.blog-layout {
flex-direction: column;
}
.blog-sidebar {
width: 100%;
}
}
</style>
`;
// Load blog index
await loadBlogIndex();
// Setup filter handlers
setupFilterHandlers();
}
async function loadBlogIndex(): Promise<void> {
const listEl = document.getElementById('blog-list');
const tagCloudEl = document.getElementById('tag-cloud');
if (!listEl || !tagCloudEl) return;
try {
// Try to fetch from local data directory
const response = await fetch('data/blog/index.json');
if (!response.ok) {
throw new Error('Blog index not found');
}
cachedIndex = await response.json() as BlogIndex;
renderBlogList(cachedIndex.posts);
renderTagCloud(cachedIndex.posts);
} catch {
// Show placeholder if no blog data yet
listEl.innerHTML = `
<div class="empty-state">
<p>No blog posts yet.</p>
<p style="margin-top: 8px; font-size: 0.875rem;">Weekly meta reports and chronicles will appear here once matches are running.</p>
</div>
`;
tagCloudEl.innerHTML = '<span class="sidebar-text">No tags yet</span>';
}
}
function renderBlogList(posts: BlogEntry[], filter: string = 'all'): void {
const listEl = document.getElementById('blog-list');
if (!listEl) return;
const filtered = filter === 'all'
? posts
: posts.filter(p => p.type === filter);
if (filtered.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<p>No posts found for this filter.</p>
</div>
`;
return;
}
listEl.innerHTML = filtered.map(post => `
<div class="blog-card" data-slug="${post.slug}">
<div class="blog-card-meta">
<span class="blog-card-type ${post.type}">${formatPostType(post.type)}</span>
<span class="blog-card-date">${formatDate(post.date)}</span>
</div>
<h3 class="blog-card-title">${escapeHtml(post.title)}</h3>
<p class="blog-card-summary">${escapeHtml(post.summary)}</p>
<div class="blog-card-tags">
${post.tags.slice(0, 4).map(tag => `<span class="blog-tag">${escapeHtml(tag)}</span>`).join('')}
</div>
</div>
`).join('');
// Add click handlers
listEl.querySelectorAll('.blog-card').forEach(card => {
card.addEventListener('click', () => {
const slug = card.getAttribute('data-slug');
if (slug) {
router.navigate(`/blog/${slug}`);
}
});
});
}
function renderTagCloud(posts: BlogEntry[]): void {
const tagCloudEl = document.getElementById('tag-cloud');
if (!tagCloudEl) return;
// Count tag occurrences
const tagCounts = new Map<string, number>();
posts.forEach(post => {
post.tags.forEach(tag => {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
});
});
// Sort by count and take top 10
const sortedTags = Array.from(tagCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
if (sortedTags.length === 0) {
tagCloudEl.innerHTML = '<span class="sidebar-text">No tags yet</span>';
return;
}
tagCloudEl.innerHTML = sortedTags.map(([tag, count]) =>
`<span class="tag-item" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)} (${count})</span>`
).join('');
}
function setupFilterHandlers(): void {
const filterBtns = document.querySelectorAll('.filter-btn');
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
// Update active state
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Filter posts
const filter = btn.getAttribute('data-filter') || 'all';
if (cachedIndex) {
renderBlogList(cachedIndex.posts, filter);
}
});
});
}
// Individual blog post page
export async function renderBlogPostPage(params: Record<string, string>): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
const slug = params.slug;
if (!slug) {
router.navigate('/blog');
return;
}
app.innerHTML = `
<div class="blog-post-page">
<a href="#/blog" class="back-link">← Back to Blog</a>
<div id="post-content" class="post-content">
<div class="loading">Loading post...</div>
</div>
</div>
<style>
.blog-post-page {
max-width: 800px;
}
.back-link {
display: inline-block;
color: var(--accent);
text-decoration: none;
margin-bottom: 20px;
font-size: 0.875rem;
}
.back-link:hover {
text-decoration: underline;
}
.post-content {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 32px;
}
.post-header {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.post-type-badge {
display: inline-block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 4px 10px;
border-radius: 4px;
font-weight: 600;
margin-bottom: 12px;
}
.post-type-badge.meta-report {
background-color: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.post-type-badge.chronicle {
background-color: rgba(16, 185, 129, 0.2);
color: #34d399;
}
.post-title {
font-size: 2rem;
color: var(--text-primary);
margin-bottom: 12px;
line-height: 1.3;
}
.post-date {
color: var(--text-muted);
font-size: 0.875rem;
}
.post-body {
color: var(--text-secondary);
line-height: 1.7;
}
.post-body h2 {
color: var(--text-primary);
margin: 32px 0 16px;
font-size: 1.5rem;
}
.post-body h3 {
color: var(--text-primary);
margin: 24px 0 12px;
font-size: 1.25rem;
}
.post-body p {
margin-bottom: 16px;
}
.post-body ul, .post-body ol {
margin-bottom: 16px;
padding-left: 24px;
}
.post-body li {
margin-bottom: 8px;
}
.post-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.post-body th, .post-body td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.post-body th {
color: var(--text-primary);
font-weight: 600;
}
.post-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 32px 0;
}
.post-body code {
background-color: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.875em;
}
.post-body strong {
color: var(--text-primary);
}
.post-tags {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.post-tag {
font-size: 0.75rem;
color: var(--text-muted);
background-color: var(--bg-tertiary);
padding: 4px 10px;
border-radius: 4px;
}
.loading {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
}
.not-found {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
}
</style>
`;
try {
const response = await fetch(`data/blog/posts/${slug}.json`);
if (!response.ok) {
throw new Error('Post not found');
}
const post = await response.json() as BlogPost;
renderPost(post);
} catch {
const contentEl = document.getElementById('post-content');
if (contentEl) {
contentEl.innerHTML = `
<div class="not-found">
<p>Post not found.</p>
<a href="#/blog" class="back-link" style="margin-top: 16px; display: inline-block;">← Back to Blog</a>
</div>
`;
}
}
}
function renderPost(post: BlogPost): void {
const contentEl = document.getElementById('post-content');
if (!contentEl) return;
contentEl.innerHTML = `
<div class="post-header">
<span class="post-type-badge ${post.type}">${formatPostType(post.type)}</span>
<h1 class="post-title">${escapeHtml(post.title)}</h1>
<div class="post-date">${formatDate(post.date)}</div>
</div>
<div class="post-body">
${markdownToHtml(post.content_md)}
</div>
<div class="post-tags">
${post.tags.map(tag => `<span class="post-tag">${escapeHtml(tag)}</span>`).join('')}
</div>
`;
}
function formatPostType(type: string): string {
switch (type) {
case 'meta-report':
return 'Meta Report';
case 'chronicle':
return 'Chronicle';
default:
return type;
}
}
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateStr;
}
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Simple markdown to HTML converter for basic formatting
function markdownToHtml(md: string): string {
let html = md;
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Code (inline)
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Horizontal rule
html = html.replace(/^---$/gm, '<hr>');
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
// Ordered lists
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Tables (basic)
const tableRegex = /\|(.+)\|\n\|[-|\s]+\|\n((?:\|.+\|\n?)+)/g;
html = html.replace(tableRegex, (_, header, body) => {
const headers = header.split('|').filter((h: string) => h.trim()).map((h: string) => `<th>${h.trim()}</th>`).join('');
const rows = body.trim().split('\n').map((row: string) => {
const cells = row.split('|').filter((c: string) => c.trim()).map((c: string) => `<td>${c.trim()}</td>`).join('');
return `<tr>${cells}</tr>`;
}).join('');
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
});
// Paragraphs (must be last)
html = html.split('\n\n').map(para => {
para = para.trim();
if (!para) return '';
if (para.startsWith('<')) return para;
return `<p>${para}</p>`;
}).join('\n');
return html;
}