← back to Norma
components/PulseSearch.tsx
377 lines
'use client';
/* ═══════════════════════════════════════════════════════════════════════════
PulseSearch — public-tier global search bar for the Pulse layout.
Distinct from <GlobalSearch /> (which is dark-themed for the Norma admin /
Nikki staff shells). PulseSearch matches the Pulse "Pulse of America"
theme: Georgia serif, cream / dark-green / gold palette, lighter typography.
Hits /api/search/public — NO auth required. Only returns entities that are
safe for unauthenticated end-users (petitions, politicians, organizations,
topics, articles). Never journalists/contacts/grants/foundations.
Keyboard: ⌘K / Ctrl+K opens, Esc closes, ↑/↓ navigate, Enter selects.
═══════════════════════════════════════════════════════════════════════════ */
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Search, X, FileText, Vote, Building2, Radio, Newspaper, ArrowRight,
} from 'lucide-react';
interface SearchResult {
id: string;
type: 'petition' | 'politician' | 'organization' | 'topic' | 'article';
title: string;
subtitle: string;
meta?: string;
url?: string;
}
interface SearchResponse {
results: SearchResult[];
query: string;
counts: Record<string, number>;
total: number;
}
interface PulseTheme {
darkGreen: string;
gold: string;
goldHover: string;
cream: string;
textLight: string;
}
const TYPE_CONFIG: Record<SearchResult['type'], { icon: React.ElementType; color: string; label: string }> = {
petition: { icon: FileText, color: '#e11d48', label: 'Petition' },
politician: { icon: Vote, color: '#1d4ed8', label: 'Politician' },
organization: { icon: Building2, color: '#0a7c59', label: 'Organization' },
topic: { icon: Radio, color: '#0891b2', label: 'Topic' },
article: { icon: Newspaper, color: '#6366f1', label: 'Article' },
};
export default function PulseSearch({ theme }: { theme: PulseTheme }) {
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);
const router = useRouter();
/* ── 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/public?q=${encodeURIComponent(query)}&limit=20`);
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]);
const filtered = activeFilter ? results.filter(r => r.type === activeFilter) : 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]);
function handleSelect(r: SearchResult) {
// Articles with URLs open externally
if (r.type === 'article' && r.url) {
window.open(r.url, '_blank', 'noopener,noreferrer');
setOpen(false);
setQuery('');
return;
}
// Petitions deep-link to /pulse/petitions/<id>
if (r.type === 'petition') {
router.push(`/pulse/petitions/${r.id}`);
} else if (r.type === 'topic') {
// Topics filter the petition feed by category
router.push(`/pulse/petitions?category=${encodeURIComponent(r.subtitle || r.title)}`);
} else if (r.type === 'organization') {
// Orgs: search-filter the petition feed
router.push(`/pulse/petitions?search=${encodeURIComponent(r.title)}`);
} else if (r.type === 'politician') {
// Politicians: pulse doesn't have a politician detail page yet; surface as search filter
router.push(`/pulse/petitions?search=${encodeURIComponent(r.title)}`);
} else if (r.url) {
window.open(r.url, '_blank', 'noopener,noreferrer');
}
setOpen(false);
setQuery('');
setResults([]);
}
const activeTypes = Object.entries(counts).filter(([, c]) => c > 0);
return (
<>
{/* Trigger button — sits in the Pulse nav between Theme toggle and Auth section */}
<button
onClick={() => setOpen(true)}
title="Search petitions, politicians, orgs (⌘K)"
aria-label="Open search"
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'rgba(255,255,255,0.10)', color: theme.textLight,
border: '1px solid rgba(255,255,255,0.18)',
padding: '6px 12px', borderRadius: 8,
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: 13, fontWeight: 500, cursor: 'pointer',
transition: 'background-color 150ms',
}}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.18)')}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.10)')}
>
<Search size={14} />
<span>Search</span>
<kbd style={{
fontSize: 10, padding: '1px 6px', borderRadius: 4,
backgroundColor: 'rgba(255,255,255,0.15)',
color: theme.textLight,
fontFamily: 'inherit',
}}>⌘K</kbd>
</button>
{/* Modal overlay */}
{open && (
<div
role="dialog"
aria-modal="true"
aria-label="Global search"
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(27, 67, 50, 0.40)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: 80, padding: '80px 16px 16px',
}}
>
<div
ref={panelRef}
style={{
width: '100%', maxWidth: 640, maxHeight: '78vh',
background: theme.cream, borderRadius: 12,
boxShadow: '0 24px 60px rgba(0,0,0,0.28)',
border: `1px solid rgba(0,0,0,0.06)`,
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
fontFamily: 'Georgia, "Times New Roman", serif',
}}
>
{/* Search input */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '14px 18px',
borderBottom: `1px solid rgba(0,0,0,0.08)`,
background: theme.cream,
}}>
<Search size={18} color={theme.darkGreen} />
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search petitions, politicians, organizations…"
style={{
flex: 1, border: 'none', background: 'transparent', outline: 'none',
fontSize: 16, fontFamily: 'Georgia, "Times New Roman", serif',
color: '#1a1a1a',
}}
/>
<button
onClick={() => { setOpen(false); setQuery(''); setResults([]); }}
aria-label="Close search"
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 4,
color: '#6b7280',
}}
>
<X size={18} />
</button>
</div>
{/* Filter chips */}
{activeTypes.length > 0 && (
<div style={{
display: 'flex', gap: 6, padding: '10px 18px', flexWrap: 'wrap',
borderBottom: `1px solid rgba(0,0,0,0.06)`,
}}>
<button
onClick={() => setActiveFilter('')}
style={{
fontSize: 12, padding: '4px 10px', borderRadius: 999,
background: activeFilter === '' ? theme.darkGreen : 'transparent',
color: activeFilter === '' ? theme.gold : theme.darkGreen,
border: `1px solid ${theme.darkGreen}`,
cursor: 'pointer', fontFamily: 'system-ui, sans-serif', fontWeight: 600,
}}
>
All ({results.length})
</button>
{activeTypes.map(([type, count]) => {
// counts are keyed plural (petitions, politicians, ...) — map to singular for TYPE_CONFIG.
const singularType = type.slice(0, -1) as SearchResult['type'];
const cfg = TYPE_CONFIG[singularType];
if (!cfg) return null;
const isActive = activeFilter === singularType;
return (
<button
key={type}
onClick={() => setActiveFilter(isActive ? '' : singularType)}
style={{
fontSize: 12, padding: '4px 10px', borderRadius: 999,
background: isActive ? cfg.color : 'transparent',
color: isActive ? '#fff' : cfg.color,
border: `1px solid ${cfg.color}`,
cursor: 'pointer', fontFamily: 'system-ui, sans-serif', fontWeight: 600,
}}
>
{cfg.label} ({count})
</button>
);
})}
</div>
)}
{/* Results list */}
<div style={{ overflow: 'auto', flex: 1, padding: '6px 0' }}>
{loading && (
<div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
Searching…
</div>
)}
{!loading && query.length < 2 && (
<div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
Type at least 2 characters to search.
</div>
)}
{!loading && query.length >= 2 && filtered.length === 0 && (
<div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
No results for <strong>“{query}”</strong>.
</div>
)}
{filtered.map((r, i) => {
const cfg = TYPE_CONFIG[r.type];
const Icon = cfg.icon;
const isSelected = i === selectedIndex;
return (
<button
key={`${r.type}-${r.id}-${i}`}
onClick={() => handleSelect(r)}
onMouseEnter={() => setSelectedIndex(i)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 18px', textAlign: 'left',
background: isSelected ? `${cfg.color}14` : 'transparent',
border: 'none', borderLeft: `3px solid ${isSelected ? cfg.color : 'transparent'}`,
cursor: 'pointer', fontFamily: 'Georgia, "Times New Roman", serif',
}}
>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: `${cfg.color}1a`, color: cfg.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<Icon size={16} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 15, color: '#1a1a1a', fontWeight: 600,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{r.title}
</div>
<div style={{
fontSize: 12, color: '#6b7280',
fontFamily: 'system-ui, sans-serif',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{cfg.label}{r.subtitle ? ` · ${r.subtitle}` : ''}{r.meta ? ` · ${r.meta}` : ''}
</div>
</div>
{isSelected && <ArrowRight size={14} color={cfg.color} />}
</button>
);
})}
</div>
{/* Footer hint */}
<div style={{
padding: '8px 18px', borderTop: `1px solid rgba(0,0,0,0.06)`,
fontSize: 11, color: '#6b7280', fontFamily: 'system-ui, sans-serif',
display: 'flex', justifyContent: 'space-between',
}}>
<span><kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↑</kbd> <kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↓</kbd> navigate <kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↵</kbd> select</span>
<span><kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>esc</kbd> close</span>
</div>
</div>
</div>
)}
</>
);
}