← back to Grant

components/news/NewsTab.tsx

373 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
  Newspaper,
  Search,
  Sparkles,
  ExternalLink,
  Loader2,
  Calendar,
  Tag,
  User,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useAuth } from '../AuthProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';

const NEWS_SORT_OPTIONS = [
  { value: 'date_desc', label: 'Newest First' },
  { value: 'date_asc', label: 'Oldest First' },
  { value: 'relevance_desc', label: 'Most Relevant' },
  { value: 'outlet', label: 'Outlet A-Z' },
];

const NEWS_SORT_CONFIGS: Record<string, SortConfig> = {
  date_desc: { key: 'published_at', direction: 'desc', type: 'date' },
  date_asc: { key: 'published_at', direction: 'asc', type: 'date' },
  relevance_desc: { key: 'relevance_score', direction: 'desc', type: 'number' },
  outlet: { key: 'outlet', direction: 'asc', type: 'string' },
};

/* ─── Types ──────────────────────────────────────────────────────────────── */
interface NewsItem {
  id: string;
  headline: string;
  outlet: string | null;
  url: string | null;
  published_at: string | null;
  summary: string | null;
  tags: string[];
  relevance_score: number | null;
  author_name: string | null;
  source_type: string | null;
  created_at: string;
}

const SOURCE_TYPES = [
  { id: 'all', label: 'All Sources' },
  { id: 'google_news', label: 'News' },
  { id: 'press_release', label: 'Press Release' },
  { id: 'policy_brief', label: 'Policy Brief' },
  { id: 'academic', label: 'Academic' },
  { id: 'industry', label: 'Industry' },
];

const OUTLET_COLORS: Record<string, string> = {
  'NPR': '#e01e5a',
  'The Washington Post': '#2b2b2b',
  'Inside Higher Ed': '#006699',
  'The Hill': '#2e68ab',
  'Reuters': '#ff8000',
  'CNBC': '#005689',
  'Politico': '#e81c23',
  'The New York Times': '#1a1a1a',
  'Forbes': '#b5121b',
  'AP News': '#ed1c24',
};

function formatDate(d: string | null): string {
  if (!d) return 'Unknown date';
  return new Date(d).toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
  });
}

/* ─── Main Component ─────────────────────────────────────────────────────── */
export default function NewsTab() {
  const { addToast } = useToast();
  const { showAdmin } = useAuth();
  const [news, setNews] = useState<NewsItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [discovering, setDiscovering] = useState(false);
  const [search, setSearch] = useState('');
  const debouncedSearch = useDebounce(search, 300);
  const [sourceFilter, setSourceFilter] = useState('all');
  const [sortBy, setSortBy] = useState('date_desc');
  const sortedNews = useClientSort(news, sortBy, NEWS_SORT_CONFIGS);

  const fetchNews = useCallback(async () => {
    try {
      const params = new URLSearchParams();
      if (debouncedSearch) params.set('search', debouncedSearch);
      if (sourceFilter !== 'all') params.set('source_type', sourceFilter);
      const res = await fetch(`/api/news?${params}`, { credentials: 'include' });
      if (res.ok) {
        const data = await res.json();
        setNews(data.rows || []);
      }
    } catch {
      // fetch error handled by loading state
    } finally {
      setLoading(false);
    }
  }, [debouncedSearch, sourceFilter]);

  useEffect(() => {
    fetchNews();
  }, [fetchNews]);

  const handleDiscover = async () => {
    setDiscovering(true);
    try {
      const res = await fetch('/api/news/discover', {
        method: 'POST',
        credentials: 'include',
      });
      if (res.ok) {
        await fetchNews();
        addToast('News articles discovered', 'success');
      } else {
        addToast('News discovery failed', 'error');
      }
    } catch {
      addToast('News discovery failed', 'error');
    } finally {
      setDiscovering(false);
    }
  };

  return (
    <div className="p-6 space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between flex-wrap gap-3">
        <div>
          <h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
            News Monitoring
          </h2>
          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
            AI-curated news relevant to your mission, funders, and sector.
          </p>
        </div>
        {showAdmin && (
          <button
            className="btn btn-secondary"
            onClick={handleDiscover}
            disabled={discovering}
          >
            {discovering ? (
              <Loader2 size={15} className="animate-spin" />
            ) : (
              <Sparkles size={15} />
            )}
            {discovering ? 'Discovering...' : 'AI Discover News'}
          </button>
        )}
      </div>

      {/* Search */}
      <div className="relative">
        <Search
          size={16}
          className="absolute left-3 top-1/2 -translate-y-1/2"
          style={{ color: 'var(--color-text-muted)' }}
        />
        <input
          type="text"
          className="input"
          placeholder="Search news articles..."
          aria-label="Search"
          style={{ paddingLeft: '2.25rem' }}
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
      </div>

      {/* Sort + Source Type Filter */}
      <div className="flex gap-2 flex-wrap items-center">
        <SortDropdown options={NEWS_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
        {SOURCE_TYPES.map(({ id, label }) => {
          const isActive = sourceFilter === id;
          return (
            <button
              key={id}
              className="btn btn-sm"
              onClick={() => setSourceFilter(id)}
              style={{
                backgroundColor: isActive ? 'rgba(5,150,105,0.12)' : 'var(--color-surface-el)',
                color: isActive ? '#34d399' : 'var(--color-text-secondary)',
                border: isActive
                  ? '1px solid rgba(5,150,105,0.3)'
                  : '1px solid var(--color-border)',
              }}
            >
              {label}
            </button>
          );
        })}
      </div>

      {/* News Cards */}
      {loading ? (
        <SkeletonList count={4} />
      ) : sortedNews.length === 0 ? (
        <div className="card">
          <div className="flex flex-col items-center justify-center py-16 text-center">
            <div
              className="w-14 h-14 rounded-xl flex items-center justify-center mb-4"
              style={{
                backgroundColor: 'var(--color-surface-el)',
                border: '1px solid var(--color-border)',
              }}
            >
              <Newspaper size={24} style={{ color: 'var(--color-text-muted)' }} />
            </div>
            <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
              No news articles yet
            </h3>
            <p className="text-sm max-w-md" style={{ color: 'var(--color-text-muted)' }}>
              Click &quot;AI Discover News&quot; to find relevant articles about student debt, education policy, and nonprofit funding.
            </p>
          </div>
        </div>
      ) : (
        <div className="space-y-3">
          {sortedNews.map((item) => {
            const relevancePct = item.relevance_score != null
              ? Math.round(item.relevance_score * 100)
              : null;
            const outletColor = item.outlet
              ? OUTLET_COLORS[item.outlet] || '#059669'
              : '#6b7f75';

            return (
              <div key={item.id} className="card" style={{ padding: '1rem' }}>
                <div className="flex items-start justify-between gap-3">
                  <div className="flex-1 min-w-0">
                    {/* Headline */}
                    <div className="flex items-center gap-2 flex-wrap mb-1">
                      {item.url ? (
                        <a
                          href={item.url}
                          target="_blank"
                          rel="noopener noreferrer"
                          className="text-sm font-semibold hover:underline"
                          style={{ color: 'var(--color-text)' }}
                        >
                          {item.headline}
                        </a>
                      ) : (
                        <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
                          {item.headline}
                        </h3>
                      )}
                      {item.url && (
                        <a
                          href={item.url}
                          target="_blank"
                          rel="noopener noreferrer"
                          className="shrink-0"
                        >
                          <ExternalLink size={12} style={{ color: 'var(--color-text-muted)' }} />
                        </a>
                      )}
                    </div>

                    {/* Meta row */}
                    <div className="flex items-center gap-3 flex-wrap text-xs mb-2" style={{ color: 'var(--color-text-muted)' }}>
                      {item.outlet && (
                        <span
                          className="px-2 py-0.5 rounded-full font-semibold text-xs"
                          style={{
                            backgroundColor: `${outletColor}20`,
                            color: outletColor,
                            border: `1px solid ${outletColor}40`,
                          }}
                        >
                          {item.outlet}
                        </span>
                      )}
                      <span className="flex items-center gap-1">
                        <Calendar size={11} />
                        {formatDate(item.published_at)}
                      </span>
                      {item.author_name && (
                        <span className="flex items-center gap-1">
                          <User size={11} />
                          {item.author_name}
                        </span>
                      )}
                      {item.source_type && (
                        <span className="flex items-center gap-1">
                          <Tag size={11} />
                          {item.source_type.replace(/_/g, ' ')}
                        </span>
                      )}
                    </div>

                    {/* Summary */}
                    {item.summary && (
                      <p className="text-xs leading-relaxed mb-2" style={{ color: 'var(--color-text-secondary)' }}>
                        {item.summary}
                      </p>
                    )}

                    {/* Tags */}
                    {item.tags && item.tags.length > 0 && (
                      <div className="flex gap-1 flex-wrap">
                        {item.tags.map((tag) => (
                          <span
                            key={tag}
                            className="text-xs px-2 py-0.5 rounded-full"
                            style={{
                              backgroundColor: 'rgba(59,130,246,0.1)',
                              color: '#60a5fa',
                              border: '1px solid rgba(59,130,246,0.2)',
                            }}
                          >
                            {tag}
                          </span>
                        ))}
                      </div>
                    )}
                  </div>

                  {/* Relevance Score */}
                  {relevancePct != null && (
                    <div className="shrink-0 text-center">
                      <div
                        className="w-12 h-12 rounded-lg flex items-center justify-center text-xs font-bold"
                        style={{
                          backgroundColor:
                            relevancePct >= 80
                              ? 'rgba(34,197,94,0.15)'
                              : relevancePct >= 50
                                ? 'rgba(245,158,11,0.15)'
                                : 'rgba(107,127,117,0.15)',
                          color:
                            relevancePct >= 80
                              ? '#22c55e'
                              : relevancePct >= 50
                                ? '#f59e0b'
                                : '#6b7f75',
                          border: `1px solid ${
                            relevancePct >= 80
                              ? 'rgba(34,197,94,0.3)'
                              : relevancePct >= 50
                                ? 'rgba(245,158,11,0.3)'
                                : 'rgba(107,127,117,0.3)'
                          }`,
                        }}
                      >
                        {relevancePct}%
                      </div>
                      <span className="text-xs mt-0.5 block" style={{ color: 'var(--color-text-muted)', fontSize: '0.625rem' }}>
                        relevance
                      </span>
                    </div>
                  )}
                </div>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}