- New AvBeatLogo inline-SVG component (rounded-square emblem with forward-leaning AV monogram + horizontal beam detail) at src/components/AvBeatLogo.tsx, three variants (full/wordmark/icon). - Header.tsx now uses the responsive AV BEAT logo lockup in the top-left, links to /, full lockup on desktop (emblem+wordmark+tagline), emblem+wordmark on tablet, emblem-only on mobile. - Removed DualSpeedTicker (the 'RECENT FORUM POSTS' ticker strip); the news ticker / glow chrome does not match the new aesthetic and the forum row was the explicit removal target. - Tailwind theme + globals.css now expose the AV BEAT 2026 palette as semantic tokens: --brand-blue (#1D4ED8), --brand-blue-bright (#38BDF8), --brand-navy (#0F172A), --brand-bg (#F8FAFC), --brand-surface, --brand-border, --brand-text, --brand-text-muted. Legacy --color-* aliases re-point to the new tokens so any inline-styled component re-themes for free. - Site-wide hex sweep migrates 2,769 hardcoded color literals across 144 files from the old dark-broadcast palette to the new tokens (orange -> blue, dark-brown -> white surface / navy text, cream -> navy). - Layout body class flipped from 'bb-neon' to 'bg-brand-bg text-brand-text' so the dark glow chrome no longer leaks through the new light theme.
879 lines
44 KiB
TypeScript
879 lines
44 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 ShowEvent {
|
||
id: string;
|
||
event_name: string;
|
||
event_type: string;
|
||
site_id: number;
|
||
city: string | null;
|
||
venue: string | null;
|
||
country: string | null;
|
||
start_date: string | null;
|
||
end_date: string | null;
|
||
story_engine_start_date: string | null;
|
||
story_engine_end_date: string | null;
|
||
official_site: string | null;
|
||
seo_tier: number;
|
||
status: string;
|
||
year: number;
|
||
phase1_volume_min: number;
|
||
phase1_volume_max: number;
|
||
phase2_volume_min: number;
|
||
phase2_volume_max: number;
|
||
phase3_volume_min: number;
|
||
phase3_volume_max: number;
|
||
phase4_volume_min: number;
|
||
phase4_volume_max: number;
|
||
engine_paused: boolean;
|
||
notes: string | null;
|
||
current_phase: string;
|
||
}
|
||
|
||
interface StoryQueueItem {
|
||
id: string;
|
||
company_name: string;
|
||
story_type: string;
|
||
show_story_type: string | null;
|
||
show_phase: string | null;
|
||
assigned_pen_name: string;
|
||
site_id: number;
|
||
status: string;
|
||
generated_title: string | null;
|
||
generated_content: string | null;
|
||
generated_excerpt: string | null;
|
||
quality_confidence: number | null;
|
||
source_show: string | null;
|
||
seo_meta_description: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
interface Exhibitor {
|
||
id: string;
|
||
company_name: string;
|
||
booth_number: string | null;
|
||
preview_story_status: string;
|
||
announcement_count: number;
|
||
is_av_eligible: boolean;
|
||
created_at: string;
|
||
}
|
||
|
||
type TabType = 'calendar' | 'queue' | 'exhibitors' | 'style-profiles' | 'settings';
|
||
type SiteFilter = '' | '1' | '2';
|
||
|
||
const SITE_CONFIG = {
|
||
1: { name: 'AV Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]' },
|
||
2: { name: 'AV Beat', badge: 'AV', color: '#f97316', bgClass: 'bg-[#f97316]/10', textClass: 'text-[#f97316]' },
|
||
};
|
||
|
||
const SEO_TIER_LABELS: Record<number, { label: string; color: string }> = {
|
||
1: { label: 'Tier 1 — Max SEO', color: 'text-yellow-400' },
|
||
2: { label: 'Tier 2 — High', color: 'text-blue-400' },
|
||
3: { label: 'Tier 3 — Standard',color: 'text-gray-400' },
|
||
4: { label: 'Tier 4 — Light', color: 'text-gray-500' },
|
||
};
|
||
|
||
const PHASE_LABELS: Record<string, { label: string; color: string }> = {
|
||
phase1_buildup: { label: 'Phase 1 — Buildup', color: 'text-blue-400' },
|
||
phase2_surge: { label: 'Phase 2 — Surge', color: 'text-yellow-400' },
|
||
phase3_show_week: { label: 'Phase 3 — Show Week', color: 'text-green-400' },
|
||
phase4_post_show: { label: 'Phase 4 — Post-Show', color: 'text-orange-400' },
|
||
phase5_off_season: { label: 'Phase 5 — Off-Season',color: 'text-gray-500' },
|
||
};
|
||
|
||
function SiteBadge({ siteId }: { siteId: number }) {
|
||
const cfg = SITE_CONFIG[siteId as 1 | 2] || SITE_CONFIG[1];
|
||
return (
|
||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold ${cfg.bgClass} ${cfg.textClass}`}>
|
||
{cfg.badge}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function formatDate(d: string | null): string {
|
||
if (!d) return '—';
|
||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||
}
|
||
|
||
export default function ShowCalendarPage() {
|
||
const { user, loading } = useAuth();
|
||
const router = useRouter();
|
||
|
||
const [activeTab, setActiveTab] = useState<TabType>('calendar');
|
||
const [siteFilter, setSiteFilter] = useState<SiteFilter>('');
|
||
const [showFilter, setShowFilter] = useState('');
|
||
const [queueStatus, setQueueStatus] = useState('generated');
|
||
|
||
const [events, setEvents] = useState<ShowEvent[]>([]);
|
||
const [queue, setQueue] = useState<StoryQueueItem[]>([]);
|
||
const [exhibitors, setExhibitors] = useState<Exhibitor[]>([]);
|
||
const [styleProfiles, setStyleProfiles] = useState<any[]>([]);
|
||
|
||
const [loadingData, setLoadingData] = useState(true);
|
||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||
const [expandedItem, setExpandedItem] = useState<string | null>(null);
|
||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||
|
||
// Add/Edit event modal
|
||
const [showEventModal, setShowEventModal] = useState(false);
|
||
const [editingEvent, setEditingEvent] = useState<Partial<ShowEvent> | null>(null);
|
||
|
||
// Generate story modal
|
||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||
const [generateForm, setGenerateForm] = useState({
|
||
event_id: '', story_type: 'exhibitor_preview', company_name: '', source_content: '',
|
||
});
|
||
|
||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||
setToast({ message, type });
|
||
setTimeout(() => setToast(null), 4000);
|
||
};
|
||
|
||
const fetchData = useCallback(async () => {
|
||
setLoadingData(true);
|
||
try {
|
||
const params = new URLSearchParams({ tab: activeTab });
|
||
if (siteFilter) params.set('site', siteFilter);
|
||
if (showFilter) params.set('show', showFilter);
|
||
if (activeTab === 'queue' && queueStatus) params.set('status', queueStatus);
|
||
|
||
const res = await fetch(`/api/admin/show-calendar?${params}`);
|
||
const data = await res.json();
|
||
|
||
if (activeTab === 'calendar') setEvents(data.events || []);
|
||
else if (activeTab === 'queue') setQueue(data.queue || []);
|
||
else if (activeTab === 'exhibitors') setExhibitors(data.exhibitors || []);
|
||
else if (activeTab === 'style-profiles') setStyleProfiles(data.profiles || []);
|
||
} catch { showToast('Failed to load data', 'error'); }
|
||
finally { setLoadingData(false); }
|
||
}, [activeTab, siteFilter, showFilter, queueStatus]);
|
||
|
||
useEffect(() => {
|
||
if (!loading && !user) router.push('/login');
|
||
}, [user, loading, router]);
|
||
|
||
useEffect(() => {
|
||
if (user) fetchData();
|
||
}, [user, fetchData]);
|
||
|
||
const handleToggleEngine = async (eventId: string, paused: boolean) => {
|
||
setActionLoading(eventId);
|
||
try {
|
||
const res = await fetch('/api/admin/show-calendar', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'toggle_engine', event_id: eventId, paused }),
|
||
});
|
||
if ((await res.json()).success) {
|
||
showToast(paused ? 'Engine paused' : 'Engine resumed');
|
||
fetchData();
|
||
}
|
||
} catch { showToast('Failed', 'error'); }
|
||
finally { setActionLoading(null); }
|
||
};
|
||
|
||
const handleExtractStyle = async (eventName: string) => {
|
||
setActionLoading('style-' + eventName);
|
||
try {
|
||
const res = await fetch('/api/admin/show-calendar', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'extract_style', event_name: eventName }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
showToast(`Style extracted from ${data.articleCount} articles`);
|
||
fetchData();
|
||
} else showToast(data.error || 'Failed', 'error');
|
||
} catch { showToast('Failed', 'error'); }
|
||
finally { setActionLoading(null); }
|
||
};
|
||
|
||
const handleGenerateStory = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setActionLoading('generate');
|
||
try {
|
||
const siteId = siteFilter === '2' ? 2 : 1;
|
||
const res = await fetch('/api/admin/show-calendar', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'generate_story', ...generateForm, site_id: siteId }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.story) {
|
||
showToast(`Story generated — assigned to ${data.penName}`);
|
||
setShowGenerateModal(false);
|
||
setActiveTab('queue');
|
||
fetchData();
|
||
} else showToast(data.error || 'Failed', 'error');
|
||
} catch { showToast('Failed', 'error'); }
|
||
finally { setActionLoading(null); }
|
||
};
|
||
|
||
const handleQueueAction = async (id: string, action: string, extra?: any) => {
|
||
setActionLoading(id + action);
|
||
try {
|
||
const res = await fetch('/api/admin/show-calendar', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id, action, ...extra }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
showToast(action === 'approve_story' ? 'Story published' : 'Story rejected');
|
||
fetchData();
|
||
} else showToast(data.error || 'Failed', 'error');
|
||
} catch { showToast('Failed', 'error'); }
|
||
finally { setActionLoading(null); }
|
||
};
|
||
|
||
const handleSaveEvent = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!editingEvent) return;
|
||
setActionLoading('save-event');
|
||
try {
|
||
const res = await fetch('/api/admin/show-calendar', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'upsert_event', ...editingEvent }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.event) {
|
||
showToast('Event saved');
|
||
setShowEventModal(false);
|
||
setEditingEvent(null);
|
||
fetchData();
|
||
} else showToast(data.error || 'Failed', 'error');
|
||
} catch { showToast('Failed', 'error'); }
|
||
finally { setActionLoading(null); }
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const tabs: { id: TabType; label: string }[] = [
|
||
{ id: 'calendar', label: 'Show Calendar' },
|
||
{ id: 'queue', label: 'Story Queue' },
|
||
{ id: 'exhibitors', label: 'Exhibitor List' },
|
||
{ id: 'style-profiles',label: 'Style Profiles' },
|
||
{ id: 'settings', label: 'Settings' },
|
||
];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
||
{/* Toast */}
|
||
{toast && (
|
||
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-lg ${
|
||
toast.type === 'success' ? 'bg-green-900/90 text-green-200 border border-green-700' : 'bg-red-900/90 text-red-200 border border-red-700'
|
||
}`}>
|
||
{toast.message}
|
||
</div>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<div className="border-b border-[#FFFFFF] bg-[#0f0f0f]">
|
||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-1">
|
||
<Link href="/admin" className="text-[#555] hover:text-white text-sm transition-colors">← Dashboard</Link>
|
||
</div>
|
||
<h1 className="text-xl font-bold text-white">Show Calendar Engine</h1>
|
||
<p className="text-[#555] text-sm mt-0.5">AV Beat + AV Beat — Global Show Coverage</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => { setEditingEvent({ site_id: 1, year: new Date().getFullYear(), seo_tier: 3, event_type: 'trade_show' }); setShowEventModal(true); }}
|
||
className="px-3 py-1.5 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
+ Add Event
|
||
</button>
|
||
<button
|
||
onClick={() => setShowGenerateModal(true)}
|
||
className="px-3 py-1.5 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
Generate Story
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Site Filter */}
|
||
<div className="flex items-center gap-2 mt-4">
|
||
<span className="text-[#555] text-xs uppercase tracking-wider">Site:</span>
|
||
{(['', '1', '2'] as SiteFilter[]).map(s => (
|
||
<button
|
||
key={s}
|
||
onClick={() => setSiteFilter(s)}
|
||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||
siteFilter === s
|
||
? 'bg-[#1D4ED8] text-white'
|
||
: 'bg-[#FFFFFF] text-[#888] hover:text-white border border-[#333]'
|
||
}`}
|
||
>
|
||
{s === '' ? 'All Sites' : s === '1' ? '🔵 AV Beat' : '🟠 AV Beat'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-1 mt-4 border-b border-[#FFFFFF]">
|
||
{tabs.map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||
activeTab === tab.id
|
||
? 'border-[#1D4ED8] text-white'
|
||
: 'border-transparent text-[#666] hover:text-white'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||
{loadingData ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* ── SHOW CALENDAR TAB ── */}
|
||
{activeTab === 'calendar' && (
|
||
<div>
|
||
<div className="grid gap-3">
|
||
{events.length === 0 && (
|
||
<div className="text-center py-16 text-[#555]">No events found. Add events to get started.</div>
|
||
)}
|
||
{events.map(event => {
|
||
const siteCfg = SITE_CONFIG[event.site_id as 1 | 2] || SITE_CONFIG[1];
|
||
const tierInfo = SEO_TIER_LABELS[event.seo_tier] || SEO_TIER_LABELS[3];
|
||
const phaseInfo = PHASE_LABELS[event.current_phase] || PHASE_LABELS.phase5_off_season;
|
||
return (
|
||
<div key={event.id} className="bg-[#111] border border-[#1e1e1e] rounded-xl p-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||
<SiteBadge siteId={event.site_id} />
|
||
<span className={`text-xs font-medium ${tierInfo.color}`}>{tierInfo.label}</span>
|
||
<span className={`text-xs ${phaseInfo.color}`}>{phaseInfo.label}</span>
|
||
{event.engine_paused && (
|
||
<span className="text-xs text-red-400 bg-red-900/20 px-1.5 py-0.5 rounded">Engine Paused</span>
|
||
)}
|
||
</div>
|
||
<h3 className="text-white font-semibold">{event.event_name}</h3>
|
||
<p className="text-[#666] text-sm mt-0.5">
|
||
{[event.city, event.venue, event.country].filter(Boolean).join(' · ')}
|
||
</p>
|
||
<div className="flex items-center gap-4 mt-2 text-xs text-[#555]">
|
||
<span>Show: {formatDate(event.start_date)} – {formatDate(event.end_date)}</span>
|
||
<span>Engine: {formatDate(event.story_engine_start_date)} – {formatDate(event.story_engine_end_date)}</span>
|
||
{event.official_site && (
|
||
<a href={`https://${event.official_site}`} target="_blank" rel="noopener noreferrer"
|
||
className="text-[#1D4ED8] hover:underline">{event.official_site}</a>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-3 mt-2 text-xs text-[#555]">
|
||
<span>Ph1: {event.phase1_volume_min}–{event.phase1_volume_max}/day</span>
|
||
<span>Ph2: {event.phase2_volume_min}–{event.phase2_volume_max}/day</span>
|
||
<span>Ph3: {event.phase3_volume_min}–{event.phase3_volume_max}/day</span>
|
||
<span>Ph4: {event.phase4_volume_min}–{event.phase4_volume_max}/day</span>
|
||
</div>
|
||
{event.notes && (
|
||
<p className="text-[#555] text-xs mt-2 italic">{event.notes}</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<button
|
||
onClick={() => handleExtractStyle(event.event_name)}
|
||
disabled={actionLoading === 'style-' + event.event_name}
|
||
className="px-2 py-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors disabled:opacity-50"
|
||
>
|
||
{actionLoading === 'style-' + event.event_name ? 'Extracting...' : 'Extract Style'}
|
||
</button>
|
||
<button
|
||
onClick={() => { setEditingEvent(event); setShowEventModal(true); }}
|
||
className="px-2 py-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors"
|
||
>
|
||
Edit Dates
|
||
</button>
|
||
<button
|
||
onClick={() => handleToggleEngine(event.id, !event.engine_paused)}
|
||
disabled={actionLoading === event.id}
|
||
className={`px-2 py-1 text-xs rounded transition-colors border ${
|
||
event.engine_paused
|
||
? 'bg-green-900/20 border-green-700 text-green-400 hover:bg-green-900/40' :'bg-red-900/20 border-red-700 text-red-400 hover:bg-red-900/40'
|
||
}`}
|
||
>
|
||
{event.engine_paused ? 'Resume' : 'Pause'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── STORY QUEUE TAB ── */}
|
||
{activeTab === 'queue' && (
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<span className="text-[#555] text-xs uppercase tracking-wider">Status:</span>
|
||
{['generated', 'in_review', 'approved', 'published', 'rejected', 'all'].map(s => (
|
||
<button
|
||
key={s}
|
||
onClick={() => setQueueStatus(s)}
|
||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||
queueStatus === s
|
||
? 'bg-[#1D4ED8] text-white'
|
||
: 'bg-[#FFFFFF] text-[#888] hover:text-white border border-[#333]'
|
||
}`}
|
||
>
|
||
{s.replace('_', ' ')}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
{queue.length === 0 && (
|
||
<div className="text-center py-16 text-[#555]">No stories in queue.</div>
|
||
)}
|
||
{queue.map(item => (
|
||
<div key={item.id} className="bg-[#111] border border-[#1e1e1e] rounded-xl p-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||
<SiteBadge siteId={item.site_id} />
|
||
{item.source_show && (
|
||
<span className="text-xs text-[#555] bg-[#FFFFFF] px-1.5 py-0.5 rounded">{item.source_show}</span>
|
||
)}
|
||
{item.show_phase && (
|
||
<span className={`text-xs ${PHASE_LABELS[item.show_phase]?.color || 'text-gray-500'}`}>
|
||
{PHASE_LABELS[item.show_phase]?.label || item.show_phase}
|
||
</span>
|
||
)}
|
||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||
item.status === 'published' ? 'bg-green-900/20 text-green-400' :
|
||
item.status === 'rejected' ? 'bg-red-900/20 text-red-400' :
|
||
item.status === 'generated'? 'bg-blue-900/20 text-blue-400' : 'bg-[#FFFFFF] text-[#888]'
|
||
}`}>{item.status}</span>
|
||
{item.quality_confidence !== null && (
|
||
<span className={`text-xs ${item.quality_confidence >= 0.85 ? 'text-green-400' : item.quality_confidence >= 0.7 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||
{Math.round((item.quality_confidence || 0) * 100)}% confidence
|
||
</span>
|
||
)}
|
||
</div>
|
||
<h3 className="text-white font-medium text-sm">
|
||
{item.generated_title || '(No title generated)'}
|
||
</h3>
|
||
<p className="text-[#666] text-xs mt-0.5">
|
||
By {item.assigned_pen_name} · {item.show_story_type || item.story_type}
|
||
</p>
|
||
{item.generated_excerpt && (
|
||
<p className="text-[#555] text-xs mt-1 line-clamp-2">{item.generated_excerpt}</p>
|
||
)}
|
||
|
||
{/* Expanded content */}
|
||
{expandedItem === item.id && item.generated_content && (
|
||
<div className="mt-3 p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||
<div
|
||
className="text-[#aaa] text-xs prose prose-invert max-w-none"
|
||
dangerouslySetInnerHTML={{ __html: item.generated_content.slice(0, 1500) + (item.generated_content.length > 1500 ? '...' : '') }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<button
|
||
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
|
||
className="px-2 py-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors"
|
||
>
|
||
{expandedItem === item.id ? 'Hide' : 'Preview'}
|
||
</button>
|
||
{item.status === 'generated' && (
|
||
<>
|
||
<button
|
||
onClick={() => handleQueueAction(item.id, 'approve_story')}
|
||
disabled={actionLoading === item.id + 'approve_story'}
|
||
className="px-2 py-1 bg-green-900/20 hover:bg-green-900/40 border border-green-700 text-green-400 text-xs rounded transition-colors disabled:opacity-50"
|
||
>
|
||
{actionLoading === item.id + 'approve_story' ? '...' : 'Approve'}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const reason = prompt('Rejection reason:');
|
||
if (reason !== null) handleQueueAction(item.id, 'reject_story', { reason });
|
||
}}
|
||
className="px-2 py-1 bg-red-900/20 hover:bg-red-900/40 border border-red-700 text-red-400 text-xs rounded transition-colors"
|
||
>
|
||
Reject
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── EXHIBITOR LIST TAB ── */}
|
||
{activeTab === 'exhibitors' && (
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<span className="text-[#555] text-xs">Filter by show:</span>
|
||
<select
|
||
value={showFilter}
|
||
onChange={e => setShowFilter(e.target.value)}
|
||
className="bg-[#FFFFFF] border border-[#333] text-white text-xs rounded px-2 py-1"
|
||
>
|
||
<option value="">All Shows</option>
|
||
{events.map(e => <option key={e.id} value={e.id}>{e.event_name}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="bg-[#111] border border-[#1e1e1e] rounded-xl overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-[#1e1e1e]">
|
||
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">Company</th>
|
||
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">Booth</th>
|
||
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">Preview Story</th>
|
||
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">AV Eligible</th>
|
||
<th className="text-left px-4 py-3 text-[#555] font-medium text-xs uppercase tracking-wider">Announcements</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{exhibitors.length === 0 && (
|
||
<tr><td colSpan={5} className="text-center py-8 text-[#555]">No exhibitors found.</td></tr>
|
||
)}
|
||
{exhibitors.map(ex => (
|
||
<tr key={ex.id} className="border-b border-[#FFFFFF] hover:bg-[#0f0f0f]">
|
||
<td className="px-4 py-3 text-white font-medium">{ex.company_name}</td>
|
||
<td className="px-4 py-3 text-[#888]">{ex.booth_number || '—'}</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||
ex.preview_story_status === 'published' ? 'bg-green-900/20 text-green-400' :
|
||
ex.preview_story_status === 'generated'? 'bg-blue-900/20 text-blue-400' : 'bg-[#FFFFFF] text-[#888]'
|
||
}`}>{ex.preview_story_status}</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{ex.is_av_eligible ? (
|
||
<span className="text-xs text-[#f97316]">AV Beat</span>
|
||
) : (
|
||
<span className="text-xs text-[#555]">BB only</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 text-[#888]">{ex.announcement_count}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── STYLE PROFILES TAB ── */}
|
||
{activeTab === 'style-profiles' && (
|
||
<div>
|
||
<div className="mb-4 p-4 bg-[#111] border border-[#1e1e1e] rounded-xl">
|
||
<p className="text-[#888] text-sm">
|
||
Style profiles are extracted from Ryan Salazar's existing NAB Show and event coverage articles. These profiles are injected into every show story generation prompt so output matches the publication's established voice.
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-3">
|
||
{styleProfiles.length === 0 && (
|
||
<div className="text-center py-16 text-[#555]">
|
||
No style profiles yet. Use "Extract Style" on any show in the Calendar tab.
|
||
</div>
|
||
)}
|
||
{styleProfiles.map(profile => (
|
||
<div key={profile.id} className="bg-[#111] border border-[#1e1e1e] rounded-xl p-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<h3 className="text-white font-medium">{profile.event_name || profile.event_type}</h3>
|
||
<p className="text-[#555] text-xs mt-0.5">
|
||
Author: {profile.author_username} · {profile.source_article_count} reference articles ·
|
||
Updated: {formatDate(profile.last_updated)}
|
||
</p>
|
||
{expandedItem === profile.id && profile.style_guide_text && (
|
||
<div className="mt-3 p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||
<p className="text-[#aaa] text-xs whitespace-pre-wrap">{profile.style_guide_text}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => setExpandedItem(expandedItem === profile.id ? null : profile.id)}
|
||
className="px-2 py-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors flex-shrink-0"
|
||
>
|
||
{expandedItem === profile.id ? 'Hide' : 'View Guide'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── SETTINGS TAB ── */}
|
||
{activeTab === 'settings' && (
|
||
<div className="max-w-2xl">
|
||
<div className="bg-[#111] border border-[#1e1e1e] rounded-xl p-6">
|
||
<h2 className="text-white font-semibold mb-4">Show Engine Settings</h2>
|
||
<div className="space-y-4 text-sm text-[#888]">
|
||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||
<p className="text-white font-medium mb-1">Annual Date Discovery</p>
|
||
<p>Runs automatically on January 1 each year. Searches official show websites and Google for confirmed dates. Sends admin notification when dates are set or when manual review is needed.</p>
|
||
</div>
|
||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||
<p className="text-white font-medium mb-1">Pen Name Rosters</p>
|
||
<p className="mb-2"><strong className="text-[#aaa]">AV Beat (12 writers):</strong> Michael Strand, David Harlow, Karen Fielding, James Mercer (primary NAB), Peter Calloway, Sandra Voss, Brian Kowalski, Laura Pennington, Thomas Reeves, Christine Vale, Marcus Webb, Ellen Forsythe</p>
|
||
<p><strong className="text-[#aaa]">AV Beat (7 writers):</strong> Rex Chandler, Dana Flux, Derek Wainwright, Sloane Rigging, Chip Crosspoint, Blair Presenter, Jordan Lumen</p>
|
||
</div>
|
||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||
<p className="text-white font-medium mb-1">SEO Tier Priority</p>
|
||
<p>Tier 1 (Max): NAB Las Vegas, IBC, InfoComm, ISE · Tier 2 (High): NAB NY, BroadcastAsia, CABSAT, AES, InfoComm EDGE/China/LatAm · Tier 3 (Standard): MPTS, SET Expo, NATPE, InfoComm India, FORTÉ LIVE · Tier 4 (Light): NewsTECHForum, TAB, SVVS, Hamburg Open, AVIXA Regional</p>
|
||
</div>
|
||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||
<p className="text-white font-medium mb-1">NAB Cross-Post to AV Beat</p>
|
||
<p>NAB exhibitors with AV-eligible products (projectors, displays, conferencing, control systems, digital signage, installed audio, AV-over-IP) automatically generate stories on AV Beat using AV Beat pen names.</p>
|
||
</div>
|
||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||
<p className="text-white font-medium mb-1">Blocked Competitor Sources</p>
|
||
<p>BB: tvtechnology.com, broadcastingcable.com, sportsvideo.org, digitaltveurope.com, nexttv.com · AV: ravepubs.com, avnetwork.com, commercialintegrator.com, systemscontractor.com, avinteractive.com · Note: infocommshow.org, iseurope.org, nabshow.com, avixa.org are PRIMARY sources — not blocked.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Add/Edit Event Modal ── */}
|
||
{showEventModal && editingEvent && (
|
||
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
|
||
<div className="bg-[#111] border border-[#333] rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||
<h2 className="text-white font-semibold mb-4">{editingEvent.id ? 'Edit Event' : 'Add Event'}</h2>
|
||
<form onSubmit={handleSaveEvent} className="space-y-3">
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Event Name *</label>
|
||
<input
|
||
required
|
||
value={editingEvent.event_name || ''}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, event_name: e.target.value }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Site</label>
|
||
<select
|
||
value={editingEvent.site_id || 1}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, site_id: parseInt(e.target.value) }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
>
|
||
<option value={1}>AV Beat</option>
|
||
<option value={2}>AV Beat</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">SEO Tier</label>
|
||
<select
|
||
value={editingEvent.seo_tier || 3}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, seo_tier: parseInt(e.target.value) }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
>
|
||
<option value={1}>Tier 1 — Max SEO</option>
|
||
<option value={2}>Tier 2 — High</option>
|
||
<option value={3}>Tier 3 — Standard</option>
|
||
<option value={4}>Tier 4 — Light</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Start Date</label>
|
||
<input
|
||
type="date"
|
||
value={editingEvent.start_date || ''}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, start_date: e.target.value }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">End Date</label>
|
||
<input
|
||
type="date"
|
||
value={editingEvent.end_date || ''}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, end_date: e.target.value }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Engine Start Date</label>
|
||
<input
|
||
type="date"
|
||
value={editingEvent.story_engine_start_date || ''}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, story_engine_start_date: e.target.value }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Engine End Date</label>
|
||
<input
|
||
type="date"
|
||
value={editingEvent.story_engine_end_date || ''}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, story_engine_end_date: e.target.value }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">City</label>
|
||
<input
|
||
value={editingEvent.city || ''}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, city: e.target.value }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Official Site</label>
|
||
<input
|
||
value={editingEvent.official_site || ''}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, official_site: e.target.value }))}
|
||
placeholder="nabshow.com"
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Notes</label>
|
||
<textarea
|
||
value={editingEvent.notes || ''}
|
||
onChange={e => setEditingEvent(prev => ({ ...prev, notes: e.target.value }))}
|
||
rows={2}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2 resize-none"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-3 pt-2">
|
||
<button
|
||
type="submit"
|
||
disabled={actionLoading === 'save-event'}
|
||
className="flex-1 py-2 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||
>
|
||
{actionLoading === 'save-event' ? 'Saving...' : 'Save Event'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setShowEventModal(false); setEditingEvent(null); }}
|
||
className="px-4 py-2 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Generate Story Modal ── */}
|
||
{showGenerateModal && (
|
||
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
|
||
<div className="bg-[#111] border border-[#333] rounded-xl p-6 w-full max-w-lg">
|
||
<h2 className="text-white font-semibold mb-4">Generate Show Story</h2>
|
||
<form onSubmit={handleGenerateStory} className="space-y-3">
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Show *</label>
|
||
<select
|
||
required
|
||
value={generateForm.event_id}
|
||
onChange={e => setGenerateForm(prev => ({ ...prev, event_id: e.target.value }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
>
|
||
<option value="">Select show...</option>
|
||
{events.map(e => <option key={e.id} value={e.id}>{e.event_name} ({e.year})</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Story Type *</label>
|
||
<select
|
||
value={generateForm.story_type}
|
||
onChange={e => setGenerateForm(prev => ({ ...prev, story_type: e.target.value }))}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
>
|
||
<option value="exhibitor_preview">Exhibitor Preview</option>
|
||
<option value="show_preview_guide">Show Preview Guide</option>
|
||
<option value="breaking_announcement">Breaking Announcement</option>
|
||
<option value="show_floor_report">Show Floor Report</option>
|
||
<option value="best_of_roundup">Best-Of Roundup</option>
|
||
<option value="recap">Show Recap</option>
|
||
<option value="award_announcement">Award Announcement</option>
|
||
<option value="product_announcement">Product Announcement</option>
|
||
<option value="keynote_preview">Keynote Preview</option>
|
||
<option value="travel_logistics">Travel & Logistics</option>
|
||
<option value="product_availability">Product Availability</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Company Name (optional)</label>
|
||
<input
|
||
value={generateForm.company_name}
|
||
onChange={e => setGenerateForm(prev => ({ ...prev, company_name: e.target.value }))}
|
||
placeholder="e.g. Sony Professional"
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-[#888] text-xs block mb-1">Source Content (optional)</label>
|
||
<textarea
|
||
value={generateForm.source_content}
|
||
onChange={e => setGenerateForm(prev => ({ ...prev, source_content: e.target.value }))}
|
||
placeholder="Paste press release or source material..."
|
||
rows={4}
|
||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2 resize-none"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-3 pt-2">
|
||
<button
|
||
type="submit"
|
||
disabled={actionLoading === 'generate'}
|
||
className="flex-1 py-2 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||
>
|
||
{actionLoading === 'generate' ? 'Generating...' : 'Generate Story'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowGenerateModal(false)}
|
||
className="px-4 py-2 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|