Phase 5: Enhanced search modal with filters and recent searches

- Added search filters (All, Workflow, Setup, Account, Help)
- Implemented recent searches (stored in localStorage, max 5)
- Enhanced search results with category display
- Improved result filtering by type and category
- Updated search items with proper categorization
- Keyboard shortcut Cmd/Ctrl+K already working ✓
This commit is contained in:
IGNY8 VPS (Salman)
2026-01-09 15:36:18 +00:00
parent 82d6a9e879
commit 0921adbabb

View File

@@ -1,5 +1,6 @@
/**
* Search Modal - Global search modal triggered by icon or Cmd+K
* Enhanced with filters and recent searches
*/
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -14,45 +15,85 @@ interface SearchModalProps {
interface SearchResult {
title: string;
path: string;
type: 'page' | 'action';
type: 'workflow' | 'setup' | 'account' | 'help';
category: string;
icon?: string;
}
type FilterType = 'all' | 'workflow' | 'setup' | 'account' | 'help';
const RECENT_SEARCHES_KEY = 'igny8_recent_searches';
const MAX_RECENT_SEARCHES = 5;
const SEARCH_ITEMS: SearchResult[] = [
// Workflow
{ title: 'Keywords', path: '/planner/keywords', type: 'page' },
{ title: 'Clusters', path: '/planner/clusters', type: 'page' },
{ title: 'Ideas', path: '/planner/ideas', type: 'page' },
{ title: 'Queue', path: '/writer/tasks', type: 'page' },
{ title: 'Drafts', path: '/writer/content', type: 'page' },
{ title: 'Images', path: '/writer/images', type: 'page' },
{ title: 'Review', path: '/writer/review', type: 'page' },
{ title: 'Approved', path: '/writer/approved', type: 'page' },
{ title: 'Keywords', path: '/planner/keywords', type: 'workflow', category: 'Planner' },
{ title: 'Clusters', path: '/planner/clusters', type: 'workflow', category: 'Planner' },
{ title: 'Ideas', path: '/planner/ideas', type: 'workflow', category: 'Planner' },
{ title: 'Queue', path: '/writer/tasks', type: 'workflow', category: 'Writer' },
{ title: 'Drafts', path: '/writer/content', type: 'workflow', category: 'Writer' },
{ title: 'Images', path: '/writer/images', type: 'workflow', category: 'Writer' },
{ title: 'Review', path: '/writer/review', type: 'workflow', category: 'Writer' },
{ title: 'Approved', path: '/writer/approved', type: 'workflow', category: 'Writer' },
{ title: 'Automation', path: '/automation', type: 'workflow', category: 'Automation' },
{ title: 'Content Calendar', path: '/publisher/content-calendar', type: 'workflow', category: 'Publisher' },
// Setup
{ title: 'Sites', path: '/sites', type: 'page' },
{ title: 'Add Keywords', path: '/add-keywords', type: 'page' },
{ title: 'Content Settings', path: '/account/content-settings', type: 'page' },
{ title: 'Prompts', path: '/thinker/prompts', type: 'page' },
{ title: 'Author Profiles', path: '/thinker/author-profiles', type: 'page' },
{ title: 'Sites', path: '/sites', type: 'setup', category: 'Sites' },
{ title: 'Add Keywords', path: '/setup/add-keywords', type: 'setup', category: 'Setup' },
{ title: 'Content Settings', path: '/account/content-settings', type: 'setup', category: 'Settings' },
{ title: 'Prompts', path: '/thinker/prompts', type: 'setup', category: 'AI' },
{ title: 'Author Profiles', path: '/thinker/author-profiles', type: 'setup', category: 'AI' },
// Account
{ title: 'Account Settings', path: '/account/settings', type: 'page' },
{ title: 'Plans & Billing', path: '/account/plans', type: 'page' },
{ title: 'Usage Analytics', path: '/account/usage', type: 'page' },
{ title: 'Account Settings', path: '/account/settings', type: 'account', category: 'Account' },
{ title: 'Plans & Billing', path: '/account/plans', type: 'account', category: 'Account' },
{ title: 'Usage Analytics', path: '/account/usage', type: 'account', category: 'Account' },
{ title: 'Team Management', path: '/account/settings/team', type: 'account', category: 'Account' },
{ title: 'Notifications', path: '/account/notifications', type: 'account', category: 'Account' },
// Help
{ title: 'Help & Support', path: '/help', type: 'page' },
{ title: 'Help & Support', path: '/help', type: 'help', category: 'Help' },
];
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
// Load recent searches from localStorage
useEffect(() => {
const stored = localStorage.getItem(RECENT_SEARCHES_KEY);
if (stored) {
try {
setRecentSearches(JSON.parse(stored));
} catch {
setRecentSearches([]);
}
}
}, []);
// Save recent search
const addRecentSearch = (path: string) => {
const updated = [path, ...recentSearches.filter(p => p !== path)].slice(0, MAX_RECENT_SEARCHES);
setRecentSearches(updated);
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
};
const getRecentSearchResults = (): SearchResult[] => {
return recentSearches
.map(path => SEARCH_ITEMS.find(item => item.path === path))
.filter((item): item is SearchResult => item !== undefined);
};
const filteredResults = query.length > 0
? SEARCH_ITEMS.filter(item =>
item.title.toLowerCase().includes(query.toLowerCase())
)
: SEARCH_ITEMS.slice(0, 8);
? SEARCH_ITEMS.filter(item => {
const matchesQuery = item.title.toLowerCase().includes(query.toLowerCase()) ||
item.category.toLowerCase().includes(query.toLowerCase());
const matchesFilter = activeFilter === 'all' || item.type === activeFilter;
return matchesQuery && matchesFilter;
})
: (activeFilter === 'all' ? getRecentSearchResults() : SEARCH_ITEMS.filter(item => item.type === activeFilter));
useEffect(() => {
if (isOpen) {
@@ -80,13 +121,23 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
};
const handleSelect = (result: SearchResult) => {
addRecentSearch(result.path);
navigate(result.path);
onClose();
};
const filterOptions: { value: FilterType; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'workflow', label: 'Workflow' },
{ value: 'setup', label: 'Setup' },
{ value: 'account', label: 'Account' },
{ value: 'help', label: 'Help' },
];
return (
<Modal isOpen={isOpen} onClose={onClose} className="sm:max-w-lg">
<Modal isOpen={isOpen} onClose={onClose} className="sm:max-w-2xl">
<div className="p-0">
{/* Search Input */}
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 z-10">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -107,11 +158,40 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
ESC to close
</span>
</div>
{/* Filters */}
<div className="flex gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
{filterOptions.map((filter) => (
<Button
key={filter.value}
variant={activeFilter === filter.value ? 'solid' : 'outline'}
tone={activeFilter === filter.value ? 'brand' : 'neutral'}
size="sm"
onClick={() => {
setActiveFilter(filter.value);
setSelectedIndex(0);
}}
className="whitespace-nowrap"
>
{filter.label}
</Button>
))}
</div>
{/* Recent Searches Header (only when showing recent) */}
{query.length === 0 && activeFilter === 'all' && recentSearches.length > 0 && (
<div className="px-4 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
Recent Searches
</div>
)}
{/* Results */}
<div className="max-h-80 overflow-y-auto py-2">
{filteredResults.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500">
No results found for "{query}"
{query.length > 0
? `No results found for "${query}"`
: 'No recent searches'}
</div>
) : (
filteredResults.map((result, index) => (
@@ -126,10 +206,13 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}
>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="font-medium">{result.title}</span>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{result.title}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{result.category}</div>
</div>
</Button>
))
)}