diff --git a/frontend/src/components/Search/CommandPalette.tsx b/frontend/src/components/Search/CommandPalette.tsx new file mode 100644 index 0000000..bd93727 --- /dev/null +++ b/frontend/src/components/Search/CommandPalette.tsx @@ -0,0 +1,218 @@ +'use client' + +import React, { useEffect, useState, useRef, useCallback } from 'react' +import { Search as SearchIcon, X, Users, Coins, User } from 'lucide-react' +import { cn } from '@/lib/utils' + +/* ---------- types ---------- */ + +interface SearchResult { + id: string + title: string + section: 'guilds' | 'bounties' | 'users' + url?: string +} + +interface CommandPaletteProps { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +/* ---------- dummy data (independence note) ---------- */ + +const DUMMY_RESULTS: SearchResult[] = [ + { id: 'g1', title: 'Stellar Developers Guild', section: 'guilds', url: '/guilds/stellar-devs' }, + { id: 'g2', title: 'Rust Builders Collective', section: 'guilds', url: '/guilds/rust-builders' }, + { id: 'g3', title: 'Protocol Engineers', section: 'guilds', url: '/guilds/protocol-eng' }, + { id: 'b1', title: 'Fix OAuth2 token refresh flow', section: 'bounties', url: '/bounties/42' }, + { id: 'b2', title: 'Add S3 file storage adapter', section: 'bounties', url: '/bounties/43' }, + { id: 'b3', title: 'Implement role-based guards', section: 'bounties', url: '/bounties/44' }, + { id: 'u1', title: 'alice_stellar', section: 'users', url: '/users/alice' }, + { id: 'u2', title: 'bob_builder', section: 'users', url: '/users/bob' }, +] + +const SECTION_META: Record = { + guilds: { label: 'Guilds', icon: Users }, + bounties: { label: 'Bounties', icon: Coins }, + users: { label: 'Users', icon: User }, +} + +/* ---------- component ---------- */ + +export default function CommandPalette({ + open: controlledOpen, + onOpenChange, +}: CommandPaletteProps) { + const [internalOpen, setInternalOpen] = useState(false) + const isOpen = controlledOpen ?? internalOpen + const setIsOpen = onOpenChange ?? setInternalOpen + + const [query, setQuery] = useState('') + const [activeIndex, setActiveIndex] = useState(0) + const inputRef = useRef(null) + const listRef = useRef(null) + + // ---- filter ---- + const filtered = DUMMY_RESULTS.filter((r) => + r.title.toLowerCase().includes(query.toLowerCase()) + ) + + // group by section preserving order + const grouped = Object.entries(SECTION_META).reduce>((acc, [key]) => { + const hits = filtered.filter((r) => r.section === key) + if (hits.length > 0) acc[key] = hits + return acc + }, {}) + + const flatResults = Object.values(grouped).flat() + + // ---- keyboard shortcut (Cmd+K / Ctrl+K) ---- + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + setIsOpen(!isOpen) + } + if (e.key === 'Escape') { + setIsOpen(false) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, setIsOpen]) + + // ---- focus input when opened ---- + useEffect(() => { + if (isOpen) { + setQuery('') + setActiveIndex(0) + // small delay so the transition starts + requestAnimationFrame(() => inputRef.current?.focus()) + } + }, [isOpen]) + + // ---- arrow-key navigation ---- + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setActiveIndex((i) => Math.min(i + 1, flatResults.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setActiveIndex((i) => Math.max(i - 1, 0)) + break + case 'Enter': + e.preventDefault() + if (flatResults[activeIndex]?.url) { + window.location.href = flatResults[activeIndex].url! + } + break + } + }, + [activeIndex, flatResults] + ) + + // ---- scroll active item into view ---- + useEffect(() => { + if (listRef.current) { + const el = listRef.current.querySelector('[data-active="true"]') as HTMLElement + el?.scrollIntoView({ block: 'nearest' }) + } + }, [activeIndex]) + + if (!isOpen) return null + + return ( +
+ {/* backdrop */} +
setIsOpen(false)} + aria-hidden="true" + /> + + {/* dialog */} +
+ {/* search row */} +
+
+ + {/* results */} +
    + {flatResults.length === 0 && ( +
  • + No results found for “{query}” +
  • + )} + + {Object.entries(grouped).map(([section, results]) => ( +
  • + {/* section header */} +
    + {SECTION_META[section as SearchResult['section']].label} +
    + {results.map((result) => { + const idx = flatResults.indexOf(result) + const isActive = idx === activeIndex + const meta = SECTION_META[result.section] + const Icon = meta.icon + + return ( + + ) + })} +
  • + ))} +
+ + {/* footer */} +
+ + ↑↓ navigate + open + esc close + + Cmd+K to toggle +
+
+
+ ) +} diff --git a/frontend/src/components/Search/index.ts b/frontend/src/components/Search/index.ts new file mode 100644 index 0000000..3fb1fc3 --- /dev/null +++ b/frontend/src/components/Search/index.ts @@ -0,0 +1,5 @@ +export { default as CommandPalette } from './CommandPalette' +export { default as SearchInput } from './SearchInput' +export { default as SearchResults } from './SearchResults' +export { default as SearchFilters } from './SearchFilters' +export { default as SearchSidebar } from './SearchSidebar'