625 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|