Files
avbeat-com/src/app/admin/notifications/page.tsx

625 lines
24 KiB
TypeScript

'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
// ─── Types ────────────────────────────────────────────────────────────────────
type AlertType = 'pending_approval' | 'failed_import' | 'scheduled_publish' | 'info';
type AlertSeverity = 'critical' | 'warning' | 'info' | 'success';
interface Notification {
id: string;
type: AlertType;
severity: AlertSeverity;
title: string;
message: string;
articleId?: string;
articleTitle?: string;
author?: string;
timestamp: string;
read: boolean;
actionUrl?: string;
actionLabel?: string;
meta?: Record<string, string | number>;
}
interface NotificationStats {
total: number;
unread: number;
pendingApprovals: number;
failedImports: number;
scheduledPublishes: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatScheduledTime(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
const SEVERITY_CONFIG: Record<AlertSeverity, { bg: string; border: string; badge: string; dot: string }> = {
critical: {
bg: 'bg-red-500/5',
border: 'border-red-500/30',
badge: 'text-red-400 bg-red-400/10',
dot: 'bg-red-400',
},
warning: {
bg: 'bg-yellow-500/5',
border: 'border-yellow-500/30',
badge: 'text-yellow-400 bg-yellow-400/10',
dot: 'bg-yellow-400',
},
info: {
bg: 'bg-blue-500/5',
border: 'border-blue-500/30',
badge: 'text-blue-400 bg-blue-400/10',
dot: 'bg-blue-400',
},
success: {
bg: 'bg-green-500/5',
border: 'border-green-500/30',
badge: 'text-green-400 bg-green-400/10',
dot: 'bg-green-400',
},
};
const TYPE_LABELS: Record<AlertType, string> = {
pending_approval: 'Pending Approval',
failed_import: 'Failed Import',
scheduled_publish: 'Scheduled Publish',
info: 'Info',
};
// ─── Icons ────────────────────────────────────────────────────────────────────
function BellIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
);
}
function ClockIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" /><path strokeLinecap="round" d="M12 6v6l4 2" />
</svg>
);
}
function AlertIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} 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>
);
}
function CheckCircleIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
function ImportIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
);
}
function RefreshIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
);
}
function TypeIcon({ type }: { type: AlertType }) {
const cls = 'w-4 h-4';
if (type === 'pending_approval') return <CheckCircleIcon className={`${cls} text-yellow-400`} />;
if (type === 'failed_import') return <ImportIcon className={`${cls} text-red-400`} />;
if (type === 'scheduled_publish') return <ClockIcon className={`${cls} text-blue-400`} />;
return <BellIcon className={`${cls} text-[#888]`} />;
}
// ─── Stat Card ────────────────────────────────────────────────────────────────
interface StatCardProps {
label: string;
value: number;
icon: React.ReactNode;
color: string;
active: boolean;
onClick: () => void;
}
function StatCard({ label, value, icon, color, active, onClick }: StatCardProps) {
return (
<button
onClick={onClick}
className={`w-full text-left p-4 rounded-lg border transition-all ${
active
? `${color} border-current/30`
: 'bg-[#111] border-[#3a322b] hover:border-[#333]'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-[#888] text-xs font-body uppercase tracking-wider">{label}</span>
<span className={active ? '' : 'text-[#555]'}>{icon}</span>
</div>
<p className={`text-2xl font-bold font-heading ${active ? '' : 'text-white'}`}>{value}</p>
</button>
);
}
// ─── Notification Row ─────────────────────────────────────────────────────────
interface NotificationRowProps {
notification: Notification;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}
function NotificationRow({ notification: n, onMarkRead, onDismiss }: NotificationRowProps) {
const cfg = SEVERITY_CONFIG[n.severity];
return (
<div
className={`relative p-4 rounded-lg border transition-all ${
n.read ? 'bg-[#1c1815] border-[#1e1e1e]' : `${cfg.bg} ${cfg.border}`
}`}
>
{/* Unread dot */}
{!n.read && (
<span className={`absolute top-4 right-4 w-2 h-2 rounded-full ${cfg.dot}`} />
)}
<div className="flex items-start gap-3">
{/* Icon */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
n.read ? 'bg-[#231d18]' : cfg.badge
}`}>
<TypeIcon type={n.type} />
</div>
{/* Content */}
<div className="flex-1 min-w-0 pr-6">
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className={`text-xs font-body font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${cfg.badge}`}>
{TYPE_LABELS[n.type]}
</span>
<span className={`text-xs font-body px-1.5 py-0.5 rounded ${cfg.badge}`}>
{n.severity.toUpperCase()}
</span>
<span className="text-[#555] text-xs font-body">{timeAgo(n.timestamp)}</span>
</div>
<h3 className={`text-sm font-heading font-semibold mb-0.5 ${n.read ? 'text-[#888]' : 'text-white'}`}>
{n.title}
</h3>
<p className="text-[#666] text-xs font-body leading-relaxed">{n.message}</p>
{/* Article info */}
{n.articleTitle && (
<p className="text-[#555] text-xs font-body mt-1 truncate">
Article: <span className="text-[#777]">{n.articleTitle}</span>
{n.author && <span className="ml-2">· by {n.author}</span>}
</p>
)}
{/* Scheduled time meta */}
{n.type === 'scheduled_publish' && n.meta?.scheduledAt && (
<p className="text-blue-400/70 text-xs font-body mt-1">
Scheduled for: {formatScheduledTime(String(n.meta.scheduledAt))}
</p>
)}
{/* Actions */}
<div className="flex items-center gap-3 mt-2">
{n.actionUrl && (
<Link
href={n.actionUrl}
className="text-xs font-body font-bold text-[#F0A623] hover:text-blue-300 transition-colors"
>
{n.actionLabel || 'View →'}
</Link>
)}
{!n.read && (
<button
onClick={() => onMarkRead(n.id)}
className="text-xs font-body text-[#555] hover:text-[#888] transition-colors"
>
Mark as read
</button>
)}
<button
onClick={() => onDismiss(n.id)}
className="text-xs font-body text-[#444] hover:text-red-400 transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function NotificationsPage() {
const { user } = useAuth();
const router = useRouter();
const supabase = createClient();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [stats, setStats] = useState<NotificationStats>({
total: 0, unread: 0, pendingApprovals: 0, failedImports: 0, scheduledPublishes: 0,
});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<AlertType | 'all' | 'unread'>('all');
const [lastRefreshed, setLastRefreshed] = useState<Date>(new Date());
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Auth guard
useEffect(() => {
if (user === null) router.push('/login?next=' + encodeURIComponent(window.location.pathname));
}, [user, router]);
// Build notifications from live DB data
const fetchNotifications = useCallback(async () => {
try {
const built: Notification[] = [];
// 1. Pending approvals
const { data: pending } = await supabase
.from('articles')
.select('id, title, author_name, created_at, source_type')
.eq('status', 'pending')
.order('created_at', { ascending: false })
.limit(20);
(pending || []).forEach((a) => {
built.push({
id: `pending-${a.id}`,
type: 'pending_approval',
severity: 'warning',
title: 'Article Awaiting Approval',
message: `"${a.title}" has been submitted and is waiting for editorial review.`,
articleId: a.id,
articleTitle: a.title,
author: a.author_name,
timestamp: a.created_at,
read: false,
actionUrl: '/admin/articles',
actionLabel: 'Review Articles →',
});
});
// 2. Failed imports — articles imported with errors (source_type=imported, status=draft older than 1h)
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const { data: failedImports } = await supabase
.from('articles')
.select('id, title, author_name, created_at, source_type')
.eq('source_type', 'imported')
.eq('status', 'draft')
.lt('created_at', oneHourAgo)
.order('created_at', { ascending: false })
.limit(10);
(failedImports || []).forEach((a) => {
built.push({
id: `import-${a.id}`,
type: 'failed_import',
severity: 'critical',
title: 'Import Stuck in Draft',
message: `Imported article "${a.title}" has been in draft for over 1 hour and may require manual review.`,
articleId: a.id,
articleTitle: a.title,
author: a.author_name,
timestamp: a.created_at,
read: false,
actionUrl: '/admin/import',
actionLabel: 'Go to Import →',
});
});
// 3. Scheduled publishes — articles with scheduled_at in the future
const { data: scheduled } = await supabase
.from('articles')
.select('id, title, author_name, scheduled_at, created_at')
.eq('status', 'scheduled')
.order('scheduled_at', { ascending: true })
.limit(15);
(scheduled || []).forEach((a) => {
const scheduledAt = a.scheduled_at || a.created_at;
const isImminent = new Date(scheduledAt).getTime() - Date.now() < 2 * 60 * 60 * 1000;
built.push({
id: `scheduled-${a.id}`,
type: 'scheduled_publish',
severity: isImminent ? 'warning' : 'info',
title: isImminent ? 'Publish Imminent' : 'Scheduled Publish',
message: isImminent
? `"${a.title}" is scheduled to publish within the next 2 hours.`
: `"${a.title}" is queued for scheduled publication.`,
articleId: a.id,
articleTitle: a.title,
author: a.author_name,
timestamp: a.created_at,
read: false,
actionUrl: '/admin/articles',
actionLabel: 'Manage Articles →',
meta: { scheduledAt },
});
});
// Sort by timestamp desc
built.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Restore read state from sessionStorage
const readIds: string[] = JSON.parse(sessionStorage.getItem('bb_notif_read') || '[]');
const dismissed: string[] = JSON.parse(sessionStorage.getItem('bb_notif_dismissed') || '[]');
const filtered = built
.filter((n) => !dismissed.includes(n.id))
.map((n) => ({ ...n, read: readIds.includes(n.id) }));
setNotifications(filtered);
setStats({
total: filtered.length,
unread: filtered.filter((n) => !n.read).length,
pendingApprovals: filtered.filter((n) => n.type === 'pending_approval').length,
failedImports: filtered.filter((n) => n.type === 'failed_import').length,
scheduledPublishes: filtered.filter((n) => n.type === 'scheduled_publish').length,
});
setLastRefreshed(new Date());
} catch (err) {
console.error('Failed to fetch notifications:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [supabase]);
useEffect(() => {
fetchNotifications();
// Real-time polling every 30 seconds
intervalRef.current = setInterval(fetchNotifications, 30000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [fetchNotifications]);
// Real-time Supabase subscription for articles table changes
useEffect(() => {
const channel = supabase
.channel('notifications-articles')
.on('postgres_changes', { event: '*', schema: 'public', table: 'articles' }, () => {
fetchNotifications();
})
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [supabase, fetchNotifications]);
const handleMarkRead = (id: string) => {
const readIds: string[] = JSON.parse(sessionStorage.getItem('bb_notif_read') || '[]');
if (!readIds.includes(id)) {
sessionStorage.setItem('bb_notif_read', JSON.stringify([...readIds, id]));
}
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
setStats((prev) => ({ ...prev, unread: Math.max(0, prev.unread - 1) }));
};
const handleDismiss = (id: string) => {
const dismissed: string[] = JSON.parse(sessionStorage.getItem('bb_notif_dismissed') || '[]');
if (!dismissed.includes(id)) {
sessionStorage.setItem('bb_notif_dismissed', JSON.stringify([...dismissed, id]));
}
const n = notifications.find((x) => x.id === id);
setNotifications((prev) => prev.filter((x) => x.id !== id));
setStats((prev) => ({
...prev,
total: prev.total - 1,
unread: n && !n.read ? Math.max(0, prev.unread - 1) : prev.unread,
pendingApprovals: n?.type === 'pending_approval' ? prev.pendingApprovals - 1 : prev.pendingApprovals,
failedImports: n?.type === 'failed_import' ? prev.failedImports - 1 : prev.failedImports,
scheduledPublishes: n?.type === 'scheduled_publish' ? prev.scheduledPublishes - 1 : prev.scheduledPublishes,
}));
};
const handleMarkAllRead = () => {
const ids = notifications.map((n) => n.id);
sessionStorage.setItem('bb_notif_read', JSON.stringify(ids));
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setStats((prev) => ({ ...prev, unread: 0 }));
};
const handleRefresh = async () => {
setRefreshing(true);
await fetchNotifications();
};
const filtered = notifications.filter((n) => {
if (filter === 'all') return true;
if (filter === 'unread') return !n.read;
return n.type === filter;
});
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-2 border-[#F0A623] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-[#555] text-sm font-body">Loading notifications</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a]">
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-start justify-between mb-6 gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Link href="/admin" className="text-[#555] hover:text-[#888] text-xs font-body transition-colors">
Dashboard
</Link>
<span className="text-[#333] text-xs">/</span>
<span className="text-[#888] text-xs font-body">Notifications</span>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-heading font-bold text-white">Notifications</h1>
{stats.unread > 0 && (
<span className="px-2 py-0.5 rounded-full text-xs font-body font-bold bg-red-500/20 text-red-400 border border-red-500/30">
{stats.unread} unread
</span>
)}
</div>
<p className="text-[#555] text-xs font-body mt-1">
Real-time alerts · Last updated {lastRefreshed.toLocaleTimeString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{stats.unread > 0 && (
<button
onClick={handleMarkAllRead}
className="px-3 py-1.5 text-xs font-body font-bold text-[#F0A623] border border-[#F0A623]/30 rounded hover:bg-[#F0A623]/10 transition-colors"
>
Mark all read
</button>
)}
<button
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body text-[#888] border border-[#3a322b] rounded hover:border-[#333] hover:text-white transition-colors disabled:opacity-50"
>
<RefreshIcon className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<StatCard
label="Pending Approvals"
value={stats.pendingApprovals}
icon={<CheckCircleIcon className="w-4 h-4" />}
color="text-yellow-400 bg-yellow-400/5 border-yellow-400/30"
active={filter === 'pending_approval'}
onClick={() => setFilter(filter === 'pending_approval' ? 'all' : 'pending_approval')}
/>
<StatCard
label="Failed Imports"
value={stats.failedImports}
icon={<AlertIcon className="w-4 h-4" />}
color="text-red-400 bg-red-400/5 border-red-400/30"
active={filter === 'failed_import'}
onClick={() => setFilter(filter === 'failed_import' ? 'all' : 'failed_import')}
/>
<StatCard
label="Scheduled Publishes"
value={stats.scheduledPublishes}
icon={<ClockIcon className="w-4 h-4" />}
color="text-blue-400 bg-blue-400/5 border-blue-400/30"
active={filter === 'scheduled_publish'}
onClick={() => setFilter(filter === 'scheduled_publish' ? 'all' : 'scheduled_publish')}
/>
<StatCard
label="Unread"
value={stats.unread}
icon={<BellIcon className="w-4 h-4" />}
color="text-[#F0A623] bg-[#F0A623]/5 border-[#F0A623]/30"
active={filter === 'unread'}
onClick={() => setFilter(filter === 'unread' ? 'all' : 'unread')}
/>
</div>
{/* Filter Tabs */}
<div className="flex items-center gap-1 mb-4 border-b border-[#1e1e1e] pb-3">
{(['all', 'unread', 'pending_approval', 'failed_import', 'scheduled_publish'] as const).map((f) => {
const labels: Record<string, string> = {
all: `All (${stats.total})`,
unread: `Unread (${stats.unread})`,
pending_approval: 'Pending',
failed_import: 'Failed Imports',
scheduled_publish: 'Scheduled',
};
return (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 text-xs font-body font-bold rounded transition-colors ${
filter === f
? 'bg-[#F0A623] text-white'
: 'text-[#555] hover:text-[#888] hover:bg-[#111]'
}`}
>
{labels[f]}
</button>
);
})}
</div>
{/* Notification List */}
{filtered.length === 0 ? (
<div className="text-center py-16 border border-dashed border-[#1e1e1e] rounded-lg">
<BellIcon className="w-10 h-10 text-[#333] mx-auto mb-3" />
<p className="text-[#555] font-body text-sm">No notifications</p>
<p className="text-[#333] font-body text-xs mt-1">
{filter === 'all' ? 'Everything looks good!' : `No ${filter.replace('_', ' ')} alerts`}
</p>
</div>
) : (
<div className="space-y-2">
{filtered.map((n) => (
<NotificationRow
key={n.id}
notification={n}
onMarkRead={handleMarkRead}
onDismiss={handleDismiss}
/>
))}
</div>
)}
{/* Footer info */}
{filtered.length > 0 && (
<p className="text-center text-[#333] text-xs font-body mt-6">
Auto-refreshes every 30 seconds · Real-time updates via Supabase
</p>
)}
</div>
</div>
);
}