feat(search): Enhance Mission Control search functionality
- Add search result highlighting with match positions - Implement advanced filters (type, status, date range) - Add relevance scoring algorithm with recency/status boosts - Add search history with localStorage persistence - Create useSearch and useSearchHistory hooks - Add filter UI with popover component - Improve visual feedback and status icons Task: 56ae2be4-fcf1-403a-87fb-ea9de966f456
This commit is contained in:
parent
b1ee0da1b8
commit
0092b318c2
148
SEARCH_ENHANCEMENTS.md
Normal file
148
SEARCH_ENHANCEMENTS.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# Mission Control Search Enhancement
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Enhanced Mission Control's search functionality with the following improvements:
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Enhanced Search API (`/app/api/search/route.ts`)
|
||||||
|
|
||||||
|
#### New Features:
|
||||||
|
- **Search Result Highlighting**: Added `SearchResultHighlight` interface with match positions for highlighting matching terms in titles and snippets
|
||||||
|
- **Advanced Filters**: Added support for filtering by:
|
||||||
|
- Type (task, project, document, sprint)
|
||||||
|
- Status (open, in-progress, done, etc.)
|
||||||
|
- Date ranges (from/to)
|
||||||
|
- **Relevance Scoring**: Implemented `calculateRelevanceScore` function that considers:
|
||||||
|
- Exact title matches (+100)
|
||||||
|
- Title starts with query (+80)
|
||||||
|
- Title contains query (+60)
|
||||||
|
- Content matches (+10 per match, max 40)
|
||||||
|
- Recency boost (+10 for items updated < 7 days, +5 for < 30 days)
|
||||||
|
- Active status boost (+5 for in-progress/open/active items)
|
||||||
|
- **Performance Metrics**: Added `executionTimeMs` to track search performance
|
||||||
|
- **Improved Snippets**: Added `createHighlightedSnippet` function for better context around matches
|
||||||
|
|
||||||
|
#### Response Structure:
|
||||||
|
```typescript
|
||||||
|
interface SearchResponse {
|
||||||
|
results: SearchableResult[];
|
||||||
|
total: number;
|
||||||
|
query: string;
|
||||||
|
filters?: SearchFilters;
|
||||||
|
executionTimeMs?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Enhanced Quick Search Component (`/components/layout/quick-search.tsx`)
|
||||||
|
|
||||||
|
#### New Features:
|
||||||
|
- **Search Filters UI**: Added Popover-based filter panel with:
|
||||||
|
- Type filters (Tasks, Projects, Documents, Sprints)
|
||||||
|
- Status filters (Open, In Progress, Done, etc.)
|
||||||
|
- Active filter count badge
|
||||||
|
- Clear all filters button
|
||||||
|
- **Search History**: Added localStorage-based search history with:
|
||||||
|
- Last 10 searches stored
|
||||||
|
- 30-day expiration
|
||||||
|
- History display when search is empty
|
||||||
|
- Individual history item removal
|
||||||
|
- Clear all history option
|
||||||
|
- Result count tracking
|
||||||
|
- **Highlighted Results**: Added `HighlightedText` component that shows matching terms with yellow background
|
||||||
|
- **Improved UI**: Better grouping, status icons, and visual hierarchy
|
||||||
|
|
||||||
|
### 3. New Search Hook (`/hooks/useSearch.ts`)
|
||||||
|
|
||||||
|
#### Features:
|
||||||
|
- **`useSearch` hook**: Comprehensive search state management with:
|
||||||
|
- Debounced search (configurable, default 150ms)
|
||||||
|
- Request cancellation support
|
||||||
|
- Error handling
|
||||||
|
- Filter management
|
||||||
|
- Callbacks for results and errors
|
||||||
|
|
||||||
|
- **`useSearchHistory` hook**: Reusable history management with:
|
||||||
|
- Automatic localStorage sync
|
||||||
|
- Duplicate prevention
|
||||||
|
- Max item limit (10)
|
||||||
|
- 30-day automatic cleanup
|
||||||
|
|
||||||
|
- **Utilities**:
|
||||||
|
- `createHighlightedText`: Helper for text highlighting
|
||||||
|
|
||||||
|
## API Usage Examples
|
||||||
|
|
||||||
|
### Basic Search
|
||||||
|
```
|
||||||
|
GET /api/search?q=mission
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search with Filters
|
||||||
|
```
|
||||||
|
GET /api/search?q=mission&types=task,project&status=open,in-progress
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search with Date Range
|
||||||
|
```
|
||||||
|
GET /api/search?q=mission&dateFrom=2026-01-01&dateTo=2026-12-31
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
### QuickSearch
|
||||||
|
The QuickSearch component now provides:
|
||||||
|
- ⌘K keyboard shortcut
|
||||||
|
- Filter button with active count
|
||||||
|
- Search history (recent searches)
|
||||||
|
- Highlighted matching terms
|
||||||
|
- Status icons
|
||||||
|
- Color indicators for projects
|
||||||
|
|
||||||
|
### Filter Popover
|
||||||
|
- Type filter buttons
|
||||||
|
- Status filter buttons
|
||||||
|
- Clear all option
|
||||||
|
- Visual active state
|
||||||
|
|
||||||
|
## Technical Improvements
|
||||||
|
|
||||||
|
1. **Performance**:
|
||||||
|
- Debounced API calls (150ms)
|
||||||
|
- Request cancellation
|
||||||
|
- Limited to 50 results
|
||||||
|
- Execution time tracking
|
||||||
|
|
||||||
|
2. **User Experience**:
|
||||||
|
- Visual feedback during loading
|
||||||
|
- No results message with filter reset
|
||||||
|
- Keyboard navigation support
|
||||||
|
- Persistent search history
|
||||||
|
|
||||||
|
3. **Code Quality**:
|
||||||
|
- TypeScript interfaces for all types
|
||||||
|
- Reusable hooks
|
||||||
|
- Clean separation of concerns
|
||||||
|
- Proper error handling
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. `/app/api/search/route.ts` - Enhanced API with highlighting and filters
|
||||||
|
2. `/components/layout/quick-search.tsx` - Enhanced UI with filters and history
|
||||||
|
3. `/hooks/useSearch.ts` - New search hook
|
||||||
|
4. `/components/ui/popover.tsx` - New shadcn component (auto-installed)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The build completes successfully with these changes. The search functionality is backwards compatible - existing search functionality continues to work while new features are opt-in.
|
||||||
|
|
||||||
|
## Future Enhancements (Optional)
|
||||||
|
|
||||||
|
Potential additional improvements:
|
||||||
|
1. Fuzzy search with Levenshtein distance
|
||||||
|
2. Search analytics/tracking
|
||||||
|
3. Saved searches
|
||||||
|
4. Advanced date range picker
|
||||||
|
5. Search result pagination
|
||||||
|
6. Full-text search with PostgreSQL
|
||||||
76
TASK_UPDATE_56ae2be4.md
Normal file
76
TASK_UPDATE_56ae2be4.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Task Update: Mission Control Search Enhancement
|
||||||
|
|
||||||
|
**Task ID:** 56ae2be4-fcf1-403a-87fb-ea9de966f456
|
||||||
|
**Status:** COMPLETED
|
||||||
|
**Completed:** 2026-02-25
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
### 1. Enhanced Search API (`/app/api/search/route.ts`)
|
||||||
|
- ✅ Added search result highlighting with match positions
|
||||||
|
- ✅ Implemented advanced filters (type, status, date range)
|
||||||
|
- ✅ Added relevance scoring algorithm (exact match > starts with > contains)
|
||||||
|
- ✅ Added recency and status boosts to scoring
|
||||||
|
- ✅ Added execution time tracking
|
||||||
|
- ✅ Improved snippet generation with context
|
||||||
|
|
||||||
|
### 2. Enhanced QuickSearch Component (`/components/layout/quick-search.tsx`)
|
||||||
|
- ✅ Added filter UI with Popover component
|
||||||
|
- ✅ Implemented search history (localStorage, 10 items, 30-day expiry)
|
||||||
|
- ✅ Added highlighted text component for matching terms
|
||||||
|
- ✅ Added filter count badge
|
||||||
|
- ✅ Added clear filters functionality
|
||||||
|
- ✅ Improved visual hierarchy and status icons
|
||||||
|
|
||||||
|
### 3. New Search Hook (`/hooks/useSearch.ts`)
|
||||||
|
- ✅ Created `useSearch` hook with debouncing and cancellation
|
||||||
|
- ✅ Created `useSearchHistory` hook for persistent history
|
||||||
|
- ✅ Added utility functions for text highlighting
|
||||||
|
- ✅ Full TypeScript support
|
||||||
|
|
||||||
|
### 4. Dependencies
|
||||||
|
- ✅ Installed `@radix-ui/react-popover` via shadcn
|
||||||
|
|
||||||
|
## Build Status
|
||||||
|
✅ Build completes successfully
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
1. `/app/api/search/route.ts` - Enhanced API
|
||||||
|
2. `/components/layout/quick-search.tsx` - Enhanced UI
|
||||||
|
3. `/hooks/useSearch.ts` - New hook
|
||||||
|
4. `/components/ui/popover.tsx` - New component
|
||||||
|
5. `/SEARCH_ENHANCEMENTS.md` - Documentation
|
||||||
|
|
||||||
|
## Key Features Delivered
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| Search highlighting | ✅ |
|
||||||
|
| Type filters | ✅ |
|
||||||
|
| Status filters | ✅ |
|
||||||
|
| Date range filters | ✅ |
|
||||||
|
| Search history | ✅ |
|
||||||
|
| Relevance scoring | ✅ |
|
||||||
|
| Performance tracking | ✅ |
|
||||||
|
| Debounced search | ✅ |
|
||||||
|
| Request cancellation | ✅ |
|
||||||
|
|
||||||
|
## API Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
# Basic search
|
||||||
|
GET /api/search?q=mission
|
||||||
|
|
||||||
|
# With filters
|
||||||
|
GET /api/search?q=mission&types=task,project&status=open
|
||||||
|
|
||||||
|
# With date range
|
||||||
|
GET /api/search?q=mission&dateFrom=2026-01-01&dateTo=2026-12-31
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- All changes are backwards compatible
|
||||||
|
- Search history is stored in localStorage
|
||||||
|
- Filters are optional and additive
|
||||||
|
- Maximum 50 results returned
|
||||||
|
- Default debounce is 150ms
|
||||||
@ -15,6 +15,12 @@ export const runtime = "nodejs";
|
|||||||
|
|
||||||
export type SearchableType = "task" | "project" | "document" | "sprint";
|
export type SearchableType = "task" | "project" | "document" | "sprint";
|
||||||
|
|
||||||
|
export interface SearchResultHighlight {
|
||||||
|
field: string;
|
||||||
|
text: string;
|
||||||
|
matches: Array<{ start: number; end: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchableResult {
|
export interface SearchableResult {
|
||||||
id: string;
|
id: string;
|
||||||
type: SearchableType;
|
type: SearchableType;
|
||||||
@ -24,12 +30,25 @@ export interface SearchableResult {
|
|||||||
icon: string;
|
icon: string;
|
||||||
status?: string; // For visual badges
|
status?: string; // For visual badges
|
||||||
color?: string; // For project/task colors
|
color?: string; // For project/task colors
|
||||||
|
highlights?: SearchResultHighlight[]; // Highlighted matches
|
||||||
|
score: number; // Relevance score for sorting
|
||||||
|
updatedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResponse {
|
export interface SearchResponse {
|
||||||
results: SearchableResult[];
|
results: SearchableResult[];
|
||||||
total: number;
|
total: number;
|
||||||
query: string;
|
query: string;
|
||||||
|
filters?: SearchFilters;
|
||||||
|
executionTimeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchFilters {
|
||||||
|
types?: SearchableType[];
|
||||||
|
status?: string[];
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -44,6 +63,7 @@ interface SearchableEntityConfig {
|
|||||||
snippetField?: string;
|
snippetField?: string;
|
||||||
statusField?: string;
|
statusField?: string;
|
||||||
colorField?: string;
|
colorField?: string;
|
||||||
|
dateFields?: string[];
|
||||||
icon: string;
|
icon: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
searchFields: string[];
|
searchFields: string[];
|
||||||
@ -60,6 +80,7 @@ const searchableEntities: SearchableEntityConfig[] = [
|
|||||||
titleField: "title",
|
titleField: "title",
|
||||||
snippetField: "description",
|
snippetField: "description",
|
||||||
statusField: "status",
|
statusField: "status",
|
||||||
|
dateFields: ["created_at", "updated_at", "due_date"],
|
||||||
icon: "kanban",
|
icon: "kanban",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
searchFields: ["title", "description"],
|
searchFields: ["title", "description"],
|
||||||
@ -75,6 +96,7 @@ const searchableEntities: SearchableEntityConfig[] = [
|
|||||||
snippetField: "description",
|
snippetField: "description",
|
||||||
colorField: "color",
|
colorField: "color",
|
||||||
statusField: "status",
|
statusField: "status",
|
||||||
|
dateFields: ["created_at", "updated_at"],
|
||||||
icon: "folder-kanban",
|
icon: "folder-kanban",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
searchFields: ["name", "description"],
|
searchFields: ["name", "description"],
|
||||||
@ -89,6 +111,7 @@ const searchableEntities: SearchableEntityConfig[] = [
|
|||||||
titleField: "name",
|
titleField: "name",
|
||||||
snippetField: "goal",
|
snippetField: "goal",
|
||||||
statusField: "status",
|
statusField: "status",
|
||||||
|
dateFields: ["start_date", "end_date", "created_at"],
|
||||||
icon: "timer",
|
icon: "timer",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
searchFields: ["name", "goal"],
|
searchFields: ["name", "goal"],
|
||||||
@ -102,39 +125,165 @@ const searchableEntities: SearchableEntityConfig[] = [
|
|||||||
type: "document",
|
type: "document",
|
||||||
titleField: "title",
|
titleField: "title",
|
||||||
snippetField: "content",
|
snippetField: "content",
|
||||||
|
dateFields: ["created_at", "updated_at"],
|
||||||
icon: "file-text",
|
icon: "file-text",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
searchFields: ["title", "content"],
|
searchFields: ["title", "content", "tags"],
|
||||||
getUrl: () => getMissionControlDocumentsUrl(),
|
getUrl: () => getMissionControlDocumentsUrl(),
|
||||||
getSnippet: (item) => item.content
|
getSnippet: (item) => item.content
|
||||||
? `${item.content.substring(0, 150)}${item.content.length > 150 ? "..." : ""}`
|
? `${item.content.substring(0, 150)}${item.content.length > 150 ? "..." : ""}`
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
// Add new searchable entities here:
|
|
||||||
// {
|
|
||||||
// table: "meetings",
|
|
||||||
// type: "meeting",
|
|
||||||
// titleField: "title",
|
|
||||||
// icon: "calendar",
|
|
||||||
// searchFields: ["title", "notes"],
|
|
||||||
// getUrl: (item) => `/meetings/${item.id}`,
|
|
||||||
// }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HIGHLIGHTING UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatches(text: string, query: string): Array<{ start: number; end: number }> {
|
||||||
|
const matches: Array<{ start: number; end: number }> = [];
|
||||||
|
if (!text || !query) return matches;
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while ((index = lowerText.indexOf(lowerQuery, index)) !== -1) {
|
||||||
|
matches.push({ start: index, end: index + query.length });
|
||||||
|
index += query.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHighlightedSnippet(
|
||||||
|
text: string,
|
||||||
|
query: string,
|
||||||
|
maxLength: number = 150
|
||||||
|
): { text: string; matches: Array<{ start: number; end: number }> } {
|
||||||
|
if (!text) return { text: "", matches: [] };
|
||||||
|
|
||||||
|
const matches = findMatches(text, query);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
// No match in this field, return truncated text
|
||||||
|
return {
|
||||||
|
text: text.length > maxLength ? text.substring(0, maxLength) + "..." : text,
|
||||||
|
matches: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first match and center the snippet around it
|
||||||
|
const firstMatch = matches[0];
|
||||||
|
const contextSize = Math.floor((maxLength - query.length) / 2);
|
||||||
|
let start = Math.max(0, firstMatch.start - contextSize);
|
||||||
|
let end = Math.min(text.length, firstMatch.end + contextSize);
|
||||||
|
|
||||||
|
// Adjust if we're at the beginning or end
|
||||||
|
if (start === 0) {
|
||||||
|
end = Math.min(text.length, maxLength);
|
||||||
|
} else if (end === text.length) {
|
||||||
|
start = Math.max(0, text.length - maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
let snippet = text.substring(start, end);
|
||||||
|
const prefix = start > 0 ? "..." : "";
|
||||||
|
const suffix = end < text.length ? "..." : "";
|
||||||
|
|
||||||
|
// Adjust match positions for the snippet
|
||||||
|
const adjustedMatches = matches
|
||||||
|
.filter(m => m.start >= start && m.end <= end)
|
||||||
|
.map(m => ({
|
||||||
|
start: m.start - start + prefix.length,
|
||||||
|
end: m.end - start + prefix.length
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: prefix + snippet + suffix,
|
||||||
|
matches: adjustedMatches
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RELEVANCE SCORING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function calculateRelevanceScore(
|
||||||
|
item: any,
|
||||||
|
query: string,
|
||||||
|
entity: SearchableEntityConfig
|
||||||
|
): number {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Title match scoring
|
||||||
|
const title = item[entity.titleField]?.toLowerCase() || "";
|
||||||
|
if (title === lowerQuery) {
|
||||||
|
score += 100; // Exact match
|
||||||
|
} else if (title.startsWith(lowerQuery)) {
|
||||||
|
score += 80; // Starts with query
|
||||||
|
} else if (title.includes(lowerQuery)) {
|
||||||
|
score += 60; // Contains query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippet/content match scoring
|
||||||
|
if (entity.snippetField) {
|
||||||
|
const snippet = item[entity.snippetField]?.toLowerCase() || "";
|
||||||
|
const snippetMatches = (snippet.match(new RegExp(escapeRegex(lowerQuery), 'g')) || []).length;
|
||||||
|
score += Math.min(snippetMatches * 10, 40); // Up to 40 points for multiple matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recency boost (if updated_at exists)
|
||||||
|
if (item.updated_at) {
|
||||||
|
const daysSinceUpdate = (Date.now() - new Date(item.updated_at).getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (daysSinceUpdate < 7) score += 10;
|
||||||
|
else if (daysSinceUpdate < 30) score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status boost for active items
|
||||||
|
if (entity.statusField) {
|
||||||
|
const status = item[entity.statusField]?.toLowerCase();
|
||||||
|
if (status === "in-progress" || status === "open" || status === "active") {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API HANDLER
|
// API HANDLER
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const query = searchParams.get("q")?.trim().toLowerCase();
|
const query = searchParams.get("q")?.trim();
|
||||||
|
|
||||||
|
// Parse filters
|
||||||
|
const filters: SearchFilters = {};
|
||||||
|
const typesParam = searchParams.get("types");
|
||||||
|
if (typesParam) {
|
||||||
|
filters.types = typesParam.split(",") as SearchableType[];
|
||||||
|
}
|
||||||
|
const statusParam = searchParams.get("status");
|
||||||
|
if (statusParam) {
|
||||||
|
filters.status = statusParam.split(",");
|
||||||
|
}
|
||||||
|
filters.dateFrom = searchParams.get("dateFrom") || undefined;
|
||||||
|
filters.dateTo = searchParams.get("dateTo") || undefined;
|
||||||
|
|
||||||
if (!query || query.length < 2) {
|
if (!query || query.length < 2) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
results: [],
|
results: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
query: query || ""
|
query: query || "",
|
||||||
|
filters
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,6 +304,9 @@ export async function GET(request: Request) {
|
|||||||
// Search each enabled entity
|
// Search each enabled entity
|
||||||
for (const entity of searchableEntities) {
|
for (const entity of searchableEntities) {
|
||||||
if (!entity.enabled) continue;
|
if (!entity.enabled) continue;
|
||||||
|
|
||||||
|
// Skip if type filter is applied and this entity is not included
|
||||||
|
if (filters.types && !filters.types.includes(entity.type)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build OR filter for search fields
|
// Build OR filter for search fields
|
||||||
@ -167,12 +319,33 @@ export async function GET(request: Request) {
|
|||||||
if (entity.snippetField) selectFields.push(entity.snippetField);
|
if (entity.snippetField) selectFields.push(entity.snippetField);
|
||||||
if (entity.statusField) selectFields.push(entity.statusField);
|
if (entity.statusField) selectFields.push(entity.statusField);
|
||||||
if (entity.colorField) selectFields.push(entity.colorField);
|
if (entity.colorField) selectFields.push(entity.colorField);
|
||||||
|
if (entity.dateFields) {
|
||||||
|
entity.dateFields.forEach(df => {
|
||||||
|
if (!selectFields.includes(df)) selectFields.push(df);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
let dbQuery = supabase
|
||||||
.from(entity.table)
|
.from(entity.table)
|
||||||
.select(selectFields.join(", "))
|
.select(selectFields.join(", "))
|
||||||
.or(orConditions)
|
.or(orConditions);
|
||||||
.limit(10);
|
|
||||||
|
// Apply status filter if specified
|
||||||
|
if (filters.status && entity.statusField) {
|
||||||
|
dbQuery = dbQuery.in(entity.statusField, filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply date filters if specified
|
||||||
|
if (entity.dateFields) {
|
||||||
|
if (filters.dateFrom && entity.dateFields[0]) {
|
||||||
|
dbQuery = dbQuery.gte(entity.dateFields[0], filters.dateFrom);
|
||||||
|
}
|
||||||
|
if (filters.dateTo && entity.dateFields[0]) {
|
||||||
|
dbQuery = dbQuery.lte(entity.dateFields[0], filters.dateTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await dbQuery.limit(10);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(`Search error in ${entity.table}:`, error);
|
console.error(`Search error in ${entity.table}:`, error);
|
||||||
@ -180,16 +353,43 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const mappedResults: SearchableResult[] = data.map((item: any) => ({
|
const mappedResults: SearchableResult[] = data.map((item: any) => {
|
||||||
id: item.id,
|
const score = calculateRelevanceScore(item, query, entity);
|
||||||
type: entity.type,
|
const titleSnippet = createHighlightedSnippet(
|
||||||
title: item[entity.titleField] || "Untitled",
|
item[entity.titleField],
|
||||||
snippet: entity.getSnippet ? entity.getSnippet(item) : undefined,
|
query,
|
||||||
url: entity.getUrl(item),
|
100
|
||||||
icon: entity.icon,
|
);
|
||||||
status: entity.statusField ? item[entity.statusField] : undefined,
|
const contentSnippet = entity.snippetField
|
||||||
color: entity.colorField ? item[entity.colorField] : undefined,
|
? createHighlightedSnippet(item[entity.snippetField], query, 150)
|
||||||
}));
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: entity.type,
|
||||||
|
title: item[entity.titleField] || "Untitled",
|
||||||
|
snippet: entity.getSnippet ? entity.getSnippet(item) : undefined,
|
||||||
|
url: entity.getUrl(item),
|
||||||
|
icon: entity.icon,
|
||||||
|
status: entity.statusField ? item[entity.statusField] : undefined,
|
||||||
|
color: entity.colorField ? item[entity.colorField] : undefined,
|
||||||
|
highlights: [
|
||||||
|
{
|
||||||
|
field: entity.titleField,
|
||||||
|
text: titleSnippet.text,
|
||||||
|
matches: titleSnippet.matches
|
||||||
|
},
|
||||||
|
...(contentSnippet ? [{
|
||||||
|
field: entity.snippetField!,
|
||||||
|
text: contentSnippet.text,
|
||||||
|
matches: contentSnippet.matches
|
||||||
|
}] : [])
|
||||||
|
],
|
||||||
|
score,
|
||||||
|
updatedAt: item.updated_at || item.updatedAt,
|
||||||
|
createdAt: item.created_at || item.createdAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
results.push(...mappedResults);
|
results.push(...mappedResults);
|
||||||
}
|
}
|
||||||
@ -198,19 +398,8 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by relevance: exact match > starts with > contains
|
// Sort by relevance score (highest first)
|
||||||
results.sort((a, b) => {
|
results.sort((a, b) => b.score - a.score);
|
||||||
const aTitle = a.title.toLowerCase();
|
|
||||||
const bTitle = b.title.toLowerCase();
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
|
|
||||||
if (aTitle === lowerQuery && bTitle !== lowerQuery) return -1;
|
|
||||||
if (bTitle === lowerQuery && aTitle !== lowerQuery) return 1;
|
|
||||||
if (aTitle.startsWith(lowerQuery) && !bTitle.startsWith(lowerQuery)) return -1;
|
|
||||||
if (bTitle.startsWith(lowerQuery) && !aTitle.startsWith(lowerQuery)) return 1;
|
|
||||||
|
|
||||||
return aTitle.localeCompare(bTitle);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Limit total results
|
// Limit total results
|
||||||
const limitedResults = results.slice(0, 50);
|
const limitedResults = results.slice(0, 50);
|
||||||
@ -219,6 +408,8 @@ export async function GET(request: Request) {
|
|||||||
results: limitedResults,
|
results: limitedResults,
|
||||||
total: limitedResults.length,
|
total: limitedResults.length,
|
||||||
query,
|
query,
|
||||||
|
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
||||||
|
executionTimeMs: Date.now() - startTime,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Search API error:", error);
|
console.error("Search API error:", error);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@ -11,6 +11,13 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@ -27,21 +34,47 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Timer,
|
Timer,
|
||||||
Circle,
|
Circle,
|
||||||
|
Filter,
|
||||||
|
History,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { siteUrls } from "@/lib/config/sites";
|
import { siteUrls } from "@/lib/config/sites";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// Search result type from API - matches SearchableResult interface
|
// Search result type from API - matches SearchableResult interface
|
||||||
type SearchResultType = "task" | "project" | "document" | "sprint";
|
type SearchResultType = "task" | "project" | "document" | "sprint";
|
||||||
|
|
||||||
|
interface SearchResultHighlight {
|
||||||
|
field: string;
|
||||||
|
text: string;
|
||||||
|
matches: Array<{ start: number; end: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
id: string;
|
id: string;
|
||||||
type: SearchResultType;
|
type: SearchResultType;
|
||||||
title: string;
|
title: string;
|
||||||
snippet?: string; // Brief preview text (replaces subtitle/description)
|
snippet?: string;
|
||||||
url: string; // Deep link to full view
|
url: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
status?: string; // For visual badges
|
status?: string;
|
||||||
color?: string; // For project/task colors
|
color?: string;
|
||||||
|
highlights?: SearchResultHighlight[];
|
||||||
|
score: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFilters {
|
||||||
|
types?: SearchResultType[];
|
||||||
|
status?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchHistoryItem {
|
||||||
|
query: string;
|
||||||
|
filters?: SearchFilters;
|
||||||
|
timestamp: number;
|
||||||
|
resultCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@ -77,6 +110,22 @@ const typeLabels: Record<SearchResultType, string> = {
|
|||||||
sprint: "Sprint",
|
sprint: "Sprint",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Available filters
|
||||||
|
const typeFilterOptions: { value: SearchResultType; label: string; icon: React.ElementType }[] = [
|
||||||
|
{ value: "task", label: "Tasks", icon: Kanban },
|
||||||
|
{ value: "project", label: "Projects", icon: FolderKanban },
|
||||||
|
{ value: "document", label: "Documents", icon: FileText },
|
||||||
|
{ value: "sprint", label: "Sprints", icon: Timer },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ value: "open", label: "Open" },
|
||||||
|
{ value: "in-progress", label: "In Progress" },
|
||||||
|
{ value: "done", label: "Done" },
|
||||||
|
{ value: "todo", label: "Todo" },
|
||||||
|
{ value: "completed", label: "Completed" },
|
||||||
|
];
|
||||||
|
|
||||||
function getStatusIcon(status?: string) {
|
function getStatusIcon(status?: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "done":
|
case "done":
|
||||||
@ -92,33 +141,152 @@ function getStatusIcon(status?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Highlight text component
|
||||||
|
function HighlightedText({
|
||||||
|
text,
|
||||||
|
matches,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
matches: Array<{ start: number; end: number }>;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!matches || matches.length === 0) {
|
||||||
|
return <span className={className}>{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastEnd = 0;
|
||||||
|
|
||||||
|
matches.forEach((match, index) => {
|
||||||
|
// Add text before match
|
||||||
|
if (match.start > lastEnd) {
|
||||||
|
parts.push(
|
||||||
|
<span key={`text-${index}`}>{text.substring(lastEnd, match.start)}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Add highlighted match
|
||||||
|
parts.push(
|
||||||
|
<mark
|
||||||
|
key={`mark-${index}`}
|
||||||
|
className="bg-yellow-200 dark:bg-yellow-900/50 text-inherit rounded px-0.5"
|
||||||
|
>
|
||||||
|
{text.substring(match.start, match.end)}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
lastEnd = match.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (lastEnd < text.length) {
|
||||||
|
parts.push(<span key="text-end">{text.substring(lastEnd)}</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={className}>{parts}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_HISTORY_KEY = "mission-control-search-history";
|
||||||
|
const MAX_HISTORY_ITEMS = 10;
|
||||||
|
|
||||||
export function QuickSearch() {
|
export function QuickSearch() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [filters, setFilters] = useState<SearchFilters>({});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Load search history from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
// Filter out items older than 30 days
|
||||||
|
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
setSearchHistory(parsed.filter((item: SearchHistoryItem) => item.timestamp > thirtyDaysAgo));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load search history:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save search history to localStorage
|
||||||
|
const saveToHistory = useCallback((query: string, resultCount: number, filters?: SearchFilters) => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newItem: SearchHistoryItem = {
|
||||||
|
query: query.trim(),
|
||||||
|
filters: Object.keys(filters || {}).length > 0 ? filters : undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resultCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSearchHistory(prev => {
|
||||||
|
// Remove duplicate queries
|
||||||
|
const filtered = prev.filter(item => item.query.toLowerCase() !== query.toLowerCase());
|
||||||
|
// Add new item at the beginning
|
||||||
|
const updated = [newItem, ...filtered].slice(0, MAX_HISTORY_ITEMS);
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save search history:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear search history
|
||||||
|
const clearHistory = useCallback(() => {
|
||||||
|
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||||
|
setSearchHistory([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Remove single history item
|
||||||
|
const removeHistoryItem = useCallback((index: number) => {
|
||||||
|
setSearchHistory(prev => {
|
||||||
|
const updated = prev.filter((_, i) => i !== index);
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || query.length < 2) {
|
if (!open || query.length < 2) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setShowHistory(query.length === 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowHistory(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
const params = new URLSearchParams();
|
||||||
|
params.set("q", query);
|
||||||
|
if (filters.types?.length) params.set("types", filters.types.join(","));
|
||||||
|
if (filters.status?.length) params.set("status", filters.status.join(","));
|
||||||
|
|
||||||
|
const res = await fetch(`/api/search?${params.toString()}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Search request failed with status ${res.status}`);
|
throw new Error(`Search request failed with status ${res.status}`);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as { results?: SearchResult[] };
|
const data = (await res.json()) as { results?: SearchResult[]; total?: number };
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResults(Array.isArray(data.results) ? data.results : []);
|
setResults(Array.isArray(data.results) ? data.results : []);
|
||||||
|
// Save to history if we got results
|
||||||
|
if (data.total && data.total > 0) {
|
||||||
|
saveToHistory(query, data.total, filters);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Search error:", err);
|
console.error("Search error:", err);
|
||||||
@ -136,7 +304,7 @@ export function QuickSearch() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [query, open]);
|
}, [query, open, filters, saveToHistory]);
|
||||||
|
|
||||||
// Keyboard shortcut
|
// Keyboard shortcut
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -153,6 +321,7 @@ export function QuickSearch() {
|
|||||||
const runCommand = useCallback((command: () => void) => {
|
const runCommand = useCallback((command: () => void) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setShowHistory(false);
|
||||||
command();
|
command();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -166,6 +335,40 @@ export function QuickSearch() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleHistorySelect = (item: SearchHistoryItem) => {
|
||||||
|
setQuery(item.query);
|
||||||
|
if (item.filters) {
|
||||||
|
setFilters(item.filters);
|
||||||
|
}
|
||||||
|
setShowHistory(false);
|
||||||
|
// Focus input after selection
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTypeFilter = (type: SearchResultType) => {
|
||||||
|
setFilters(prev => ({
|
||||||
|
...prev,
|
||||||
|
types: prev.types?.includes(type)
|
||||||
|
? prev.types.filter(t => t !== type)
|
||||||
|
: [...(prev.types || []), type]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStatusFilter = (status: string) => {
|
||||||
|
setFilters(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: prev.status?.includes(status)
|
||||||
|
? prev.status.filter(s => s !== status)
|
||||||
|
: [...(prev.status || []), status]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFilters({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFilterCount = (filters.types?.length || 0) + (filters.status?.length || 0);
|
||||||
|
|
||||||
// Group results by type
|
// Group results by type
|
||||||
const groupedResults = results.reduce((acc, result) => {
|
const groupedResults = results.reduce((acc, result) => {
|
||||||
if (!acc[result.type]) acc[result.type] = [];
|
if (!acc[result.type]) acc[result.type] = [];
|
||||||
@ -178,6 +381,22 @@ export function QuickSearch() {
|
|||||||
(type) => groupedResults[type]?.length > 0
|
(type) => groupedResults[type]?.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get title highlight for a result
|
||||||
|
const getTitleHighlight = (result: SearchResult) => {
|
||||||
|
const titleHighlight = result.highlights?.find(h => h.field === "title");
|
||||||
|
if (titleHighlight && titleHighlight.matches.length > 0) {
|
||||||
|
return titleHighlight;
|
||||||
|
}
|
||||||
|
return { text: result.title, matches: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get snippet highlight for a result
|
||||||
|
const getSnippetHighlight = (result: SearchResult) => {
|
||||||
|
const snippetField = result.type === "sprint" ? "goal" :
|
||||||
|
result.type === "document" ? "content" : "description";
|
||||||
|
return result.highlights?.find(h => h.field === snippetField);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Search Button Trigger */}
|
{/* Search Button Trigger */}
|
||||||
@ -195,14 +414,165 @@ export function QuickSearch() {
|
|||||||
|
|
||||||
{/* Command Dialog */}
|
{/* Command Dialog */}
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
<CommandInput
|
<div className="flex items-center gap-2 px-3 border-b">
|
||||||
placeholder="Search tasks, projects, sprints, documents..."
|
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
value={query}
|
<CommandInput
|
||||||
onValueChange={setQuery}
|
ref={inputRef}
|
||||||
/>
|
placeholder="Search tasks, projects, sprints, documents..."
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
className="flex-1 border-0"
|
||||||
|
/>
|
||||||
|
{/* Filter Button */}
|
||||||
|
<Popover open={showFilters} onOpenChange={setShowFilters}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1",
|
||||||
|
activeFilterCount > 0 && "bg-primary/10 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="h-5 px-1 text-xs">
|
||||||
|
{activeFilterCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80" align="end">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-sm">Filters</h4>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Filters */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Type</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{typeFilterOptions.map(option => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isActive = filters.types?.includes(option.value);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
variant={isActive ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleTypeFilter(option.value)}
|
||||||
|
className="h-7 gap-1"
|
||||||
|
>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filters */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Status</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusFilterOptions.map(option => {
|
||||||
|
const isActive = filters.status?.includes(option.value);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
variant={isActive ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleStatusFilter(option.value)}
|
||||||
|
className="h-7"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
{/* Search History */}
|
||||||
|
{showHistory && searchHistory.length > 0 && !loading && (
|
||||||
|
<CommandGroup heading="Recent Searches">
|
||||||
|
{searchHistory.slice(0, 5).map((item, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={index}
|
||||||
|
value={`history-${item.query}`}
|
||||||
|
onSelect={() => handleHistorySelect(item)}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4 mr-2 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
<span className="truncate">{item.query}</span>
|
||||||
|
{item.filters && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.filters.types?.map(t => typeLabels[t]).join(", ")}
|
||||||
|
{item.filters.status && ` • ${item.filters.status.join(", ")}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.resultCount !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
{item.resultCount} results
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 group-hover:opacity-100 ml-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeHistoryItem(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
<CommandItem
|
||||||
|
onSelect={clearHistory}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Clear search history
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{loading ? "Searching..." : query.length < 2 ? "Type to search..." : "No results found."}
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
) : query.length < 2 ? (
|
||||||
|
"Type to search..."
|
||||||
|
) : (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<p>No results found{activeFilterCount > 0 ? " with current filters" : ""}</p>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
|
||||||
{/* Show search results when query exists */}
|
{/* Show search results when query exists */}
|
||||||
@ -214,40 +584,53 @@ export function QuickSearch() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandGroup key={type} heading={`${typeLabels[type]}s (${typeResults.length})`}>
|
<CommandGroup key={type} heading={`${typeLabels[type]}s (${typeResults.length})`}>
|
||||||
{typeResults.slice(0, 5).map((result) => (
|
{typeResults.slice(0, 5).map((result) => {
|
||||||
<CommandItem
|
const titleHighlight = getTitleHighlight(result);
|
||||||
key={`${result.type}-${result.id}`}
|
const snippetHighlight = getSnippetHighlight(result);
|
||||||
value={`${result.title} ${result.snippet ?? ""} ${result.status ?? ""} ${typeLabels[result.type]}`}
|
|
||||||
keywords={[query]}
|
return (
|
||||||
onSelect={() => handleResultSelect(result)}
|
<CommandItem
|
||||||
>
|
key={`${result.type}-${result.id}`}
|
||||||
{result.type === "task" ? (
|
value={`${result.title} ${result.snippet ?? ""} ${result.status ?? ""} ${typeLabels[result.type]}`}
|
||||||
getStatusIcon(result.status)
|
keywords={[query]}
|
||||||
) : result.type === "project" && result.color ? (
|
onSelect={() => handleResultSelect(result)}
|
||||||
<div
|
>
|
||||||
className="w-3 h-3 rounded-full mr-2"
|
{result.type === "task" ? (
|
||||||
style={{ backgroundColor: result.color }}
|
getStatusIcon(result.status)
|
||||||
/>
|
) : result.type === "project" && result.color ? (
|
||||||
) : (
|
<div
|
||||||
<Icon className="w-4 h-4 mr-2" />
|
className="w-3 h-3 rounded-full mr-2"
|
||||||
)}
|
style={{ backgroundColor: result.color }}
|
||||||
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
) : (
|
||||||
<span className="truncate">{result.title}</span>
|
<Icon className="w-4 h-4 mr-2" />
|
||||||
{result.snippet && (
|
)}
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{result.snippet}
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
<span className="truncate">
|
||||||
|
<HighlightedText
|
||||||
|
text={titleHighlight.text}
|
||||||
|
matches={titleHighlight.matches}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{snippetHighlight && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
<HighlightedText
|
||||||
|
text={snippetHighlight.text}
|
||||||
|
matches={snippetHighlight.matches}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.status && result.type !== "task" && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2 capitalize">
|
||||||
|
{result.status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CommandItem>
|
||||||
|
);
|
||||||
{result.status && result.type !== "task" && (
|
})}
|
||||||
<span className="text-xs text-muted-foreground ml-2 capitalize">
|
|
||||||
{result.status}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
89
components/ui/popover.tsx
Normal file
89
components/ui/popover.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverDescription,
|
||||||
|
}
|
||||||
302
hooks/useSearch.ts
Normal file
302
hooks/useSearch.ts
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
export type SearchResultType = "task" | "project" | "document" | "sprint";
|
||||||
|
|
||||||
|
export interface SearchResultHighlight {
|
||||||
|
field: string;
|
||||||
|
text: string;
|
||||||
|
matches: Array<{ start: number; end: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchFilters {
|
||||||
|
types?: SearchResultType[];
|
||||||
|
status?: string[];
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
type: SearchResultType;
|
||||||
|
title: string;
|
||||||
|
snippet?: string;
|
||||||
|
url: string;
|
||||||
|
icon: string;
|
||||||
|
status?: string;
|
||||||
|
color?: string;
|
||||||
|
highlights?: SearchResultHighlight[];
|
||||||
|
score: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchState {
|
||||||
|
query: string;
|
||||||
|
results: SearchResult[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
filters: SearchFilters;
|
||||||
|
executionTimeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSearchOptions {
|
||||||
|
debounceMs?: number;
|
||||||
|
minQueryLength?: number;
|
||||||
|
onResults?: (results: SearchResult[]) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearch(options: UseSearchOptions = {}) {
|
||||||
|
const { debounceMs = 150, minQueryLength = 2, onResults, onError } = options;
|
||||||
|
|
||||||
|
const [state, setState] = useState<SearchState>({
|
||||||
|
query: "",
|
||||||
|
results: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
filters: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const setQuery = useCallback((query: string) => {
|
||||||
|
setState(prev => ({ ...prev, query }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFilters = useCallback((filters: SearchFilters) => {
|
||||||
|
setState(prev => ({ ...prev, filters }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, filters: {} }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const search = useCallback(async () => {
|
||||||
|
const { query, filters } = state;
|
||||||
|
|
||||||
|
if (!query || query.length < minQueryLength) {
|
||||||
|
setState(prev => ({ ...prev, results: [], loading: false, error: null }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending request
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("q", query);
|
||||||
|
if (filters.types?.length) params.set("types", filters.types.join(","));
|
||||||
|
if (filters.status?.length) params.set("status", filters.status.join(","));
|
||||||
|
if (filters.dateFrom) params.set("dateFrom", filters.dateFrom);
|
||||||
|
if (filters.dateTo) params.set("dateTo", filters.dateTo);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/search?${params.toString()}`, {
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Search failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const newState: SearchState = {
|
||||||
|
query,
|
||||||
|
results: data.results || [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
filters,
|
||||||
|
executionTimeMs: data.executionTimeMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(newState);
|
||||||
|
onResults?.(data.results || []);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
return; // Request was cancelled, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Search failed";
|
||||||
|
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
|
||||||
|
onError?.(errorMessage);
|
||||||
|
}
|
||||||
|
}, [state.query, state.filters, minQueryLength, onResults, onError]);
|
||||||
|
|
||||||
|
// Debounced search effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
search();
|
||||||
|
}, debounceMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state.query, state.filters, search, debounceMs]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
setQuery,
|
||||||
|
setFilters,
|
||||||
|
clearFilters,
|
||||||
|
refresh: search,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for search history management
|
||||||
|
const SEARCH_HISTORY_KEY = "mission-control-search-history";
|
||||||
|
const MAX_HISTORY_ITEMS = 10;
|
||||||
|
|
||||||
|
export interface SearchHistoryItem {
|
||||||
|
query: string;
|
||||||
|
filters?: SearchFilters;
|
||||||
|
timestamp: number;
|
||||||
|
resultCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchHistory() {
|
||||||
|
const [history, setHistory] = useState<SearchHistoryItem[]>([]);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Load history from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
// Filter out items older than 30 days
|
||||||
|
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const filtered = parsed.filter((item: SearchHistoryItem) => item.timestamp > thirtyDaysAgo);
|
||||||
|
setHistory(filtered);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load search history:", e);
|
||||||
|
}
|
||||||
|
setIsLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save history to localStorage whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoaded) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save search history:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [history, isLoaded]);
|
||||||
|
|
||||||
|
const addToHistory = useCallback((item: Omit<SearchHistoryItem, "timestamp">) => {
|
||||||
|
if (!item.query.trim()) return;
|
||||||
|
|
||||||
|
setHistory(prev => {
|
||||||
|
// Remove duplicate queries
|
||||||
|
const filtered = prev.filter(h => h.query.toLowerCase() !== item.query.toLowerCase());
|
||||||
|
// Add new item at the beginning
|
||||||
|
const newItem: SearchHistoryItem = {
|
||||||
|
...item,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
return [newItem, ...filtered].slice(0, MAX_HISTORY_ITEMS);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeFromHistory = useCallback((index: number) => {
|
||||||
|
setHistory(prev => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearHistory = useCallback(() => {
|
||||||
|
setHistory([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
history,
|
||||||
|
isLoaded,
|
||||||
|
addToHistory,
|
||||||
|
removeFromHistory,
|
||||||
|
clearHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to highlight search matches
|
||||||
|
export function createHighlightedText(
|
||||||
|
text: string,
|
||||||
|
query: string,
|
||||||
|
maxLength: number = 150
|
||||||
|
): { text: string; matches: Array<{ start: number; end: number }> } {
|
||||||
|
if (!text || !query) {
|
||||||
|
return {
|
||||||
|
text: text?.length > maxLength ? text.substring(0, maxLength) + "..." : text || "",
|
||||||
|
matches: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const matches: Array<{ start: number; end: number }> = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while ((index = lowerText.indexOf(lowerQuery, index)) !== -1) {
|
||||||
|
matches.push({ start: index, end: index + query.length });
|
||||||
|
index += query.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return {
|
||||||
|
text: text.length > maxLength ? text.substring(0, maxLength) + "..." : text,
|
||||||
|
matches: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center snippet around first match
|
||||||
|
const firstMatch = matches[0];
|
||||||
|
const contextSize = Math.floor((maxLength - query.length) / 2);
|
||||||
|
let start = Math.max(0, firstMatch.start - contextSize);
|
||||||
|
let end = Math.min(text.length, firstMatch.end + contextSize);
|
||||||
|
|
||||||
|
if (start === 0) {
|
||||||
|
end = Math.min(text.length, maxLength);
|
||||||
|
} else if (end === text.length) {
|
||||||
|
start = Math.max(0, text.length - maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = start > 0 ? "..." : "";
|
||||||
|
const suffix = end < text.length ? "..." : "";
|
||||||
|
|
||||||
|
const adjustedMatches = matches
|
||||||
|
.filter(m => m.start >= start && m.end <= end)
|
||||||
|
.map(m => ({
|
||||||
|
start: m.start - start + prefix.length,
|
||||||
|
end: m.end - start + prefix.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: prefix + text.substring(start, end) + suffix,
|
||||||
|
matches: adjustedMatches,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user