Files
avbeat-com/src/app/dashboard/ai-console/page.tsx
Ryan Salazar (via Claude) 52fe7bceb6 slice 4: accent palette refinement — blue → teal
Replace the four accent-family hex codes site-wide with the teal palette
(keeps the dark theme; only swaps the accent family, not the dark base):

  #3b82f6 (accent primary CTA)   → #5B7C8D  (teal)        [839×]
  #2563eb (accent-dark / hover)  → #4A6473  (darker teal) [44×]
  #60a5fa (info link, lighter)   → #8FB0C3  (mid teal)    [68×]
  #1e3a5f (accent-muted bg)      → #2F4F5F  (navy)        [44×]

tailwind.config.js tokens updated to match (accent/accent-dark/accent-muted).
Sweep also covers tailwind.css. 108 files touched. The dark base (#0d0d0d,
#111, #161616, #1a1a1a etc.) is intentionally untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:33:04 +00:00

1356 lines
66 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { BB_PEN_NAMES, AV_PEN_NAMES } from '@/lib/auth/permissions';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
provider?: 'ollama' | 'anthropic';
}
interface Session {
id: string;
site_id: number;
pen_name: string | null;
first_message_preview: string;
provider_used: string;
last_message_at: string;
}
interface AIStatus {
ollama: { online: boolean; latencyMs: number; models: string[] };
anthropic: { configured: boolean };
currentProvider: 'ollama' | 'anthropic';
}
// ─── Pen name style guide data ────────────────────────────────────────────────
interface PenNameProfile {
name: string;
title: string;
beat: string;
site: 'BB' | 'AV';
styleNotes: string;
toneKeywords: string[];
exampleLead: string;
}
const PEN_NAME_PROFILES: PenNameProfile[] = [
{
name: 'Michael Strand', title: 'Senior Technology Editor', site: 'BB',
beat: 'Broadcast technology, industry news, NAB Show primary correspondent',
styleNotes: 'Authoritative and comprehensive. Leads with the big-picture industry implication before drilling into product specifics. Favors longer paragraphs with technical depth. Uses precise broadcast engineering terminology without over-explaining.',
toneKeywords: ['authoritative', 'comprehensive', 'technical', 'industry-wide'],
exampleLead: 'The shift toward IP-based production infrastructure reached a new milestone this week as...',
},
{
name: 'David Harlow', title: 'Broadcast Engineering Correspondent', site: 'BB',
beat: 'Broadcast engineering, transmission, RF, satellite, signal distribution',
styleNotes: 'Engineering-first perspective. Leads with the technical specification or engineering challenge being solved. Uses RF and transmission terminology fluently. Concise, factual sentences. Avoids marketing language entirely.',
toneKeywords: ['engineering-focused', 'technical', 'concise', 'specification-driven'],
exampleLead: 'A new RF transmission system capable of sustaining 4K HDR delivery across legacy infrastructure...',
},
{
name: 'Karen Fielding', title: 'Post Production Editor', site: 'BB',
beat: 'Post production, editing, color grading, VFX, media asset management',
styleNotes: 'Workflow-oriented. Explains how tools fit into real post production pipelines. Speaks to colorists, editors, and MAM administrators. Uses post-specific terminology (LUT, roundtrip, proxy, conform) naturally.',
toneKeywords: ['workflow-focused', 'practical', 'pipeline-aware', 'post-specific'],
exampleLead: 'Colorists working in high-volume episodic environments will find the new grading pipeline...',
},
{
name: 'James Mercer', title: 'NAB Show Senior Correspondent', site: 'BB',
beat: 'NAB Show Las Vegas, NAB Show New York, show floor coverage, exhibitor news',
styleNotes: 'Show-floor energy. Vivid, present-tense descriptions of booth experiences and product demos. Contextualizes announcements within the broader NAB narrative. Primary byline for all major NAB pillar content.',
toneKeywords: ['show-floor', 'vivid', 'contextual', 'event-driven'],
exampleLead: 'Walking the South Hall at NAB Show Las Vegas, the density of IP production announcements...',
},
{
name: 'Peter Calloway', title: 'Radio & Transmission Reporter', site: 'BB',
beat: 'Radio broadcasting, AM/FM/DAB, transmission infrastructure, tower and antenna technology',
styleNotes: 'Radio-industry insider voice. Understands the economics of tower infrastructure and the regulatory landscape. Writes for station engineers and broadcast directors. Straightforward, no-nonsense style.',
toneKeywords: ['radio-focused', 'regulatory-aware', 'infrastructure', 'practical'],
exampleLead: 'Station engineers managing aging AM transmitter infrastructure now have a cost-effective upgrade path...',
},
{
name: 'Sandra Voss', title: 'Production Technology Editor', site: 'BB',
beat: 'Studio production, cameras, switchers, graphics systems, live production',
styleNotes: 'Live production specialist. Focuses on the director and technical director perspective. Evaluates cameras and switchers in terms of production flexibility and reliability under live conditions.',
toneKeywords: ['live-production', 'reliability-focused', 'director-perspective', 'studio-aware'],
exampleLead: "Technical directors running multi-camera live productions will appreciate the new switcher's...",
},
{
name: 'Brian Kowalski', title: 'Streaming & OTT Correspondent', site: 'BB',
beat: 'Streaming technology, OTT platforms, CDN, video delivery, cloud broadcast',
styleNotes: 'Cloud-native perspective. Comfortable with CDN architecture, encoding ladders, and OTT business models. Writes for streaming engineers and platform operators. Uses streaming-specific metrics (rebuffering rate, ABR, latency).',
toneKeywords: ['cloud-native', 'platform-aware', 'metrics-driven', 'OTT-focused'],
exampleLead: 'As sub-second latency becomes table stakes for live streaming, the new delivery architecture...',
},
{
name: 'Laura Pennington', title: 'Industry News Editor', site: 'BB',
beat: 'Company news, acquisitions, appointments, executive moves, industry business coverage',
styleNotes: 'Business journalism style. Leads with the news fact, then provides context and industry reaction. Quotes executives accurately. Covers M&A with an understanding of strategic rationale.',
toneKeywords: ['business-journalism', 'fact-first', 'executive-focused', 'strategic'],
exampleLead: "The broadcast industry's transition to cloud production is accelerating faster than most analysts predicted...",
},
{
name: 'Thomas Reeves', title: 'Audio Technology Correspondent', site: 'BB',
beat: 'Broadcast audio, mixing consoles, audio processing, AES Convention coverage',
styleNotes: "Broadcast audio engineer's perspective. Evaluates consoles and processors from a workflow standpoint. Uses AES and SMPTE audio terminology. Covers both technical specifications and operational workflow.",
toneKeywords: ['audio-engineering', 'AES-fluent', 'console-focused', 'workflow-aware'],
exampleLead: 'Broadcast audio engineers demanding more flexible routing in live production environments...',
},
{
name: 'Christine Vale', title: 'Business & Industry Reporter', site: 'BB',
beat: 'Industry analysis, market trends, opinion, business of broadcast',
styleNotes: 'Analytical and opinion-forward. Comfortable with market data and industry forecasts. Writes longer-form analysis pieces. Challenges conventional wisdom when data supports it.',
toneKeywords: ['analytical', 'opinion-forward', 'data-driven', 'market-aware'],
exampleLead: "The broadcast industry's transition to cloud production is accelerating faster than most analysts predicted...",
},
{
name: 'Marcus Webb', title: 'Live Production Correspondent', site: 'BB',
beat: 'Live events, sports broadcasting, remote production, REMI and IP production workflows',
styleNotes: 'Sports and live events specialist. Understands REMI/at-home production economics. Writes for remote production engineers and sports broadcast directors. Covers IP workflows in the context of real production deployments.',
toneKeywords: ['sports-broadcast', 'REMI-focused', 'IP-production', 'event-driven'],
exampleLead: 'The remote production workflow deployed for last season\'s playoff coverage has now become...',
},
{
name: 'Ellen Forsythe', title: 'International Markets Editor', site: 'BB',
beat: 'IBC Show, BroadcastAsia, CABSAT, international broadcast markets',
styleNotes: 'International perspective. Contextualizes technology announcements within regional market dynamics. Covers IBC and BroadcastAsia with an understanding of European and Asian regulatory environments.',
toneKeywords: ['international', 'market-contextual', 'regulatory-aware', 'IBC-focused'],
exampleLead: 'European broadcasters navigating the DVB-I transition are finding new deployment options...',
},
{
name: 'Rex Chandler', title: 'AV Integration Senior Correspondent', site: 'AV',
beat: 'AV systems integration, integrator news, company announcements, InfoComm coverage',
styleNotes: 'Integration-industry insider. Writes for AV integrators and systems designers. Understands the full project lifecycle from design through commissioning. Primary InfoComm correspondent.',
toneKeywords: ['integration-focused', 'project-lifecycle', 'integrator-voice', 'InfoComm-primary'],
exampleLead: 'AV integrators tackling large-scale corporate campus deployments now have a unified platform...',
},
{
name: 'Dana Flux', title: 'Digital Signage & Display Editor', site: 'AV',
beat: 'Digital signage, LED walls, display technology, CMS platforms',
styleNotes: 'Display technology specialist. Evaluates LED walls and CMS platforms from a deployment and content management perspective. Understands pixel pitch, brightness specs, and content scheduling workflows.',
toneKeywords: ['display-focused', 'CMS-aware', 'deployment-practical', 'spec-driven'],
exampleLead: "The latest generation of fine-pitch LED displays is pushing the boundaries of what's achievable...",
},
{
name: 'Derek Wainwright', title: 'Unified Communications Correspondent', site: 'AV',
beat: 'UC&C, video conferencing, hybrid meeting rooms, Microsoft Teams Rooms, Zoom Rooms',
styleNotes: 'UC&C specialist. Writes for IT managers and AV integrators deploying hybrid meeting solutions. Understands the Microsoft and Zoom certification ecosystems. Focuses on user experience and IT manageability.',
toneKeywords: ['UC-focused', 'hybrid-work', 'IT-aware', 'certification-fluent'],
exampleLead: 'As hybrid work becomes permanent, IT managers are demanding meeting room solutions that...',
},
{
name: 'Sloane Rigging', title: 'Installed AV & Audio Editor', site: 'AV',
beat: 'Installed audio, sound reinforcement, houses of worship AV, DSP, loudspeakers',
styleNotes: 'Installed audio specialist. Writes for system designers and audio consultants. Understands DSP configuration, loudspeaker coverage patterns, and the unique requirements of houses of worship.',
toneKeywords: ['installed-audio', 'DSP-fluent', 'worship-AV', 'system-design'],
exampleLead: 'Houses of worship seeking intelligible speech reinforcement across challenging acoustical spaces...',
},
{
name: 'Chip Crosspoint', title: 'AV Networking & Technology Correspondent', site: 'AV',
beat: 'AV-over-IP, Dante, IPMX, control systems, IT/AV convergence',
styleNotes: 'AV networking deep-diver. Comfortable with Dante, IPMX, and AES67 at a technical level. Writes for AV engineers and IT/AV convergence specialists. Explains complex networking concepts clearly.',
toneKeywords: ['AV-networking', 'Dante-fluent', 'IPMX-aware', 'IT-AV-convergence'],
exampleLead: 'The adoption of IPMX as a unified AV-over-IP standard is accelerating as major manufacturers...',
},
{
name: 'Blair Presenter', title: 'Education & Corporate AV Editor', site: 'AV',
beat: 'Education AV, classroom technology, corporate AV, government AV, ISE coverage',
styleNotes: 'Education and corporate AV specialist. Understands the procurement cycles and budget constraints of K-12, higher education, and government AV. ISE correspondent.',
toneKeywords: ['education-AV', 'corporate-AV', 'procurement-aware', 'ISE-focused'],
exampleLead: 'K-12 districts managing post-pandemic classroom technology refreshes are finding that...',
},
{
name: 'Jordan Lumen', title: 'Display & Visualization Correspondent', site: 'AV',
beat: 'Projectors, large-venue displays, video walls, visualization technology, simulation',
styleNotes: 'Large-venue display specialist. Evaluates projectors and video walls for large-venue and simulation applications. Understands lumen output, throw ratios, and blending technology.',
toneKeywords: ['large-venue', 'projection-focused', 'simulation-aware', 'visualization'],
exampleLead: 'Large-venue integrators specifying projection systems for auditoriums and simulation centers...',
},
];
const QUICK_ACTIONS = [
{ label: 'New Story from URL', key: 'url_story' },
{ label: 'Write Exhibitor Preview', key: 'exhibitor_preview' },
{ label: 'Rewrite Press Release', key: 'press_release' },
{ label: 'Generate NAB Story', key: 'nab_story' },
{ label: 'Generate InfoComm Story', key: 'infocomm_story' },
{ label: 'Social Media Posts', key: 'social_posts' },
{ label: 'Story Headline Options', key: 'headlines' },
{ label: 'Translate Story', key: 'translate' },
{ label: 'Company Bio', key: 'company_bio' },
{ label: 'Product Description', key: 'product_desc' },
];
const QUICK_ACTION_TEMPLATES: Record<string, string> = {
url_story: `Write a news story for [Broadcast Beat / AV Beat] based on this URL: [URL]. Use the byline [pen name]. Target length: [400-600] words. Focus angle: [product launch / company news / industry trend].`,
exhibitor_preview: `Write a pre-show exhibitor preview for [company name] at [NAB Show / InfoComm] [year]. They will be showing [product/technology]. Booth: [number if known]. Use [pen name] byline. 400-500 words.`,
press_release: `Rewrite the following press release as a real news story for [Broadcast Beat / AV Beat]. Remove marketing language, lead with the news hook, add industry context. Byline: [pen name].
[PASTE PRESS RELEASE HERE]`,
nab_story: `Write a [story type: preview / announcement / recap] story about [topic/company] for NAB Show [year]. Include relevant SEO keywords. Byline: James Mercer. [400-800] words.`,
infocomm_story: `Write a [story type: preview / announcement / recap] story about [topic/company] for InfoComm [year]. Include relevant SEO keywords. Byline: Rex Chandler. [400-800] words.`,
social_posts: `Generate social media posts for the following story. Create versions for: Twitter/X (280 chars max), LinkedIn (professional tone, 150 words max), Facebook (conversational, 100 words max).
Story headline: [HEADLINE]
Story summary: [SUMMARY]`,
headlines: `Generate 8 headline options for this story. Include 2 SEO-optimized versions targeting [keyword], 2 social media-friendly versions, 2 trade publication style versions, and 2 alternatives. Story summary: [SUMMARY]`,
translate: `Translate the following story into [language]. Maintain the professional trade publication tone. Keep technical terms in their internationally recognized form. [PASTE STORY HERE]`,
company_bio: `Write a 150-word company bio for [company name] suitable for use as a boilerplate paragraph at the end of news stories on [Broadcast Beat / AV Beat]. The company makes [products/services]. Website: [URL].`,
product_desc: `Write a 200-word product description for [product name] by [company]. Key features: [features]. Target audience: [integrators / broadcast engineers / etc]. Suitable for use in a product announcement story.`,
};
function renderMarkdown(text: string): string {
return text
.replace(/^### (.+)$/gm, '<h3 class="text-base font-bold text-white mt-4 mb-1">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-lg font-bold text-white mt-5 mb-2">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-xl font-bold text-white mt-5 mb-2">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white font-semibold">$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code class="bg-[#1a2535] text-[#5B7C8D] px-1 py-0.5 rounded text-sm font-mono">$1</code>')
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc text-[#aaa]">$1</li>')
.replace(/^\d+\. (.+)$/gm, '<li class="ml-4 list-decimal text-[#aaa]">$1</li>')
.replace(/\n\n/g, '</p><p class="mb-3 text-[#ccc] leading-relaxed">')
.replace(/\n/g, '<br/>');
}
// ─── Draft Preview Modal ───────────────────────────────────────────────────────
interface DraftPreviewModalProps {
content: string;
title: string;
penName: string;
siteId: number;
onClose: () => void;
onSave: (openEditor: boolean) => void;
}
function DraftPreviewModal({ content, title, penName, siteId, onClose, onSave }: DraftPreviewModalProps) {
const wordCount = content.replace(/<[^>]+>/g, '').split(/\s+/).filter(Boolean).length;
return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-[#111827] border border-[#1e2a3a] rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[#1e2a3a]">
<div>
<h2 className="text-white font-semibold text-sm">Draft Preview</h2>
<p className="text-[#555] text-xs mt-0.5">Review before saving to {siteId === 1 ? 'Broadcast Beat' : 'AV Beat'}</p>
</div>
<button onClick={onClose} className="text-[#555] hover:text-white transition-colors text-lg leading-none"></button>
</div>
<div className="flex items-center gap-4 px-5 py-3 bg-[#0d1117] border-b border-[#1e2a3a]">
<div className="flex items-center gap-2">
<span className="text-[#555] text-xs">Byline:</span>
<span className="text-white text-xs font-medium">{penName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[#555] text-xs">Site:</span>
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${siteId === 2 ? 'bg-orange-900/40 text-orange-400' : 'bg-blue-900/40 text-blue-400'}`}>
{siteId === 2 ? 'AV Beat' : 'Broadcast Beat'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[#555] text-xs">Words:</span>
<span className="text-[#aaa] text-xs">{wordCount}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[#555] text-xs">Status:</span>
<span className="text-yellow-400 text-xs">Draft</span>
</div>
</div>
<div className="px-5 py-3 border-b border-[#1e2a3a]">
<p className="text-[#555] text-xs mb-1">Title</p>
<p className="text-white text-base font-semibold">{title || 'Untitled Draft'}</p>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
<div
className="prose-sm text-[#ccc] text-sm leading-relaxed"
dangerouslySetInnerHTML={{
__html: `<p class="mb-3 text-[#ccc] leading-relaxed">${renderMarkdown(content)}</p>`,
}}
/>
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[#1e2a3a] bg-[#0d1117] rounded-b-xl">
<button onClick={onClose} className="text-[#555] hover:text-[#aaa] text-sm transition-colors">Cancel</button>
<div className="flex gap-3">
<button
onClick={() => onSave(false)}
className="bg-[#1e2a3a] hover:bg-[#253347] text-[#aaa] hover:text-white text-sm px-4 py-2 rounded-lg transition-colors border border-[#2a3a50]"
>
Save as Draft
</button>
<button
onClick={() => onSave(true)}
className="bg-[#5B7C8D] hover:bg-[#4A6473] text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
>
Open in Editor
</button>
</div>
</div>
</div>
</div>
);
}
// ─── Regenerate Modal ──────────────────────────────────────────────────────────
interface RegenerateModalProps {
onClose: () => void;
onRegenerate: (prompt: string) => void;
}
function RegenerateModal({ onClose, onRegenerate }: RegenerateModalProps) {
const [customPrompt, setCustomPrompt] = useState('');
const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
const variants = [
{ label: 'Different angle', prompt: 'Regenerate this with a completely different angle and approach.' },
{ label: 'More technical', prompt: 'Regenerate this with more technical depth and engineering detail.' },
{ label: 'More concise', prompt: 'Regenerate this as a shorter, more concise version (under 300 words).' },
{ label: 'More conversational', prompt: 'Regenerate this with a more conversational, accessible tone.' },
{ label: 'Lead with data', prompt: 'Regenerate this leading with industry data, statistics, or market context.' },
{ label: 'Stronger headline', prompt: 'Keep the same content but rewrite with a stronger, more compelling headline and opening paragraph.' },
{ label: 'Add quotes', prompt: 'Regenerate this and add realistic attributed quotes from company executives.' },
{ label: 'SEO optimized', prompt: 'Regenerate this with stronger SEO optimization — include keyword-rich subheadings and a meta description.' },
];
const handleSubmit = () => {
const prompt = customPrompt.trim() || selectedVariant || 'Please regenerate the previous response with a different approach.';
onRegenerate(prompt);
onClose();
};
return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-[#111827] border border-[#1e2a3a] rounded-xl w-full max-w-lg shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[#1e2a3a]">
<h2 className="text-white font-semibold text-sm">Regenerate with Different Prompt</h2>
<button onClick={onClose} className="text-[#555] hover:text-white transition-colors text-lg leading-none"></button>
</div>
<div className="p-5 space-y-4">
<div>
<p className="text-[#555] text-xs mb-2 uppercase tracking-wider font-bold">Quick Variants</p>
<div className="grid grid-cols-2 gap-2">
{variants.map(v => (
<button
key={v.label}
onClick={() => { setSelectedVariant(v.prompt); setCustomPrompt(''); }}
className={`text-left text-xs px-3 py-2 rounded border transition-colors ${
selectedVariant === v.prompt
? 'border-[#5B7C8D] bg-[#2F4F5F] text-white'
: 'border-[#1e2a3a] text-[#aaa] hover:border-[#2a3a50] hover:text-white hover:bg-[#161f2e]'
}`}
>
{v.label}
</button>
))}
</div>
</div>
<div>
<p className="text-[#555] text-xs mb-2 uppercase tracking-wider font-bold">Or Write Custom Instructions</p>
<textarea
value={customPrompt}
onChange={e => { setCustomPrompt(e.target.value); setSelectedVariant(null); }}
placeholder="e.g. Rewrite this focusing on the European market impact..."
rows={3}
className="w-full bg-[#0d1117] border border-[#1e2a3a] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#5B7C8D] placeholder-[#333] resize-none"
/>
</div>
{(selectedVariant || customPrompt.trim()) && (
<div className="bg-[#0d1117] border border-[#1e2a3a] rounded-lg px-3 py-2">
<p className="text-[#555] text-[10px] mb-1 uppercase tracking-wider">Will send:</p>
<p className="text-[#aaa] text-xs italic">{customPrompt.trim() || selectedVariant}</p>
</div>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[#1e2a3a] bg-[#0d1117] rounded-b-xl">
<button onClick={onClose} className="text-[#555] hover:text-[#aaa] text-sm transition-colors">Cancel</button>
<button
onClick={handleSubmit}
className="bg-[#5B7C8D] hover:bg-[#4A6473] text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
>
Regenerate
</button>
</div>
</div>
</div>
);
}
// ─── Style Guides Panel ────────────────────────────────────────────────────────
interface StyleGuidesPanelProps {
onClose: () => void;
onSelectPenName: (name: string) => void;
}
function StyleGuidesPanel({ onClose, onSelectPenName }: StyleGuidesPanelProps) {
const [leftPen, setLeftPen] = useState<PenNameProfile>(PEN_NAME_PROFILES[0]);
const [rightPen, setRightPen] = useState<PenNameProfile>(PEN_NAME_PROFILES[12]);
const [filterSite, setFilterSite] = useState<'ALL' | 'BB' | 'AV'>('ALL');
const filtered = PEN_NAME_PROFILES.filter(p => filterSite === 'ALL' || p.site === filterSite);
const PenCard = ({ profile, side }: { profile: PenNameProfile; side: 'left' | 'right' }) => (
<div className="flex-1 min-w-0 flex flex-col">
<div className="mb-3">
<select
value={profile.name}
onChange={e => {
const found = PEN_NAME_PROFILES.find(p => p.name === e.target.value);
if (found) side === 'left' ? setLeftPen(found) : setRightPen(found);
}}
className="w-full bg-[#0d1117] border border-[#1e2a3a] text-white text-sm rounded px-2 py-1.5 focus:outline-none focus:border-[#5B7C8D]"
>
{PEN_NAME_PROFILES.map(p => (
<option key={p.name} value={p.name}>{p.name} ({p.site})</option>
))}
</select>
</div>
<div className="bg-[#0d1117] border border-[#1e2a3a] rounded-lg p-4 flex-1 space-y-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${profile.site === 'AV' ? 'bg-orange-900/40 text-orange-400' : 'bg-blue-900/40 text-blue-400'}`}>
{profile.site}
</span>
<span className="text-white font-semibold text-sm">{profile.name}</span>
</div>
<p className="text-[#5B7C8D] text-xs">{profile.title}</p>
</div>
<div>
<p className="text-[#555] text-[10px] uppercase tracking-wider font-bold mb-1">Beat</p>
<p className="text-[#aaa] text-xs leading-relaxed">{profile.beat}</p>
</div>
<div>
<p className="text-[#555] text-[10px] uppercase tracking-wider font-bold mb-1">Writing Style</p>
<p className="text-[#ccc] text-xs leading-relaxed">{profile.styleNotes}</p>
</div>
<div>
<p className="text-[#555] text-[10px] uppercase tracking-wider font-bold mb-1">Tone Keywords</p>
<div className="flex flex-wrap gap-1">
{profile.toneKeywords.map(kw => (
<span key={kw} className="text-[10px] bg-[#1e2a3a] text-[#aaa] px-2 py-0.5 rounded-full border border-[#2a3a50]">{kw}</span>
))}
</div>
</div>
<div>
<p className="text-[#555] text-[10px] uppercase tracking-wider font-bold mb-1">Example Lead</p>
<p className="text-[#aaa] text-xs italic leading-relaxed border-l-2 border-[#5B7C8D]/40 pl-3">"{profile.exampleLead}"</p>
</div>
<button
onClick={() => onSelectPenName(profile.name)}
className="w-full text-xs bg-[#1e2a3a] hover:bg-[#253347] text-[#aaa] hover:text-white border border-[#2a3a50] px-3 py-2 rounded-lg transition-colors"
>
Use {profile.name} as Byline
</button>
</div>
</div>
);
return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-[#111827] border border-[#1e2a3a] rounded-xl w-full max-w-5xl max-h-[90vh] flex flex-col shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[#1e2a3a]">
<div className="flex items-center gap-4">
<h2 className="text-white font-semibold text-sm">Pen Name Style Guides</h2>
<div className="flex gap-1">
{(['ALL', 'BB', 'AV'] as const).map(f => (
<button
key={f}
onClick={() => setFilterSite(f)}
className={`text-xs px-2.5 py-1 rounded transition-colors ${filterSite === f ? 'bg-[#5B7C8D] text-white' : 'bg-[#1e2a3a] text-[#aaa] hover:text-white'}`}
>
{f === 'ALL' ? 'All Writers' : f === 'BB' ? 'Broadcast Beat' : 'AV Beat'}
</button>
))}
</div>
</div>
<button onClick={onClose} className="text-[#555] hover:text-white transition-colors text-lg leading-none"></button>
</div>
<div className="p-5 flex-1 overflow-y-auto">
<p className="text-[#555] text-xs mb-4">Compare two pen name styles side by side. Select a writer from each dropdown.</p>
<div className="flex gap-5">
<PenCard profile={leftPen} side="left" />
<div className="w-px bg-[#1e2a3a] flex-shrink-0" />
<PenCard profile={rightPen} side="right" />
</div>
<div className="mt-6 pt-5 border-t border-[#1e2a3a]">
<p className="text-[#555] text-xs uppercase tracking-wider font-bold mb-3">All Writers Quick Reference</p>
<div className="grid grid-cols-2 gap-2">
{filtered.map(p => (
<button
key={p.name}
onClick={() => setLeftPen(p)}
className="text-left p-2.5 rounded border border-[#1e2a3a] hover:border-[#2a3a50] hover:bg-[#161f2e] transition-colors group"
>
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-[9px] font-bold px-1 rounded ${p.site === 'AV' ? 'bg-orange-900/40 text-orange-400' : 'bg-blue-900/40 text-blue-400'}`}>{p.site}</span>
<span className="text-white text-xs font-medium group-hover:text-[#5B7C8D] transition-colors">{p.name}</span>
</div>
<p className="text-[#555] text-[10px]">{p.title}</p>
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
}
// ─── Response History Sidebar ──────────────────────────────────────────────────
interface ResponseHistorySidebarProps {
messages: Message[];
onJumpTo: (msgId: string) => void;
onClose: () => void;
}
function ResponseHistorySidebar({ messages, onJumpTo, onClose }: ResponseHistorySidebarProps) {
const assistantMessages = messages.filter(m => m.role === 'assistant' && m.content);
return (
<div className="w-72 flex-shrink-0 bg-[#111827] border-l border-[#1e2a3a] flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-[#1e2a3a]">
<h2 className="text-[#555] text-xs font-bold uppercase tracking-widest">Response History</h2>
<button onClick={onClose} className="text-[#555] hover:text-[#aaa] text-xs transition-colors"></button>
</div>
{assistantMessages.length === 0 ? (
<div className="p-4 text-center">
<p className="text-[#444] text-xs">No responses yet in this session</p>
</div>
) : (
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{assistantMessages.map((msg, idx) => {
const preview = msg.content.replace(/<[^>]+>/g, '').slice(0, 120);
const wordCount = msg.content.split(/\s+/).filter(Boolean).length;
return (
<button
key={msg.id}
onClick={() => onJumpTo(msg.id)}
className="w-full text-left p-3 rounded border border-[#1e2a3a] hover:border-[#2a3a50] hover:bg-[#161f2e] transition-colors group"
>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-[#5B7C8D] font-bold">Response #{idx + 1}</span>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${msg.provider === 'ollama' ? 'bg-green-500' : 'bg-yellow-500'}`} />
</div>
<p className="text-[#aaa] text-xs line-clamp-3 leading-relaxed">{preview}</p>
<div className="flex items-center gap-3 mt-2">
<span className="text-[10px] text-[#444]">{msg.timestamp.toLocaleTimeString()}</span>
<span className="text-[10px] text-[#444]">{wordCount} words</span>
<span className="text-[10px] text-[#5B7C8D] opacity-0 group-hover:opacity-100 transition-opacity">Jump </span>
</div>
</button>
);
})}
</div>
)}
{assistantMessages.length > 0 && (
<div className="p-3 border-t border-[#1e2a3a]">
<p className="text-[#444] text-[10px] text-center">
{assistantMessages.length} response{assistantMessages.length !== 1 ? 's' : ''} in this session
</p>
</div>
)}
</div>
);
}
// ─── Main Component ────────────────────────────────────────────────────────────
export default function AIConsolePage() {
const { user, loading } = useAuth();
const router = useRouter();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [sessions, setSessions] = useState<Session[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [aiStatus, setAiStatus] = useState<AIStatus | null>(null);
const [selectedSite, setSelectedSite] = useState<1 | 2>(1);
const [selectedPenName, setSelectedPenName] = useState('Michael Strand');
const [urlInput, setUrlInput] = useState('');
const [showUrlInput, setShowUrlInput] = useState(false);
const [fetchingUrl, setFetchingUrl] = useState(false);
const [urlContext, setUrlContext] = useState<{ content: string; domain: string; wordCount: number } | null>(null);
const [lastResponseContent, setLastResponseContent] = useState('');
const [lastResponseTitle, setLastResponseTitle] = useState('');
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// New feature states
const [showHistorySidebar, setShowHistorySidebar] = useState(false);
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
const [showStyleGuides, setShowStyleGuides] = useState(false);
const [showDraftPreview, setShowDraftPreview] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageRefs = useRef<Record<string, HTMLDivElement | null>>({});
const inputRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 4000);
};
useEffect(() => {
if (!loading && !user) router.push('/login');
}, [user, loading, router]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !isStreaming) {
e.preventDefault();
handleSend();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [input, isStreaming]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (!user) return;
fetch('/api/ai/console?action=ping')
.then(r => r.json())
.then(data => setAiStatus(data))
.catch(() => {});
fetch('/api/ai/console-settings', { method: 'PATCH' })
.then(r => r.json())
.then(data => setSessions(data.sessions || []))
.catch(() => {});
const interval = setInterval(() => {
fetch('/api/ai/console?action=ping')
.then(r => r.json())
.then(data => setAiStatus(data))
.catch(() => {});
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [user]);
useEffect(() => {
setSelectedPenName(selectedSite === 1 ? 'Michael Strand' : 'Rex Chandler');
}, [selectedSite]);
const penNames = selectedSite === 1 ? BB_PEN_NAMES : AV_PEN_NAMES;
const buildSystemPrompt = () => {
const siteName = selectedSite === 1 ? 'Broadcast Beat' : 'AV Beat';
const siteDesc = selectedSite === 1
? 'a professional B2B trade publication for broadcast engineers, production professionals, and industry executives'
: 'a professional B2B trade publication for AV integrators, technology managers, and AV industry professionals';
const audience = selectedSite === 1
? 'broadcast engineers, production professionals, and industry executives' :'AV integrators, technology managers, and AV industry professionals';
const availablePenNames = penNames.join(', ');
return `You are an AI editorial assistant for ${siteName} (${siteDesc}).
You help administrators generate professional industry news stories, press release rewrites, exhibitor previews, social media content, and other editorial content.
When writing stories:
- Write in the style of ${selectedPenName} if specified
- Maintain a professional B2B trade publication tone
- Target audience: ${audience}
- Always write original content — never copy source text
- Keep technical terminology accurate
- Lead with the news hook, not company boilerplate
- Never mention competitor publications
Available pen names for this session: ${availablePenNames}
Respond conversationally and helpfully. If asked to write a story, write the full story unless told otherwise. If asked for options or headlines, provide a numbered list.`;
};
const handleSend = async (overrideInput?: string) => {
const sendInput = overrideInput !== undefined ? overrideInput : input;
if (!sendInput.trim() || isStreaming) return;
const userContent = urlContext
? `${sendInput}\n\n---\nContent from ${urlContext.domain} (${urlContext.wordCount} words):\n${urlContext.content}`
: sendInput;
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: sendInput,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMsg]);
if (overrideInput === undefined) setInput('');
setUrlContext(null);
setShowUrlInput(false);
setIsStreaming(true);
let sessionId = currentSessionId;
if (!sessionId) {
try {
const res = await fetch('/api/ai/console-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'create_session',
site_id: selectedSite,
pen_name: selectedPenName,
first_message: sendInput,
}),
});
const data = await res.json();
sessionId = data.session?.id || null;
setCurrentSessionId(sessionId);
} catch {}
}
if (sessionId) {
fetch('/api/ai/console-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_message', session_id: sessionId, role: 'user', content: sendInput }),
}).catch(() => {});
}
const apiMessages = [
{ role: 'system', content: buildSystemPrompt() },
...messages.map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: userContent },
];
const assistantMsgId = (Date.now() + 1).toString();
setMessages(prev => [...prev, {
id: assistantMsgId,
role: 'assistant',
content: '',
timestamp: new Date(),
}]);
let fullContent = '';
let responseProvider: 'ollama' | 'anthropic' = 'anthropic';
try {
abortRef.current = new AbortController();
const response = await fetch('/api/ai/console', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: apiMessages, stream: true, sessionId }),
signal: abortRef.current.signal,
});
if (!response.ok) throw new Error('AI request failed');
responseProvider = (response.headers.get('X-AI-Provider') as 'ollama' | 'anthropic') || 'anthropic';
const reader = response.body?.getReader();
if (!reader) throw new Error('No stream');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'chunk' && data.chunk) {
fullContent += data.chunk;
setMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: fullContent, provider: responseProvider }
: m
));
} else if (data.type === 'start' && data.provider) {
responseProvider = data.provider;
}
} catch {}
}
}
}
} catch (err: any) {
if (err.name !== 'AbortError') {
setMessages(prev => prev.map(m =>
m.id === assistantMsgId
? { ...m, content: 'Error: Could not get AI response. Please try again.' }
: m
));
}
} finally {
setIsStreaming(false);
setLastResponseContent(fullContent);
const titleMatch = fullContent.match(/^#+ (.+)$/m) || fullContent.match(/^(.{20,80})\n/);
setLastResponseTitle(titleMatch?.[1] || 'AI Draft');
if (sessionId && fullContent) {
fetch('/api/ai/console-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_message', session_id: sessionId, role: 'assistant', content: fullContent, provider: responseProvider }),
}).catch(() => {});
}
fetch('/api/ai/console-settings', { method: 'PATCH' })
.then(r => r.json())
.then(data => setSessions(data.sessions || []))
.catch(() => {});
}
};
const handleFetchUrl = async () => {
if (!urlInput.trim()) return;
setFetchingUrl(true);
try {
const res = await fetch('/api/ai/fetch-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: urlInput }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
setUrlContext({ content: data.content, domain: data.domain, wordCount: data.wordCount });
setUrlInput('');
setShowUrlInput(false);
showToast(`Added content from ${data.domain} (${data.wordCount} words) to context`);
} catch (err: any) {
showToast(err.message || 'Failed to fetch URL', 'error');
} finally {
setFetchingUrl(false);
}
};
const handleSaveDraft = async (openEditor = false) => {
if (!lastResponseContent) return;
setShowDraftPreview(false);
try {
const res = await fetch('/api/ai/console-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'save_draft',
title: lastResponseTitle,
content: `<p>${lastResponseContent.replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br/>')}</p>`,
pen_name: selectedPenName,
site_id: selectedSite,
}),
});
const data = await res.json();
if (data.draft) {
showToast('Saved as draft');
if (openEditor) router.push(`/admin/articles?draft=${data.draft.id}`);
}
} catch {
showToast('Failed to save draft', 'error');
}
};
const handleDeleteSession = async (sessionId: string) => {
await fetch('/api/ai/console-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_session', session_id: sessionId }),
});
setSessions(prev => prev.filter(s => s.id !== sessionId));
if (currentSessionId === sessionId) {
setCurrentSessionId(null);
setMessages([]);
}
};
const handleNewSession = () => {
setCurrentSessionId(null);
setMessages([]);
setLastResponseContent('');
setLastResponseTitle('');
setUrlContext(null);
setShowHistorySidebar(false);
inputRef.current?.focus();
};
const handleJumpToMessage = (msgId: string) => {
const el = messageRefs.current[msgId];
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const getStatusDot = () => {
if (!aiStatus) return { color: 'bg-gray-500', label: 'Checking...' };
if (aiStatus.ollama.online) return { color: 'bg-green-500', label: `Local Ollama (${aiStatus.ollama.latencyMs}ms)` };
if (aiStatus.anthropic.configured) return { color: 'bg-yellow-500', label: 'Cloud AI (Anthropic)' };
return { color: 'bg-red-500', label: 'No AI available' };
};
const statusDot = getStatusDot();
const isFailover = aiStatus && !aiStatus.ollama.online && aiStatus.anthropic.configured;
const assistantMessageCount = messages.filter(m => m.role === 'assistant' && m.content).length;
if (loading) return null;
return (
<div className="min-h-screen bg-[#0d1117] flex flex-col">
{/* Header */}
<div className="bg-[#111827] border-b border-[#1e2a3a] px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin" className="text-[#555] hover:text-[#5B7C8D] text-sm transition-colors"> Dashboard</Link>
<span className="text-[#333]">/</span>
<h1 className="text-white font-bold text-sm tracking-wide">AI Console</h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowStyleGuides(true)}
className="text-xs text-[#555] hover:text-[#aaa] bg-[#1e2a3a] hover:bg-[#253347] border border-[#2a3a50] px-2.5 py-1.5 rounded transition-colors"
title="View pen name style guides side by side"
>
Style Guides
</button>
<button
onClick={() => setShowHistorySidebar(v => !v)}
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${
showHistorySidebar
? 'bg-[#2F4F5F] border-[#5B7C8D]/40 text-[#5B7C8D]'
: 'bg-[#1e2a3a] border-[#2a3a50] text-[#555] hover:text-[#aaa]'
}`}
title="Toggle response history sidebar"
>
History
{assistantMessageCount > 0 && (
<span className="ml-1.5 bg-[#5B7C8D] text-white text-[9px] font-bold px-1 py-0.5 rounded-full">{assistantMessageCount}</span>
)}
</button>
<Link href="/dashboard/ai-console/health" className="text-[#555] hover:text-[#5B7C8D] text-xs transition-colors">Health</Link>
<Link href="/dashboard/ai-console/settings" className="text-[#555] hover:text-[#5B7C8D] text-xs transition-colors">Settings</Link>
<button onClick={handleNewSession} className="text-xs bg-[#1e2a3a] hover:bg-[#253347] text-[#aaa] px-3 py-1.5 rounded transition-colors">
+ New Session
</button>
</div>
</div>
{/* Failover banner */}
{isFailover && (
<div className="bg-yellow-900/30 border-b border-yellow-700/40 px-4 py-2 text-center">
<span className="text-yellow-400 text-xs"> Using cloud AI local server unavailable</span>
</div>
)}
<div className="flex flex-1 overflow-hidden">
{/* LEFT PANEL */}
<div className="w-72 flex-shrink-0 bg-[#111827] border-r border-[#1e2a3a] flex flex-col overflow-y-auto">
{/* Quick Actions */}
<div className="p-4 border-b border-[#1e2a3a]">
<h2 className="text-[#555] text-xs font-bold uppercase tracking-widest mb-3">Quick Actions</h2>
<div className="space-y-1">
{QUICK_ACTIONS.map(action => (
<button
key={action.key}
onClick={() => {
setInput(QUICK_ACTION_TEMPLATES[action.key] || '');
inputRef.current?.focus();
}}
className="w-full text-left text-xs text-[#aaa] hover:text-white hover:bg-[#1e2a3a] px-3 py-2 rounded transition-colors"
>
{action.label}
</button>
))}
</div>
</div>
{/* Recent Sessions */}
<div className="p-4 border-b border-[#1e2a3a] flex-1">
<h2 className="text-[#555] text-xs font-bold uppercase tracking-widest mb-3">Recent Sessions</h2>
{sessions.length === 0 ? (
<p className="text-[#444] text-xs">No sessions yet</p>
) : (
<div className="space-y-2">
{sessions.map(session => (
<div
key={session.id}
className={`group p-2.5 rounded border cursor-pointer transition-colors ${
currentSessionId === session.id
? 'border-[#5B7C8D]/40 bg-[#1e2a3a]'
: 'border-[#1e2a3a] hover:border-[#2a3a50] hover:bg-[#161f2e]'
}`}
>
<div className="flex items-start justify-between gap-1">
<p className="text-xs text-[#aaa] line-clamp-2 flex-1">{session.first_message_preview || 'Session'}</p>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteSession(session.id); }}
className="text-[#333] hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all text-xs flex-shrink-0"
title="Delete session"
></button>
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`text-[10px] px-1 rounded font-bold ${session.site_id === 2 ? 'bg-orange-900/40 text-orange-400' : 'bg-blue-900/40 text-blue-400'}`}>
{session.site_id === 2 ? 'AV' : 'BB'}
</span>
<span className="text-[10px] text-[#444]">
{new Date(session.last_message_at).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* AI Status */}
<div className="p-4">
<h2 className="text-[#555] text-xs font-bold uppercase tracking-widest mb-3">AI Status</h2>
<div className="flex items-center gap-2 mb-2">
<div className={`w-2.5 h-2.5 rounded-full ${statusDot.color} flex-shrink-0`} />
<span className="text-xs text-[#aaa]">{statusDot.label}</span>
</div>
{aiStatus?.ollama.models && aiStatus.ollama.models.length > 0 && (
<div className="text-[10px] text-[#444] mb-2">
Models: {aiStatus.ollama.models.slice(0, 3).join(', ')}
{aiStatus.ollama.models.length > 3 && ` +${aiStatus.ollama.models.length - 3} more`}
</div>
)}
<div className="flex gap-2 mt-2">
<button
onClick={() => {
fetch('/api/ai/console?action=ping').then(r => r.json()).then(setAiStatus).catch(() => {});
}}
className="text-[10px] text-[#555] hover:text-[#5B7C8D] transition-colors"
>
Refresh
</button>
<Link href="/dashboard/ai-console/health" className="text-[10px] text-[#555] hover:text-[#5B7C8D] transition-colors">
Health Dashboard
</Link>
</div>
</div>
</div>
{/* CENTER — Chat */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Site + Pen Name selectors */}
<div className="bg-[#0d1117] border-b border-[#1e2a3a] px-4 py-2 flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-[#555] text-xs">Publish to:</span>
<select
value={selectedSite}
onChange={e => setSelectedSite(Number(e.target.value) as 1 | 2)}
className="bg-[#1e2a3a] border border-[#2a3a50] text-white text-xs rounded px-2 py-1 focus:outline-none focus:border-[#5B7C8D]"
>
<option value={1}>Broadcast Beat</option>
<option value={2}>AV Beat</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-[#555] text-xs">Byline:</span>
<select
value={selectedPenName}
onChange={e => setSelectedPenName(e.target.value)}
className="bg-[#1e2a3a] border border-[#2a3a50] text-white text-xs rounded px-2 py-1 focus:outline-none focus:border-[#5B7C8D]"
>
{penNames.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<button
onClick={() => setShowStyleGuides(true)}
className="text-[10px] text-[#5B7C8D] hover:text-[#8FB0C3] transition-colors ml-auto"
>
View style guide
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-12 h-12 rounded-full bg-[#1e2a3a] flex items-center justify-center mb-4">
<span className="text-2xl"></span>
</div>
<h2 className="text-white font-semibold mb-2">AI Editorial Console</h2>
<p className="text-[#555] text-sm max-w-md">
Generate stories, headlines, social posts, and more. Use Quick Actions on the left or type your request below.
</p>
<p className="text-[#333] text-xs mt-3">Cmd+Enter to send · Cmd+Shift+A to open from anywhere</p>
</div>
)}
{messages.map((msg, idx) => (
<div
key={msg.id}
ref={el => { messageRefs.current[msg.id] = el; }}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[80%] ${msg.role === 'user' ? 'order-2' : 'order-1'}`}>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mb-1">
<div className={`w-1.5 h-1.5 rounded-full ${msg.provider === 'ollama' ? 'bg-green-500' : 'bg-yellow-500'}`} />
<span className="text-[10px] text-[#444]">
{msg.provider === 'ollama' ? 'Local AI' : 'Cloud AI'} · {msg.timestamp.toLocaleTimeString()}
</span>
</div>
)}
<div className={`rounded-lg px-4 py-3 ${
msg.role === 'user' ?'bg-[#2F4F5F] text-white text-sm' :'bg-[#111827] border border-[#1e2a3a] text-[#ccc] text-sm'
}`}>
{msg.role === 'user' ? (
<p className="whitespace-pre-wrap">{msg.content}</p>
) : (
<div
className="prose-sm"
dangerouslySetInnerHTML={{
__html: `<p class="mb-3 text-[#ccc] leading-relaxed">${renderMarkdown(msg.content)}</p>`,
}}
/>
)}
{msg.role === 'user' && (
<p className="text-[10px] text-[#4a6a8a] mt-1 text-right">{msg.timestamp.toLocaleTimeString()}</p>
)}
</div>
{/* Post-response action bar */}
{msg.role === 'assistant' && idx === messages.length - 1 && !isStreaming && msg.content && (
<div className="flex items-center gap-2 mt-2 flex-wrap">
<button
onClick={() => navigator.clipboard.writeText(msg.content).then(() => showToast('Copied'))}
className="text-[10px] text-[#555] hover:text-[#aaa] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
Copy
</button>
<button
onClick={() => setShowDraftPreview(true)}
className="text-[10px] text-[#5B7C8D] hover:text-[#8FB0C3] bg-[#111827] border border-[#5B7C8D]/30 px-2 py-1 rounded transition-colors font-medium"
>
Preview Draft
</button>
<button
onClick={() => handleSaveDraft(false)}
className="text-[10px] text-[#555] hover:text-[#aaa] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
Save as Draft
</button>
<button
onClick={() => handleSaveDraft(true)}
className="text-[10px] text-[#555] hover:text-[#aaa] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
Open in Editor
</button>
<button
onClick={() => setShowRegenerateModal(true)}
className="text-[10px] text-[#555] hover:text-[#aaa] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
Regenerate
</button>
<button
onClick={() => handleSend('Make the previous response shorter and more concise.')}
className="text-[10px] text-[#555] hover:text-[#aaa] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
Make Shorter
</button>
<button
onClick={() => handleSend('Expand the previous response with more detail and depth.')}
className="text-[10px] text-[#555] hover:text-[#aaa] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
Make Longer
</button>
</div>
)}
</div>
</div>
))}
{isStreaming && messages[messages.length - 1]?.role === 'assistant' && messages[messages.length - 1]?.content === '' && (
<div className="flex justify-start">
<div className="bg-[#111827] border border-[#1e2a3a] rounded-lg px-4 py-3">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 bg-[#5B7C8D] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-1.5 h-1.5 bg-[#5B7C8D] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-1.5 h-1.5 bg-[#5B7C8D] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* URL context indicator */}
{urlContext && (
<div className="mx-4 mb-2 bg-[#1e2a3a] border border-[#2a3a50] rounded px-3 py-2 flex items-center justify-between">
<span className="text-xs text-[#aaa]">
📎 Context from <strong className="text-white">{urlContext.domain}</strong> ({urlContext.wordCount} words)
</span>
<button onClick={() => setUrlContext(null)} className="text-[#555] hover:text-red-400 text-xs ml-2"></button>
</div>
)}
{/* URL input */}
{showUrlInput && (
<div className="mx-4 mb-2 flex gap-2">
<input
type="url"
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleFetchUrl()}
placeholder="https://example.com/press-release"
className="flex-1 bg-[#1e2a3a] border border-[#2a3a50] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-[#5B7C8D] placeholder-[#444]"
autoFocus
/>
<button
onClick={handleFetchUrl}
disabled={fetchingUrl}
className="bg-[#5B7C8D] hover:bg-[#4A6473] text-white text-xs px-3 py-2 rounded transition-colors disabled:opacity-50"
>
{fetchingUrl ? 'Fetching...' : 'Add'}
</button>
<button onClick={() => setShowUrlInput(false)} className="text-[#555] hover:text-[#aaa] text-xs px-2"></button>
</div>
)}
{/* Input area */}
<div className="border-t border-[#1e2a3a] bg-[#0d1117] p-4">
<div className="flex gap-2 mb-2">
<button
onClick={() => setShowUrlInput(!showUrlInput)}
className="text-xs text-[#555] hover:text-[#5B7C8D] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
+ Add URL
</button>
<button
onClick={() => setInput(prev => prev + '\n\n[Paste story content here for reference]')}
className="text-xs text-[#555] hover:text-[#5B7C8D] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
+ Add Story
</button>
<button
onClick={() => setInput(prev => prev + `\n\nWrite in the style of ${selectedPenName}.`)}
className="text-xs text-[#555] hover:text-[#5B7C8D] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
+ Add Style
</button>
</div>
<div className="flex gap-3">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleSend();
}
}}
placeholder="Ask the AI to write a story, generate headlines, translate content..."
rows={3}
className="flex-1 bg-[#111827] border border-[#1e2a3a] text-white text-sm rounded-lg px-4 py-3 focus:outline-none focus:border-[#5B7C8D] placeholder-[#333] resize-none"
/>
<div className="flex flex-col gap-2">
<button
onClick={() => handleSend()}
disabled={isStreaming || !input.trim()}
className="bg-[#5B7C8D] hover:bg-[#4A6473] disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium flex-1"
>
{isStreaming ? (
<span className="flex items-center gap-1">
<span className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
</span>
) : 'Send'}
</button>
{isStreaming && (
<button
onClick={() => abortRef.current?.abort()}
className="text-xs text-[#555] hover:text-red-400 bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
>
Stop
</button>
)}
</div>
</div>
<p className="text-[#333] text-[10px] mt-1.5">Cmd+Enter to send</p>
</div>
</div>
{/* RIGHT — Response History Sidebar */}
{showHistorySidebar && (
<ResponseHistorySidebar
messages={messages}
onJumpTo={handleJumpToMessage}
onClose={() => setShowHistorySidebar(false)}
/>
)}
</div>
{/* Toast */}
{toast && (
<div className={`fixed bottom-6 right-6 px-4 py-3 rounded-lg text-sm font-medium shadow-lg z-50 transition-all ${
toast.type === 'error' ? 'bg-red-900 text-red-200 border border-red-700' : 'bg-green-900 text-green-200 border border-green-700'
}`}>
{toast.message}
</div>
)}
{/* Modals */}
{showDraftPreview && lastResponseContent && (
<DraftPreviewModal
content={lastResponseContent}
title={lastResponseTitle}
penName={selectedPenName}
siteId={selectedSite}
onClose={() => setShowDraftPreview(false)}
onSave={handleSaveDraft}
/>
)}
{showRegenerateModal && (
<RegenerateModal
onClose={() => setShowRegenerateModal(false)}
onRegenerate={(prompt) => handleSend(prompt)}
/>
)}
{showStyleGuides && (
<StyleGuidesPanel
onClose={() => setShowStyleGuides(false)}
onSelectPenName={(name) => {
setSelectedPenName(name);
setShowStyleGuides(false);
showToast(`Byline set to ${name}`);
}}
/>
)}
</div>
);
}