initial commit: rocket.new export of broadcastbeat

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

View File

@@ -0,0 +1,574 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Article {
id: string;
title: string;
status: string;
category: string | null;
site_id: number;
created_at: string;
updated_at: string;
scheduled_at: string | null;
published_at: string | null;
submitted_for_review_at: string | null;
scope_flagged?: boolean;
editor_note?: string | null;
}
interface UserProfile {
full_name: string;
email: string;
bio: string | null;
role: string;
wp_username: string | null;
display_name: string | null;
}
type DashboardView = 'overview' | 'new-post' | 'my-posts' | 'profile';
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
draft: { label: 'Draft', color: 'text-gray-400', bg: 'bg-gray-400/10' },
pending: { label: 'Pending Review', color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
scope_flagged: { label: 'Scope Flagged', color: 'text-amber-400', bg: 'bg-amber-400/10' },
scheduled: { label: 'Scheduled', color: 'text-blue-400', bg: 'bg-blue-400/10' },
scheduled_pending_review: { label: 'Scheduled — Pending Review', color: 'text-orange-400', bg: 'bg-orange-400/10' },
published: { label: 'Published', color: 'text-green-400', bg: 'bg-green-400/10' },
archived: { label: 'Archived', color: 'text-gray-500', bg: 'bg-gray-500/10' },
rejected: { label: 'Rejected', color: 'text-red-400', bg: 'bg-red-400/10' },
};
const BB_CATEGORIES = [
'Audio', 'Broadcast', 'Business', 'Camera', 'Cloud', 'Content Creation',
'Distribution', 'Education', 'Events', 'IP & Networking', 'Live Production',
'Media Management', 'News', 'OTT', 'Post Production', 'Radio',
'Sports', 'Streaming', 'Technology', 'Uncategorized',
];
const AV_CATEGORIES = [
'AV Integration', 'Digital Signage', 'Unified Communications',
'Streaming Technology', 'Conferencing & Collaboration', 'Houses of Worship',
'Education AV', 'Control Systems', 'Display Technology', 'Sound & Audio',
'Trade Shows & Events', 'Product News', 'Industry News',
];
const SITE_CONFIG = {
1: { name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]', borderClass: 'border-[#0ea5e9]/30' },
2: { name: 'AV Beat', badge: 'AV', color: '#f97316', bgClass: 'bg-[#f97316]/10', textClass: 'text-[#f97316]', borderClass: 'border-[#f97316]/30' },
};
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>
);
}
export default function ContributorDashboard() {
const { user, loading } = useAuth();
const router = useRouter();
const supabase = createClient();
const [view, setView] = useState<DashboardView>('overview');
const [profile, setProfile] = useState<UserProfile | null>(null);
const [articles, setArticles] = useState<Article[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
// New post form state
const [postTitle, setPostTitle] = useState('');
const [postContent, setPostContent] = useState('');
const [postExcerpt, setPostExcerpt] = useState('');
const [postCategory, setPostCategory] = useState('Uncategorized');
const [postScheduledAt, setPostScheduledAt] = useState('');
const [postSiteId, setPostSiteId] = useState(1);
const [submittingPost, setSubmittingPost] = useState(false);
const [scopeWarning, setScopeWarning] = useState<string | null>(null);
const [checkingScope, setCheckingScope] = useState(false);
// Profile edit state
const [editName, setEditName] = useState('');
const [editBio, setEditBio] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const showNotification = (type: 'success' | 'error', message: string) => {
setNotification({ type, message });
setTimeout(() => setNotification(null), 5000);
};
const fetchData = useCallback(async () => {
if (!user) return;
setLoadingData(true);
try {
const [profileRes, articlesRes] = await Promise.all([
supabase.from('user_profiles').select('full_name, email, bio, role, wp_username, display_name').eq('id', user.id).single(),
fetch('/api/contributor/posts'),
]);
if (profileRes.data) {
setProfile(profileRes.data);
setEditName(profileRes.data.full_name || '');
setEditBio(profileRes.data.bio || '');
}
if (articlesRes.ok) {
const data = await articlesRes.json();
setArticles(data.articles || []);
}
} catch (err) {
console.error('Failed to fetch contributor data:', err);
} finally {
setLoadingData(false);
}
}, [user]);
useEffect(() => {
if (!loading && !user) { router.push('/login'); return; }
if (user) fetchData();
}, [user, loading, fetchData, router]);
useEffect(() => {
if (profile && !['contributor', 'author'].includes(profile.role)) {
router.push('/admin');
}
}, [profile, router]);
// When site changes, reset category to appropriate default
useEffect(() => {
setPostCategory(postSiteId === 1 ? 'Uncategorized' : 'AV Integration');
setScopeWarning(null);
}, [postSiteId]);
// AV Beat scope check
const checkAVScope = useCallback(async () => {
if (postSiteId !== 2 || !postTitle.trim()) return;
setCheckingScope(true);
try {
const res = await fetch('/api/contributor/scope-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: postTitle, content: postContent, siteId: 2 }),
});
const data = await res.json();
if (data.scope === 'broadcast' && data.confidence > 0.75) {
setScopeWarning('This content may be better suited for Broadcast Beat. AV Beat covers professional AV integration. Your post has been flagged for editorial review.');
} else {
setScopeWarning(null);
}
} catch {
setScopeWarning(null);
} finally {
setCheckingScope(false);
}
}, [postSiteId, postTitle, postContent]);
const handleSubmitPost = async (e: React.FormEvent) => {
e.preventDefault();
if (!postTitle.trim()) { showNotification('error', 'Post title is required.'); return; }
setSubmittingPost(true);
try {
const res = await fetch('/api/contributor/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: postTitle,
content: postContent,
excerpt: postExcerpt,
category: postCategory,
scheduled_at: postScheduledAt || null,
site_id: postSiteId,
scope_flagged: !!scopeWarning,
}),
});
const data = await res.json();
if (!res.ok) {
if (data.blocked) {
showNotification('error', 'This post could not be submitted. Please contact the editorial team at editor@broadcastbeat.com for assistance.');
} else {
showNotification('error', data.error || 'Failed to submit post.');
}
} else {
const statusLabel = STATUS_CONFIG[data.status]?.label || data.status;
showNotification('success', `Post submitted! Status: ${statusLabel}`);
setPostTitle(''); setPostContent(''); setPostExcerpt('');
setPostCategory('Uncategorized'); setPostScheduledAt('');
setPostSiteId(1); setScopeWarning(null);
setView('my-posts');
fetchData();
}
} catch {
showNotification('error', 'Failed to submit post. Please try again.');
} finally {
setSubmittingPost(false);
}
};
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
setSavingProfile(true);
try {
const { error } = await supabase
.from('user_profiles')
.update({ full_name: editName, bio: editBio, updated_at: new Date().toISOString() })
.eq('id', user.id);
if (error) { showNotification('error', error.message); }
else { showNotification('success', 'Profile updated successfully.'); fetchData(); }
} catch { showNotification('error', 'Failed to save profile.'); }
finally { setSavingProfile(false); }
};
const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
const formatDateTime = (d: string | null) => d ? new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
const getMinDateTime = () => { const n = new Date(); n.setMinutes(n.getMinutes() + 5); return n.toISOString().slice(0, 16); };
const pendingCount = articles.filter(a => ['pending','scheduled_pending_review'].includes(a.status)).length;
const publishedCount = articles.filter(a => a.status === 'published').length;
const scheduledCount = articles.filter(a => ['scheduled','scheduled_pending_review'].includes(a.status)).length;
const currentCategories = postSiteId === 1 ? BB_CATEGORIES : AV_CATEGORIES;
if (loading || loadingData) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#c9a84c] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
{notification && (
<div className={`fixed top-4 right-4 z-50 max-w-sm px-5 py-3 rounded-lg text-sm font-medium shadow-lg ${
notification.type === 'success' ? 'bg-green-500/20 border border-green-500/40 text-green-300' : 'bg-red-500/20 border border-red-500/40 text-red-300'
}`}>
{notification.message}
</div>
)}
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="w-60 bg-[#111] border-r border-[#222] flex flex-col">
<div className="p-5 border-b border-[#222]">
<div className="text-xs font-bold text-[#555] uppercase tracking-widest mb-1">Relevant Media Properties</div>
<div className="text-white font-bold text-sm">Publisher Dashboard</div>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs px-1.5 py-0.5 rounded bg-[#0ea5e9]/10 text-[#0ea5e9] font-bold">BB</span>
<span className="text-[#444] text-xs">·</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[#f97316]/10 text-[#f97316] font-bold">AV</span>
</div>
</div>
<nav className="flex-1 p-3 space-y-1">
{[
{ id: 'overview', label: 'Overview', icon: '⊞' },
{ id: 'new-post', label: 'New Post', icon: '+' },
{ id: 'my-posts', label: 'My Posts', icon: '≡' },
{ id: 'profile', label: 'My Profile', icon: '◯' },
].map(item => (
<button
key={item.id}
onClick={() => setView(item.id as DashboardView)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors text-left ${
view === item.id
? 'bg-[#c9a84c]/10 text-[#c9a84c] border border-[#c9a84c]/20'
: 'text-[#888] hover:text-white hover:bg-[#1a1a1a]'
}`}
>
<span className="text-base w-5 text-center">{item.icon}</span>
{item.label}
</button>
))}
</nav>
<div className="p-4 border-t border-[#222]">
<div className="text-xs text-[#555] mb-1">Signed in as</div>
<div className="text-sm text-[#888] truncate">{profile?.full_name || user?.email}</div>
<div className="mt-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-400/10 text-blue-400 capitalize">
{profile?.role || 'contributor'}
</span>
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto">
{/* Overview */}
{view === 'overview' && (
<div className="p-8">
<h1 className="text-2xl font-bold text-white mb-1">
Welcome back, {profile?.full_name?.split(' ')[0] || 'Contributor'}
</h1>
<p className="text-[#555] text-sm mb-8">Here's a summary of your submissions across both publications.</p>
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ label: 'Total Posts', value: articles.length, color: 'text-white' },
{ label: 'Pending Review', value: pendingCount, color: 'text-yellow-400' },
{ label: 'Published', value: publishedCount, color: 'text-green-400' },
].map(stat => (
<div key={stat.label} className="bg-[#111] border border-[#222] rounded-xl p-5">
<div className={`text-3xl font-bold ${stat.color}`}>{stat.value}</div>
<div className="text-[#555] text-sm mt-1">{stat.label}</div>
</div>
))}
</div>
{scheduledCount > 0 && (
<div className="bg-blue-500/5 border border-blue-500/20 rounded-xl p-4 mb-6">
<div className="text-blue-400 font-medium text-sm">
📅 You have {scheduledCount} scheduled post{scheduledCount > 1 ? 's' : ''} awaiting publication
</div>
</div>
)}
<div className="flex gap-3">
<button onClick={() => setView('new-post')} className="px-5 py-2.5 bg-[#c9a84c] text-black font-semibold rounded-lg text-sm hover:bg-[#b8973b] transition-colors">
+ New Post
</button>
<button onClick={() => setView('my-posts')} className="px-5 py-2.5 bg-[#1a1a1a] border border-[#333] text-white rounded-lg text-sm hover:bg-[#222] transition-colors">
View My Posts
</button>
</div>
</div>
)}
{/* New Post */}
{view === 'new-post' && (
<div className="p-8 max-w-3xl">
<h1 className="text-2xl font-bold text-white mb-1">New Post</h1>
<p className="text-[#555] text-sm mb-8">
{profile?.role === 'contributor' ? 'Your post will be submitted for editorial review before publishing.' : 'Your post will be published directly.'}
</p>
<form onSubmit={handleSubmitPost} className="space-y-5">
{/* Publish To — site selector */}
<div className="bg-[#111] border border-[#333] rounded-xl p-5">
<div className="text-sm font-semibold text-white mb-3">Publish To</div>
<div className="flex gap-3">
{[
{ id: 1, name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9' },
{ id: 2, name: 'AV Beat', badge: 'AV', color: '#f97316' },
].map(site => (
<button
key={site.id}
type="button"
onClick={() => setPostSiteId(site.id)}
className={`flex-1 flex items-center gap-3 px-4 py-3 rounded-lg border-2 transition-all ${
postSiteId === site.id
? 'border-current bg-current/5' :'border-[#333] hover:border-[#444]'
}`}
style={{ color: postSiteId === site.id ? site.color : '#888' }}
>
<span className="w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0" style={{ borderColor: 'currentColor' }}>
{postSiteId === site.id && <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: site.color }} />}
</span>
<div className="text-left">
<div className="text-sm font-semibold">{site.name}</div>
</div>
</button>
))}
</div>
</div>
{/* Scope warning */}
{scopeWarning && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-4 py-3 flex items-start gap-3">
<svg className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-yellow-300 text-sm">{scopeWarning}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-[#888] mb-2">Title *</label>
<input
type="text"
value={postTitle}
onChange={e => setPostTitle(e.target.value)}
onBlur={() => { if (postSiteId === 2) checkAVScope(); }}
placeholder="Enter post title..."
className="w-full bg-[#111] border border-[#333] rounded-lg px-4 py-3 text-white placeholder-[#444] focus:outline-none focus:border-[#c9a84c] text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-[#888] mb-2">Excerpt</label>
<textarea value={postExcerpt} onChange={e => setPostExcerpt(e.target.value)} placeholder="Brief summary..." rows={2}
className="w-full bg-[#111] border border-[#333] rounded-lg px-4 py-3 text-white placeholder-[#444] focus:outline-none focus:border-[#c9a84c] text-sm resize-none" />
</div>
<div>
<label className="block text-sm font-medium text-[#888] mb-2">Content</label>
<textarea value={postContent} onChange={e => setPostContent(e.target.value)} placeholder="Write your post content here..." rows={12}
className="w-full bg-[#111] border border-[#333] rounded-lg px-4 py-3 text-white placeholder-[#444] focus:outline-none focus:border-[#c9a84c] text-sm resize-none font-mono" />
</div>
<div>
<label className="block text-sm font-medium text-[#888] mb-2">Category</label>
<select value={postCategory} onChange={e => setPostCategory(e.target.value)}
className="w-full bg-[#111] border border-[#333] rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#c9a84c] text-sm">
{currentCategories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
<p className="text-[#444] text-xs mt-1">Note: The Featured category is reserved for editorial use only.</p>
</div>
{/* Scheduling */}
<div className="bg-[#111] border border-[#333] rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-[#c9a84c]">📅</span>
<span className="text-sm font-medium text-white">Schedule Publication</span>
<span className="text-xs text-[#555]">(optional)</span>
</div>
<input type="datetime-local" value={postScheduledAt} onChange={e => setPostScheduledAt(e.target.value)} min={getMinDateTime()}
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-4 py-3 text-white focus:outline-none focus:border-[#c9a84c] text-sm" />
{postScheduledAt && (
<p className="text-[#555] text-xs mt-2">
{profile?.role === 'contributor' ? ' Scheduled posts still require editorial approval before going live.' : ' Post will automatically publish at the scheduled time.'}
</p>
)}
</div>
<div className="flex gap-3 pt-2">
<button type="submit" disabled={submittingPost || checkingScope}
className="px-6 py-2.5 bg-[#c9a84c] text-black font-semibold rounded-lg text-sm hover:bg-[#b8973b] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{submittingPost ? 'Submitting...' : profile?.role === 'contributor' ? 'Submit for Review' : 'Publish Post'}
</button>
<button type="button" onClick={() => setView('overview')}
className="px-5 py-2.5 bg-[#1a1a1a] border border-[#333] text-[#888] rounded-lg text-sm hover:text-white hover:bg-[#222] transition-colors">
Cancel
</button>
</div>
</form>
</div>
)}
{/* My Posts */}
{view === 'my-posts' && (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">My Posts</h1>
<p className="text-[#555] text-sm mt-1">{articles.length} total submission{articles.length !== 1 ? 's' : ''} across both publications</p>
</div>
<button onClick={() => setView('new-post')} className="px-4 py-2 bg-[#c9a84c] text-black font-semibold rounded-lg text-sm hover:bg-[#b8973b] transition-colors">
+ New Post
</button>
</div>
{articles.length === 0 ? (
<div className="bg-[#111] border border-[#222] rounded-xl p-12 text-center">
<div className="text-4xl mb-3">📝</div>
<div className="text-[#555] text-sm">No posts yet. Create your first post!</div>
<button onClick={() => setView('new-post')} className="mt-4 px-5 py-2 bg-[#c9a84c] text-black font-semibold rounded-lg text-sm hover:bg-[#b8973b] transition-colors">
Write a Post
</button>
</div>
) : (
<div className="space-y-3">
{articles.map(article => {
const statusCfg = STATUS_CONFIG[article.status] || { label: article.status, color: 'text-gray-400', bg: 'bg-gray-400/10' };
return (
<div key={article.id} className="bg-[#111] border border-[#222] rounded-xl p-5 hover:border-[#333] transition-colors">
<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">
<SiteBadge siteId={article.site_id || 1} />
<h3 className="text-white font-medium text-sm truncate">{article.title}</h3>
</div>
<div className="flex items-center gap-3 mt-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusCfg.color} ${statusCfg.bg}`}>
{statusCfg.label}
</span>
{article.category && <span className="text-xs text-[#555]">{article.category}</span>}
{article.scope_flagged && (
<span className="text-xs px-2 py-0.5 rounded-full bg-yellow-400/10 text-yellow-400">Scope Review</span>
)}
</div>
{article.editor_note && article.status === 'rejected' && (
<div className="mt-2 text-xs text-red-400 bg-red-400/5 border border-red-400/20 rounded px-3 py-2">
Editor note: {article.editor_note}
</div>
)}
</div>
<div className="text-right text-xs text-[#444] shrink-0">
{article.status === 'published' && article.published_at ? (
<div>Published {formatDate(article.published_at)}</div>
) : article.scheduled_at ? (
<div className="text-blue-400">Scheduled: {formatDateTime(article.scheduled_at)}</div>
) : article.submitted_for_review_at ? (
<div>Submitted {formatDate(article.submitted_for_review_at)}</div>
) : (
<div>Created {formatDate(article.created_at)}</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Profile */}
{view === 'profile' && (
<div className="p-8 max-w-xl">
<h1 className="text-2xl font-bold text-white mb-1">My Profile</h1>
<p className="text-[#555] text-sm mb-8">Update your display name and bio.</p>
<form onSubmit={handleSaveProfile} className="space-y-5">
<div>
<label className="block text-sm font-medium text-[#888] mb-2">Display Name</label>
<input type="text" value={editName} onChange={e => setEditName(e.target.value)}
className="w-full bg-[#111] border border-[#333] rounded-lg px-4 py-3 text-white placeholder-[#444] focus:outline-none focus:border-[#c9a84c] text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-[#888] mb-2">Email</label>
<input type="email" value={user?.email || ''} disabled
className="w-full bg-[#0a0a0a] border border-[#222] rounded-lg px-4 py-3 text-[#555] text-sm cursor-not-allowed" />
<p className="text-[#444] text-xs mt-1">Email cannot be changed here.</p>
</div>
{profile?.wp_username && (
<div>
<label className="block text-sm font-medium text-[#888] mb-2">WordPress Username</label>
<input type="text" value={profile.wp_username} disabled
className="w-full bg-[#0a0a0a] border border-[#222] rounded-lg px-4 py-3 text-[#555] text-sm cursor-not-allowed" />
</div>
)}
<div>
<label className="block text-sm font-medium text-[#888] mb-2">Bio</label>
<textarea value={editBio} onChange={e => setEditBio(e.target.value)} placeholder="Tell readers about yourself..." rows={4}
className="w-full bg-[#111] border border-[#333] rounded-lg px-4 py-3 text-white placeholder-[#444] focus:outline-none focus:border-[#c9a84c] text-sm resize-none" />
</div>
<button type="submit" disabled={savingProfile}
className="px-6 py-2.5 bg-[#c9a84c] text-black font-semibold rounded-lg text-sm hover:bg-[#b8973b] transition-colors disabled:opacity-50">
{savingProfile ? 'Saving...' : 'Save Profile'}
</button>
</form>
</div>
)}
</main>
</div>
</div>
);
}