← 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' }}
>
“{item.headline}”
</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>
);
}