initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,514 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface NodeStatus {
name: string;
online: boolean;
queueDepth: number;
latencyMs: number;
host: string;
port: number;
}
interface PipelineStatus {
generatedToday: number;
siteBreakdown: Record<string, number>;
queueDepth: number;
pressReleasesRemaining: number;
thinContentRemaining: number;
missingSeoRemaining: number;
nodes: NodeStatus[];
timestamp: string;
}
interface AuditSummary {
totalArticles: number;
totalPublished: number;
thinContentCount: number;
missingFeaturedImageCount: number;
missingSeoAnyCount: number;
pressReleaseCount: number;
averageWordCount: number;
}
interface LaunchChecklist {
zeroPagesWithMissingFeaturedImages: boolean;
zeroPagesWithThinContent: boolean;
zeroPagesMissingSeoMeta: boolean;
zeroUnrewrittenPressReleases: boolean;
}
// ─── Site labels ──────────────────────────────────────────────────────────────
const SITE_LABELS: Record<string, { label: string; color: string; target: string }> = {
'broadcast-beat': { label: 'Broadcast Beat', color: 'text-blue-400', target: '1525/day' },
'av-beat': { label: 'AV Beat', color: 'text-purple-400', target: '1015/day' },
'backlot-beat': { label: 'Backlot Beat', color: 'text-amber-400', target: '1015/day' },
'broadcast-engineering': { label: 'BroadcastEngineering.com', color: 'text-green-400', target: '1015/day' },
};
// ─── Sub-components ───────────────────────────────────────────────────────────
function NodeCard({ node }: { node: NodeStatus }) {
return (
<div className={`bg-[#111] border rounded-lg p-4 flex flex-col gap-2 ${node.online ? 'border-green-500/30' : 'border-red-500/30'}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-display font-bold text-white">{node.name}</span>
<span className={`text-xs font-body font-semibold px-2 py-0.5 rounded-full ${node.online ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{node.online ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
<p className="text-xs text-[#666] font-body">{node.host}:{node.port}</p>
{node.online && (
<div className="flex gap-4 mt-1">
<div>
<p className="text-xs text-[#555] font-body">Queue</p>
<p className={`text-sm font-display font-bold ${node.queueDepth > 10 ? 'text-amber-400' : 'text-green-400'}`}>
{node.queueDepth === 999 ? '—' : node.queueDepth}
</p>
</div>
<div>
<p className="text-xs text-[#555] font-body">Latency</p>
<p className="text-sm font-display font-bold text-white">{node.latencyMs}ms</p>
</div>
</div>
)}
</div>
);
}
function StatCard({
label,
value,
sub,
accent,
good,
}: {
label: string;
value: number | string;
sub?: string;
accent: string;
good?: boolean;
}) {
return (
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-2">
<p className={`text-2xl font-display font-bold ${accent}`}>{value}</p>
<p className="text-sm text-[#888] font-body">{label}</p>
{sub && <p className="text-xs text-[#555] font-body">{sub}</p>}
{good !== undefined && (
<span className={`text-xs font-body font-semibold mt-1 ${good ? 'text-green-400' : 'text-amber-400'}`}>
{good ? '✓ Launch ready' : '⚠ Action needed'}
</span>
)}
</div>
);
}
function ChecklistItem({ label, done }: { label: string; done: boolean }) {
return (
<div className={`flex items-center gap-3 p-3 rounded-lg border ${done ? 'border-green-500/20 bg-green-500/5' : 'border-amber-500/20 bg-amber-500/5'}`}>
<span className={`text-lg ${done ? 'text-green-400' : 'text-amber-400'}`}>{done ? '✓' : '○'}</span>
<span className={`text-sm font-body ${done ? 'text-green-300' : 'text-amber-300'}`}>{label}</span>
</div>
);
}
// ─── Generate Controls ────────────────────────────────────────────────────────
type RMPSite = 'broadcast-beat' | 'av-beat' | 'backlot-beat' | 'broadcast-engineering';
function GeneratePanel({ onGenerated }: { onGenerated: () => void }) {
const [site, setSite] = useState<RMPSite>('broadcast-beat');
const [topic, setTopic] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleGenerate = async () => {
setLoading(true);
setResult(null);
setError(null);
try {
const res = await fetch('/api/content/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site, topic: topic.trim() || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Generation failed');
setResult(`✓ Generated: "${data.article?.title}" via ${data.article?.node} (${data.article?.wordCount} words)`);
onGenerated();
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleSEOBatch = async () => {
setLoading(true);
setResult(null);
setError(null);
try {
const res = await fetch('/api/content/seo-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'SEO batch failed');
setResult(`✓ SEO meta updated: ${data.updated} articles. Remaining: ${data.remaining}`);
onGenerated();
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
return (
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-4">
<h3 className="text-sm font-display font-bold text-white">Manual Controls</h3>
<div className="flex flex-col gap-3">
<div>
<label className="text-xs text-[#666] font-body mb-1 block">Site</label>
<select
value={site}
onChange={(e) => setSite(e.target.value as RMPSite)}
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-sm text-white font-body focus:outline-none focus:border-[#3b82f6]"
>
{Object.entries(SITE_LABELS).map(([key, val]) => (
<option key={key} value={key}>{val.label}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-[#666] font-body mb-1 block">Topic (optional)</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="e.g. ATSC 3.0 deployment update"
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-sm text-white font-body placeholder-[#444] focus:outline-none focus:border-[#3b82f6]"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleGenerate}
disabled={loading}
className="flex-1 bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white text-sm font-body font-semibold py-2 px-4 rounded-lg transition-colors"
>
{loading ? 'Generating…' : 'Generate Article'}
</button>
<button
onClick={handleSEOBatch}
disabled={loading}
className="flex-1 bg-[#1a1a1a] hover:bg-[#252525] disabled:opacity-50 border border-[#333] text-white text-sm font-body font-semibold py-2 px-4 rounded-lg transition-colors"
>
{loading ? 'Running…' : 'Batch SEO Meta'}
</button>
</div>
{result && <p className="text-xs text-green-400 font-body bg-green-500/10 border border-green-500/20 rounded-lg p-2">{result}</p>}
{error && <p className="text-xs text-red-400 font-body bg-red-500/10 border border-red-500/20 rounded-lg p-2">{error}</p>}
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function ContentStatusPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [status, setStatus] = useState<PipelineStatus | null>(null);
const [audit, setAudit] = useState<AuditSummary | null>(null);
const [checklist, setChecklist] = useState<LaunchChecklist | null>(null);
const [loadingData, setLoadingData] = useState(true);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const fetchAll = useCallback(async () => {
try {
const [statusRes, auditRes] = await Promise.all([
fetch('/api/content/pipeline-status'),
fetch('/api/content/audit'),
]);
if (statusRes.ok) {
const s = await statusRes.json();
setStatus(s);
}
if (auditRes.ok) {
const a = await auditRes.json();
setAudit(a.summary);
setChecklist(a.launchChecklist);
}
setLastRefresh(new Date());
} catch {
// silent
} finally {
setLoadingData(false);
}
}, []);
useEffect(() => {
if (!loading && !user) router.push('/login');
}, [user, loading, router]);
useEffect(() => {
if (user) fetchAll();
}, [user, fetchAll]);
// Auto-refresh every 30 seconds
useEffect(() => {
if (!autoRefresh || !user) return;
const interval = setInterval(fetchAll, 30_000);
return () => clearInterval(interval);
}, [autoRefresh, user, fetchAll]);
if (loading || loadingData) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
<p className="text-[#555] text-sm font-body">Loading content status</p>
</div>
</div>
);
}
if (!user) return null;
const allNodesOffline = status?.nodes.every((n) => !n.online) ?? false;
const launchReady = checklist
? Object.values(checklist).every(Boolean)
: false;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
{/* Header */}
<div className="border-b border-[#1a1a1a] bg-[#0d0d0d] px-6 py-4">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin" className="text-[#555] hover:text-white transition-colors text-sm font-body">
Admin
</Link>
<span className="text-[#333]">/</span>
<h1 className="text-lg font-display font-bold text-white">Content Pipeline Status</h1>
{allNodesOffline && (
<span className="text-xs font-body font-semibold px-2 py-0.5 rounded-full bg-red-500/15 text-red-400 animate-pulse">
ALL NODES OFFLINE
</span>
)}
</div>
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-xs text-[#555] font-body">
Updated {lastRefresh.toLocaleTimeString()}
</span>
)}
<button
onClick={() => setAutoRefresh((v) => !v)}
className={`text-xs font-body px-3 py-1.5 rounded-lg border transition-colors ${
autoRefresh
? 'border-green-500/30 text-green-400 bg-green-500/10' :'border-[#333] text-[#666] bg-[#111]'
}`}
>
{autoRefresh ? '⟳ Auto-refresh ON' : '⟳ Auto-refresh OFF'}
</button>
<button
onClick={fetchAll}
className="text-xs font-body px-3 py-1.5 rounded-lg border border-[#333] text-white bg-[#111] hover:bg-[#1a1a1a] transition-colors"
>
Refresh Now
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-8 flex flex-col gap-8">
{/* AI Node Status */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">AI Node Status</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{(status?.nodes || [
{ name: 'AI_001', online: false, queueDepth: 0, latencyMs: 0, host: '68.248.192.64', port: 8765 },
{ name: 'AI_002', online: false, queueDepth: 0, latencyMs: 0, host: '68.248.192.64', port: 8766 },
]).map((node) => (
<NodeCard key={node.name} node={node} />
))}
</div>
{allNodesOffline && (
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-400 font-body">
Both AI nodes are offline. New generation jobs are being queued and will retry automatically every 5 minutes.
</p>
</div>
)}
</section>
{/* Today's Pipeline */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">Today's Pipeline</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard
label="Articles Generated Today"
value={status?.generatedToday ?? ''}
accent="text-blue-400"
sub="All sites combined"
/>
<StatCard
label="Queue Depth"
value={status?.queueDepth ?? ''}
accent={status && status.queueDepth > 10 ? 'text-amber-400' : 'text-green-400'}
sub="Jobs pending across nodes"
/>
<StatCard
label="Press Releases Remaining"
value={status?.pressReleasesRemaining ?? ''}
accent={status && status.pressReleasesRemaining > 0 ? 'text-amber-400' : 'text-green-400'}
good={status?.pressReleasesRemaining === 0}
/>
<StatCard
label="SEO Meta Missing"
value={status?.missingSeoRemaining ?? ''}
accent={status && status.missingSeoRemaining > 0 ? 'text-amber-400' : 'text-green-400'}
good={status?.missingSeoRemaining === 0}
/>
</div>
</section>
{/* Per-Site Breakdown */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">Per-Site Breakdown (Today)</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.entries(SITE_LABELS).map(([key, cfg]) => {
const count = status?.siteBreakdown?.[key] ?? 0;
return (
<div key={key} className="bg-[#111] border border-[#252525] rounded-lg p-4 flex flex-col gap-2">
<p className={`text-xs font-body font-semibold ${cfg.color}`}>{cfg.label}</p>
<p className="text-3xl font-display font-bold text-white">{count}</p>
<p className="text-xs text-[#555] font-body">Target: {cfg.target}</p>
</div>
);
})}
</div>
</section>
{/* Content Audit Summary */}
{audit && (
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">Content Audit</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard label="Total Articles" value={audit.totalArticles} accent="text-white" />
<StatCard label="Published" value={audit.totalPublished} accent="text-green-400" />
<StatCard
label="Thin Content (<300w)"
value={audit.thinContentCount}
accent={audit.thinContentCount > 0 ? 'text-amber-400' : 'text-green-400'}
good={audit.thinContentCount === 0}
/>
<StatCard
label="Missing Featured Image"
value={audit.missingFeaturedImageCount}
accent={audit.missingFeaturedImageCount > 0 ? 'text-amber-400' : 'text-green-400'}
good={audit.missingFeaturedImageCount === 0}
/>
<StatCard
label="Missing SEO Meta"
value={audit.missingSeoAnyCount}
accent={audit.missingSeoAnyCount > 0 ? 'text-amber-400' : 'text-green-400'}
good={audit.missingSeoAnyCount === 0}
/>
<StatCard label="Avg Word Count" value={audit.averageWordCount} accent="text-blue-400" />
</div>
</section>
)}
{/* Launch Checklist */}
<section>
<div className="flex items-center gap-3 mb-4">
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider">Launch Checklist</h2>
{launchReady && (
<span className="text-xs font-body font-semibold px-2 py-0.5 rounded-full bg-green-500/15 text-green-400">
LAUNCH READY
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ChecklistItem
label="Zero pages with missing featured images"
done={checklist?.zeroPagesWithMissingFeaturedImages ?? false}
/>
<ChecklistItem
label="Zero pages with thin content (under 300 words)"
done={checklist?.zeroPagesWithThinContent ?? false}
/>
<ChecklistItem
label="Zero pages missing SEO meta title and description"
done={checklist?.zeroPagesMissingSeoMeta ?? false}
/>
<ChecklistItem
label="Zero unrewritten press releases in published state"
done={checklist?.zeroUnrewrittenPressReleases ?? false}
/>
<ChecklistItem
label="Daily content pipeline running and generating new articles"
done={(status?.generatedToday ?? 0) > 0}
/>
<ChecklistItem
label="AI nodes online and responding"
done={!allNodesOffline}
/>
</div>
</section>
{/* Manual Controls */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">Manual Controls</h2>
<div className="max-w-lg">
<GeneratePanel onGenerated={fetchAll} />
</div>
</section>
{/* API Reference */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">API Endpoints</h2>
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ method: 'GET', path: '/api/content/audit', desc: 'Full content audit report' },
{ method: 'GET', path: '/api/content/pipeline-status', desc: 'Live pipeline + node status' },
{ method: 'POST', path: '/api/content/generate', desc: 'Generate one article { site, topic? }' },
{ method: 'GET', path: '/api/content/press-releases', desc: 'List unedited press releases' },
{ method: 'POST', path: '/api/content/press-releases', desc: 'Rewrite a press release { articleId, source, site }' },
{ method: 'POST', path: '/api/content/seo-batch', desc: 'Batch-generate missing SEO meta { site? }' },
{ method: 'GET', path: '/api/ai/node-health', desc: 'AI_001 node health check' },
{ method: 'GET', path: '/api/ai/ollama-health', desc: 'AI_001 ping check' },
].map((ep) => (
<div key={ep.path} className="flex items-start gap-2">
<span className={`text-xs font-body font-bold px-1.5 py-0.5 rounded flex-shrink-0 ${ep.method === 'GET' ? 'bg-blue-500/15 text-blue-400' : 'bg-green-500/15 text-green-400'}`}>
{ep.method}
</span>
<div>
<code className="text-xs text-[#ccc] font-mono">{ep.path}</code>
<p className="text-xs text-[#555] font-body mt-0.5">{ep.desc}</p>
</div>
</div>
))}
</div>
</section>
</div>
</div>
);
}