← back to Patty

components/news/NewsFeedTab.tsx

196 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { ExternalLink, Loader2, ChevronDown } from 'lucide-react';

interface Article {
  id: string;
  title: string;
  url: string;
  author: string | null;
  summary: string | null;
  published_at: string;
  sentiment: string;
  sentiment_score: number;
  geo_state: string | null;
  tags: string[];
  feed_name: string;
  feed_category: string;
  station: string | null;
  article_num: number;
}

const CATEGORIES = ['all', 'national_tv', 'newspaper', 'local_tv', 'wire', 'blog', 'magazine', 'substack', 'government', 'regional'];
const CAT_LABELS: Record<string, string> = {
  all: 'All', national_tv: 'TV National', newspaper: 'Newspaper', local_tv: 'TV Local',
  wire: 'Wire', blog: 'Blog', magazine: 'Magazine', substack: 'Substack',
  government: 'Government', regional: 'Regional',
};

function sentimentDot(s: string) {
  const colors: Record<string, string> = { positive: '#22c55e', negative: '#ef4444', neutral: '#6b7280' };
  return <span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: colors[s] || '#6b7280', display: 'inline-block', flexShrink: 0 }} />;
}

function decodeEntities(str: string) {
  if (!str) return str;
  return str
    .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
    .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
    .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'")
    .replace(/\uFFFD+/g, "'");
}

export default function NewsFeedTab() {
  const [articles, setArticles] = useState<Article[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(true);
  const [loadingMore, setLoadingMore] = useState(false);
  const [category, setCategory] = useState('all');
  const [offset, setOffset] = useState(0);
  const LIMIT = 50;

  const fetchArticles = useCallback(async (reset = false) => {
    const newOffset = reset ? 0 : offset;
    reset ? setLoading(true) : setLoadingMore(true);
    try {
      const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
      if (category !== 'all') params.set('category', category);
      const res = await fetch(`/api/pulse/articles?${params}`);
      if (res.ok) {
        const data = await res.json();
        const newArticles = data.articles || [];
        if (reset) {
          setArticles(newArticles);
          setOffset(LIMIT);
        } else {
          setArticles(prev => [...prev, ...newArticles]);
          setOffset(prev => prev + LIMIT);
        }
        setTotal(data.total || 0);
      }
    } catch (err) { console.error('Fetch articles error:', err); }
    finally { setLoading(false); setLoadingMore(false); }
  }, [category, offset]);

  useEffect(() => { fetchArticles(true); }, [category]); // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <div style={{ padding: 24, maxWidth: 1200 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
        <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
          News Feed
          <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
            {total.toLocaleString()} articles
          </span>
        </h2>
      </div>

      {/* Category Filters */}
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
        {CATEGORIES.map(cat => (
          <button
            key={cat}
            onClick={() => setCategory(cat)}
            style={{
              padding: '5px 12px', borderRadius: 16, border: '1px solid',
              borderColor: category === cat ? '#7c3aed' : 'var(--color-border)',
              backgroundColor: category === cat ? '#7c3aed22' : 'transparent',
              color: category === cat ? '#a78bfa' : 'var(--color-text-muted)',
              fontSize: 12, fontWeight: 500, cursor: 'pointer',
              transition: 'all 0.15s',
            }}
          >
            {CAT_LABELS[cat] || cat}
          </button>
        ))}
      </div>

      {loading ? (
        <div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
          <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading articles...
        </div>
      ) : (
        <>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
            {articles.map(a => (
              <div key={a.id} style={{
                display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px',
                backgroundColor: 'var(--color-surface)', borderBottom: '1px solid var(--color-border)',
                transition: 'background-color 0.1s',
              }}
              onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
              onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface)'; }}
              >
                {/* Article # */}
                <span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 500, minWidth: 36, textAlign: 'right', fontFamily: 'monospace' }}>
                  #{a.article_num}
                </span>

                {/* Feed badge */}
                <span style={{
                  fontSize: 10, fontWeight: 600, padding: '2px 6px', borderRadius: 4,
                  backgroundColor: '#7c3aed22', color: '#a78bfa', whiteSpace: 'nowrap', minWidth: 60, textAlign: 'center',
                }}>
                  {a.feed_name?.length > 14 ? a.feed_name.substring(0, 12) + '…' : a.feed_name}
                </span>

                {/* Title */}
                <a
                  href={a.url}
                  target="_blank"
                  rel="noopener noreferrer"
                  style={{
                    flex: 1, fontSize: 13, color: 'var(--color-text)', textDecoration: 'none',
                    overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
                    fontWeight: 500,
                  }}
                >
                  {decodeEntities(a.title)}
                </a>

                {/* Sentiment dot */}
                {sentimentDot(a.sentiment)}

                {/* State */}
                {a.geo_state && (
                  <span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 500 }}>{a.geo_state}</span>
                )}

                {/* Time */}
                <span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap', minWidth: 55, textAlign: 'right' }}>
                  {a.published_at ? new Date(a.published_at).toLocaleTimeString('en-US', { timeZone: 'America/Los_Angeles', hour: 'numeric', minute: '2-digit', hour12: true }) : ''}
                </span>

                {/* Link icon */}
                <a href={a.url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
                  <ExternalLink size={13} />
                </a>
              </div>
            ))}
          </div>

          {/* Load More */}
          {articles.length < total && (
            <div style={{ display: 'flex', justifyContent: 'center', padding: 20 }}>
              <button
                onClick={() => fetchArticles(false)}
                disabled={loadingMore}
                style={{
                  display: 'flex', alignItems: 'center', gap: 6, padding: '8px 20px',
                  borderRadius: 8, border: '1px solid var(--color-border)',
                  backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
                  cursor: loadingMore ? 'wait' : 'pointer', fontSize: 13,
                }}
              >
                {loadingMore ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <ChevronDown size={14} />}
                {loadingMore ? 'Loading...' : `Load More (${articles.length} of ${total.toLocaleString()})`}
              </button>
            </div>
          )}
        </>
      )}
    </div>
  );
}