Add global search and filtering enhancements
This commit is contained in:
265
frontend/src/components/GlobalSearch.tsx
Normal file
265
frontend/src/components/GlobalSearch.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user