← 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 &quot;{query}&quot;
                    </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>
    </>
  );
}