import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { NavIcon } from './NavIcon'; import { __ } from '../lib/i18n'; type Hit = { id: number; title: string; subtitle: string; url: string; }; type SearchResponse = { ok?: boolean; query?: string; results?: Record & { users?: Hit[]; courses?: Hit[]; orders?: Hit[]; }; }; /** Display labels for known buckets. Unknown buckets (Pro-contributed) get a TitleCase fallback. */ const BUCKET_LABELS: Record = { courses: 'Courses', users: 'Users', orders: 'Orders', }; const KNOWN_BUCKETS = new Set(['users', 'courses', 'orders']); function titleCase(s: string): string { return s .split(/[-_]+/) .filter(Boolean) .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) .join(' '); } const isMacLike = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || ''); /** * Global admin search — Cmd/Ctrl+K opens a small palette that fans out across * users, courses, and orders by hitting `/admin/search`. Closes on Esc or * outside click. Designed to live in the TopBar. */ export function GlobalSearchPalette() { const [open, setOpen] = useState(false); const [q, setQ] = useState(''); const [hits, setHits] = useState({ users: [], courses: [], orders: [] }); const [loading, setLoading] = useState(false); const inputRef = useRef(null); const panelRef = useRef(null); const reqRef = useRef(0); // Cmd/Ctrl+K opens the palette globally. useEffect(() => { const onKey = (e: KeyboardEvent) => { const meta = isMacLike() ? e.metaKey : e.ctrlKey; if (meta && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); setOpen(true); return; } if (e.key === 'Escape' && open) { setOpen(false); } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [open]); // Focus the input when the palette opens. useEffect(() => { if (open) { requestAnimationFrame(() => inputRef.current?.focus()); } else { setQ(''); setHits({ users: [], courses: [], orders: [] }); } }, [open]); // Close on outside click while open. useEffect(() => { if (!open) return; const onClick = (e: MouseEvent) => { const el = panelRef.current; if (el && !el.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, [open]); // Debounced fetch on query change. useEffect(() => { if (!open) return; if (q.trim().length < 2) { setHits({ users: [], courses: [], orders: [] }); setLoading(false); return; } const myReq = ++reqRef.current; setLoading(true); const t = window.setTimeout(async () => { try { const r = await getSikshyaApi().get( `${SIKSHYA_ENDPOINTS.admin.search}?q=${encodeURIComponent(q.trim())}&limit=5` ); if (myReq !== reqRef.current) return; setHits(r?.results ?? { users: [], courses: [], orders: [] }); } catch { if (myReq !== reqRef.current) return; setHits({ users: [], courses: [], orders: [] }); } finally { if (myReq === reqRef.current) { setLoading(false); } } }, 220); return () => window.clearTimeout(t); }, [q, open]); const totalHits = useMemo(() => { if (!hits) return 0; return Object.values(hits).reduce((acc, rows) => acc + (rows?.length ?? 0), 0); }, [hits]); // Bucket render order: core buckets first (Courses, Users, Orders), then any // Pro-contributed buckets in stable alphabetical order. const orderedBuckets = useMemo(() => { const coreOrder = ['courses', 'users', 'orders']; const known = coreOrder.filter((k) => (hits?.[k]?.length ?? 0) > 0); const extra = Object.keys(hits ?? {}) .filter((k) => !KNOWN_BUCKETS.has(k) && (hits?.[k]?.length ?? 0) > 0) .sort(); return [...known, ...extra]; }, [hits]); const renderGroup = useCallback( (label: string, rows: Hit[] | undefined) => { if (!rows || rows.length === 0) return null; return ( ); }, [] ); return ( <> {open ? (
setQ(e.target.value)} placeholder={__('Search users, courses, orders…', 'sikshya')} className="w-full bg-transparent text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-white" data-testid="topbar-global-search-input" />
{q.trim().length < 2 ? (

{__('Type at least 2 characters to search.', 'sikshya')}

) : loading ? (

{__('Searching…', 'sikshya')}

) : totalHits === 0 ? (

{__('No matches.', 'sikshya')}

) : ( <> {orderedBuckets.map((key) => { const label = BUCKET_LABELS[key] ?? titleCase(key); return (
{renderGroup(label, hits?.[key])}
); })} )}
) : null} ); }