← back to Norma

components/datasource/DataSourcesTab.tsx

786 lines

'use client';

/**
 * DataSourcesTab — Dashboard for managing discovered data sources.
 *
 * Usage:
 *   import DataSourcesTab from '@/components/datasource/DataSourcesTab';
 *   <DataSourcesTab />
 *
 * Sub-tabs: APIs (discovered_apis) | Headlines (newspaper_frontpages) | Statements (nonprofit_statements)
 * Fetch: GET /api/datasource?type=apis|headlines|statements&search=...&page=1
 */

import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
  Database,
  Newspaper,
  ScrollText,
  RefreshCw,
  Search,
  ChevronLeft,
  ChevronRight,
  AlertCircle,
  Globe,
  Tag,
  Clock,
  TrendingUp,
  CheckCircle2,
  XCircle,
  Archive,
  ExternalLink,
} from 'lucide-react';
import { useToast } from '../ToastProvider';

/* ─── Types ─────────────────────────────────────────────────────────────────── */

type SubTab = 'apis' | 'headlines' | 'statements';

interface ApiItem {
  id: string;
  api_name: string;
  description: string | null;
  category: string | null;
  base_url: string | null;
  relevance_score: number | null;
  status: string | null;
  matched_topics: unknown;
  last_checked_at: string | null;
  created_at: string;
}

interface HeadlineItem {
  id: string;
  newspaper_name: string;
  country: string | null;
  headline: string | null;
  url: string | null;
  relevance_score: number | null;
  published_at: string | null;
  scraped_at: string | null;
}

interface StatementItem {
  id: string;
  org_name: string;
  topic: string | null;
  stance: string | null;
  statement_text: string | null;
  source_url: string | null;
  relevance_score: number | null;
  published_at: string | null;
  created_at: string;
}

type DataItem = ApiItem | HeadlineItem | StatementItem;

interface DataResponse {
  type: string;
  items: DataItem[];
  total: number;
  page: number;
  limit: number;
}

interface Stats {
  apis: { total: number; by_status: Record<string, number> };
  headlines: { total: number; today: number };
  statements: { total: number; last_24h: number };
}

/* ─── Status badge config ────────────────────────────────────────────────────── */

const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string; icon: React.ReactNode }> = {
  discovered: { color: '#6366f1', bg: 'rgba(99,102,241,0.1)', label: 'Discovered', icon: <Globe size={11} /> },
  validated:  { color: '#22c55e', bg: 'rgba(34,197,94,0.1)',  label: 'Validated',  icon: <CheckCircle2 size={11} /> },
  active:     { color: '#10b981', bg: 'rgba(16,185,129,0.1)',  label: 'Active',    icon: <TrendingUp size={11} /> },
  failed:     { color: '#ef4444', bg: 'rgba(239,68,68,0.1)',  label: 'Failed',     icon: <XCircle size={11} /> },
  archived:   { color: '#71717a', bg: 'rgba(113,113,122,0.1)', label: 'Archived',  icon: <Archive size={11} /> },
};

function StatusBadge({ status }: { status: string | null }) {
  const cfg = STATUS_CONFIG[status ?? ''] ?? {
    color: '#a1a1aa',
    bg: 'rgba(161,161,170,0.1)',
    label: status ?? 'Unknown',
    icon: null,
  };
  return (
    <span
      className="badge"
      style={{
        color: cfg.color,
        backgroundColor: cfg.bg,
        border: `1px solid ${cfg.color}44`,
        display: 'inline-flex',
        alignItems: 'center',
        gap: 4,
        fontSize: '0.68rem',
      }}
    >
      <span aria-hidden="true">{cfg.icon}</span>
      {cfg.label}
    </span>
  );
}

/* ─── Score bar ─────────────────────────────────────────────────────────────── */

function ScoreBar({ score, color = '#10b981' }: { score: number | null; color?: string }) {
  if (score === null) return null;
  const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
  return (
    <div className="flex items-center gap-2" style={{ minWidth: 120 }}>
      <div
        style={{
          flex: 1,
          height: 4,
          borderRadius: 2,
          backgroundColor: 'var(--color-surface-el)',
          overflow: 'hidden',
        }}
        role="meter"
        aria-valuenow={pct}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-label={`Relevance: ${pct}%`}
      >
        <div
          style={{
            height: '100%',
            width: `${pct}%`,
            backgroundColor: color,
            borderRadius: 2,
            transition: 'width 0.4s ease-out',
          }}
        />
      </div>
      <span className="text-xs font-semibold" style={{ color, minWidth: 32 }}>
        {pct}%
      </span>
    </div>
  );
}

/* ─── Topic tags ─────────────────────────────────────────────────────────────── */

function TopicTags({ topics }: { topics: unknown }) {
  if (!topics) return null;
  const arr: string[] = Array.isArray(topics)
    ? topics.map((t) => String(t))
    : typeof topics === 'string'
    ? topics.split(',').map((t) => t.trim()).filter(Boolean)
    : [];
  if (arr.length === 0) return null;
  return (
    <div className="flex items-center gap-1 flex-wrap mt-1">
      {arr.slice(0, 5).map((t) => (
        <span
          key={t}
          className="badge"
          style={{
            fontSize: '0.62rem',
            backgroundColor: 'rgba(99,102,241,0.08)',
            color: '#34d399',
            border: '1px solid rgba(99,102,241,0.2)',
          }}
        >
          {t}
        </span>
      ))}
      {arr.length > 5 && (
        <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
          +{arr.length - 5} more
        </span>
      )}
    </div>
  );
}

/* ─── Card components ───────────────────────────────────────────────────────── */

function ApiCard({ item, index }: { item: ApiItem; index: number }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 6 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ delay: index * 0.03, duration: 0.2 }}
      className="card"
      style={{ padding: '14px 16px' }}
    >
      <div className="flex items-start justify-between gap-3 mb-2">
        <div className="flex-1 min-w-0">
          <div className="flex items-center gap-2 mb-1">
            <Database size={14} style={{ color: '#6366f1', flexShrink: 0 }} aria-hidden="true" />
            <span
              className="font-semibold text-sm"
              style={{ color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
            >
              {item.api_name}
            </span>
          </div>
          <div className="flex items-center gap-2 flex-wrap">
            {item.category && (
              <span
                className="badge"
                style={{
                  fontSize: '0.65rem',
                  backgroundColor: 'rgba(99,102,241,0.1)',
                  color: '#818cf8',
                  border: '1px solid rgba(99,102,241,0.25)',
                }}
              >
                <Tag size={10} aria-hidden="true" style={{ marginRight: 2 }} />
                {item.category}
              </span>
            )}
            <StatusBadge status={item.status} />
          </div>
        </div>
        <ScoreBar score={item.relevance_score} color="#6366f1" />
      </div>

      {item.description && (
        <p
          className="text-xs mb-2 line-clamp-2"
          style={{ color: 'var(--color-text-secondary)' }}
        >
          {item.description}
        </p>
      )}

      <TopicTags topics={item.matched_topics} />

      <div
        className="flex items-center justify-between mt-2"
        style={{ fontSize: '0.7rem', color: 'var(--color-text-muted)' }}
      >
        <div className="flex items-center gap-1">
          <Clock size={10} aria-hidden="true" />
          {item.last_checked_at
            ? `Checked ${new Date(item.last_checked_at).toLocaleDateString()}`
            : `Added ${new Date(item.created_at).toLocaleDateString()}`}
        </div>
        {item.base_url && (
          <a
            href={item.base_url}
            target="_blank"
            rel="noopener noreferrer"
            className="flex items-center gap-1"
            style={{ color: '#6366f1', textDecoration: 'none' }}
            aria-label={`Open ${item.api_name} API`}
          >
            <ExternalLink size={10} aria-hidden="true" />
            Open API
          </a>
        )}
      </div>
    </motion.div>
  );
}

function HeadlineCard({ item, index }: { item: HeadlineItem; index: number }) {
  const dateStr = item.scraped_at
    ? new Date(item.scraped_at).toLocaleDateString()
    : item.published_at
    ? new Date(item.published_at).toLocaleDateString()
    : '';

  return (
    <motion.div
      initial={{ opacity: 0, y: 6 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ delay: index * 0.03, duration: 0.2 }}
      className="card"
      style={{ padding: '14px 16px' }}
    >
      <div className="flex items-start justify-between gap-3 mb-2">
        <div className="flex-1 min-w-0">
          <div className="flex items-center gap-2 mb-1">
            <Newspaper size={14} style={{ color: '#6366f1', flexShrink: 0 }} aria-hidden="true" />
            <span
              className="font-semibold text-sm"
              style={{ color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
            >
              {item.newspaper_name}
            </span>
          </div>
          <div className="flex items-center gap-2 flex-wrap">
            {item.country && (
              <span
                className="badge"
                style={{
                  fontSize: '0.65rem',
                  backgroundColor: 'rgba(99,102,241,0.1)',
                  color: '#34d399',
                  border: '1px solid rgba(99,102,241,0.25)',
                }}
              >
                <Globe size={10} aria-hidden="true" style={{ marginRight: 2 }} />
                {item.country}
              </span>
            )}
            {dateStr && (
              <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
                <Clock size={10} aria-hidden="true" />
                {dateStr}
              </span>
            )}
          </div>
        </div>
        <ScoreBar score={item.relevance_score} color="#6366f1" />
      </div>

      {item.headline && (
        <p
          className="text-xs mb-2 line-clamp-3"
          style={{ color: 'var(--color-text-secondary)', fontStyle: 'italic' }}
        >
          &ldquo;{item.headline}&rdquo;
        </p>
      )}

      {item.url && (
        <a
          href={item.url}
          target="_blank"
          rel="noopener noreferrer"
          className="flex items-center gap-1 text-xs"
          style={{ color: '#6366f1', textDecoration: 'none' }}
          aria-label={`Read full story from ${item.newspaper_name}`}
        >
          <ExternalLink size={10} aria-hidden="true" />
          Read story
        </a>
      )}
    </motion.div>
  );
}

function StatementCard({ item, index }: { item: StatementItem; index: number }) {
  const dateStr = item.published_at
    ? new Date(item.published_at).toLocaleDateString()
    : new Date(item.created_at).toLocaleDateString();

  return (
    <motion.div
      initial={{ opacity: 0, y: 6 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ delay: index * 0.03, duration: 0.2 }}
      className="card"
      style={{ padding: '14px 16px' }}
    >
      <div className="flex items-start justify-between gap-3 mb-2">
        <div className="flex-1 min-w-0">
          <div className="flex items-center gap-2 mb-1">
            <ScrollText size={14} style={{ color: '#06b6d4', flexShrink: 0 }} aria-hidden="true" />
            <span
              className="font-semibold text-sm"
              style={{ color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
            >
              {item.org_name}
            </span>
          </div>
          <div className="flex items-center gap-2 flex-wrap">
            {item.topic && (
              <span
                className="badge"
                style={{
                  fontSize: '0.65rem',
                  backgroundColor: 'rgba(6,182,212,0.1)',
                  color: '#22d3ee',
                  border: '1px solid rgba(6,182,212,0.25)',
                }}
              >
                <Tag size={10} aria-hidden="true" style={{ marginRight: 2 }} />
                {item.topic}
              </span>
            )}
            {item.stance && (
              <span
                className="badge"
                style={{
                  fontSize: '0.65rem',
                  backgroundColor: 'rgba(234,179,8,0.1)',
                  color: '#fbbf24',
                  border: '1px solid rgba(234,179,8,0.25)',
                }}
              >
                {item.stance}
              </span>
            )}
            <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
              <Clock size={10} aria-hidden="true" />
              {dateStr}
            </span>
          </div>
        </div>
        <ScoreBar score={item.relevance_score} color="#06b6d4" />
      </div>

      {item.statement_text && (
        <p
          className="text-xs mb-2 line-clamp-3"
          style={{ color: 'var(--color-text-secondary)' }}
        >
          {item.statement_text}
        </p>
      )}

      {item.source_url && (
        <a
          href={item.source_url}
          target="_blank"
          rel="noopener noreferrer"
          className="flex items-center gap-1 text-xs"
          style={{ color: '#06b6d4', textDecoration: 'none' }}
          aria-label={`View source statement from ${item.org_name}`}
        >
          <ExternalLink size={10} aria-hidden="true" />
          View source
        </a>
      )}
    </motion.div>
  );
}

/* ─── Main component ────────────────────────────────────────────────────────── */

const LIMIT = 20;

export default function DataSourcesTab() {
  const { addToast } = useToast();

  const [subTab, setSubTab]       = useState<SubTab>('apis');
  const [search, setSearch]       = useState('');
  const [page, setPage]           = useState(1);
  const [data, setData]           = useState<DataResponse | null>(null);
  const [stats, setStats]         = useState<Stats | null>(null);
  const [loading, setLoading]     = useState(true);
  const [statsLoading, setStatsLoading] = useState(true);
  const [error, setError]         = useState<string | null>(null);

  const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const [debouncedSearch, setDebouncedSearch] = useState('');

  // Debounce search input
  useEffect(() => {
    if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
    searchDebounceRef.current = setTimeout(() => {
      setDebouncedSearch(search);
      setPage(1);
    }, 350);
    return () => {
      if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
    };
  }, [search]);

  // Fetch stats once on mount
  const fetchStats = useCallback(async () => {
    setStatsLoading(true);
    try {
      const res = await fetch('/api/datasource/stats');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json = await res.json();
      setStats(json);
    } catch {
      // Non-fatal — stats bar just won't show
    } finally {
      setStatsLoading(false);
    }
  }, []);

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

  // Fetch data on subTab / search / page change
  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const params = new URLSearchParams({
        type: subTab,
        page: String(page),
        limit: String(LIMIT),
      });
      if (debouncedSearch) params.set('search', debouncedSearch);
      const res = await fetch(`/api/datasource?${params.toString()}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json = await res.json();
      setData(json);
    } catch (err) {
      const msg = (err as Error).message;
      setError(msg);
      addToast(msg, 'error');
    } finally {
      setLoading(false);
    }
  }, [subTab, page, debouncedSearch, addToast]);

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

  const totalPages = data ? Math.ceil(data.total / LIMIT) : 1;

  function handleSubTabChange(t: SubTab) {
    setSubTab(t);
    setPage(1);
    setSearch('');
    setDebouncedSearch('');
  }

  const SUB_TABS: { id: SubTab; label: string; icon: React.ReactNode; color: string }[] = [
    { id: 'apis',       label: 'APIs',       icon: <Database size={14} />,   color: '#6366f1' },
    { id: 'headlines',  label: 'Headlines',  icon: <Newspaper size={14} />,  color: '#6366f1' },
    { id: 'statements', label: 'Statements', icon: <ScrollText size={14} />, color: '#06b6d4' },
  ];

  return (
    <div
      className="flex flex-col"
      style={{ height: '100%', background: 'var(--color-bg)' }}
    >
      {/* ── Header ──────────────────────────────────────────────────────────── */}
      <div
        className="px-5 py-4"
        style={{ borderBottom: '1px solid var(--color-border)' }}
      >
        <div className="flex items-center justify-between mb-4">
          <div className="flex items-center gap-2">
            <Database size={18} style={{ color: '#6366f1' }} aria-hidden="true" />
            <h2 className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
              Data Sources
            </h2>
          </div>
          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={() => { fetchData(); fetchStats(); }}
              disabled={loading}
              className="btn btn-ghost btn-sm"
              aria-label="Refresh data"
            >
              <RefreshCw
                size={14}
                aria-hidden="true"
                style={{ animation: loading ? 'spin 0.7s linear infinite' : 'none' }}
              />
              Refresh
            </button>
          </div>
        </div>

        {/* Stats bar */}
        {!statsLoading && stats && (
          <div
            className="card flex items-center gap-6 flex-wrap py-2 px-4 mb-4"
            style={{ fontSize: '0.75rem' }}
          >
            <div className="flex items-center gap-2">
              <Database size={12} style={{ color: '#6366f1' }} aria-hidden="true" />
              <span style={{ color: 'var(--color-text-muted)' }}>APIs:</span>
              <strong style={{ color: 'var(--color-text)' }}>{stats.apis.total}</strong>
              {stats.apis.by_status.active > 0 && (
                <span className="badge badge-success" style={{ fontSize: '0.6rem' }}>
                  {stats.apis.by_status.active} active
                </span>
              )}
            </div>
            <div className="flex items-center gap-2">
              <Newspaper size={12} style={{ color: '#6366f1' }} aria-hidden="true" />
              <span style={{ color: 'var(--color-text-muted)' }}>Headlines:</span>
              <strong style={{ color: 'var(--color-text)' }}>{stats.headlines.total}</strong>
              {stats.headlines.today > 0 && (
                <span style={{ color: 'var(--color-text-muted)' }}>
                  ({stats.headlines.today} today)
                </span>
              )}
            </div>
            <div className="flex items-center gap-2">
              <ScrollText size={12} style={{ color: '#06b6d4' }} aria-hidden="true" />
              <span style={{ color: 'var(--color-text-muted)' }}>Statements:</span>
              <strong style={{ color: 'var(--color-text)' }}>{stats.statements.total}</strong>
              {stats.statements.last_24h > 0 && (
                <span style={{ color: 'var(--color-text-muted)' }}>
                  ({stats.statements.last_24h} last 24h)
                </span>
              )}
            </div>
          </div>
        )}

        {/* Sub-tabs + search */}
        <div className="flex items-center justify-between gap-3 flex-wrap">
          <div
            className="flex items-center gap-1"
            role="tablist"
            aria-label="Data source type"
          >
            {SUB_TABS.map(({ id, label, icon, color }) => {
              const isActive = subTab === id;
              return (
                <button
                  key={id}
                  type="button"
                  role="tab"
                  aria-selected={isActive}
                  onClick={() => handleSubTabChange(id)}
                  className="btn btn-sm"
                  style={{
                    gap: 6,
                    backgroundColor: isActive ? `${color}14` : 'transparent',
                    border: `1px solid ${isActive ? color + '55' : 'var(--color-border)'}`,
                    color: isActive ? color : 'var(--color-text-muted)',
                    transition: 'all 150ms ease',
                    fontSize: '0.8rem',
                  }}
                >
                  <span aria-hidden="true" style={{ display: 'flex', alignItems: 'center' }}>{icon}</span>
                  {label}
                </button>
              );
            })}
          </div>

          {/* Search */}
          <div style={{ position: 'relative', width: 220 }}>
            <Search
              size={13}
              aria-hidden="true"
              style={{
                position: 'absolute',
                left: 10,
                top: '50%',
                transform: 'translateY(-50%)',
                color: 'var(--color-text-muted)',
                pointerEvents: 'none',
              }}
            />
            <input
              type="search"
              placeholder={`Search ${subTab}...`}
              value={search}
              onChange={(e) => setSearch(e.target.value)}
              className="input"
              style={{ paddingLeft: 30, fontSize: '0.8rem', height: 32 }}
              aria-label={`Search ${subTab}`}
            />
          </div>
        </div>
      </div>

      {/* ── Content ─────────────────────────────────────────────────────────── */}
      <div className="flex-1 overflow-auto p-5">
        {loading ? (
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
              gap: 12,
            }}
          >
            {Array.from({ length: 8 }).map((_, i) => (
              <div key={i} className="skeleton" style={{ height: 100, borderRadius: 8 }} />
            ))}
          </div>
        ) : error ? (
          <div
            className="flex flex-col items-center justify-center gap-3 py-16"
            role="alert"
            style={{ color: 'var(--color-error)' }}
          >
            <AlertCircle size={40} style={{ opacity: 0.5 }} aria-hidden="true" />
            <p className="text-sm font-medium">{error}</p>
            <button type="button" onClick={fetchData} className="btn btn-secondary btn-sm">
              <RefreshCw size={13} aria-hidden="true" />
              Retry
            </button>
          </div>
        ) : !data || data.items.length === 0 ? (
          <div
            className="flex flex-col items-center justify-center gap-3 py-16"
            style={{ color: 'var(--color-text-muted)' }}
          >
            <Database size={48} style={{ opacity: 0.25 }} aria-hidden="true" />
            <p className="font-semibold" style={{ color: 'var(--color-text-secondary)' }}>
              No {subTab} found{debouncedSearch ? ` for "${debouncedSearch}"` : ''}.
            </p>
          </div>
        ) : (
          <>
            {/* Result count */}
            <div
              className="text-xs mb-3"
              style={{ color: 'var(--color-text-muted)' }}
              aria-live="polite"
            >
              {data.total} result{data.total !== 1 ? 's' : ''}
              {debouncedSearch ? ` for "${debouncedSearch}"` : ''}
            </div>

            {/* Cards grid */}
            <div
              style={{
                display: 'grid',
                gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
                gap: 12,
              }}
              role="list"
              aria-label={`${subTab} list`}
            >
              <AnimatePresence mode="popLayout">
                {data.items.map((item, i) => (
                  <div key={item.id} role="listitem">
                    {subTab === 'apis' ? (
                      <ApiCard item={item as ApiItem} index={i} />
                    ) : subTab === 'headlines' ? (
                      <HeadlineCard item={item as HeadlineItem} index={i} />
                    ) : (
                      <StatementCard item={item as StatementItem} index={i} />
                    )}
                  </div>
                ))}
              </AnimatePresence>
            </div>
          </>
        )}
      </div>

      {/* ── Pagination ──────────────────────────────────────────────────────── */}
      {data && data.total > LIMIT && (
        <div
          className="flex items-center justify-center gap-3 px-5 py-3"
          style={{ borderTop: '1px solid var(--color-border)' }}
        >
          <button
            type="button"
            onClick={() => setPage((p) => Math.max(1, p - 1))}
            disabled={page <= 1}
            className="btn btn-ghost btn-sm"
            aria-label="Previous page"
          >
            <ChevronLeft size={14} aria-hidden="true" />
            Prev
          </button>
          <span
            className="text-xs"
            style={{ color: 'var(--color-text-muted)' }}
            aria-live="polite"
          >
            Page {page} of {totalPages}
          </span>
          <button
            type="button"
            onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
            disabled={page >= totalPages}
            className="btn btn-ghost btn-sm"
            aria-label="Next page"
          >
            Next
            <ChevronRight size={14} aria-hidden="true" />
          </button>
        </div>
      )}
    </div>
  );
}