515 lines
21 KiB
TypeScript
515 lines
21 KiB
TypeScript
'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: 'AV Beat', color: 'text-blue-400', target: '15–25/day' },
|
||
'av-beat': { label: 'AV Beat', color: 'text-purple-400', target: '10–15/day' },
|
||
'backlot-beat': { label: 'Backlot Beat', color: 'text-amber-400', target: '10–15/day' },
|
||
'broadcast-engineering': { label: 'BroadcastEngineering.com', color: 'text-green-400', target: '10–15/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-[#ffb800]"
|
||
>
|
||
{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-[#ffb800]"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleGenerate}
|
||
disabled={loading}
|
||
className="flex-1 bg-[#ffb800] hover:bg-[#d99700] 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?next=' + encodeURIComponent(window.location.pathname));
|
||
}, [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-[#ffb800] 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>
|
||
);
|
||
}
|