Fixes 2 important-level security issues identified in M5 QA: 1. XSS Protection (SearchResults.tsx): - Add DOMPurify sanitization for search result snippets - Configure to allow only <mark> tags for highlighting - Provides defense-in-depth against potential XSS 2. Error State (SearchPage): - Add user-facing error message when search fails - Display friendly error notification instead of silent failure - Improves UX by informing users of temporary issues Testing: - All 32 search component tests passing - TypeScript typecheck passing - DOMPurify properly sanitizes HTML while preserving highlighting Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
181 lines
5.2 KiB
TypeScript
181 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import DOMPurify from "dompurify";
|
|
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: DOMPurify.sanitize(result.headline, {
|
|
ALLOWED_TAGS: ["mark"],
|
|
ALLOWED_ATTR: [],
|
|
}),
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|