feat(#67): implement search UI with filters and shortcuts
Implements comprehensive search interface for knowledge base: Components: - SearchInput: Debounced search with Cmd+K (Ctrl+K) shortcut - SearchResults: Main results view with highlighted snippets - SearchFilters: Sidebar for filtering by status and tags - Search page: Full search experience at /knowledge/search Features: - Search-as-you-type with 300ms debounce - HTML snippet highlighting (using <mark> from API) - Tag and status filters with PDA-friendly language - Keyboard shortcuts (Cmd+K/Ctrl+K to open, Escape to clear) - No results state with helpful suggestions - Loading states - Visual status indicators (🟢 Active, 🔵 Scheduled, etc.) Navigation: - Added search button to header with keyboard hint - Global Cmd+K shortcut redirects to search page - Added "Knowledge" link to main navigation Infrastructure: - Updated Input component to support forwardRef for proper ref handling - Comprehensive test coverage (100% on main components) - All tests passing (339 passed) - TypeScript strict mode compliant - ESLint compliant Fixes #67 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
174
apps/web/src/components/search/SearchResults.tsx
Normal file
174
apps/web/src/components/search/SearchResults.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SearchFilters } from "./SearchFilters";
|
||||
import type { SearchResult, SearchFiltersState, Tag } from "./types";
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
totalResults: number;
|
||||
isLoading: boolean;
|
||||
selectedTags?: string[] | undefined;
|
||||
selectedStatus?: string | undefined;
|
||||
availableTags?: Tag[] | undefined;
|
||||
onFilterChange: (filters: SearchFiltersState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator icons (PDA-friendly)
|
||||
*/
|
||||
const STATUS_ICONS: Record<string, string> = {
|
||||
ACTIVE: "🟢",
|
||||
SCHEDULED: "🔵",
|
||||
PAUSED: "⏸️",
|
||||
DORMANT: "💤",
|
||||
ARCHIVED: "⚪",
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual search result item with highlighted snippet
|
||||
*/
|
||||
function SearchResultItem({ result }: { result: SearchResult }): React.JSX.Element {
|
||||
const statusIcon = STATUS_ICONS[result.status] ?? "⚪";
|
||||
|
||||
return (
|
||||
<Link href={`/knowledge/${result.slug}`}>
|
||||
<Card className="p-4 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="space-y-2">
|
||||
{/* Title and status */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex-1">{result.title}</h3>
|
||||
<span className="text-xl" aria-label={result.status}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Highlighted snippet */}
|
||||
{result.headline && (
|
||||
<div
|
||||
className="text-sm text-gray-600 line-clamp-2"
|
||||
dangerouslySetInnerHTML={{ __html: result.headline }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{result.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{result.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
style={
|
||||
tag.color
|
||||
? {
|
||||
backgroundColor: `${tag.color}20`,
|
||||
color: tag.color,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-xs text-gray-500">
|
||||
Updated {new Date(result.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state
|
||||
*/
|
||||
function LoadingState(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-gray-200 border-t-blue-600" />
|
||||
<p className="text-gray-600">Searching...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No results state with PDA-friendly suggestions
|
||||
*/
|
||||
function NoResultsState({ query }: { query: string }): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-4 text-center">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">No results found</h3>
|
||||
<p className="text-gray-600 max-w-md">We couldn't find any entries matching "{query}"</p>
|
||||
<div className="mt-6 space-y-2 text-sm text-gray-600">
|
||||
<p className="font-medium">Consider these options:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Try different keywords</li>
|
||||
<li>Check your spelling</li>
|
||||
<li>Use more general terms</li>
|
||||
<li>Remove filters to broaden your search</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main search results component with filter sidebar
|
||||
*/
|
||||
export function SearchResults({
|
||||
results,
|
||||
query,
|
||||
totalResults,
|
||||
isLoading,
|
||||
selectedTags = [],
|
||||
selectedStatus,
|
||||
availableTags = [],
|
||||
onFilterChange,
|
||||
}: SearchResultsProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Main results area */}
|
||||
<div className="flex-1 p-6">
|
||||
{/* Results header */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Search Results</h2>
|
||||
{!isLoading && (
|
||||
<p className="text-gray-600">
|
||||
{totalResults} {totalResults === 1 ? "result" : "results"} for "{query}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results content */}
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : results.length === 0 ? (
|
||||
<NoResultsState query={query} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{results.map((result) => (
|
||||
<SearchResultItem key={result.id} result={result} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter sidebar */}
|
||||
<SearchFilters
|
||||
availableTags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
selectedStatus={selectedStatus as never}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user