Compare commits
1 Commits
11131c2071
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe5cda20c5 |
@@ -28,6 +28,7 @@ from .schemas import (
|
||||
ResumableUploadCompleteRequest,
|
||||
ResumableUploadCompleteResponse,
|
||||
ResumableUploadStatusResponse,
|
||||
GlobalSearchResponse, SearchResultProject, SearchResultPackage, SearchResultArtifact,
|
||||
)
|
||||
from .metadata import extract_metadata
|
||||
|
||||
@@ -51,32 +52,155 @@ def health_check():
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
|
||||
# Global search
|
||||
@router.get("/api/v1/search", response_model=GlobalSearchResponse)
|
||||
def global_search(
|
||||
request: Request,
|
||||
q: str = Query(..., min_length=1, description="Search query"),
|
||||
limit: int = Query(default=5, ge=1, le=20, description="Results per type"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Search across all entity types (projects, packages, artifacts/tags).
|
||||
Returns limited results for each type plus total counts.
|
||||
"""
|
||||
user_id = get_user_id(request)
|
||||
search_lower = q.lower()
|
||||
|
||||
# Search projects (name and description)
|
||||
project_query = db.query(Project).filter(
|
||||
or_(Project.is_public == True, Project.created_by == user_id),
|
||||
or_(
|
||||
func.lower(Project.name).contains(search_lower),
|
||||
func.lower(Project.description).contains(search_lower)
|
||||
)
|
||||
)
|
||||
project_count = project_query.count()
|
||||
projects = project_query.order_by(Project.name).limit(limit).all()
|
||||
|
||||
# Search packages (name and description) with project name
|
||||
package_query = db.query(Package, Project.name.label("project_name")).join(
|
||||
Project, Package.project_id == Project.id
|
||||
).filter(
|
||||
or_(Project.is_public == True, Project.created_by == user_id),
|
||||
or_(
|
||||
func.lower(Package.name).contains(search_lower),
|
||||
func.lower(Package.description).contains(search_lower)
|
||||
)
|
||||
)
|
||||
package_count = package_query.count()
|
||||
package_results = package_query.order_by(Package.name).limit(limit).all()
|
||||
|
||||
# Search tags/artifacts (tag name and original filename)
|
||||
artifact_query = db.query(
|
||||
Tag, Artifact, Package.name.label("package_name"), Project.name.label("project_name")
|
||||
).join(
|
||||
Artifact, Tag.artifact_id == Artifact.id
|
||||
).join(
|
||||
Package, Tag.package_id == Package.id
|
||||
).join(
|
||||
Project, Package.project_id == Project.id
|
||||
).filter(
|
||||
or_(Project.is_public == True, Project.created_by == user_id),
|
||||
or_(
|
||||
func.lower(Tag.name).contains(search_lower),
|
||||
func.lower(Artifact.original_name).contains(search_lower)
|
||||
)
|
||||
)
|
||||
artifact_count = artifact_query.count()
|
||||
artifact_results = artifact_query.order_by(Tag.name).limit(limit).all()
|
||||
|
||||
return GlobalSearchResponse(
|
||||
query=q,
|
||||
projects=[SearchResultProject(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
is_public=p.is_public
|
||||
) for p in projects],
|
||||
packages=[SearchResultPackage(
|
||||
id=pkg.id,
|
||||
project_id=pkg.project_id,
|
||||
project_name=project_name,
|
||||
name=pkg.name,
|
||||
description=pkg.description,
|
||||
format=pkg.format
|
||||
) for pkg, project_name in package_results],
|
||||
artifacts=[SearchResultArtifact(
|
||||
tag_id=tag.id,
|
||||
tag_name=tag.name,
|
||||
artifact_id=artifact.id,
|
||||
package_id=tag.package_id,
|
||||
package_name=package_name,
|
||||
project_name=project_name,
|
||||
original_name=artifact.original_name
|
||||
) for tag, artifact, package_name, project_name in artifact_results],
|
||||
counts={
|
||||
"projects": project_count,
|
||||
"packages": package_count,
|
||||
"artifacts": artifact_count,
|
||||
"total": project_count + package_count + artifact_count
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Project routes
|
||||
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse])
|
||||
def list_projects(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1, description="Page number"),
|
||||
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
|
||||
search: Optional[str] = Query(default=None, description="Search by project name"),
|
||||
search: Optional[str] = Query(default=None, description="Search by project name or description"),
|
||||
visibility: Optional[str] = Query(default=None, description="Filter by visibility (public, private)"),
|
||||
sort: str = Query(default="name", description="Sort field (name, created_at, updated_at)"),
|
||||
order: str = Query(default="asc", description="Sort order (asc, desc)"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = get_user_id(request)
|
||||
|
||||
# Validate sort field
|
||||
valid_sort_fields = {"name": Project.name, "created_at": Project.created_at, "updated_at": Project.updated_at}
|
||||
if sort not in valid_sort_fields:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid sort field. Must be one of: {', '.join(valid_sort_fields.keys())}")
|
||||
|
||||
# Validate order
|
||||
if order not in ("asc", "desc"):
|
||||
raise HTTPException(status_code=400, detail="Invalid order. Must be 'asc' or 'desc'")
|
||||
|
||||
# Base query - filter by access
|
||||
query = db.query(Project).filter(
|
||||
or_(Project.is_public == True, Project.created_by == user_id)
|
||||
)
|
||||
|
||||
# Apply search filter (case-insensitive)
|
||||
# Apply visibility filter
|
||||
if visibility == "public":
|
||||
query = query.filter(Project.is_public == True)
|
||||
elif visibility == "private":
|
||||
query = query.filter(Project.is_public == False, Project.created_by == user_id)
|
||||
|
||||
# Apply search filter (case-insensitive on name and description)
|
||||
if search:
|
||||
query = query.filter(func.lower(Project.name).contains(search.lower()))
|
||||
search_lower = search.lower()
|
||||
query = query.filter(
|
||||
or_(
|
||||
func.lower(Project.name).contains(search_lower),
|
||||
func.lower(Project.description).contains(search_lower)
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count before pagination
|
||||
total = query.count()
|
||||
|
||||
# Apply sorting
|
||||
sort_column = valid_sort_fields[sort]
|
||||
if order == "desc":
|
||||
query = query.order_by(sort_column.desc())
|
||||
else:
|
||||
query = query.order_by(sort_column.asc())
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * limit
|
||||
projects = query.order_by(Project.name).offset(offset).limit(limit).all()
|
||||
projects = query.offset(offset).limit(limit).all()
|
||||
|
||||
# Calculate total pages
|
||||
total_pages = math.ceil(total / limit) if total > 0 else 1
|
||||
@@ -882,9 +1006,15 @@ def list_tags(
|
||||
# Base query with JOIN to artifact for metadata
|
||||
query = db.query(Tag, Artifact).join(Artifact, Tag.artifact_id == Artifact.id).filter(Tag.package_id == package.id)
|
||||
|
||||
# Apply search filter (case-insensitive on tag name)
|
||||
# Apply search filter (case-insensitive on tag name OR artifact original filename)
|
||||
if search:
|
||||
query = query.filter(func.lower(Tag.name).contains(search.lower()))
|
||||
search_lower = search.lower()
|
||||
query = query.filter(
|
||||
or_(
|
||||
func.lower(Tag.name).contains(search_lower),
|
||||
func.lower(Artifact.original_name).contains(search_lower)
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count before pagination
|
||||
total = query.count()
|
||||
|
||||
@@ -269,6 +269,51 @@ class ConsumerResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Global search schemas
|
||||
class SearchResultProject(BaseModel):
|
||||
"""Project result for global search"""
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str]
|
||||
is_public: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SearchResultPackage(BaseModel):
|
||||
"""Package result for global search"""
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
project_name: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
format: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SearchResultArtifact(BaseModel):
|
||||
"""Artifact/tag result for global search"""
|
||||
tag_id: UUID
|
||||
tag_name: str
|
||||
artifact_id: str
|
||||
package_id: UUID
|
||||
package_name: str
|
||||
project_name: str
|
||||
original_name: Optional[str]
|
||||
|
||||
|
||||
class GlobalSearchResponse(BaseModel):
|
||||
"""Combined search results across all entity types"""
|
||||
query: str
|
||||
projects: List[SearchResultProject]
|
||||
packages: List[SearchResultPackage]
|
||||
artifacts: List[SearchResultArtifact]
|
||||
counts: Dict[str, int] # Total counts for each type
|
||||
|
||||
|
||||
# Health check
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
TagListParams,
|
||||
PackageListParams,
|
||||
ArtifactListParams,
|
||||
ProjectListParams,
|
||||
GlobalSearchResponse,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
@@ -34,8 +36,15 @@ function buildQueryString(params: Record<string, unknown>): string {
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
// Global Search API
|
||||
export async function globalSearch(query: string, limit: number = 5): Promise<GlobalSearchResponse> {
|
||||
const params = buildQueryString({ q: query, limit });
|
||||
const response = await fetch(`${API_BASE}/search${params}`);
|
||||
return handleResponse<GlobalSearchResponse>(response);
|
||||
}
|
||||
|
||||
// Project API
|
||||
export async function listProjects(params: ListParams = {}): Promise<PaginatedResponse<Project>> {
|
||||
export async function listProjects(params: ProjectListParams = {}): Promise<PaginatedResponse<Project>> {
|
||||
const query = buildQueryString(params as Record<string, unknown>);
|
||||
const response = await fetch(`${API_BASE}/projects${query}`);
|
||||
return handleResponse<PaginatedResponse<Project>>(response);
|
||||
|
||||
75
frontend/src/components/FilterDropdown.css
Normal file
75
frontend/src/components/FilterDropdown.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.filter-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-dropdown__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-dropdown__trigger:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-dropdown__trigger--active {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-dropdown__chevron {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-dropdown__chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.filter-dropdown__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 150px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-dropdown__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-dropdown__option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.filter-dropdown__option--selected {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.filter-dropdown__option svg {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
80
frontend/src/components/FilterDropdown.tsx
Normal file
80
frontend/src/components/FilterDropdown.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import './FilterDropdown.css';
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FilterDropdownProps {
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FilterDropdown({ label, options, value, onChange, className = '' }: FilterDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`filter-dropdown ${className}`.trim()} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-dropdown__trigger ${value ? 'filter-dropdown__trigger--active' : ''}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span>{selectedOption ? selectedOption.label : label}</span>
|
||||
<svg
|
||||
className={`filter-dropdown__chevron ${isOpen ? 'filter-dropdown__chevron--open' : ''}`}
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="filter-dropdown__menu">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`filter-dropdown__option ${option.value === value ? 'filter-dropdown__option--selected' : ''}`}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === value && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
frontend/src/components/GlobalSearch.css
Normal file
216
frontend/src/components/GlobalSearch.css
Normal file
@@ -0,0 +1,216 @@
|
||||
.global-search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.global-search__input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.global-search__icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-search__input {
|
||||
width: 100%;
|
||||
padding: 8px 40px 8px 36px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.global-search__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.global-search__input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.global-search__shortcut {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-search__spinner {
|
||||
position: absolute;
|
||||
right: 36px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border-primary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.global-search__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.global-search__empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.global-search__section {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.global-search__section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.global-search__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 12px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.global-search__count {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.global-search__result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.global-search__result:hover,
|
||||
.global-search__result.selected {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.global-search__result svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.global-search__result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.global-search__result-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.global-search__result-path {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.global-search__result-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.global-search__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.global-search__badge.public {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.global-search__badge.private {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.global-search__badge.format {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.global-search {
|
||||
max-width: none;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.global-search__shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.global-search {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { GlobalSearch } from './GlobalSearch';
|
||||
import './Layout.css';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -32,6 +33,7 @@ function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
<span className="logo-text">Orchard</span>
|
||||
</Link>
|
||||
<GlobalSearch />
|
||||
<nav className="nav">
|
||||
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
|
||||
@@ -4,6 +4,9 @@ export { Breadcrumb } from './Breadcrumb';
|
||||
export { SearchInput } from './SearchInput';
|
||||
export { SortDropdown } from './SortDropdown';
|
||||
export type { SortOption } from './SortDropdown';
|
||||
export { FilterDropdown } from './FilterDropdown';
|
||||
export type { FilterOption } from './FilterDropdown';
|
||||
export { FilterChip, FilterChipGroup } from './FilterChip';
|
||||
export { DataTable } from './DataTable';
|
||||
export { Pagination } from './Pagination';
|
||||
export { GlobalSearch } from './GlobalSearch';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listProjects, createProject } from '../api';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { SearchInput } from '../components/SearchInput';
|
||||
import { SortDropdown, SortOption } from '../components/SortDropdown';
|
||||
import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
|
||||
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import './Home.css';
|
||||
@@ -15,6 +16,12 @@ const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: 'updated_at', label: 'Updated' },
|
||||
];
|
||||
|
||||
const VISIBILITY_OPTIONS: FilterOption[] = [
|
||||
{ value: '', label: 'All Projects' },
|
||||
{ value: 'public', label: 'Public Only' },
|
||||
{ value: 'private', label: 'Private Only' },
|
||||
];
|
||||
|
||||
function Home() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -30,6 +37,7 @@ function Home() {
|
||||
const search = searchParams.get('search') || '';
|
||||
const sort = searchParams.get('sort') || 'name';
|
||||
const order = (searchParams.get('order') || 'asc') as 'asc' | 'desc';
|
||||
const visibility = searchParams.get('visibility') || '';
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | undefined>) => {
|
||||
@@ -49,7 +57,13 @@ function Home() {
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listProjects({ page, search, sort, order });
|
||||
const data = await listProjects({
|
||||
page,
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
visibility: visibility as 'public' | 'private' | undefined || undefined,
|
||||
});
|
||||
setProjectsData(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -57,7 +71,7 @@ function Home() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, sort, order]);
|
||||
}, [page, search, sort, order, visibility]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
@@ -86,6 +100,10 @@ function Home() {
|
||||
updateParams({ sort: newSort, order: newOrder, page: '1' });
|
||||
};
|
||||
|
||||
const handleVisibilityChange = (value: string) => {
|
||||
updateParams({ visibility: value, page: '1' });
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
updateParams({ page: String(newPage) });
|
||||
};
|
||||
@@ -94,7 +112,7 @@ function Home() {
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
const hasActiveFilters = search !== '';
|
||||
const hasActiveFilters = search !== '' || visibility !== '';
|
||||
const projects = projectsData?.items || [];
|
||||
const pagination = projectsData?.pagination;
|
||||
|
||||
@@ -160,12 +178,25 @@ function Home() {
|
||||
placeholder="Search projects..."
|
||||
className="list-controls__search"
|
||||
/>
|
||||
<FilterDropdown
|
||||
label="Visibility"
|
||||
options={VISIBILITY_OPTIONS}
|
||||
value={visibility}
|
||||
onChange={handleVisibilityChange}
|
||||
/>
|
||||
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<FilterChipGroup onClearAll={clearFilters}>
|
||||
{search && <FilterChip label="Search" value={search} onRemove={() => handleSearchChange('')} />}
|
||||
{visibility && (
|
||||
<FilterChip
|
||||
label="Visibility"
|
||||
value={visibility === 'public' ? 'Public' : 'Private'}
|
||||
onRemove={() => handleVisibilityChange('')}
|
||||
/>
|
||||
)}
|
||||
</FilterChipGroup>
|
||||
)}
|
||||
|
||||
|
||||
@@ -117,3 +117,47 @@ export interface UploadResponse {
|
||||
package: string;
|
||||
tag: string | null;
|
||||
}
|
||||
|
||||
// Global search types
|
||||
export interface SearchResultProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResultPackage {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
format: string;
|
||||
}
|
||||
|
||||
export interface SearchResultArtifact {
|
||||
tag_id: string;
|
||||
tag_name: string;
|
||||
artifact_id: string;
|
||||
package_id: string;
|
||||
package_name: string;
|
||||
project_name: string;
|
||||
original_name: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalSearchResponse {
|
||||
query: string;
|
||||
projects: SearchResultProject[];
|
||||
packages: SearchResultPackage[];
|
||||
artifacts: SearchResultArtifact[];
|
||||
counts: {
|
||||
projects: number;
|
||||
packages: number;
|
||||
artifacts: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectListParams extends ListParams {
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user