266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { globalSearch } from '../api';
|
|
import { GlobalSearchResponse } from '../types';
|
|
import './GlobalSearch.css';
|
|
|
|
export function GlobalSearch() {
|
|
const navigate = useNavigate();
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<GlobalSearchResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Build flat list of results for keyboard navigation
|
|
const flatResults = results
|
|
? [
|
|
...results.projects.map((p) => ({ type: 'project' as const, item: p })),
|
|
...results.packages.map((p) => ({ type: 'package' as const, item: p })),
|
|
...results.artifacts.map((a) => ({ type: 'artifact' as const, item: a })),
|
|
]
|
|
: [];
|
|
|
|
const handleSearch = useCallback(async (searchQuery: string) => {
|
|
if (!searchQuery.trim()) {
|
|
setResults(null);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const data = await globalSearch(searchQuery);
|
|
setResults(data);
|
|
setIsOpen(true);
|
|
setSelectedIndex(-1);
|
|
} catch (err) {
|
|
console.error('Search failed:', err);
|
|
setResults(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Debounced search
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
handleSearch(query);
|
|
}, 300);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [query, handleSearch]);
|
|
|
|
// Close on click outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Keyboard navigation
|
|
useEffect(() => {
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)) {
|
|
event.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
|
|
if (!isOpen) return;
|
|
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
setSelectedIndex((prev) => Math.min(prev + 1, flatResults.length - 1));
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
setSelectedIndex((prev) => Math.max(prev - 1, -1));
|
|
break;
|
|
case 'Enter':
|
|
if (selectedIndex >= 0 && flatResults[selectedIndex]) {
|
|
event.preventDefault();
|
|
navigateToResult(flatResults[selectedIndex]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
setIsOpen(false);
|
|
inputRef.current?.blur();
|
|
break;
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, selectedIndex, flatResults]);
|
|
|
|
function navigateToResult(result: (typeof flatResults)[0]) {
|
|
setIsOpen(false);
|
|
setQuery('');
|
|
|
|
switch (result.type) {
|
|
case 'project':
|
|
navigate(`/project/${result.item.name}`);
|
|
break;
|
|
case 'package':
|
|
navigate(`/project/${result.item.project_name}/${result.item.name}`);
|
|
break;
|
|
case 'artifact':
|
|
navigate(`/project/${result.item.project_name}/${result.item.package_name}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const hasResults = results && results.counts.total > 0;
|
|
|
|
return (
|
|
<div className="global-search" ref={containerRef}>
|
|
<div className="global-search__input-wrapper">
|
|
<svg
|
|
className="global-search__icon"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onFocus={() => query && results && setIsOpen(true)}
|
|
placeholder="Search projects, packages, artifacts..."
|
|
className="global-search__input"
|
|
/>
|
|
<kbd className="global-search__shortcut">/</kbd>
|
|
{loading && <span className="global-search__spinner" />}
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div className="global-search__dropdown">
|
|
{!hasResults && query && (
|
|
<div className="global-search__empty">No results found for "{query}"</div>
|
|
)}
|
|
|
|
{hasResults && (
|
|
<>
|
|
{results.projects.length > 0 && (
|
|
<div className="global-search__section">
|
|
<div className="global-search__section-header">
|
|
Projects
|
|
<span className="global-search__count">{results.counts.projects}</span>
|
|
</div>
|
|
{results.projects.map((project, index) => {
|
|
const flatIndex = index;
|
|
return (
|
|
<button
|
|
key={project.id}
|
|
className={`global-search__result ${selectedIndex === flatIndex ? 'selected' : ''}`}
|
|
onClick={() => navigateToResult({ type: 'project', item: project })}
|
|
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
<div className="global-search__result-content">
|
|
<span className="global-search__result-name">{project.name}</span>
|
|
{project.description && (
|
|
<span className="global-search__result-desc">{project.description}</span>
|
|
)}
|
|
</div>
|
|
<span className={`global-search__badge ${project.is_public ? 'public' : 'private'}`}>
|
|
{project.is_public ? 'Public' : 'Private'}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{results.packages.length > 0 && (
|
|
<div className="global-search__section">
|
|
<div className="global-search__section-header">
|
|
Packages
|
|
<span className="global-search__count">{results.counts.packages}</span>
|
|
</div>
|
|
{results.packages.map((pkg, index) => {
|
|
const flatIndex = results.projects.length + index;
|
|
return (
|
|
<button
|
|
key={pkg.id}
|
|
className={`global-search__result ${selectedIndex === flatIndex ? 'selected' : ''}`}
|
|
onClick={() => navigateToResult({ type: 'package', item: pkg })}
|
|
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
|
</svg>
|
|
<div className="global-search__result-content">
|
|
<span className="global-search__result-name">{pkg.name}</span>
|
|
<span className="global-search__result-path">{pkg.project_name}</span>
|
|
{pkg.description && (
|
|
<span className="global-search__result-desc">{pkg.description}</span>
|
|
)}
|
|
</div>
|
|
<span className="global-search__badge format">{pkg.format}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{results.artifacts.length > 0 && (
|
|
<div className="global-search__section">
|
|
<div className="global-search__section-header">
|
|
Artifacts / Tags
|
|
<span className="global-search__count">{results.counts.artifacts}</span>
|
|
</div>
|
|
{results.artifacts.map((artifact, index) => {
|
|
const flatIndex = results.projects.length + results.packages.length + index;
|
|
return (
|
|
<button
|
|
key={artifact.tag_id}
|
|
className={`global-search__result ${selectedIndex === flatIndex ? 'selected' : ''}`}
|
|
onClick={() => navigateToResult({ type: 'artifact', item: artifact })}
|
|
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
|
<line x1="7" y1="7" x2="7.01" y2="7" />
|
|
</svg>
|
|
<div className="global-search__result-content">
|
|
<span className="global-search__result-name">{artifact.tag_name}</span>
|
|
<span className="global-search__result-path">
|
|
{artifact.project_name} / {artifact.package_name}
|
|
</span>
|
|
{artifact.original_name && (
|
|
<span className="global-search__result-desc">{artifact.original_name}</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|