← back to Norma
components/GlobalSearch.tsx
410 lines
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search, X, Newspaper, Users, Vote, Building2, FileText,
Radio, Contact, Landmark, DollarSign, ExternalLink, ArrowRight,
} from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface SearchResult {
id: string;
type: 'journalist' | 'article' | 'politician' | 'organization' | 'petition' | 'topic' | 'contact' | 'grant' | 'foundation';
title: string;
subtitle: string;
meta?: string;
url?: string;
}
interface SearchResponse {
results: SearchResult[];
query: string;
counts: Record<string, number>;
total: number;
}
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; label: string; tab?: string }> = {
journalist: { icon: Newspaper, color: '#f59e0b', label: 'Journalist', tab: 'journalists' },
article: { icon: FileText, color: '#6366f1', label: 'Article' },
politician: { icon: Vote, color: '#ef4444', label: 'Politician', tab: 'congress' },
organization: { icon: Building2, color: '#22c55e', label: 'Organization', tab: 'organizations' },
petition: { icon: FileText, color: '#8b5cf6', label: 'Petition', tab: 'petitions' },
topic: { icon: Radio, color: '#0891b2', label: 'Topic', tab: 'topic-radar' },
contact: { icon: Contact, color: '#ec4899', label: 'Contact', tab: 'contacts' },
grant: { icon: DollarSign, color: '#10b981', label: 'Grant', tab: 'agent-grants' },
foundation: { icon: Landmark, color: '#d97706', label: 'Foundation', tab: 'foundations' },
};
/* ─── Component ──────────────────────────────────────────────────────────── */
export default function GlobalSearch({ onNavigate }: { onNavigate: (tab: string, id?: string) => void }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(false);
const [activeFilter, setActiveFilter] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
/* ── Keyboard shortcut: Cmd+K / Ctrl+K ────────────────────────────────── */
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setOpen(true);
}
if (e.key === 'Escape') {
setOpen(false);
setQuery('');
setResults([]);
}
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);
/* ── Focus input when opened ───────────────────────────────────────────── */
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50);
}, [open]);
/* ── Close on outside click ────────────────────────────────────────────── */
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
/* ── Debounced search ──────────────────────────────────────────────────── */
useEffect(() => {
if (query.length < 2) {
setResults([]);
setCounts({});
return;
}
setLoading(true);
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=25`, { credentials: 'include' });
if (res.ok) {
const data: SearchResponse = await res.json();
setResults(data.results);
setCounts(data.counts);
setSelectedIndex(0);
}
} catch { /* ignore */ }
setLoading(false);
}, 250);
return () => clearTimeout(timer);
}, [query]);
/* ── Filter results by type ────────────────────────────────────────────── */
const filtered = activeFilter ? results.filter((r) => r.type === activeFilter) : results;
/* ── Keyboard nav within results ───────────────────────────────────────── */
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && filtered[selectedIndex]) {
e.preventDefault();
handleSelect(filtered[selectedIndex]);
}
}, [filtered, selectedIndex]);
/* ── Handle result selection ───────────────────────────────────────────── */
function handleSelect(result: SearchResult) {
const config = TYPE_CONFIG[result.type];
// Articles with URLs open externally
if (result.type === 'article' && result.url) {
window.open(result.url, '_blank', 'noopener,noreferrer');
setOpen(false);
setQuery('');
return;
}
// Navigate to appropriate tab with the selected entity
if (config?.tab) {
onNavigate(config.tab, result.id);
}
setOpen(false);
setQuery('');
setResults([]);
}
/* ── Active filter badges ──────────────────────────────────────────────── */
const activeTypes = Object.entries(counts).filter(([, c]) => c > 0);
return (
<>
{/* ── Search trigger button ──────────────────────────────────────────── */}
<button
className="btn btn-ghost btn-sm"
onClick={() => setOpen(true)}
title="Search everything (⌘K)"
style={{ gap: 6 }}
>
<Search size={15} />
<span className="hidden sm:inline text-xs" style={{ color: 'var(--color-text-muted)' }}>Search</span>
<kbd
className="hidden sm:inline"
style={{
fontSize: 9, padding: '1px 5px', borderRadius: 4,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-muted)',
fontFamily: 'inherit',
}}
>
⌘K
</kbd>
</button>
{/* ── Search overlay ─────────────────────────────────────────────────── */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: 80,
}}
>
<motion.div
ref={panelRef}
initial={{ opacity: 0, y: -10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
transition={{ duration: 0.15 }}
style={{
width: '100%', maxWidth: 640,
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 16,
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
overflow: 'hidden',
maxHeight: 'calc(100vh - 160px)',
display: 'flex', flexDirection: 'column',
}}
>
{/* ── Search input ─────────────────────────────────────────────── */}
<div
className="flex items-center gap-3 px-4"
style={{ borderBottom: '1px solid var(--color-border)', height: 52 }}
>
<Search size={18} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<input
ref={inputRef}
className="flex-1"
placeholder="Search journalists, articles, politicians, orgs..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
style={{
background: 'transparent', border: 'none', outline: 'none',
fontSize: 15, color: 'var(--color-text)',
height: '100%',
}}
/>
{query && (
<button
className="btn btn-ghost btn-sm"
onClick={() => { setQuery(''); setResults([]); setActiveFilter(''); inputRef.current?.focus(); }}
style={{ padding: 4 }}
>
<X size={14} />
</button>
)}
<kbd
style={{
fontSize: 10, padding: '2px 6px', borderRadius: 4,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
ESC
</kbd>
</div>
{/* ── Filter pills ─────────────────────────────────────────────── */}
{activeTypes.length > 1 && (
<div
className="flex items-center gap-1 px-4 py-2 flex-wrap"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<button
className="badge"
style={{
fontSize: 10, cursor: 'pointer',
backgroundColor: !activeFilter ? 'var(--color-primary)' + '22' : 'transparent',
color: !activeFilter ? 'var(--color-primary)' : 'var(--color-text-muted)',
border: `1px solid ${!activeFilter ? 'var(--color-primary)' : 'var(--color-border)'}`,
}}
onClick={() => setActiveFilter('')}
>
All ({results.length})
</button>
{activeTypes.map(([type, count]) => {
const cfg = TYPE_CONFIG[type];
if (!cfg) return null;
return (
<button
key={type}
className="badge"
style={{
fontSize: 10, cursor: 'pointer',
backgroundColor: activeFilter === type ? cfg.color + '22' : 'transparent',
color: activeFilter === type ? cfg.color : 'var(--color-text-muted)',
border: `1px solid ${activeFilter === type ? cfg.color : 'var(--color-border)'}`,
}}
onClick={() => setActiveFilter(activeFilter === type ? '' : type)}
>
{cfg.label} ({count})
</button>
);
})}
</div>
)}
{/* ── Results ──────────────────────────────────────────────────── */}
<div className="overflow-auto" style={{ flex: 1 }}>
{loading && query.length >= 2 && (
<div className="flex items-center justify-center py-8">
<div className="spinner" />
</div>
)}
{!loading && query.length >= 2 && filtered.length === 0 && (
<div className="text-center py-8">
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
No results for "{query}"
</p>
</div>
)}
{!loading && filtered.length > 0 && (
<div className="py-2">
{filtered.map((r, i) => {
const cfg = TYPE_CONFIG[r.type];
const Icon = cfg?.icon || FileText;
const isSelected = i === selectedIndex;
return (
<button
key={`${r.type}-${r.id}`}
className="w-full text-left"
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 16px',
backgroundColor: isSelected ? 'var(--color-surface-el)' : 'transparent',
border: 'none', cursor: 'pointer',
transition: 'background-color 0.1s',
}}
onClick={() => handleSelect(r)}
onMouseEnter={() => setSelectedIndex(i)}
>
<div style={{
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: `${cfg?.color || '#666'}15`,
border: `1px solid ${cfg?.color || '#666'}30`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon size={15} style={{ color: cfg?.color || '#666' }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>
{r.title}
</span>
<span
className="badge"
style={{
fontSize: 8, padding: '0 5px', flexShrink: 0,
color: cfg?.color, backgroundColor: `${cfg?.color}15`,
border: `1px solid ${cfg?.color}30`,
}}
>
{cfg?.label}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
{r.subtitle}
</span>
{r.meta && (
<span className="text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.6, flexShrink: 0 }}>
{r.meta}
</span>
)}
</div>
</div>
{r.url ? (
<ExternalLink size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
) : (
<ArrowRight size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
)}
</button>
);
})}
</div>
)}
{/* Empty state */}
{!loading && query.length < 2 && (
<div className="text-center py-8 px-4">
<p className="text-sm mb-2" style={{ color: 'var(--color-text-secondary)' }}>
Search across the entire database
</p>
<div className="flex flex-wrap justify-center gap-1">
{Object.entries(TYPE_CONFIG).map(([key, cfg]) => (
<span key={key} className="badge" style={{ fontSize: 9, color: cfg.color, backgroundColor: `${cfg.color}10`, border: `1px solid ${cfg.color}20` }}>
{cfg.label}s
</span>
))}
</div>
<p className="text-xs mt-3" style={{ color: 'var(--color-text-muted)' }}>
Type at least 2 characters • Use ↑↓ to navigate • Enter to select
</p>
</div>
)}
</div>
{/* ── Footer ───────────────────────────────────────────────────── */}
{filtered.length > 0 && (
<div
className="flex items-center justify-between px-4 py-2"
style={{ borderTop: '1px solid var(--color-border)', fontSize: 10, color: 'var(--color-text-muted)' }}
>
<span>{filtered.length} result{filtered.length !== 1 ? 's' : ''}</span>
<div className="flex items-center gap-3">
<span>↑↓ navigate</span>
<span>↵ select</span>
<span>esc close</span>
</div>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}